Files
revkit/tools/decrypt_capture.py
T
tkgstrator 1d06301d11 feat(tweak): NetflixSSLBypassをos_log対応&ロード監視強化、CBOR MSL復号支援
- NetflixSSLBypass Tweakを大幅簡略化し、ロード時のos_log出力・UIViewControllerフックログを追加
- C/Swift分離で初期化・Orion安定化、ロード検知精度を向上
- ログ監視エージェント(log-monitor)を新設し、Frida/mitmproxy/Tweakの3系統ログを横断監視・復号状況レポート可能に
- iOS用CBOR MSLメッセージの復号・エンコードパイプラインと検証CLIを新規実装
- Theosビルド/デバイス接続/ファイル分割等の運用ルールを明文化し、計画・レポート雛形もアップデート

Netflix iOS解析の自動化・再現性向上、及び復号パイプラインのクロスプラットフォーム化を目的とする
2026-04-07 06:36:03 +00:00

309 lines
9.5 KiB
Python

#!/usr/bin/env python3
"""decrypt_capture.py — MSL キャプチャファイルのオフライン復号 CLI
Frida / mitmproxy でキャプチャしたバイナリファイルと鍵素材を入力に、
AES-CBC 復号した結果を出力する。
使い方:
python tools/decrypt_capture.py --keys keys.json --input capture.bin
python tools/decrypt_capture.py --keys keys.json --input capture.bin --output decrypted.json
python tools/decrypt_capture.py --enc-key <hex> --sign-key <hex> --input capture.bin
python tools/decrypt_capture.py --keys keys.json --input-dir ./captures/
python tools/decrypt_capture.py --keys keys.json --input capture.bin --format raw
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
# src/ をモジュール検索パスに追加
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from netflix_msl.crypto import NetflixCrypto
def build_crypto(args: argparse.Namespace) -> NetflixCrypto:
"""引数から NetflixCrypto インスタンスを構築して返す."""
crypto = NetflixCrypto()
if args.keys:
crypto.import_keys_from_file(str(args.keys))
elif args.enc_key and args.sign_key:
enc = bytes.fromhex(args.enc_key)
sign = bytes.fromhex(args.sign_key)
crypto.import_session_keys(enc, sign)
else:
print(
"[!] --keys または --enc-key / --sign-key のいずれかを指定してください",
file=sys.stderr,
)
sys.exit(1)
return crypto
def verify_hmac(crypto: NetflixCrypto, data: bytes, expected_hmac: bytes) -> bool:
"""HMAC-SHA256 を検証する.
data に対して sign() を実行し expected_hmac と比較する。
sign() は文字列 → bytes.decode() 前提なので、ここでは低レベルで計算する。
"""
import hashlib
import hmac as hmac_mod
if crypto.sign_key is None:
return False
computed = hmac_mod.new(crypto.sign_key, data, hashlib.sha256).digest()
return hmac_mod.compare_digest(computed, expected_hmac)
def try_cbor_decode(data: bytes) -> dict | None:
"""CBOR デコードを試みる. 失敗時は None を返す."""
try:
import cbor2
return cbor2.loads(data)
except Exception:
return None
def try_decrypt_payload(crypto: NetflixCrypto, data: bytes) -> bytes | None:
"""MSL ペイロード (IV + CT 形式) または生 AES-CBC の復号を試みる.
まず CBOR デコードして iv / ciphertext フィールドを取得。
失敗した場合は先頭 16 bytes を IV として残りを CT とみなして復号する。
"""
import base64
cbor_obj = try_cbor_decode(data)
if cbor_obj is not None and isinstance(cbor_obj, dict):
# CBOR ペイロード: {"iv": bytes, "ciphertext": bytes} または Base64 文字列
iv_raw = cbor_obj.get("iv") or cbor_obj.get(b"iv")
ct_raw = cbor_obj.get("ciphertext") or cbor_obj.get(b"ciphertext")
if iv_raw is None or ct_raw is None:
return None
# Base64 文字列の場合はデコード
if isinstance(iv_raw, str):
iv_raw = base64.b64decode(iv_raw)
if isinstance(ct_raw, str):
ct_raw = base64.b64decode(ct_raw)
return crypto.decrypt(bytes(ct_raw), bytes(iv_raw))
# 生バイナリ: 先頭 16 bytes = IV、残り = CT
if len(data) < 32 or len(data) % 16 != 0:
return None
iv = data[:16]
ct = data[16:]
return crypto.decrypt(ct, iv)
def decrypt_file(
crypto: NetflixCrypto,
path: Path,
) -> tuple[bytes | None, str | None]:
"""ファイルを復号して (plaintext, error_message) を返す.
復号に成功した場合 error_message は None。
"""
raw = path.read_bytes()
# --- HMAC 検証 (末尾 32 bytes を HMAC として扱う) ---
hmac_warning: str | None = None
if len(raw) > 32:
body = raw[:-32]
tail = raw[-32:]
if not verify_hmac(crypto, body, tail):
hmac_warning = f"[W] {path.name}: HMAC-SHA256 検証失敗 (処理は続行)"
plaintext = try_decrypt_payload(crypto, raw)
if plaintext is None:
return None, f"[E] {path.name}: 復号失敗 (フォーマット不一致または鍵不一致)"
return plaintext, hmac_warning
def format_output(plaintext: bytes, fmt: str) -> str:
"""復号結果を指定フォーマットに変換する."""
if fmt == "hex":
return plaintext.hex()
if fmt == "raw":
# バイナリをそのまま返す (呼び出し側で bytes.write を使う)
return plaintext.decode("latin-1")
# json (default)
# JSON として解釈を試みる
try:
obj = json.loads(plaintext)
return json.dumps(obj, ensure_ascii=False, indent=2)
except (json.JSONDecodeError, UnicodeDecodeError):
pass
# CBOR として解釈を試みる
cbor_obj = try_cbor_decode(plaintext)
if cbor_obj is not None:
try:
return json.dumps(cbor_obj, ensure_ascii=False, indent=2, default=repr)
except Exception:
pass
# どちらでもない場合は hex で返す
return plaintext.hex()
def process_file(
crypto: NetflixCrypto,
path: Path,
fmt: str,
output_path: Path | None,
) -> bool:
"""単一ファイルを処理する. 成功時 True を返す."""
plaintext, error = decrypt_file(crypto, path)
if error and plaintext is None:
print(error, file=sys.stderr)
return False
if error:
# HMAC 警告
print(error, file=sys.stderr)
assert plaintext is not None
result = format_output(plaintext, fmt)
if output_path is not None:
if fmt == "raw":
output_path.write_bytes(plaintext)
else:
output_path.write_text(result, encoding="utf-8")
print(f"[+] {path.name} -> {output_path}", file=sys.stderr)
else:
if fmt == "raw":
sys.stdout.buffer.write(plaintext)
else:
print(result)
return True
def main() -> None:
parser = argparse.ArgumentParser(
description="MSL キャプチャファイルのオフライン復号",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
# 鍵素材
key_group = parser.add_argument_group("鍵素材 (いずれか必須)")
key_group.add_argument(
"--keys",
type=Path,
metavar="FILE",
help='Frida が出力した鍵 JSON ファイル ({"enc_key": "<hex>", "sign_key": "<hex>"})',
)
key_group.add_argument(
"--enc-key",
metavar="HEX",
help="AES-128 暗号化鍵を hex で直接指定",
)
key_group.add_argument(
"--sign-key",
metavar="HEX",
help="HMAC-SHA256 署名鍵を hex で直接指定",
)
# 入力
input_group = parser.add_argument_group("入力 (いずれか必須)")
input_ex = input_group.add_mutually_exclusive_group(required=True)
input_ex.add_argument(
"--input",
type=Path,
metavar="FILE",
help="単一の .bin ファイル",
)
input_ex.add_argument(
"--input-dir",
type=Path,
metavar="DIR",
help="ディレクトリ内の全 .bin ファイルを処理",
)
# 出力
parser.add_argument(
"--output",
type=Path,
metavar="FILE",
help="出力先ファイル (省略時は stdout)。--input-dir 使用時は無視される",
)
parser.add_argument(
"--format",
choices=["json", "raw", "hex"],
default="json",
help="出力フォーマット (default: json)",
)
args = parser.parse_args()
# 鍵素材の相互排他チェック
if args.keys and (args.enc_key or args.sign_key):
parser.error("--keys と --enc-key/--sign-key は同時に指定できません")
if not args.keys and not (args.enc_key and args.sign_key):
parser.error("--keys か --enc-key と --sign-key の両方を指定してください")
crypto = build_crypto(args)
# 入力ファイルの収集
if args.input is not None:
if not args.input.exists():
print(f"[!] ファイルが見つかりません: {args.input}", file=sys.stderr)
sys.exit(1)
files = [args.input]
output_path = args.output
else:
if not args.input_dir.is_dir():
print(
f"[!] ディレクトリが見つかりません: {args.input_dir}", file=sys.stderr
)
sys.exit(1)
files = sorted(args.input_dir.glob("*.bin"))
if not files:
print(
f"[!] .bin ファイルが見つかりません: {args.input_dir}", file=sys.stderr
)
sys.exit(1)
output_path = None # ディレクトリ処理時は stdout に個別出力
success = 0
failure = 0
for path in files:
if args.input_dir is not None and len(files) > 1:
# 複数ファイル処理: ファイル名をヘッダとして表示
print(f"\n=== {path.name} ===", file=sys.stderr)
ok = process_file(crypto, path, args.format, output_path)
if ok:
success += 1
else:
failure += 1
if len(files) > 1:
print(
f"\n[*] 完了: {success} 成功 / {failure} 失敗 (合計 {len(files)} ファイル)",
file=sys.stderr,
)
if failure > 0:
sys.exit(1)
if __name__ == "__main__":
main()