mirror of
https://github.com/qtmleap/revkit.git
synced 2026-09-16 08:11:51 +02:00
- 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>
469 lines
15 KiB
Python
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()
|