Files
tkgstratorandClaude Opus 4.6 22f8817867 feat(frida,mitmproxy): Fridaフック改善とmitmproxyキャプチャスクリプト追加
- hook_netflix_ios.js: readVec修正 (toUInt32), aesCbcEncryptDecrypt独立フック,
  key_b64キャプチャ対応, hookCrypto有効化
- hook_appboot_bypass.js: appboot SSLピンニングバイパス (Frida版)
- hook_appboot_openssl_bypass.js: OpenSSL C関数バイパス
- hook_crash_trace.js: クラッシュ時スタックトレースキャプチャ
- netflix_ios_capture.py: mitmproxyアドオン (TLSパススルー, Netflix通信キャプチャ)
- msl_decoder.py: MSL CBOR/JSONデコーダー

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 02:07:10 +00:00

469 lines
15 KiB
Python

"""Netflix MSL CBOR Decoder
iOS Netflix は MSL プロトコルに CBOR (RFC 7049) エンコーディングを使用する。
CBOR メッセージは数値キーを使い、値自体もネストされた CBOR バイト列を含む。
このモジュールは raw バイナリを受け取り、人間が読める Python dict に変換する。
使い方::
from msl_decoder import decode_msl_message
with open("req_appboot.bin", "rb") as f:
result = decode_msl_message(f.read())
import json
print(json.dumps(result, indent=2, ensure_ascii=False))
"""
from __future__ import annotations
import gzip
import io
import json
from typing import Any
import cbor2
# ════════════════════════════════════════════════════════════════
# Numeric key → human-readable name mappings
#
# These come from analysis of captured iOS Netflix MSL traffic.
# CBOR uses compact integer keys instead of JSON string keys.
# ════════════════════════════════════════════════════════════════
# Top-level MSL message keys
MSL_TOP_KEYS: dict[int, str] = {
16: "signature",
32: "header",
33: "payload",
34: "entityauthdata",
}
# Header fields (inside key 32)
MSL_HEADER_KEYS: dict[int, str] = {
10: "mastertoken",
11: "sender_numeric",
12: "messageid",
13: "timestamp",
14: "sequence_number",
15: "capabilities",
16: "renewable",
94: "handshake_options",
95: "handshake",
}
# Capabilities sub-fields (inside header → 15)
MSL_CAPABILITIES_KEYS: dict[int, str] = {
10: "mastertoken",
11: "sender_numeric",
12: "messageid",
13: "timestamp",
14: "sequence_number",
94: "handshake_options",
95: "handshake",
}
# Payload / key exchange fields (inside key 33)
MSL_PAYLOAD_KEYS: dict[int, str] = {
6: "ciphertext_or_scheme",
7: "iv_or_keydata",
8: "keyid",
9: "sha256_or_hmac",
}
# Entity auth data (inside key 34)
MSL_ENTITY_AUTH_KEYS: dict[int, str] = {
30: "scheme",
35: "authdata",
}
# Entity auth → authdata sub-fields
MSL_AUTHDATA_KEYS: dict[int, str] = {
50: "device_key_data",
80: "esn_prefix",
81: "esn",
}
# String keys that already appear with names (not remapped)
_STRING_KEY_PASSTHROUGH = {
"apphmac",
"appid",
"appkeyversion",
"devicetoken",
}
# ════════════════════════════════════════════════════════════════
# CBOR-aware recursive decoder
# ════════════════════════════════════════════════════════════════
def _try_cbor_decode(raw: bytes) -> Any | None:
"""Try to decode bytes as CBOR. Returns None on failure."""
try:
return cbor2.loads(raw)
except Exception:
return None
def _try_utf8(raw: bytes) -> str | None:
"""Try to decode bytes as UTF-8 text."""
try:
return raw.decode("utf-8")
except (UnicodeDecodeError, ValueError):
return None
def _bytes_to_value(raw: bytes, max_hex_len: int = 512) -> Any:
"""Convert bytes to best human-readable representation.
Priority:
1. Nested CBOR → recursively decode
2. Valid UTF-8 string
3. Hex string (if short enough)
4. Truncated hex summary
"""
# 1. Try nested CBOR
inner = _try_cbor_decode(raw)
if inner is not None and not isinstance(inner, (int, float, bool)):
# Avoid interpreting short byte sequences as bare CBOR integers
return _decode_value(inner)
# 2. Try UTF-8
text = _try_utf8(raw)
if text is not None:
return text
# 3. Hex encode
if len(raw) <= max_hex_len:
return raw.hex()
return f"<bytes:{len(raw)}>{raw[:64].hex()}..."
def _decode_value(obj: Any) -> Any:
"""Recursively decode a CBOR-decoded Python value into JSON-safe form."""
if isinstance(obj, bytes):
return _bytes_to_value(obj)
if isinstance(obj, dict):
result: dict[str, Any] = {}
for k, v in obj.items():
key_str = str(k)
result[key_str] = _decode_value(v)
return result
if isinstance(obj, list):
return [_decode_value(item) for item in obj]
if isinstance(obj, cbor2.CBORTag):
return {"__cbor_tag__": obj.tag, "value": _decode_value(obj.value)}
# Handle cbor2 break marker
type_name = type(obj).__name__
if "break_marker" in type_name:
return "__CBOR_BREAK__"
# int, float, bool, str, None are already JSON-safe
return obj
def _remap_keys(obj: Any, key_map: dict[int, str] | None = None) -> Any:
"""Apply human-readable key names to a decoded dict."""
if not isinstance(obj, dict) or not key_map:
return obj
result: dict[str, Any] = {}
for k, v in obj.items():
try:
int_key = int(k)
name = key_map.get(int_key, k)
except (ValueError, TypeError):
name = k
result[name] = v
return result
# ════════════════════════════════════════════════════════════════
# High-level MSL message mapping
# ════════════════════════════════════════════════════════════════
def _map_authdata(authdata: Any) -> Any:
"""Apply known field name mappings to entity auth data."""
if not isinstance(authdata, dict):
return authdata
result: dict[str, Any] = {}
for k, v in authdata.items():
try:
int_key = int(k)
name = MSL_AUTHDATA_KEYS.get(int_key, k)
except (ValueError, TypeError):
name = k
result[name] = v
return result
def _map_entity_auth(entity_auth: Any) -> Any:
"""Map entity auth fields (key 34)."""
if not isinstance(entity_auth, dict):
return entity_auth
mapped = _remap_keys(entity_auth, MSL_ENTITY_AUTH_KEYS)
if "authdata" in mapped and isinstance(mapped["authdata"], dict):
mapped["authdata"] = _map_authdata(mapped["authdata"])
return mapped
def _map_header(header: Any) -> Any:
"""Map header fields (key 32)."""
if not isinstance(header, dict):
return header
return _remap_keys(header, MSL_HEADER_KEYS)
def _map_payload(payload: Any) -> Any:
"""Map payload / key exchange fields (key 33)."""
if not isinstance(payload, dict):
return payload
return _remap_keys(payload, MSL_PAYLOAD_KEYS)
def _apply_msl_field_names(decoded: dict[str, Any]) -> dict[str, Any]:
"""Apply all known MSL field name mappings to a decoded message."""
result: dict[str, Any] = {}
for k, v in decoded.items():
try:
int_key = int(k)
name = MSL_TOP_KEYS.get(int_key, k)
except (ValueError, TypeError):
name = k
if name == "header":
v = _map_header(v)
elif name == "payload":
v = _map_payload(v)
elif name == "entityauthdata":
v = _map_entity_auth(v)
result[name] = v
return result
# ════════════════════════════════════════════════════════════════
# Format detection
# ════════════════════════════════════════════════════════════════
def _is_gzip(data: bytes) -> bool:
"""Check for gzip magic bytes."""
return len(data) >= 2 and data[0] == 0x1F and data[1] == 0x8B
def _is_cbor(data: bytes) -> bool:
"""Heuristic: CBOR self-describe tag (0xD9D9F7) or common CBOR map prefix."""
if len(data) < 2:
return False
# Self-describe tag
if data[:3] == b"\xd9\xd9\xf7":
return True
# Major type 5 (map) with various lengths
first = data[0]
major = first >> 5
return major == 5 # map
def _is_json(data: bytes) -> bool:
"""Check if data looks like JSON."""
stripped = data.lstrip()
return len(stripped) > 0 and stripped[0:1] in (b"{", b"[")
# ════════════════════════════════════════════════════════════════
# Streaming CBOR decoder (for responses with multiple items)
# ════════════════════════════════════════════════════════════════
def _decode_cbor_stream(data: bytes) -> list[Any]:
"""Decode potentially concatenated CBOR items from a byte stream."""
buf = io.BytesIO(data)
items: list[Any] = []
decoder = cbor2.CBORDecoder(buf)
while buf.tell() < len(data):
try:
item = decoder.decode()
items.append(item)
except Exception:
break
return items
# ════════════════════════════════════════════════════════════════
# Public API
# ════════════════════════════════════════════════════════════════
def decode_msl_message(
raw: bytes,
*,
apply_names: bool = True,
) -> dict[str, Any]:
"""Decode a Netflix MSL message from raw bytes.
Handles:
- Gzip-compressed data (responses are often gzipped)
- CBOR-encoded messages (iOS)
- JSON-encoded messages (Chrome / Android)
- URL-encoded form data (iosui endpoints)
- Nested CBOR within byte string values
- Encrypted payloads (returned as hex strings)
Args:
raw: Raw bytes from the captured request/response body.
apply_names: If True, map numeric CBOR keys to human-readable names.
Returns:
A dict with keys:
- ``format``: ``"cbor"``, ``"json"``, or ``"unknown"``
- ``compressed``: bool, whether gzip was detected
- ``messages``: list of decoded message dicts
"""
if not raw:
return {"format": "unknown", "compressed": False, "messages": []}
compressed = _is_gzip(raw)
data = raw
if compressed:
try:
data = gzip.decompress(raw)
except Exception:
return {
"format": "unknown",
"compressed": True,
"messages": [],
"error": "gzip decompression failed",
}
# ── Try CBOR ──
if _is_cbor(data):
items = _decode_cbor_stream(data)
if items:
messages = []
for item in items:
decoded = _decode_value(item)
if apply_names and isinstance(decoded, dict):
decoded = _apply_msl_field_names(decoded)
messages.append(decoded)
return {
"format": "cbor",
"compressed": compressed,
"messages": messages,
}
# ── Try JSON (Chrome / Android MSL) ──
if _is_json(data):
try:
text = data.decode("utf-8", errors="replace")
except Exception:
text = ""
# MSL JSON can be newline-delimited (header + payload chunks)
messages = []
for line in text.split("\n"):
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
if isinstance(obj, dict):
messages.append(obj)
except json.JSONDecodeError:
continue
if not messages:
# Try as single JSON object
try:
single = json.loads(text)
if isinstance(single, dict):
messages = [single]
elif isinstance(single, list):
messages = [{"__array__": single}]
except json.JSONDecodeError:
pass
if messages:
return {
"format": "json",
"compressed": compressed,
"messages": messages,
}
# ── Fallback: try CBOR anyway (no self-describe tag) ──
try:
items = _decode_cbor_stream(data)
if items:
messages = []
for item in items:
decoded = _decode_value(item)
if apply_names and isinstance(decoded, dict):
decoded = _apply_msl_field_names(decoded)
messages.append(decoded)
return {
"format": "cbor",
"compressed": compressed,
"messages": messages,
}
except Exception:
pass
# ── Unknown format ──
return {
"format": "unknown",
"compressed": compressed,
"messages": [],
"raw_preview": data[:256].hex(),
}
def decode_msl_file(path: str, *, apply_names: bool = True) -> dict[str, Any]:
"""Convenience: decode from a file path."""
with open(path, "rb") as f:
return decode_msl_message(f.read(), apply_names=apply_names)
def decode_to_json(raw: bytes, *, apply_names: bool = True, indent: int = 2) -> str:
"""Decode and return a JSON string."""
result = decode_msl_message(raw, apply_names=apply_names)
return json.dumps(result, indent=indent, ensure_ascii=False, default=str)
# ════════════════════════════════════════════════════════════════
# CLI
# ════════════════════════════════════════════════════════════════
def main() -> None:
"""Command-line entry point for decoding MSL binary files."""
import sys
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} <file.bin> [file2.bin ...]", file=sys.stderr)
sys.exit(1)
for path in sys.argv[1:]:
if len(sys.argv) > 2:
print(f"=== {path} ===")
result = decode_msl_file(path)
print(json.dumps(result, indent=2, ensure_ascii=False, default=str))
if len(sys.argv) > 2:
print()
if __name__ == "__main__":
main()