#!/usr/bin/env python3 """Netflix Cronet HTTP キャプチャ — Frida Python API. hook_cronet.js を Frida Python API で起動し、 リクエスト/レスポンスを紐付けて保存する。 Architecture: JS (hook_cronet.js) Python (このファイル) ────────────────── ──────────────────── CronetUrlRequest.start() on_message(req) → send({type:"req",...}) → pending[reqId] に保存 onSucceeded on_message(resp) → send({type:"resp",...}) → pending[reqId] と結合 → .md ファイルに保存 → capture.jsonl に追記 onFailed → send({type:"resp", 同上 (error フィールド付き) error:...}) Output: logs/android_YYYYMMDD/cronet/ {domain}/ {path_seg1}/ {path_seg2}/ 0001.md ← Request + Response 統合 (Proxyman 風) 0001_req_plain.json ← MSL 復号済リクエスト (アプリ層 JSON) 0001_resp_plain.json← MSL 復号済レスポンス (CBOR→GZIP→JSON) capture.jsonl ← 全イベント (raw) Usage: python run_cronet.py # デフォルト hook_cronet.js python run_cronet.py my_hook.js # カスタムスクリプト Ctrl+C で停止 """ from __future__ import annotations import base64 import gzip import json import os import re import signal import sys import threading import time from datetime import datetime from pathlib import Path from urllib.parse import urlparse import cbor2 import frida import frida_tools from dotenv import load_dotenv load_dotenv() # ─── Helpers ───────────────────────────────────────────── def sanitize(name: str, max_len: int = 80) -> str: """ファイル名に使えない文字を _ に置換.""" return re.sub(r"[^\w.\-]", "_", name)[:max_len] def load_java_bridge() -> str: """frida-tools の Java ブリッジソースを読み込む. create_script() は素の JS ランタイムを作成するため、 Java グローバルが存在しない。frida CLI が使用するのと同じ ブリッジファイルを先頭に付加して Java API を有効化する。 """ bridges_dir = Path(frida_tools.__file__).parent / "bridges" java_js = bridges_dir / "java.js" if not java_js.exists(): raise FileNotFoundError(f"Java bridge not found: {java_js}") source = java_js.read_text(encoding="utf-8") # java.js は `var bridge=function(){...}();` 形式。 # globalThis.Java に割り当てる。 return ( source + "\n" "Object.defineProperty(globalThis, 'Java', {" " value: bridge, writable: false, configurable: true" "});\n" ) def fmt_size(n: int | str) -> str: """バイト数を人間可読に.""" n = int(n) if n else 0 if n < 1024: return f"{n}B" if n < 1024 * 1024: return f"{n / 1024:.1f}KB" return f"{n / (1024 * 1024):.1f}MB" def is_text_content(ct: str) -> bool: """Content-Type がテキスト系か.""" return any(t in ct for t in ("json", "text", "xml", "html", "javascript", "x-www-form-urlencoded")) def pretty_json(text: str) -> str | None: """JSON 文字列を整形。失敗時は None.""" try: return json.dumps(json.loads(text), ensure_ascii=False, indent=2) except (json.JSONDecodeError, TypeError): return None # ─── Core ──────────────────────────────────────────────── class CronetCapture: """Frida Python API を使った Cronet HTTP キャプチャ.""" # 無視するドメイン(コンソール出力・ファイル保存ともにスキップ) _IGNORE_DOMAINS: set[str] = { "android14.logs.netflix.com", } def __init__(self, host: str, bundle_id: str, script_path: str): self.host = host self.bundle_id = bundle_id self.script_path = script_path # セッションディレクトリ session_id = datetime.now().strftime("%Y%m%d") self.session_dir = Path("logs") / f"android_{session_id}" self.session_dir.mkdir(parents=True, exist_ok=True) self.capture_file = self.session_dir / "capture.jsonl" # Frida ハンドル self._device: frida.core.Device | None = None self._session: frida.core.Session | None = None self._script: frida.core.Script | None = None self._pid: int | None = None # リクエスト追跡 self._pending: dict[str, dict] = {} # reqId → request info self._seq = 0 # グローバル連番 self._domain_seq: dict[str, int] = {} # domain → 連番 # エラー重複抑制 self._error_counts: dict[str, int] = {} # url_pattern → count # MSL 平文トラッキング self._msl_req_queue: dict[str, list[bytes]] = {} # url_pattern → [body, ...] self._msl_last_save: dict | None = None # {dir, base, url} self._msl_decrypt_buf: list[bytes] = [] # 復号チャンク蓄積 # 統計 self._stats = {"requests": 0, "responses": 0, "errors": 0, "bytes": 0, "msl_decrypted": 0} # 停止イベント self._stop = threading.Event() # ─── ライフサイクル ─── def start(self): """デバイスに接続し、アプリを起動してキャプチャを開始.""" print(f"[*] Connecting to {self.host}...") mgr = frida.get_device_manager() self._device = mgr.add_remote_device(self.host) print(f"[*] Spawning {self.bundle_id}...") self._pid = self._device.spawn([self.bundle_id]) print(f"[*] Attaching to PID {self._pid}...") self._session = self._device.attach(self._pid) self._session.on("detached", self._on_detached) print(f"[*] Resuming app (waiting for Java VM)...") self._device.resume(self._pid) time.sleep(2) print(f"[*] Loading Java bridge + {self.script_path}...") bridge = load_java_bridge() user_source = Path(self.script_path).read_text() source = bridge + "\n" + user_source self._script = self._session.create_script(source, runtime="v8") self._script.on("message", self._on_message) self._script.load() print(f"[*] Saving → {self.session_dir}") print(f"[*] Press Ctrl+C to stop\n") # メインスレッドをブロック self._stop.wait() def stop(self): """クリーンシャットダウン.""" if self._stop.is_set(): return self._stop.set() print(f"\n[*] Stopping...") try: if self._script: self._script.unload() except Exception: pass try: if self._session: self._session.detach() except Exception: pass try: if self._device and self._pid: self._device.kill(self._pid) except Exception: pass self._print_summary() def _on_detached(self, reason, crash): print(f"\n[*] Detached: {reason}") if crash: print(f"[!] Crash: {crash}") self._stop.set() # ─── メッセージハンドラ ─── def _on_message(self, message: dict, data: bytes | None): msg_type = message.get("type") if msg_type == "send": self._handle_event(message["payload"], data) elif msg_type == "log": level = message.get("level", "info") text = message.get("payload", "") if level == "error": print(f" [!] {text}", file=sys.stderr) else: print(f" {text}") elif msg_type == "error": desc = message.get("description", "") print(f" [!] Script error: {desc}", file=sys.stderr) def _handle_event(self, payload: dict, data: bytes | None): evt = payload.get("type") domain = payload.get("domain", "") # 無視リストに該当するドメインは完全スキップ if any(domain.endswith(d) for d in self._IGNORE_DOMAINS): return if evt == "req": self._on_request(payload) elif evt == "resp": self._on_response(payload) elif evt == "redirect": self._on_redirect(payload) elif evt == "msl_req": self._on_msl_request(payload) elif evt == "msl_decrypt": self._on_msl_decrypt(payload) # capture.jsonl に全イベントを記録 self._append_jsonl(payload) # ─── イベント処理 ─── def _on_request(self, p: dict): req_id = p["reqId"] self._pending[req_id] = { "reqId": req_id, "url": p.get("url"), "method": p.get("method", "GET"), "domain": p.get("domain", "unknown"), "headers": p.get("headers", {}), "hasBody": p.get("hasBody", False), "req_body_b64": p.get("req_body_b64"), "ts": p.get("ts"), } self._stats["requests"] += 1 method = p.get("method", "?") url = p.get("url", "?") ct = (p.get("headers") or {}).get("Content-Type", (p.get("headers") or {}).get("content-type", "")) enc = (p.get("headers") or {}).get("Content-Encoding", (p.get("headers") or {}).get("content-encoding", "")) detail = f" [{enc}]" if enc else (f" ({ct})" if ct else "") print(f" -> {method} {url}{detail}") def _on_response(self, p: dict): req_id = p["reqId"] req = self._pending.pop(req_id, None) if not req: # req が届く前に resp が来た場合のフォールバック req = { "reqId": req_id, "url": p.get("url", "unknown"), "method": p.get("method", "?"), "domain": p.get("domain", "unknown"), "headers": {}, "ts": p.get("ts"), } # ボディをデコード body_bytes: bytes | None = None if p.get("body_b64"): try: body_bytes = base64.b64decode(p["body_b64"]) except Exception: pass status = p.get("statusCode") error = p.get("error") proto = p.get("protocol", "?") body_size = len(body_bytes) if body_bytes else int(p.get("bodySize", 0) or 0) resp_headers = p.get("responseHeaders") or {} # cURL ファイルに保存 self._seq += 1 self._save_request( seq=self._seq, req=req, status=status, status_text=p.get("statusText", ""), protocol=proto, resp_headers=resp_headers, body=body_bytes, body_size=body_size, error=error, ) # コンソール出力 if error: self._stats["errors"] += 1 url_pat = self._url_pattern(req.get("url", "")) self._error_counts[url_pat] = self._error_counts.get(url_pat, 0) + 1 cnt = self._error_counts[url_pat] if cnt <= 3: print(f" ✗ {req['method']} {req['url']}") print(f" {error}") elif cnt == 4: print(f" (以降同一パターンのエラーは省略)") else: self._stats["responses"] += 1 self._stats["bytes"] += body_size body_preview = "" if body_bytes and body_size < 4096: ct = resp_headers.get("content-type", "") if is_text_content(ct): try: text = body_bytes.decode("utf-8") bp = text[:120] if len(text) > 120: bp += "..." body_preview = f"\n {bp}" except UnicodeDecodeError: pass print(f" <- {status} {req['method']} {req['url']}" f" ({fmt_size(body_size)}){body_preview}") def _on_redirect(self, p: dict): print(f" ~> {p.get('statusCode')} {p.get('url')} -> {p.get('newUrl')}") # ─── MSL 平文キャプチャ ─── def _on_msl_request(self, p: dict): """ApiHandlerImpl.apiRequest からの MSL リクエスト平文.""" url = p.get("url", "?") body_b64 = p.get("body_b64") body = base64.b64decode(body_b64) if body_b64 else None size = len(body) if body else 0 url_key = self._url_pattern(url) self._msl_req_queue.setdefault(url_key, []).append(body) print(f" \U0001f513 MSL req plaintext {url} ({fmt_size(size)})") def _on_msl_decrypt(self, p: dict): """WidevineCryptoContext.c からの復号済み MSL ペイロードチャンク.""" b64 = p.get("plaintext_b64") if not b64: return plaintext = base64.b64decode(b64) # CBOR デコード → key 62 (GZIP データ) → 展開 try: chunk = cbor2.loads(plaintext) except Exception: # CBOR デコード失敗時は生データを保存 self._msl_decrypt_buf.append(plaintext) return end_of_msg = chunk.get(63, False) compressed = chunk.get(62) # GZIP 圧縮済みペイロード if compressed: try: decompressed = gzip.decompress(compressed) self._msl_decrypt_buf.append(decompressed) except Exception: # GZIP 展開失敗 → 生データ self._msl_decrypt_buf.append( compressed if isinstance(compressed, bytes) else plaintext ) if end_of_msg and self._msl_decrypt_buf: combined = b"".join(self._msl_decrypt_buf) self._msl_decrypt_buf.clear() self._stats["msl_decrypted"] += 1 # 最後に保存した MSL リクエストと同じディレクトリに保存 if self._msl_last_save: save_dir = self._msl_last_save["dir"] base = self._msl_last_save["base"] self._save_msl_plaintext(save_dir, base, "resp", combined) url = self._msl_last_save.get("url", "?") print(f" \U0001f513 MSL resp decrypted {url} ({fmt_size(len(combined))})") def _save_msl_plaintext(self, save_dir: Path, base: str, direction: str, data: bytes): """MSL 平文をファイルに保存. JSON なら整形して .json, それ以外は .bin.""" try: text = data.decode("utf-8") parsed = json.loads(text) content = json.dumps(parsed, indent=2, ensure_ascii=False) path = save_dir / f"{base}_{direction}_plain.json" path.write_text(content + "\n") except (UnicodeDecodeError, json.JSONDecodeError): path = save_dir / f"{base}_{direction}_plain.bin" path.write_bytes(data) # ─── ファイル保存 ─── def _save_request(self, seq: int, req: dict, status, status_text: str, protocol: str, resp_headers: dict, body: bytes | None, body_size: int, error: str | None): domain = req.get("domain", "unknown") url = req.get("url", "unknown") method = req.get("method", "GET") req_headers = req.get("headers") or {} # URL パスに基づくネストされたディレクトリ構造 parsed = urlparse(url) path_segments = [s for s in parsed.path.strip("/").split("/") if s] save_dir = self.session_dir / sanitize(domain) for seg in path_segments: save_dir = save_dir / sanitize(seg) save_dir.mkdir(parents=True, exist_ok=True) # パス単位で連番管理 path_key = f"{domain}/{'/'.join(path_segments) or '/'}" self._domain_seq[path_key] = self._domain_seq.get(path_key, 0) + 1 dseq = self._domain_seq[path_key] base_name = f"{dseq:04d}" lines: list[str] = [] # ── Request セクション ── lines.append(f"## Request") lines.append("") lines.append(f"```") lines.append(f"{method} {parsed.path}{'?' + parsed.query if parsed.query else ''} {protocol}") lines.append(f"Host: {parsed.netloc}") for k, v in req_headers.items(): lines.append(f"{k}: {v}") lines.append(f"```") # リクエストボディ req_body_bytes: bytes | None = None req_body_b64 = req.get("req_body_b64") if req_body_b64: try: req_body_bytes = base64.b64decode(req_body_b64) except Exception: pass if req_body_bytes: ct = req_headers.get("Content-Type", req_headers.get("content-type", "")) lines.append("") lines.append(f"### Body ({fmt_size(len(req_body_bytes))})") lines.append("") if is_text_content(ct): try: text = req_body_bytes.decode("utf-8") pretty = pretty_json(text) lang = "json" if pretty else "" lines.append(f"```{lang}") lines.append(pretty or text) lines.append("```") except UnicodeDecodeError: lines.append(f"*Binary data — {fmt_size(len(req_body_bytes))}*") else: lines.append(f"*Binary data — {fmt_size(len(req_body_bytes))}*") elif req.get("hasBody"): lines.append("") lines.append(f"### Body") lines.append("") lines.append(f"*Body not captured*") # MSL リクエスト平文 req_enc = req_headers.get("Content-Encoding", req_headers.get("content-encoding", "")) if "msl" in req_enc.lower(): url_key = self._url_pattern(url) queue = self._msl_req_queue.get(url_key, []) msl_body = queue.pop(0) if queue else None if msl_body: self._save_msl_plaintext(save_dir, base_name, "req", msl_body) # インラインにも追加 lines.append("") lines.append(f"### MSL Plaintext (decrypted request)") lines.append("") try: text = msl_body.decode("utf-8") pretty = pretty_json(text) lines.append(f"```json") lines.append(pretty or text) lines.append("```") except UnicodeDecodeError: lines.append(f"*Binary — {fmt_size(len(msl_body))}*") # レスポンス decrypt の紐付け用に保存先を記録 self._msl_last_save = { "dir": save_dir, "base": base_name, "url": url } # ── Response セクション ── lines.append("") lines.append("---") lines.append("") if error: lines.append(f"## Response — ERROR") lines.append("") lines.append(f"```") lines.append(error) lines.append(f"```") elif status is not None: lines.append(f"## Response") lines.append("") lines.append(f"```") lines.append(f"{protocol} {status} {status_text}") for k, v in resp_headers.items(): lines.append(f"{k}: {v}") lines.append(f"```") if body: ct = resp_headers.get("content-type", "") lines.append("") lines.append(f"### Body ({fmt_size(body_size)})") lines.append("") if is_text_content(ct): try: text = body.decode("utf-8") pretty = pretty_json(text) lang = "json" if pretty else "" lines.append(f"```{lang}") lines.append(pretty or text) lines.append("```") except UnicodeDecodeError: lines.append(f"*Binary data — {fmt_size(body_size)}*") else: lines.append(f"*Binary data — {fmt_size(body_size)}*") # ── メタデータ ── lines.append("") lines.append("---") lines.append("") lines.append(f"") # .md ファイル書き出し md_path = save_dir / f"{base_name}.md" md_path.write_text("\n".join(lines) + "\n") # ─── ユーティリティ ─── def _url_pattern(self, url: str) -> str: """URL をパターン化 (重複抑制用).""" try: p = urlparse(url) return f"{p.netloc}{p.path}" except Exception: return url def _append_jsonl(self, payload: dict): """capture.jsonl にイベントを追記.""" with open(self.capture_file, "a") as f: f.write(json.dumps(payload, ensure_ascii=False) + "\n") def _print_summary(self): s = self._stats print(f"\n{'=' * 60}") print(f" Capture Summary") print(f" Requests: {s['requests']}") print(f" Responses: {s['responses']}") print(f" MSL Decrypted: {s['msl_decrypted']}") print(f" Errors: {s['errors']}") print(f" Data: {fmt_size(s['bytes'])}") print(f" Saved to: {self.session_dir}") print() for domain_dir in sorted(self.session_dir.iterdir()): if domain_dir.is_dir(): md_files = list(domain_dir.rglob("*.md")) plain_files = list(domain_dir.rglob("*_plain.*")) if md_files: extra = f", {len(plain_files)} decrypted" if plain_files else "" print(f" {domain_dir.name}/ ({len(md_files)} files{extra})") for f in sorted(md_files)[:8]: rel = f.relative_to(domain_dir) print(f" {rel}") if len(md_files) > 8: print(f" ... and {len(md_files) - 8} more") # エラー頻度の高い URL パターン if self._error_counts: print() print(f" Error patterns:") for pat, cnt in sorted(self._error_counts.items(), key=lambda x: -x[1])[:5]: print(f" {cnt:>4}x {pat}") print(f"{'=' * 60}") # ─── Main ──────────────────────────────────────────────── def main(): script = sys.argv[1] if len(sys.argv) > 1 else "hook_cronet.js" host = os.getenv("ANDROID_HOST", "192.168.0.37") bundle_id = "com.netflix.mediaclient" if not Path(script).exists(): print(f"[!] Script not found: {script}") sys.exit(1) print(f"[*] Platform: Android (Frida Python API)") print(f"[*] Host: {host}") print(f"[*] Target: {bundle_id}") print(f"[*] Script: {script}") capture = CronetCapture(host, bundle_id, script) def shutdown(sig, frame): capture.stop() signal.signal(signal.SIGINT, shutdown) signal.signal(signal.SIGTERM, shutdown) try: capture.start() except KeyboardInterrupt: pass except frida.ServerNotRunningError: print(f"[!] Frida server is not running on {host}") print(f" Start frida-server on the device first.") sys.exit(1) except frida.ProcessNotFoundError: print(f"[!] Could not spawn {bundle_id}") sys.exit(1) except Exception as e: print(f"[!] {type(e).__name__}: {e}") finally: capture.stop() if __name__ == "__main__": main()