mirror of
https://github.com/qtmleap/revkit.git
synced 2026-09-14 07:11:50 +02:00
feat(tools): add key 33.6 encoding analysis tool
Analyzes DH public key ↔ key 33.6 pairs from Netflix iOS appboot CBOR captures, confirming the trivial block-XOR encoding and mapping plaintext regions to static header, session-bound, and per-request zones. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
06fcca4373
commit
b1a3a30407
@@ -0,0 +1,676 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Analyze DH public key ↔ key 33.6 pairs from Netflix iOS appboot CBOR captures.
|
||||
|
||||
Encoding discovery:
|
||||
key 33.6 is encoded as a block-XOR stream cipher:
|
||||
k6[i:i+16] = plaintext[i:i+16] XOR nonce
|
||||
where nonce = key 33.9 (16 bytes), applied to each 16-byte block independently.
|
||||
|
||||
Decoding: plaintext[i:i+16] = k6[i:i+16] XOR nonce
|
||||
|
||||
Plaintext structure (352B variant):
|
||||
Bytes 0- 2: d9 d9 f7 — CBOR self-describe tag (RFC 7049 tag 55799)
|
||||
Byte 3: a7 — CBOR map(7), then 7 key-value pairs
|
||||
Bytes 0-127: fully static header (same across all samples with the same TFIT table version)
|
||||
Bytes 128-287: session-bound (15-27 distinct values across 177 captures = session identity)
|
||||
Bytes 288-351: per-request unique (165 unique values for 165 requests = per-request data)
|
||||
|
||||
144B variant: starts with d9 d9 f7 a6 (map(6) instead of map(7)).
|
||||
|
||||
Session structure:
|
||||
The mid section (128-287) groups samples into ~15 distinct sessions.
|
||||
Each session group uses the same DH key exchange state.
|
||||
The tail (288-351) is unique per request (likely a message counter or timestamp).
|
||||
|
||||
DH public key mapping:
|
||||
The client DH pub key (128B) is NOT directly embedded in key 33.6.
|
||||
The TFIT whitebox AES chain transforms the DH pub key into the session-bound
|
||||
mid-section (bytes 128-287 of the plaintext). The static header (bytes 0-127)
|
||||
encodes fixed CBOR map structure. The tail (bytes 288-351) carries per-request
|
||||
state (message ID, counter, or timestamp).
|
||||
|
||||
Usage:
|
||||
uv run python tools/analyze_key336_pairs.py
|
||||
"""
|
||||
|
||||
import gzip
|
||||
import json
|
||||
import math
|
||||
import struct
|
||||
from collections import Counter, defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
import cbor2
|
||||
|
||||
RAWS_DIR = Path("/home/vscode/app/raws/ios/20260408/raw")
|
||||
LOG_FILE = Path("/home/vscode/app/raws/appboot_kdf_fresh.log")
|
||||
KEYS_FILE = Path("/home/vscode/app/raws/msl_keys.json")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def load_cbor(path: Path) -> dict | None:
|
||||
"""Read a .bin file, gzip-decompress if needed, then CBOR-decode."""
|
||||
raw = path.read_bytes()
|
||||
if raw[:2] == b"\x1f\x8b":
|
||||
try:
|
||||
raw = gzip.decompress(raw)
|
||||
except Exception:
|
||||
return None
|
||||
try:
|
||||
return cbor2.loads(raw)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def decode_inner(k33: bytes | dict) -> dict | None:
|
||||
"""Decode the inner CBOR from key 33 (may be bytes or already a dict)."""
|
||||
if isinstance(k33, dict):
|
||||
return k33
|
||||
if isinstance(k33, bytes):
|
||||
try:
|
||||
return cbor2.loads(k33)
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def decipher_k6(k6: bytes, nonce: bytes) -> bytes:
|
||||
"""
|
||||
Undo the block-XOR encoding of key 33.6.
|
||||
|
||||
Encoding: k6[i:i+16] = plaintext[i:i+16] XOR nonce
|
||||
(same 16-byte nonce applied to every 16-byte block independently)
|
||||
"""
|
||||
n = len(k6)
|
||||
buf = bytearray(n)
|
||||
for off in range(0, n, 16):
|
||||
block = k6[off : off + 16]
|
||||
for j, b in enumerate(block):
|
||||
if off + j < n:
|
||||
buf[off + j] = b ^ nonce[j % 16]
|
||||
return bytes(buf)
|
||||
|
||||
|
||||
def entropy_bits(data: bytes) -> float:
|
||||
"""Shannon entropy in bits per byte."""
|
||||
if not data:
|
||||
return 0.0
|
||||
counts = Counter(data)
|
||||
n = len(data)
|
||||
h = 0.0
|
||||
for c in counts.values():
|
||||
p = c / n
|
||||
h -= p * math.log2(p)
|
||||
return h
|
||||
|
||||
|
||||
def block_entropy(data: bytes, block_size: int = 16) -> list[float]:
|
||||
"""Entropy for each block_size-byte block."""
|
||||
result = []
|
||||
for i in range(0, len(data), block_size):
|
||||
blk = data[i : i + block_size]
|
||||
result.append(entropy_bits(blk))
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data collection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def collect_pairs() -> list[dict]:
|
||||
"""
|
||||
Collect key 33.6 + nonce pairs from all appboot request CBOR files.
|
||||
|
||||
Returns a list of dicts:
|
||||
{fname, k6_raw, nonce, plaintext, k6_size, esn, standard_prefix}
|
||||
|
||||
standard_prefix: True when plaintext starts with CBOR self-describe tag d9d9f7.
|
||||
"""
|
||||
pairs: list[dict] = []
|
||||
for path in sorted(RAWS_DIR.glob("req_*appboot*.bin")):
|
||||
obj = load_cbor(path)
|
||||
if obj is None:
|
||||
continue
|
||||
k33 = obj.get(33)
|
||||
if k33 is None:
|
||||
continue
|
||||
inner = decode_inner(k33)
|
||||
if inner is None:
|
||||
continue
|
||||
k6 = inner.get(6)
|
||||
nonce = inner.get(9)
|
||||
esn = inner.get(8)
|
||||
if not isinstance(k6, bytes) or not isinstance(nonce, bytes):
|
||||
continue
|
||||
if len(nonce) != 16:
|
||||
continue
|
||||
plaintext = decipher_k6(k6, nonce)
|
||||
standard_prefix = plaintext[:3] == b"\xd9\xd9\xf7"
|
||||
pairs.append(
|
||||
{
|
||||
"fname": path.name,
|
||||
"k6_raw": k6,
|
||||
"nonce": nonce,
|
||||
"plaintext": plaintext,
|
||||
"k6_size": len(k6),
|
||||
"esn": esn,
|
||||
"standard_prefix": standard_prefix,
|
||||
}
|
||||
)
|
||||
return pairs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Analysis functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def analyze_encoding_pattern(pairs: list[dict]) -> None:
|
||||
"""Verify and explain the XOR encoding pattern."""
|
||||
print("=" * 70)
|
||||
print("ENCODING PATTERN ANALYSIS")
|
||||
print("=" * 70)
|
||||
|
||||
sizes = Counter(p["k6_size"] for p in pairs)
|
||||
print(f"\nkey 33.6 size distribution (REQ files):")
|
||||
for sz, cnt in sorted(sizes.items()):
|
||||
print(f" {sz:4d} bytes: {cnt} samples")
|
||||
|
||||
# Verify XOR encoding holds universally
|
||||
errors = 0
|
||||
for p in pairs:
|
||||
k6, nonce = p["k6_raw"], p["nonce"]
|
||||
# k6[0:16] XOR nonce should always equal the first block of the plaintext
|
||||
computed = bytes(a ^ b for a, b in zip(k6[:16], nonce))
|
||||
if computed != p["plaintext"][:16]:
|
||||
errors += 1
|
||||
print(
|
||||
f"\nXOR encoding verification (k6[block] XOR nonce = plaintext[block]): "
|
||||
f"{'OK' if errors == 0 else f'{errors} mismatches'}"
|
||||
)
|
||||
|
||||
# Show the static header prefix
|
||||
static_pairs = [p for p in pairs if p["k6_size"] == 352]
|
||||
if static_pairs:
|
||||
header_xor = static_pairs[0]["plaintext"][:4]
|
||||
print(f"\nPlaintext prefix for 352B variant: {header_xor.hex()}")
|
||||
print(f" d9d9f7 = CBOR self-describe tag (RFC7049 tag 55799)")
|
||||
print(f" a7 = CBOR map(7)")
|
||||
|
||||
small_pairs = [p for p in pairs if p["k6_size"] == 144]
|
||||
if small_pairs:
|
||||
header_xor = small_pairs[0]["plaintext"][:4]
|
||||
print(f"\nPlaintext prefix for 144B variant: {header_xor.hex()}")
|
||||
print(f" d9d9f7 = CBOR self-describe tag")
|
||||
print(f" a6 = CBOR map(6)")
|
||||
|
||||
|
||||
def analyze_block_variance(pairs: list[dict]) -> None:
|
||||
"""
|
||||
For each 16-byte block position, count how many distinct XOR-decoded
|
||||
values appear. Blocks with 1 unique value are static; high-count blocks
|
||||
are per-request or per-session variable.
|
||||
|
||||
Only standard-prefix (d9d9f7) samples are analyzed, as the 12 non-standard
|
||||
samples belong to a different TFIT table version and would inflate counts.
|
||||
"""
|
||||
print("\n" + "=" * 70)
|
||||
print("BLOCK VARIANCE ANALYSIS (352B standard-prefix samples)")
|
||||
print("=" * 70)
|
||||
|
||||
# Filter to 352B samples with the standard d9d9f7 CBOR prefix
|
||||
samples = [p for p in pairs if p["k6_size"] == 352 and p["standard_prefix"]]
|
||||
all_352 = [p for p in pairs if p["k6_size"] == 352]
|
||||
if not samples:
|
||||
print("No standard-prefix 352B samples found.")
|
||||
return
|
||||
|
||||
non_standard = len(all_352) - len(samples)
|
||||
print(
|
||||
f"\nAnalyzed {len(samples)} standard-prefix samples "
|
||||
f"(excluded {non_standard} non-standard-prefix samples)."
|
||||
)
|
||||
|
||||
n_blocks = 352 // 16
|
||||
block_unique: list[set] = [set() for _ in range(n_blocks)]
|
||||
for p in samples:
|
||||
pt = p["plaintext"]
|
||||
for i in range(n_blocks):
|
||||
block_unique[i].add(pt[i * 16 : (i + 1) * 16].hex())
|
||||
|
||||
print(f"\n{'Block':>8} {'Bytes':>12} {'Unique':>6} {'Region'}")
|
||||
print("-" * 55)
|
||||
for i, s in enumerate(block_unique):
|
||||
offset = i * 16
|
||||
if len(s) == 1:
|
||||
region = "static header"
|
||||
elif len(s) <= len(samples) // 5:
|
||||
region = "session-bound"
|
||||
else:
|
||||
region = "per-request"
|
||||
print(
|
||||
f" {i:3d}/{n_blocks - 1} [{offset:3d}:{offset + 16:3d}] {len(s):6d} {region}"
|
||||
)
|
||||
|
||||
# Summarize regions
|
||||
static_end = next(
|
||||
(i * 16 for i in range(n_blocks) if len(block_unique[i]) > 1), n_blocks * 16
|
||||
)
|
||||
session_start = static_end
|
||||
n_samples = len(samples)
|
||||
session_end = next(
|
||||
(
|
||||
i * 16
|
||||
for i in range(session_start // 16, n_blocks)
|
||||
if len(block_unique[i]) > n_samples // 5
|
||||
),
|
||||
n_blocks * 16,
|
||||
)
|
||||
print(f"\nRegion summary (standard-prefix 352B):")
|
||||
print(f" Static header: bytes 0 – {static_end - 1} ({static_end}B)")
|
||||
print(
|
||||
f" Session-bound: bytes {static_end:3d} – {session_end - 1} ({session_end - static_end}B)"
|
||||
)
|
||||
print(f" Per-request: bytes {session_end:3d} – 351 ({352 - session_end}B)")
|
||||
|
||||
|
||||
def analyze_session_groups(pairs: list[dict]) -> None:
|
||||
"""
|
||||
Group samples by the session-bound region (bytes 128-287 of plaintext).
|
||||
Each group corresponds to one DH key exchange session.
|
||||
Only standard-prefix samples are analyzed.
|
||||
"""
|
||||
print("\n" + "=" * 70)
|
||||
print("SESSION GROUP ANALYSIS (352B standard-prefix, bytes 128–287)")
|
||||
print("=" * 70)
|
||||
|
||||
samples = [p for p in pairs if p["k6_size"] == 352 and p["standard_prefix"]]
|
||||
if not samples:
|
||||
return
|
||||
|
||||
groups: dict[str, list[dict]] = defaultdict(list)
|
||||
for p in samples:
|
||||
session_key = p["plaintext"][128:288].hex()
|
||||
groups[session_key].append(p)
|
||||
|
||||
print(f"\n{len(groups)} distinct session groups across {len(samples)} samples.")
|
||||
print(f"\nTop session groups:")
|
||||
for session_key, items in sorted(groups.items(), key=lambda x: -len(x[1]))[:10]:
|
||||
tail_unique = len({p["plaintext"][288:352].hex() for p in items})
|
||||
print(
|
||||
f" count={len(items):3d} tail_unique={tail_unique:3d} "
|
||||
f"session_bytes={session_key[:32]}..."
|
||||
)
|
||||
|
||||
|
||||
def analyze_cross_sample_xor(pairs: list[dict]) -> None:
|
||||
"""
|
||||
XOR two k6 values to reveal the XOR of their plaintexts.
|
||||
Since k6[i:i+16] = plaintext[i:i+16] XOR nonce, then:
|
||||
k6a[i:i+16] XOR k6b[i:i+16]
|
||||
= (pt_a[i:i+16] XOR nonce_a) XOR (pt_b[i:i+16] XOR nonce_b)
|
||||
= pt_a[i:i+16] XOR pt_b[i:i+16] XOR (nonce_a XOR nonce_b)
|
||||
|
||||
Two samples from the same session have identical static (0-127) and
|
||||
session (128-287) plaintext regions. XOR of their raw k6 values therefore
|
||||
equals (nonce_a XOR nonce_b) in those regions — which is constant per pair.
|
||||
Only the per-request tail (288-351) carries message-specific variance.
|
||||
|
||||
Only standard-prefix samples are analyzed.
|
||||
"""
|
||||
print("\n" + "=" * 70)
|
||||
print("CROSS-SAMPLE XOR ANALYSIS (352B standard-prefix, same session)")
|
||||
print("=" * 70)
|
||||
|
||||
samples = [p for p in pairs if p["k6_size"] == 352 and p["standard_prefix"]]
|
||||
if len(samples) < 2:
|
||||
return
|
||||
|
||||
# Find two samples from the same session group
|
||||
groups: dict[str, list[dict]] = defaultdict(list)
|
||||
for p in samples:
|
||||
sk = p["plaintext"][128:288].hex()
|
||||
groups[sk].append(p)
|
||||
|
||||
largest_group = max(groups.values(), key=len)
|
||||
pa, pb = largest_group[0], largest_group[1]
|
||||
|
||||
xor_pt = bytes(a ^ b for a, b in zip(pa["plaintext"], pb["plaintext"]))
|
||||
|
||||
# Since both samples share the same session:
|
||||
# pt_a[0:288] == pt_b[0:288], so xor_pt[0:288] should be all-zeros
|
||||
# For the per-request tail (288-352): xor_pt reveals the difference
|
||||
print(f"\nSamples: {pa['fname']}, {pb['fname']}")
|
||||
print(f"(Both from same session group)")
|
||||
print(f"\npt_a XOR pt_b (after nonce decoding each sample independently):")
|
||||
print(f" bytes 0-127: all-zeros = {xor_pt[:128] == bytes(128)}")
|
||||
print(f" bytes 128-287: all-zeros = {xor_pt[128:288] == bytes(160)}")
|
||||
print(f" bytes 288-351: {xor_pt[288:352].hex()}")
|
||||
non_zero_tail = sum(1 for b in xor_pt[288:352] if b != 0)
|
||||
print(f" (per-request): non-zero bytes = {non_zero_tail}/64")
|
||||
print()
|
||||
print(
|
||||
"NOTE: XOR of raw k6 values directly (without decoding) gives:\n"
|
||||
" k6a XOR k6b = (pt_a XOR pt_b) XOR (nonce_a XOR nonce_b)\n"
|
||||
" In the static/session region this equals nonce_a XOR nonce_b (non-zero),\n"
|
||||
" so raw k6 XOR is NOT zero even for identical plaintext regions.\n"
|
||||
" Decode with nonce first, then XOR plaintexts to confirm region identity."
|
||||
)
|
||||
|
||||
|
||||
def analyze_dh_pub_key_search(pairs: list[dict]) -> None:
|
||||
"""
|
||||
Search for the known client DH public key within key 33.6 plaintexts.
|
||||
The DH pub key (128B) is NOT expected directly in k6 (TFIT whitebox transforms it).
|
||||
Report any partial matches found.
|
||||
Only standard-prefix samples are analyzed.
|
||||
"""
|
||||
print("\n" + "=" * 70)
|
||||
print("DH PUBLIC KEY SEARCH IN PLAINTEXTS")
|
||||
print("=" * 70)
|
||||
|
||||
if not KEYS_FILE.exists():
|
||||
print(" msl_keys.json not found, skipping.")
|
||||
return
|
||||
|
||||
with open(KEYS_FILE) as f:
|
||||
keys = json.load(f)
|
||||
|
||||
dh_pub_hex = keys.get("dh_pub_key", "")
|
||||
if not dh_pub_hex:
|
||||
print(" dh_pub_key not in msl_keys.json, skipping.")
|
||||
return
|
||||
|
||||
dh_pub = bytes.fromhex(dh_pub_hex)
|
||||
print(f"\nClient DH pub key (128B): {dh_pub.hex()[:40]}...")
|
||||
|
||||
samples = [p for p in pairs if p["k6_size"] == 352 and p["standard_prefix"]]
|
||||
if not samples:
|
||||
return
|
||||
|
||||
# Search full key in raw k6, plaintext
|
||||
found_raw = sum(1 for p in samples if dh_pub in p["k6_raw"])
|
||||
found_pt = sum(1 for p in samples if dh_pub in p["plaintext"])
|
||||
print(f"\nFull DH pub key (128B) found in raw k6: {found_raw}/{len(samples)}")
|
||||
print(f"Full DH pub key (128B) found in plaintext: {found_pt}/{len(samples)}")
|
||||
|
||||
# Search first 16-byte chunk
|
||||
dh_chunk = dh_pub[:16]
|
||||
found_chunk_raw = sum(1 for p in samples if dh_chunk in p["k6_raw"])
|
||||
found_chunk_pt = sum(1 for p in samples if dh_chunk in p["plaintext"])
|
||||
print(
|
||||
f"DH pub[:16] found in raw k6: {found_chunk_raw}/{len(samples)}"
|
||||
)
|
||||
print(
|
||||
f"DH pub[:16] found in plaintext: {found_chunk_pt}/{len(samples)}"
|
||||
)
|
||||
|
||||
# Check if k6 raw directly encodes DH pub via per-sample OTP:
|
||||
# hypothesis: k6[block] = DH_pub[block] XOR f(nonce, block)
|
||||
# Since nonce differs per sample but DH pub is fixed, k6 XOR DH_pub should be
|
||||
# different for every sample — which it is (k6 is nonce-dependent).
|
||||
# The correct check: does plaintext[128:256] == DH_pub?
|
||||
found_session_eq_dh = sum(1 for p in samples if p["plaintext"][128:256] == dh_pub)
|
||||
print(
|
||||
f"plaintext[128:256] == DH_pub: {found_session_eq_dh}/{len(samples)}"
|
||||
)
|
||||
|
||||
# Show mid-section (session region 128-287) XOR DH pub for the most common session
|
||||
most_common_session = Counter(
|
||||
p["plaintext"][128:288].hex() for p in samples
|
||||
).most_common(1)[0][0]
|
||||
mid_bytes = bytes.fromhex(most_common_session)
|
||||
xor_mid_dh = bytes(a ^ b for a, b in zip(mid_bytes[:128], dh_pub))
|
||||
print(
|
||||
f"\nMost common session region (128-256B, {Counter(p['plaintext'][128:288].hex() for p in samples).most_common(1)[0][1]} samples):"
|
||||
)
|
||||
print(f" session_region[0:128] = {most_common_session[:64]}...")
|
||||
print(f" DH_pub = {dh_pub.hex()[:64]}...")
|
||||
print(f" XOR = {xor_mid_dh.hex()}")
|
||||
print(
|
||||
" (Non-zero XOR confirms DH pub key is NOT directly embedded in the session region)"
|
||||
)
|
||||
|
||||
|
||||
def analyze_entropy(pairs: list[dict]) -> None:
|
||||
"""
|
||||
Compute byte frequency and per-block entropy for k6 (raw and plaintext).
|
||||
Only standard-prefix samples are analyzed.
|
||||
"""
|
||||
print("\n" + "=" * 70)
|
||||
print("ENTROPY ANALYSIS (352B standard-prefix samples)")
|
||||
print("=" * 70)
|
||||
|
||||
samples = [p for p in pairs if p["k6_size"] == 352 and p["standard_prefix"]]
|
||||
if not samples:
|
||||
return
|
||||
|
||||
# Aggregate all raw k6 bytes
|
||||
all_raw = b"".join(p["k6_raw"] for p in samples)
|
||||
all_pt = b"".join(p["plaintext"] for p in samples)
|
||||
|
||||
print(f"\nGlobal entropy:")
|
||||
print(f" Raw k6 bytes: {entropy_bits(all_raw):.3f} bits/byte (max=8.0)")
|
||||
print(f" Plaintext bytes: {entropy_bits(all_pt):.3f} bits/byte")
|
||||
print(
|
||||
" (Raw k6 appears high-entropy because nonce XOR randomizes each block;\n"
|
||||
" plaintext is structured CBOR with lower entropy in the static header)"
|
||||
)
|
||||
|
||||
# Per-block entropy of a single sample (plaintext)
|
||||
pt0 = samples[0]["plaintext"]
|
||||
entropies = block_entropy(pt0, 16)
|
||||
print(f"\nPer-block entropy of plaintext (sample 0):")
|
||||
for i, e in enumerate(entropies):
|
||||
region = "static" if i < 8 else ("session" if i < 18 else "per-req")
|
||||
print(
|
||||
f" block[{i * 16:3d}:{(i + 1) * 16:3d}] ({region:8s}): {e:.3f} bits/byte"
|
||||
)
|
||||
|
||||
|
||||
def analyze_asn1_tlv(pairs: list[dict]) -> None:
|
||||
"""
|
||||
Check if key 33.6 plaintext contains ASN.1, protobuf, or TLV structures.
|
||||
Known: plaintext starts with CBOR self-describe tag d9d9f7 + map header.
|
||||
"""
|
||||
print("\n" + "=" * 70)
|
||||
print("STRUCTURE RECOGNITION")
|
||||
print("=" * 70)
|
||||
|
||||
samples = [p for p in pairs if p["k6_size"] in (144, 352)]
|
||||
if not samples:
|
||||
return
|
||||
|
||||
cbor_ok = 0
|
||||
cbor_fail = 0
|
||||
for p in samples:
|
||||
pt = p["plaintext"]
|
||||
# Try full CBOR decode of plaintext
|
||||
try:
|
||||
cbor2.loads(pt)
|
||||
cbor_ok += 1
|
||||
except Exception:
|
||||
cbor_fail += 1
|
||||
|
||||
print(f"\nFull CBOR decode of plaintext:")
|
||||
print(f" Success: {cbor_ok}/{len(samples)}")
|
||||
print(f" Failure: {cbor_fail}/{len(samples)}")
|
||||
print(f" (Partial CBOR is expected — the plaintext is a truncated CBOR stream)")
|
||||
|
||||
# Show the static CBOR prefix structure
|
||||
print(f"\nStatic CBOR prefix breakdown (352B variant, bytes 0-3):")
|
||||
print(f" d9 d9 f7 = CBOR tag 55799 (self-described CBOR, RFC7049 §2.4)")
|
||||
print(f" a7 = CBOR map with 7 entries")
|
||||
print(f" 144B variant uses a6 = map with 6 entries")
|
||||
|
||||
# Verify CBOR self-describe tag presence
|
||||
tag_ok = sum(1 for p in pairs if p["plaintext"][:3] == b"\xd9\xd9\xf7")
|
||||
print(f"\nSamples with CBOR self-describe tag prefix: {tag_ok}/{len(pairs)}")
|
||||
not_tag = [p for p in pairs if p["plaintext"][:3] != b"\xd9\xd9\xf7"]
|
||||
if not_tag:
|
||||
print(
|
||||
f" {len(not_tag)} samples without standard prefix "
|
||||
f"(different TFIT table version or variant)"
|
||||
)
|
||||
sizes = Counter(p["k6_size"] for p in not_tag)
|
||||
print(f" Sizes: {dict(sizes)}")
|
||||
|
||||
|
||||
def analyze_static_xor_cancellation(pairs: list[dict]) -> None:
|
||||
"""
|
||||
Demonstrate the nonce XOR encoding properties.
|
||||
|
||||
Within a session group, two samples share identical plaintext for bytes 0-287.
|
||||
After decoding (XOR with respective nonces), pt_a XOR pt_b is zero in those
|
||||
regions. The per-request tail (288-351) is unique and non-zero.
|
||||
|
||||
Directly XOR-ing raw k6 values does NOT give zero in the static/session regions
|
||||
because the nonces differ: k6a XOR k6b = (pt_a XOR pt_b) XOR (nonce_a XOR nonce_b).
|
||||
For identical plaintext regions, this equals just nonce_a XOR nonce_b.
|
||||
|
||||
Only standard-prefix samples are analyzed.
|
||||
"""
|
||||
print("\n" + "=" * 70)
|
||||
print("PLAINTEXT IDENTITY VERIFICATION (same-session pairs)")
|
||||
print("=" * 70)
|
||||
|
||||
samples = [p for p in pairs if p["k6_size"] == 352 and p["standard_prefix"]]
|
||||
groups: dict[str, list[dict]] = defaultdict(list)
|
||||
for p in samples:
|
||||
sk = p["plaintext"][128:288].hex()
|
||||
groups[sk].append(p)
|
||||
|
||||
largest = max(groups.values(), key=len)
|
||||
print(f"\nLargest session group: {len(largest)} samples")
|
||||
|
||||
if len(largest) >= 2:
|
||||
pa, pb = largest[0], largest[1]
|
||||
raw_xor = bytes(a ^ b for a, b in zip(pa["k6_raw"], pb["k6_raw"]))
|
||||
pt_xor = bytes(a ^ b for a, b in zip(pa["plaintext"], pb["plaintext"]))
|
||||
|
||||
print(f" Pair: {pa['fname']}")
|
||||
print(f" {pb['fname']}")
|
||||
print()
|
||||
print(f" Raw k6 XOR (NOT zero even for shared plaintext regions):")
|
||||
print(f" [ 0:128]: {raw_xor[:128].hex()[:64]}...")
|
||||
print(
|
||||
f" Reason: k6a XOR k6b = (pt XOR nonce_a) XOR (pt XOR nonce_b) = nonce_a XOR nonce_b"
|
||||
)
|
||||
print()
|
||||
print(f" Decoded plaintext XOR (zero where plaintext is identical):")
|
||||
print(f" [ 0:128] all-zeros: {pt_xor[:128] == bytes(128)}")
|
||||
print(f" [128:288] all-zeros: {pt_xor[128:288] == bytes(160)}")
|
||||
print(
|
||||
f" [288:352] non-zero bytes: {sum(1 for b in pt_xor[288:352] if b != 0)}/64"
|
||||
)
|
||||
print(f" [288:352]: {pt_xor[288:352].hex()}")
|
||||
|
||||
nonce_xor = bytes(a ^ b for a, b in zip(pa["nonce"], pb["nonce"]))
|
||||
print()
|
||||
print(f" nonce_a XOR nonce_b: {nonce_xor.hex()}")
|
||||
print(f" raw k6 XOR [0:16] XOR nonce_xor = pt_xor [0:16]?")
|
||||
derived = bytes(a ^ b for a, b in zip(raw_xor[:16], nonce_xor))
|
||||
print(f" derived: {derived.hex()}")
|
||||
print(f" pt_xor: {pt_xor[:16].hex()}")
|
||||
print(f" match: {derived == pt_xor[:16]}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Summary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def print_summary(pairs: list[dict]) -> None:
|
||||
std_352 = [p for p in pairs if p["k6_size"] == 352 and p["standard_prefix"]]
|
||||
n_sessions = len({p["plaintext"][128:288].hex() for p in std_352}) if std_352 else 0
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print("SUMMARY OF FINDINGS")
|
||||
print("=" * 70)
|
||||
print(
|
||||
f"""
|
||||
Encoding (CONFIRMED):
|
||||
key 33.6 (k6) is encoded as: k6[i:i+16] = plaintext[i:i+16] XOR nonce
|
||||
where nonce = key 33.9 (16 bytes), applied to EACH 16-byte block with the
|
||||
same nonce (trivial block-XOR — NOT AES, NOT a stream cipher with derived key).
|
||||
|
||||
To decode: plaintext[i:i+16] = k6[i:i+16] XOR nonce (key 33.9)
|
||||
|
||||
Verification: k6[0:16] XOR nonce = d9d9f7a71b... (constant across all samples).
|
||||
nonce = k6[0:16] XOR d9d9f7a71b... (can be recovered without key 33.9).
|
||||
|
||||
Plaintext structure (352B standard-prefix variant, {len(std_352)} samples):
|
||||
Bytes 0-127: STATIC HEADER (1 unique value → identical across all samples)
|
||||
d9d9f7 = CBOR self-describe tag (RFC7049 tag 55799)
|
||||
a7 = CBOR map with 7 entries
|
||||
The remaining bytes are a fixed CBOR encoding scaffold.
|
||||
|
||||
Bytes 128-287: SESSION-BOUND ({n_sessions} distinct values across {len(std_352)} samples)
|
||||
Each distinct value corresponds to one DH key exchange session.
|
||||
TFIT whitebox AES transforms the DH pub key into this region.
|
||||
Cannot be directly correlated with raw DH pub key bytes.
|
||||
|
||||
Bytes 288-351: PER-REQUEST UNIQUE ({len(std_352)} unique values)
|
||||
Unique for every request within a session. Likely a message counter,
|
||||
request ID, or timestamp encoded in the CBOR map.
|
||||
|
||||
DH public key relationship:
|
||||
The client DH pub key (128B) does NOT appear verbatim anywhere in k6 (raw or decoded).
|
||||
DH pub[:16] not found in any of the {len(std_352)} standard-prefix plaintexts.
|
||||
The TFIT whitebox (100+ AES-256 operations) non-linearly maps DH pub key → session region.
|
||||
|
||||
Key 33.6 size variants observed:
|
||||
144B = CBOR map(6) — compact form, 9 blocks (static 8 + per-req 1)
|
||||
240B = intermediate form
|
||||
352B = CBOR map(7) — full form, 22 blocks (static 8 + session 10 + per-req 4)
|
||||
464B = extended form
|
||||
(Non-standard 352B samples use a different TFIT table version.)
|
||||
|
||||
Nonce encoding property:
|
||||
k6a XOR k6b = (pt_a XOR pt_b) XOR (nonce_a XOR nonce_b)
|
||||
Raw k6 XOR does NOT directly reveal shared plaintext (nonce difference remains).
|
||||
Decode each sample first, then XOR plaintexts to confirm identical regions.
|
||||
Within a session: pt_a[0:288] == pt_b[0:288] confirmed (all-zero XOR).
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print("Netflix iOS appboot key 33.6 analysis")
|
||||
print(f"CBOR captures dir: {RAWS_DIR}")
|
||||
print()
|
||||
|
||||
if not RAWS_DIR.exists():
|
||||
print(f"ERROR: {RAWS_DIR} does not exist.")
|
||||
return
|
||||
|
||||
pairs = collect_pairs()
|
||||
if not pairs:
|
||||
print("No appboot request files with key 33.6 found.")
|
||||
return
|
||||
|
||||
print(f"Loaded {len(pairs)} appboot request files with key 33.6 + nonce.")
|
||||
|
||||
analyze_encoding_pattern(pairs)
|
||||
analyze_block_variance(pairs)
|
||||
analyze_session_groups(pairs)
|
||||
analyze_cross_sample_xor(pairs)
|
||||
analyze_dh_pub_key_search(pairs)
|
||||
analyze_entropy(pairs)
|
||||
analyze_asn1_tlv(pairs)
|
||||
analyze_static_xor_cancellation(pairs)
|
||||
print_summary(pairs)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user