From 6b006d47f9f894e14f74fbcd49293a731103ddf4 Mon Sep 17 00:00:00 2001 From: tkgstrator <29420801+tkgstrator@users.noreply.github.com> Date: Tue, 7 Apr 2026 02:07:10 +0000 Subject: [PATCH] =?UTF-8?q?feat(frida,mitmproxy):=20Frida=E3=83=95?= =?UTF-8?q?=E3=83=83=E3=82=AF=E6=94=B9=E5=96=84=E3=81=A8mitmproxy=E3=82=AD?= =?UTF-8?q?=E3=83=A3=E3=83=97=E3=83=81=E3=83=A3=E3=82=B9=E3=82=AF=E3=83=AA?= =?UTF-8?q?=E3=83=97=E3=83=88=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- packages/frida/lldb_rsa_hook.py | 197 +++++ packages/frida/package-lock.json | 604 +++++++++++++ packages/frida/package.json | 16 + packages/frida/src/android/ale.ts | 235 +++++ packages/frida/src/android/config.ts | 2 + packages/frida/src/android/esn-override.ts | 163 ++++ packages/frida/src/android/http.ts | 157 ++++ packages/frida/src/android/index.ts | 34 + packages/frida/src/android/msl-crypto.ts | 541 ++++++++++++ packages/frida/src/android/msl-state.ts | 8 + packages/frida/src/android/msl.ts | 649 ++++++++++++++ packages/frida/src/android/ssl-capture.ts | 186 ++++ packages/frida/src/android/ssl-pinning.ts | 51 ++ packages/frida/src/android/storage-dump.ts | 245 ++++++ packages/frida/src/android/utils.ts | 71 ++ packages/frida/src/android/widevine-drm.ts | 168 ++++ .../src/android/widevine-key-exchange.ts | 76 ++ .../frida/src/chrome/cdm-host-callback.ts | 79 ++ packages/frida/src/chrome/index.ts | 45 + packages/frida/src/chrome/key-scan-only.ts | 251 ++++++ .../frida/src/chrome/private-key-extractor.ts | 237 +++++ packages/frida/src/chrome/widevine-cdm.ts | 404 +++++++++ packages/frida/src/common/base64.ts | 79 ++ packages/frida/src/common/lzw.ts | 62 ++ packages/frida/src/common/msl-processor.ts | 586 +++++++++++++ packages/frida/src/common/utils.ts | 65 ++ packages/frida/src/frida-env.d.ts | 23 + packages/frida/src/ios/ale.ts | 181 ++++ packages/frida/src/ios/anti-detection.ts | 185 ++++ packages/frida/src/ios/crypto.ts | 84 ++ packages/frida/src/ios/detect-antifrida.ts | 346 ++++++++ packages/frida/src/ios/http.ts | 542 ++++++++++++ packages/frida/src/ios/index.ts | 57 ++ packages/frida/src/ios/investigate.ts | 5 + packages/frida/src/ios/msl-crypto.ts | 237 +++++ packages/frida/src/ios/msl-trace.ts | 116 +++ packages/frida/src/ios/msl.ts | 173 ++++ packages/frida/src/ios/search-strings.ts | 87 ++ packages/frida/src/ios/ssl-capture.ts | 77 ++ packages/frida/src/ios/ssl-pinning.ts | 172 ++++ packages/frida/src/ios/storage-dump.ts | 515 +++++++++++ packages/frida/tsconfig.json | 15 + packages/mitmproxy/msl_decoder.py | 468 ++++++++++ packages/mitmproxy/netflix_ios_capture.py | 267 ++++++ packages/mitmproxy/netflix_msl_capture.py | 827 ++++++++++++++++++ packages/proxyman/README.md | 122 +++ packages/proxyman/addons/NetflixMSLParser.js | 630 +++++++++++++ packages/proxyman/netflix-ios-raw-capture.js | 13 + .../proxyman/netflix-manifest-http-capture.js | 378 ++++++++ packages/proxyman/netflix-msl-capture.js | 405 +++++++++ 50 files changed, 11136 insertions(+) create mode 100644 packages/frida/lldb_rsa_hook.py create mode 100644 packages/frida/package-lock.json create mode 100644 packages/frida/package.json create mode 100644 packages/frida/src/android/ale.ts create mode 100644 packages/frida/src/android/config.ts create mode 100644 packages/frida/src/android/esn-override.ts create mode 100644 packages/frida/src/android/http.ts create mode 100644 packages/frida/src/android/index.ts create mode 100644 packages/frida/src/android/msl-crypto.ts create mode 100644 packages/frida/src/android/msl-state.ts create mode 100644 packages/frida/src/android/msl.ts create mode 100644 packages/frida/src/android/ssl-capture.ts create mode 100644 packages/frida/src/android/ssl-pinning.ts create mode 100644 packages/frida/src/android/storage-dump.ts create mode 100644 packages/frida/src/android/utils.ts create mode 100644 packages/frida/src/android/widevine-drm.ts create mode 100644 packages/frida/src/android/widevine-key-exchange.ts create mode 100644 packages/frida/src/chrome/cdm-host-callback.ts create mode 100644 packages/frida/src/chrome/index.ts create mode 100644 packages/frida/src/chrome/key-scan-only.ts create mode 100644 packages/frida/src/chrome/private-key-extractor.ts create mode 100644 packages/frida/src/chrome/widevine-cdm.ts create mode 100644 packages/frida/src/common/base64.ts create mode 100644 packages/frida/src/common/lzw.ts create mode 100644 packages/frida/src/common/msl-processor.ts create mode 100644 packages/frida/src/common/utils.ts create mode 100644 packages/frida/src/frida-env.d.ts create mode 100644 packages/frida/src/ios/ale.ts create mode 100644 packages/frida/src/ios/anti-detection.ts create mode 100644 packages/frida/src/ios/crypto.ts create mode 100644 packages/frida/src/ios/detect-antifrida.ts create mode 100644 packages/frida/src/ios/http.ts create mode 100644 packages/frida/src/ios/index.ts create mode 100644 packages/frida/src/ios/investigate.ts create mode 100644 packages/frida/src/ios/msl-crypto.ts create mode 100644 packages/frida/src/ios/msl-trace.ts create mode 100644 packages/frida/src/ios/msl.ts create mode 100644 packages/frida/src/ios/search-strings.ts create mode 100644 packages/frida/src/ios/ssl-capture.ts create mode 100644 packages/frida/src/ios/ssl-pinning.ts create mode 100644 packages/frida/src/ios/storage-dump.ts create mode 100644 packages/frida/tsconfig.json create mode 100644 packages/mitmproxy/msl_decoder.py create mode 100644 packages/mitmproxy/netflix_ios_capture.py create mode 100644 packages/mitmproxy/netflix_msl_capture.py create mode 100644 packages/proxyman/README.md create mode 100644 packages/proxyman/addons/NetflixMSLParser.js create mode 100644 packages/proxyman/netflix-ios-raw-capture.js create mode 100644 packages/proxyman/netflix-manifest-http-capture.js create mode 100644 packages/proxyman/netflix-msl-capture.js diff --git a/packages/frida/lldb_rsa_hook.py b/packages/frida/lldb_rsa_hook.py new file mode 100644 index 0000000..e98050a --- /dev/null +++ b/packages/frida/lldb_rsa_hook.py @@ -0,0 +1,197 @@ +"""lldb Python plugin: BoringSSL RSA_sign breakpoint handler. + +lldb にインポートされ、RSA_sign のブレークポイント時に +RSA 構造体から秘密鍵コンポーネントを読み取る。 +""" + +import lldb +import struct +import json +import os + +OUTPUT_DIR = os.environ.get("RSA_DUMP_DIR", "/tmp/rsa_dump") +dump_count = 0 + + +def read_mem(process, addr, size): + error = lldb.SBError() + data = process.ReadMemory(addr, size, error) + if error.Success(): + return data + return None + + +def read_ptr(process, addr): + data = read_mem(process, addr, 8) + if data: + return struct.unpack(" 128: + return None + bn_data = read_mem(process, d_ptr, width * 8) + if not bn_data: + return None + value = 0 + for i in range(width): + word = struct.unpack("=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC" + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "dev": true, + "license": "MIT" + }, + "node_modules/frida": { + "version": "17.9.1", + "resolved": "https://registry.npmjs.org/frida/-/frida-17.9.1.tgz", + "integrity": "sha512-xLGWkToPwUtGruxHBdCDPb99+MGdIbGtgKtgF284zCzr33lIGX7xSc8/pNMD3e1UAc61HkvFTU/TaLGo047Qxg==", + "dev": true, + "hasInstallScript": true, + "license": "LGPL-2.0 WITH WxWindows-exception-3.1", + "dependencies": { + "bindings": "^1.5.0", + "minimatch": "^10.0.1", + "prebuild-install": "^7.1.3" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/frida-compile": { + "version": "19.0.5", + "resolved": "https://registry.npmjs.org/frida-compile/-/frida-compile-19.0.5.tgz", + "integrity": "sha512-dHkZBswL6zzF63bcqK2AaXOrZTYIk4ZynJhcI1EP+dFRtmCx1nhMQH/dFZfkgcS5yoP6nabA/Ra2IUji3j3GOg==", + "dev": true, + "dependencies": { + "chalk": "^5.4.1", + "commander": "^14.0.0", + "frida": "^17.8.0" + }, + "bin": { + "frida-compile": "dist/cli.js" + } + }, + "node_modules/frida-java-bridge": { + "version": "7.0.13", + "resolved": "https://registry.npmjs.org/frida-java-bridge/-/frida-java-bridge-7.0.13.tgz", + "integrity": "sha512-YSyKjxbxKnSi3KSUy9vciOvTOuq0RRh9dkxzkQVEdfIIZlw20zE8D3Cq9eL2FDqUVj4YKas6Wf09kCjL5zbffg==", + "dev": true, + "license": "LGPL-2.0 WITH WxWindows-exception-3.1" + }, + "node_modules/frida-objc-bridge": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/frida-objc-bridge/-/frida-objc-bridge-8.0.5.tgz", + "integrity": "sha512-0xnL0VgxSQXgLj3S33S0kdAvLrCHSCIZb9HVraEgEyKnf9wOcu0KZ9fI5xzx0e0y+ry/g2Vl3yw9j3dsn92Ffg==", + "dev": true, + "license": "LGPL-2.0 WITH WxWindows-exception-3.1" + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT" + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.89.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", + "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/packages/frida/package.json b/packages/frida/package.json new file mode 100644 index 0000000..05ec182 --- /dev/null +++ b/packages/frida/package.json @@ -0,0 +1,16 @@ +{ + "name": "netflix-frida-hooks", + "private": true, + "scripts": { + "build": "npm run build:ios && npm run build:android", + "build:ios": "frida-compile src/ios/index.ts -o hook_netflix.js -c -T none", + "build:android": "frida-compile src/android/index.ts -o hook_netflix_android.js -c -T none", + "watch:ios": "frida-compile src/ios/index.ts -o hook_netflix.js -w -T none", + "watch:android": "frida-compile src/android/index.ts -o hook_netflix_android.js -w -T none", + "build:chrome": "frida-compile src/chrome/index.ts -o hook_chrome_cdm.js -c -T none", + "watch:chrome": "frida-compile src/chrome/index.ts -o hook_chrome_cdm.js -w -T none" + }, + "devDependencies": { + "frida-compile": "^19.0.5" + } +} diff --git a/packages/frida/src/android/ale.ts b/packages/frida/src/android/ale.ts new file mode 100644 index 0000000..e697fdf --- /dev/null +++ b/packages/frida/src/android/ale.ts @@ -0,0 +1,235 @@ +import { logData, logAle } from "../common/utils"; +import { jbyteArrayToBase64, jbyteArrayToString } from "./utils"; + +export function hookALE(): void { + // ------------------------------------------------------- + // AleService -- ALE暗号サービス + // createSession(String) -> AleSession + // getProvisioningRequest() -> String (JSON) + // ------------------------------------------------------- + try { + const AleService = Java.use("com.netflix.ale.AleService"); + + AleService.createSession.implementation = function (responseStr: any) { + const session = this.createSession(responseStr); + const s = responseStr ? responseStr.toString() : null; + logAle("AleService.createSession: response=" + (s ? s.substring(0, 200) : "null")); + logData("ale.createSession", { + response: s ? s.substring(0, 65536) : null + }); + return session; + }; + console.log("[+] Hooked AleService.createSession"); + + AleService.getProvisioningRequest.implementation = function () { + const req = this.getProvisioningRequest(); + const s = req ? req.toString() : null; + logAle("AleService.getProvisioningRequest: " + (s ? s.substring(0, 200) : "null")); + logData("ale.provisionRequest", { + request: s ? s.substring(0, 65536) : null + }); + return req; + }; + console.log("[+] Hooked AleService.getProvisioningRequest"); + } catch (e) { + console.log("[-] AleService: " + e); + } + + // ------------------------------------------------------- + // AleSession -- ALEセッション + // encrypt(byte[]) -> String, encrypt(String) -> String + // decrypt(String) -> byte[] + // decryptString(String) -> String + // ------------------------------------------------------- + try { + const AleSession = Java.use("com.netflix.ale.AleSession"); + + // encrypt(String) + try { + AleSession.encrypt.overload("java.lang.String").implementation = function (plaintext: any) { + const result = this.encrypt(plaintext); + const pt = plaintext.toString(); + logAle("AleSession.encrypt str:" + pt.length + "->" + result.toString().length + " chars"); + logData("ale.encrypt", { + plaintext: pt.substring(0, 8192), + plaintext_size: pt.length + }); + return result; + }; + console.log("[+] Hooked AleSession.encrypt(String)"); + } catch (e2) { + console.log("[-] AleSession.encrypt(String): " + e2); + } + + // encrypt(byte[]) + try { + AleSession.encrypt.overload("[B").implementation = function (data: any) { + const result = this.encrypt(data); + logAle("AleSession.encrypt bytes:" + data.length + "B"); + logData("ale.encrypt", { + plaintext_size: data.length + }); + return result; + }; + console.log("[+] Hooked AleSession.encrypt(byte[])"); + } catch (e2) { } + + // decrypt(String) -> byte[] + try { + AleSession.decrypt.implementation = function (jweStr: any) { + const result = this.decrypt(jweStr); + const plainStr = jbyteArrayToString(result); + const preview = plainStr && plainStr.length > 300 ? plainStr.substring(0, 300) + "..." : plainStr; + logAle("AleSession.decrypt -> " + result.length + "B"); + if (preview) console.log(" " + preview); + logData("ale.decrypt", { + plaintext_size: result.length, + body: plainStr ? plainStr.substring(0, 65536) : null + }); + return result; + }; + console.log("[+] Hooked AleSession.decrypt"); + } catch (e2) { } + + // decryptString(String) -> String + try { + AleSession.decryptString.implementation = function (jweStr: any) { + const result = this.decryptString(jweStr); + const s = result.toString(); + const preview = s.length > 300 ? s.substring(0, 300) + "..." : s; + logAle("AleSession.decryptString -> " + s.length + " chars"); + console.log(" " + preview); + logData("ale.decryptString", { + plaintext: s.substring(0, 65536), + plaintext_size: s.length + }); + return result; + }; + console.log("[+] Hooked AleSession.decryptString"); + } catch (e2) { } + } catch (e) { + console.log("[-] AleSession: " + e); + } + + // ------------------------------------------------------- + // AleCryptoBouncyCastle -- ALE低レベル暗号 + // aesCbcEncrypt(AleKey, iv, plaintext) -> byte[] + // aesCbcDecrypt(AleKey, iv, ciphertext) -> byte[] + // aesGcmEncrypt(AleKey, iv, aad, plaintext) -> byte[] + // hmacSha256(AleKey, data) -> byte[] + // rsaOaepEncrypt(AleKey, data) -> byte[] + // ------------------------------------------------------- + try { + const AleCrypto = Java.use("com.netflix.ale.AleCryptoBouncyCastle"); + + try { + AleCrypto.aesCbcEncrypt.implementation = function (key: any, iv: any, plaintext: any) { + const result = this.aesCbcEncrypt(key, iv, plaintext); + logAle("AleCrypto.aesCbcEncrypt plain:" + plaintext.length + "B -> cipher:" + result.length + "B"); + logData("ale.aesCbcEncrypt", { + plaintext_size: plaintext.length, + ciphertext_size: result.length + }); + return result; + }; + console.log("[+] Hooked AleCrypto.aesCbcEncrypt"); + } catch (e2) { } + + try { + AleCrypto.aesCbcDecrypt.implementation = function (key: any, iv: any, ciphertext: any) { + const result = this.aesCbcDecrypt(key, iv, ciphertext); + logAle("AleCrypto.aesCbcDecrypt cipher:" + ciphertext.length + "B -> plain:" + result.length + "B"); + logData("ale.aesCbcDecrypt", { + plaintext_size: result.length, + ciphertext_size: ciphertext.length + }); + return result; + }; + console.log("[+] Hooked AleCrypto.aesCbcDecrypt"); + } catch (e2) { } + + try { + AleCrypto.aesGcmEncrypt.implementation = function (key: any, iv: any, aad: any, plaintext: any) { + const result = this.aesGcmEncrypt(key, iv, aad, plaintext); + logAle("AleCrypto.aesGcmEncrypt plain:" + plaintext.length + "B -> cipher:" + result.length + "B"); + logData("ale.aesGcmEncrypt", { + plaintext_size: plaintext.length + }); + return result; + }; + console.log("[+] Hooked AleCrypto.aesGcmEncrypt"); + } catch (e2) { } + + try { + AleCrypto.aesGcmDecrypt.implementation = function (key: any, iv: any, aad: any, ciphertext: any) { + const result = this.aesGcmDecrypt(key, iv, aad, ciphertext); + logAle("AleCrypto.aesGcmDecrypt cipher:" + ciphertext.length + "B -> plain:" + result.length + "B"); + logData("ale.aesGcmDecrypt", { + plaintext_size: result.length + }); + return result; + }; + console.log("[+] Hooked AleCrypto.aesGcmDecrypt"); + } catch (e2) { } + + try { + AleCrypto.hmacSha256.implementation = function (key: any, data: any) { + const result = this.hmacSha256(key, data); + logAle("AleCrypto.hmacSha256 data:" + data.length + "B -> mac:" + result.length + "B"); + return result; + }; + console.log("[+] Hooked AleCrypto.hmacSha256"); + } catch (e2) { } + + try { + AleCrypto.rsaOaepEncrypt.implementation = function (key: any, data: any) { + const result = this.rsaOaepEncrypt(key, data); + logAle("AleCrypto.rsaOaepEncrypt data:" + data.length + "B -> cipher:" + result.length + "B"); + logData("ale.rsaOaepEncrypt", { + plaintext_size: data.length + }); + return result; + }; + console.log("[+] Hooked AleCrypto.rsaOaepEncrypt"); + } catch (e2) { } + } catch (e) { + console.log("[-] AleCryptoBouncyCastle: " + e); + } + + // ------------------------------------------------------- + // JweBase -- JWE encrypt/decrypt (親クラス) + // encrypt(byte[]) -> String (JWE compact serialization) + // decrypt(String) -> byte[] + // ------------------------------------------------------- + try { + const JweBase = Java.use("com.netflix.ale.JweBase"); + + try { + JweBase.encrypt.implementation = function (plaintext: any) { + const result = this.encrypt(plaintext); + logAle("JweBase.encrypt " + plaintext.length + "B -> JWE"); + logData("ale.jwe.encrypt", { + plaintext_size: plaintext.length + }); + return result; + }; + console.log("[+] Hooked JweBase.encrypt"); + } catch (e2) { } + + try { + JweBase.decrypt.implementation = function (jweStr: any) { + const result = this.decrypt(jweStr); + const plainStr = jbyteArrayToString(result); + logAle("JweBase.decrypt JWE -> " + result.length + "B"); + logData("ale.jwe.decrypt", { + plaintext_size: result.length, + body: plainStr ? plainStr.substring(0, 65536) : null + }); + return result; + }; + console.log("[+] Hooked JweBase.decrypt"); + } catch (e2) { } + } catch (e) { + console.log("[-] JweBase: " + e); + } +} diff --git a/packages/frida/src/android/config.ts b/packages/frida/src/android/config.ts new file mode 100644 index 0000000..396eebe --- /dev/null +++ b/packages/frida/src/android/config.ts @@ -0,0 +1,2 @@ +export const ORIGINAL_ESN = "NFANDROID1-PXA-P-L3-GOOGLPIXEL=4A==5G=-22594-0202Q7INAHS2TKI5GTQESPDEHTFK7MG1BKUU7QAPUQP2QMI641A8HN08CE40C5H2K4J15NCLBC5DGJI0M03TMV0VGS1ER8VACIG0257E"; +export const OVERRIDE_ESN = "NFANDROID1-PRV-P-L3-XIAOMIM2003J15SC-22594-75D1C8A777C7CB300D2799591B8A7F295C5BBB3797D6B391876761EC86F7AA2F"; diff --git a/packages/frida/src/android/esn-override.ts b/packages/frida/src/android/esn-override.ts new file mode 100644 index 0000000..f964040 --- /dev/null +++ b/packages/frida/src/android/esn-override.ts @@ -0,0 +1,163 @@ +import { logData } from "../common/utils"; +import { ORIGINAL_ESN, OVERRIDE_ESN } from "./config"; + +export function forceProxyEsnRefetch(): void { + // ------------------------------------------------------- + // ProxyEsn.$init で expired フラグを強制 true にする + // → getProxyEsn が発火 → aleProvision も自動実行 + // ------------------------------------------------------- + try { + const ProxyEsn = Java.use("com.netflix.mediaclient.esn.impl.ProxyEsn"); + ProxyEsn.$init.overloads.forEach(function (overload: any) { + overload.implementation = function () { + overload.apply(this, arguments); + // expired フラグ (boolean フィールド) を強制 true + try { + const fields = this.getClass().getDeclaredFields(); + for (let i = 0; i < fields.length; i++) { + const f = fields[i]; + if (f.getType().getName() === "boolean") { + f.setAccessible(true); + const original = f.getBoolean(this); + f.setBoolean(this, true); + console.log("[ESN] ProxyEsn." + f.getName() + " = " + original + " -> true (force expired)"); + logData("proxyEsn.forceExpired", { + field: f.getName(), + original: original, + }); + } + } + } catch (e) { + console.log("[-] ProxyEsn expired override: " + e); + } + }; + }); + console.log("[+] Hooked ProxyEsn.$init (force expired)"); + } catch (e) { + console.log("[-] ProxyEsn: " + e); + } +} + +export function hookEsnOverride(): void { + // ------------------------------------------------------- + // 1. MslContext.getEntityAuthenticationData -> sender (ESN) の提供元 + // MessageHeader 構築時に MslContext から ESN を取得するため、 + // ここで差し替えれば MSL 層全体に反映される + // ------------------------------------------------------- + try { + const MslContext = Java.use("com.netflix.msl.util.MslContext"); + const methods = MslContext.class.getDeclaredMethods(); + methods.forEach(function (m: any) { + const name = m.getName(); + const paramCount = m.getParameterTypes().length; + const retType = m.getReturnType().getName(); + console.log("[*] MslContext." + name + "(" + paramCount + ") -> " + retType); + }); + } catch (e) { + console.log("[-] MslContext enumeration: " + e); + } + + // ------------------------------------------------------- + // 2. EntityAuthenticationData -- ESN を含む認証データ + // Netflix Android では UnauthenticatedAuthenticationData が使われ、 + // identity フィールドが ESN を保持する + // ------------------------------------------------------- + try { + const UnauthData = Java.use("com.netflix.msl.entityauth.UnauthenticatedAuthenticationData"); + // getIdentity() をフックして ESN を差し替え + try { + UnauthData.getIdentity.implementation = function () { + const original = this.getIdentity(); + const esn = original ? original.toString() : null; + if (esn) { + logData("msl.sender", { esn: esn }); + } + if (esn === ORIGINAL_ESN) { + console.log("[ESN] UnauthenticatedAuthenticationData.getIdentity -> override"); + return Java.use("java.lang.String").$new(OVERRIDE_ESN); + } + return original; + }; + console.log("[+] Hooked UnauthenticatedAuthenticationData.getIdentity (ESN capture + override)"); + } catch (e2) { + console.log("[-] UnauthenticatedAuthenticationData.getIdentity: " + e2); + // ProGuard: identity フィールドを直接書き換え + // コンストラクタをフックして identity を差し替え + try { + const constructors = UnauthData.class.getDeclaredConstructors(); + constructors.forEach(function (c: any) { + console.log("[*] UnauthenticatedAuthenticationData.(" + c.getParameterTypes().length + ")"); + }); + UnauthData.$init.overload("java.lang.String").implementation = function (identity: any) { + if (identity && identity.toString() === ORIGINAL_ESN) { + console.log("[ESN] UnauthenticatedAuthenticationData constructor -> override"); + this.$init(OVERRIDE_ESN); + } else { + this.$init(identity); + } + }; + console.log("[+] Hooked UnauthenticatedAuthenticationData constructor (ESN override)"); + } catch (e3) { + console.log("[-] UnauthenticatedAuthenticationData constructor: " + e3); + } + } + } catch (e) { + console.log("[-] UnauthenticatedAuthenticationData: " + e); + } + + // ------------------------------------------------------- + // 3. MessageHeader.sender -- CBOR key 20 に書き込まれる ESN + // MessageHeader 構築時にフックして sender を差し替え + // ------------------------------------------------------- + try { + const MessageHeader = Java.use("com.netflix.msl.msg.MessageHeader"); + const mhMethods = MessageHeader.class.getDeclaredMethods(); + mhMethods.forEach(function (m: any) { + const name = m.getName(); + const retType = m.getReturnType().getName(); + // getSender / sender 系メソッドを探す + if (name.toLowerCase().indexOf("sender") !== -1 || name.toLowerCase().indexOf("identity") !== -1) { + console.log("[*] MessageHeader." + name + "(" + m.getParameterTypes().length + ") -> " + retType); + } + }); + // getSender() をフック + try { + MessageHeader.getSender.implementation = function () { + const original = this.getSender(); + if (original && original.toString() === ORIGINAL_ESN) { + console.log("[ESN] MessageHeader.getSender -> override"); + return Java.use("java.lang.String").$new(OVERRIDE_ESN); + } + return original; + }; + console.log("[+] Hooked MessageHeader.getSender (ESN override)"); + } catch (e2) { + console.log("[-] MessageHeader.getSender: " + e2); + } + } catch (e) { + console.log("[-] MessageHeader ESN override: " + e); + } + + // ------------------------------------------------------- + // 4. 汎用 ESN 文字列置換 -- SharedPreferences / DeviceInfo + // アプリ内でESNを保持する SharedPreferences や DeviceInfo を探索 + // ------------------------------------------------------- + try { + // SharedPreferences.getString をフックして ESN 値を差し替え + // SharedPreferences はインターフェースなので実装クラスをフック + const SharedPrefsImpl = Java.use("android.app.SharedPreferencesImpl"); + SharedPrefsImpl.getString.implementation = function (key: any, defValue: any) { + const result = this.getString(key, defValue); + if (result && result.toString() === ORIGINAL_ESN) { + console.log("[ESN] SharedPreferences.getString('" + key + "') -> override"); + return Java.use("java.lang.String").$new(OVERRIDE_ESN); + } + return result; + }; + console.log("[+] Hooked SharedPreferences.getString (ESN override)"); + } catch (e) { + console.log("[-] SharedPreferences ESN override: " + e); + } + + console.log("[*] ESN Override hooks installed: " + OVERRIDE_ESN.substring(0, 40) + "..."); +} diff --git a/packages/frida/src/android/http.ts b/packages/frida/src/android/http.ts new file mode 100644 index 0000000..1dcb609 --- /dev/null +++ b/packages/frida/src/android/http.ts @@ -0,0 +1,157 @@ +import { logData, logHttpReq, logHttpResp } from "../common/utils"; +import { ORIGINAL_ESN, OVERRIDE_ESN } from "./config"; + +// URL -> domain +function domainOf(urlStr: string): string { + const m = urlStr.match(/^https?:\/\/([^\/\?:]+)/); + return m ? m[1] : "unknown"; +} + +function isLocal(urlStr: string): boolean { + return /^https?:\/\/(192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.|127\.|localhost|0\.0\.0\.0)/.test(urlStr); +} + +// 不要なアセット系リクエストをスキップ +const SKIP_EXTENSIONS = /\.(png|jpg|jpeg|gif|webp|svg|ico|bmp|tiff|avif|woff|woff2|ttf|otf|eot|css|js|map|mp4|webm|ts|m4s|m4v|m4a|aac|mp3|vtt|ttml|dfxp)(\?|$)/i; +const SKIP_DOMAINS = /\b(assets\.nflxext\.com|codex\.nflxext\.com|image\.tmdb\.org|art-[a-z]+\.nflximg\.net|occ-\d+-\d+\.nflxso\.net|lottie\.netflix\.com)\b/; + +function isSkippableUrl(urlStr: string): boolean { + if (SKIP_EXTENSIONS.test(urlStr)) return true; + if (SKIP_DOMAINS.test(urlStr)) return true; + return false; +} + +export function hookHTTP(): void { + // ------------------------------------------------------- + // OkHttp Interceptor -- リクエスト/レスポンス + // ------------------------------------------------------- + try { + // OkHttp の RealCall.execute / enqueue をフック + const RealCall = Java.use("okhttp3.internal.connection.RealCall"); + const Buffer = Java.use("okio.Buffer"); + + RealCall.getResponseWithInterceptorChain.implementation = function () { + let request = this.getOriginalRequest(); + const url = request.url().toString(); + if (isLocal(url) || url.indexOf("netflix") === -1 || isSkippableUrl(url)) { + return this.getResponseWithInterceptorChain(); + } + + // --- ESN Override: X-Netflix-ProxyEsn ヘッダーを書き換え --- + try { + const proxyEsn = request.header("X-Netflix-ProxyEsn"); + if (proxyEsn && proxyEsn.indexOf(ORIGINAL_ESN) !== -1) { + const newRequest = request.newBuilder() + .removeHeader("X-Netflix-ProxyEsn") + .addHeader("X-Netflix-ProxyEsn", OVERRIDE_ESN) + .build(); + // RealCall の originalRequest フィールドを差し替え + const origField = this.getClass().getDeclaredField("originalRequest"); + origField.setAccessible(true); + origField.set(this, newRequest); + request = newRequest; + console.log("[ESN] Replaced ProxyEsn header: " + OVERRIDE_ESN.substring(0, 40) + "..."); + } + } catch (esnErr) { + console.log("[-] ESN header replace: " + esnErr); + } + + const domain = domainOf(url); + const method = request.method(); + let bodyStr: string | null = null; + let bodySize = 0; + + // リクエストヘッダー取得 + const reqHeaders: Record = {}; + try { + const headers = request.headers(); + const namesArr = headers.names().toArray(); + for (let hi = 0; hi < namesArr.length; hi++) { + const hname = namesArr[hi].toString(); + reqHeaders[hname] = headers.get(hname); + } + } catch (e2) { } + + try { + const body = request.body(); + if (body) { + const buf = Buffer.$new(); + body.writeTo(buf); + bodySize = buf.size(); + bodyStr = buf.readUtf8(); + } + } catch (e) { } + + const reqInfo: Record = { + domain: domain, + method: method, + url: url, + headers: reqHeaders + }; + if (bodyStr) { + reqInfo.size = bodySize; + reqInfo.body = bodyStr.substring(0, 8192); + } + logData("http.request", reqInfo); + logHttpReq(method, url, bodySize || 0, Object.keys(reqHeaders).length); + + const response = this.getResponseWithInterceptorChain(); + + try { + // レスポンスヘッダー取得 + const respHeaders: Record = {}; + try { + const rh = response.headers(); + const rnamesArr = rh.names().toArray(); + for (let ri = 0; ri < rnamesArr.length; ri++) { + const rname = rnamesArr[ri].toString(); + respHeaders[rname] = rh.get(rname); + } + } catch (e3) { } + + const respBody = response.body(); + if (respBody) { + const source = respBody.source(); + source.request(Java.use("java.lang.Long").MAX_VALUE.value); + const respBuf = source.getBuffer().clone(); + const respStr = respBuf.readUtf8(); + const status = response.code(); + + logData("http.response", { + domain: domain, + url: url, + status: status, + headers: respHeaders, + size: respStr.length, + body: respStr.substring(0, 65536) + }); + logHttpResp(status, url, respStr.length, Object.keys(respHeaders).length); + } + } catch (e) { } + + return response; + }; + console.log("[+] Hooked OkHttp RealCall"); + } catch (e) { + console.log("[-] OkHttp RealCall: " + e); + } + + // ------------------------------------------------------- + // HttpURLConnection -- MSL HTTP通信 (Cronet/system) + // ------------------------------------------------------- + try { + const URL = Java.use("java.net.URL"); + URL.openConnection.overload().implementation = function () { + const conn = this.openConnection(); + const url = this.toString(); + if (url.indexOf("netflix") !== -1 && !isLocal(url)) { + console.log("[*] URL.openConnection: " + url); + logData("url", { domain: domainOf(url), url: url }); + } + return conn; + }; + console.log("[+] Hooked URL.openConnection"); + } catch (e) { + console.log("[-] URL.openConnection: " + e); + } +} diff --git a/packages/frida/src/android/index.ts b/packages/frida/src/android/index.ts new file mode 100644 index 0000000..1e2ea61 --- /dev/null +++ b/packages/frida/src/android/index.ts @@ -0,0 +1,34 @@ +import { hookSSLPinning } from "./ssl-pinning"; +import { hookSSL } from "./ssl-capture"; +import { hookEsnOverride, forceProxyEsnRefetch } from "./esn-override"; +import { hookMSL } from "./msl"; +import { hookMSLCrypto } from "./msl-crypto"; +import { hookHTTP } from "./http"; +import { hookWidevineKeyExchange } from "./widevine-key-exchange"; +import { hookALE } from "./ale"; +import { hookWidevineDRM } from "./widevine-drm"; +import { dumpStorage } from "./storage-dump"; + +console.log("[*] Netflix Android Hook starting..."); +Java.perform(() => { + console.log("[*] Java.perform started"); + hookSSLPinning(); + forceProxyEsnRefetch(); + hookEsnOverride(); + hookMSL(); + hookMSLCrypto(); + hookHTTP(); + hookWidevineKeyExchange(); + hookALE(); + hookWidevineDRM(); + console.log("[*] Java hooks installed"); + + // ストレージダンプは遅延実行 (Application コンテキスト初期化待ち) + setTimeout(() => { + Java.perform(() => { + try { dumpStorage(); } catch (e) { console.log("[-] dumpStorage: " + e); } + }); + }, 3000); +}); +hookSSL(); +console.log("[*] All hooks installed"); diff --git a/packages/frida/src/android/msl-crypto.ts b/packages/frida/src/android/msl-crypto.ts new file mode 100644 index 0000000..525ae1e --- /dev/null +++ b/packages/frida/src/android/msl-crypto.ts @@ -0,0 +1,541 @@ +import { logData, logMsl } from "../common/utils"; +import { processMslPlaintext } from "../common/msl-processor"; +import { jbyteArrayToBase64, jbyteArrayToString, jbyteArrayToArrayBuffer, extractEsnFromBytes, extractStringsFromBytes } from "./utils"; +import { mslCurrentUrl, mslCurrentDomain } from "./msl-state"; + +export function hookMSLCrypto(): void { + // ------------------------------------------------------- + // AesCbcEncryptor -- AES-CBC 暗号化/復号 + // Obfuscated: encrypt -> b or d (3-arg, returns MslCiphertextEnvelope) + // decrypt -> e (1-arg, returns byte[]) + // ------------------------------------------------------- + try { + const AesCbc = Java.use("com.netflix.msl.crypto.AesCbcEncryptor"); + + // Dynamic encrypt method detection: 3-arg method returning non-byte[] + let encryptHooked = false; + const encryptNames = ["d", "b"]; + for (let ei = 0; ei < encryptNames.length && !encryptHooked; ei++) { + const eName = encryptNames[ei]; + try { + AesCbc[eName].implementation = function (data: any, version: any, keyId: any) { + const result = this[eName](data, version, keyId); + logMsl("AesCbcEncryptor.encrypt", "plain:" + data.length + "B keyId=" + keyId); + logData("msl.aesCbcEncrypt", { + plaintext_b64: jbyteArrayToBase64(data), + plaintext_size: data.length, + keyId: keyId ? ("" + keyId) : null + }); + const ab = jbyteArrayToArrayBuffer(data); + if (ab && ab.byteLength > 0) { + try { processMslPlaintext(ab, "encrypt", "AES-CBC"); } catch (e) { } + } + return result; + }; + console.log("[+] Hooked AesCbcEncryptor." + eName + " (encrypt)"); + encryptHooked = true; + } catch (e2) { + // try next name + } + } + if (!encryptHooked) { + // Fallback: enumerate methods + const methods = AesCbc.class.getDeclaredMethods(); + methods.forEach(function (m: any) { + if (m.getParameterTypes().length === 3 && !encryptHooked) { + const name = m.getName(); + console.log("[*] AesCbcEncryptor candidate encrypt: " + name + "(" + m.getParameterTypes().length + ")"); + } + }); + console.log("[-] AesCbcEncryptor encrypt: could not hook"); + } + + // e() = decrypt: (MslCiphertextEnvelope) -> byte[] + try { + AesCbc.e.implementation = function (envelope: any) { + const result = this.e(envelope); + const plainStr = jbyteArrayToString(result); + const preview = plainStr && plainStr.length > 300 ? plainStr.substring(0, 300) + "..." : plainStr; + logMsl("AesCbcEncryptor.decrypt", "-> " + result.length + "B"); + if (preview) console.log(" " + preview); + logData("msl.aesCbcDecrypt", { + plaintext_b64: jbyteArrayToBase64(result), + plaintext_size: result.length + }); + const ab = jbyteArrayToArrayBuffer(result); + if (ab && ab.byteLength > 0) { + try { processMslPlaintext(ab, "decrypt", "AES-CBC"); } catch (e) { } + } + return result; + }; + console.log("[+] Hooked AesCbcEncryptor.e (decrypt)"); + } catch (e2) { + console.log("[-] AesCbcEncryptor.e: " + e2); + } + } catch (e) { + console.log("[-] AesCbcEncryptor: " + e); + } + + // ------------------------------------------------------- + // HmacSha256Signer -- HMAC-SHA256 署名 + // Obfuscated: sign -> a(byte[]) -> MslSignatureEnvelope + // verify -> e(byte[], MslSignatureEnvelope) -> boolean + // ------------------------------------------------------- + try { + const HmacSigner = Java.use("com.netflix.msl.crypto.HmacSha256Signer"); + + // a() = sign + try { + HmacSigner.a.implementation = function (data: any) { + const result = this.a(data); + logMsl("HmacSha256Signer.sign", "data:" + data.length + "B"); + logData("msl.hmacSha256.sign", { + data_b64: jbyteArrayToBase64(data), + data_size: data.length + }); + return result; + }; + console.log("[+] Hooked HmacSha256Signer.a (sign)"); + } catch (e2) { + console.log("[-] HmacSha256Signer.a: " + e2); + } + + // verify -- try e(), then enumerate methods returning boolean with 2 args + let verifyHooked = false; + try { + HmacSigner.e.implementation = function (data: any, sigEnvelope: any) { + const result = this.e(data, sigEnvelope); + logMsl("HmacSha256Signer.verify", "data:" + data.length + "B -> " + result); + return result; + }; + console.log("[+] Hooked HmacSha256Signer.e (verify)"); + verifyHooked = true; + } catch (e2) { + console.log("[-] HmacSha256Signer.e: " + e2); + } + if (!verifyHooked) { + // Enumerate methods to find verify (boolean return, 2 args: byte[], envelope) + const methods = HmacSigner.class.getDeclaredMethods(); + methods.forEach(function (m: any) { + const params = m.getParameterTypes(); + const ret = m.getReturnType().getName(); + if (params.length === 2 && ret === "boolean" && !verifyHooked) { + const name = m.getName(); + console.log("[*] HmacSha256Signer candidate verify: " + name + "(" + params[0].getName() + ", " + params[1].getName() + ") -> boolean"); + try { + HmacSigner[name].implementation = function (data: any, sigEnvelope: any) { + const result = this[name](data, sigEnvelope); + logMsl("HmacSha256Signer.verify", "data:" + data.length + "B -> " + result); + return result; + }; + console.log("[+] Hooked HmacSha256Signer." + name + " (verify)"); + verifyHooked = true; + } catch (ex) { + console.log("[-] HmacSha256Signer." + name + " hook failed: " + ex); + } + } + }); + } + } catch (e) { + console.log("[-] HmacSha256Signer: " + e); + } + + // ------------------------------------------------------- + // WidevineCryptoContext -- Widevine暗号 + // encrypt(byte[], MslEncoderFactory, jOK) -> byte[] + // c(byte[], MslEncoderFactory) -> byte[] (decrypt) + // b(byte[], MslEncoderFactory, jOK) -> byte[] (sign) + // c(byte[], byte[], MslEncoderFactory) -> boolean (verify) + // ------------------------------------------------------- + try { + const WvCrypto = Java.use("com.netflix.msl.client.impl.WidevineCryptoContext"); + + // encrypt (kept as encrypt) + try { + WvCrypto.encrypt.implementation = function (data: any, encoder: any, format: any) { + const result = this.encrypt(data, encoder, format); + const esn = extractEsnFromBytes(data); + const strings = extractStringsFromBytes(data); + logMsl("WidevineCryptoContext.encrypt", "data:" + data.length + "B" + (esn ? " sender=" + esn : "")); + logData("msl.widevine.encrypt", { + domain: mslCurrentDomain, + url: mslCurrentUrl, + sender: esn, + plaintext_size: data.length, + strings: strings + }); + return result; + }; + console.log("[+] Hooked WidevineCryptoContext.encrypt"); + } catch (e2) { + console.log("[-] WidevineCryptoContext.encrypt: " + e2); + } + + // c(byte[], MslEncoderFactory) = decrypt (2-arg overload) + let decryptHooked = false; + const methods = WvCrypto.class.getDeclaredMethods(); + methods.forEach(function (m: any) { + const name = m.getName(); + const paramCount = m.getParameterTypes().length; + const retType = m.getReturnType().getName(); + if (name === "c" || name === "b" || name === "d") { + console.log("[*] WidevineCryptoContext." + name + "(" + paramCount + ") -> " + retType); + } + }); + + // decrypt: 'c' with 2 args returning byte[] + try { + WvCrypto.c.overload('[B', 'com.netflix.msl.io.MslEncoderFactory').implementation = function (data: any, encoder: any) { + const result = this.c(data, encoder); + const esn = extractEsnFromBytes(result); + const strings = extractStringsFromBytes(result); + logMsl("WidevineCryptoContext.decrypt", data.length + "B -> " + result.length + "B" + (esn ? " sender=" + esn : "")); + logData("msl.widevine.decrypt", { + domain: mslCurrentDomain, + url: mslCurrentUrl, + sender: esn, + plaintext_b64: jbyteArrayToBase64(result), + ciphertext_size: data.length, + plaintext_size: result.length, + strings: strings + }); + return result; + }; + console.log("[+] Hooked WidevineCryptoContext.c (decrypt, 2-arg)"); + decryptHooked = true; + } catch (e2) { + console.log("[-] WidevineCryptoContext.c(2): " + e2); + } + // Fallback: try method name enumeration for decrypt + if (!decryptHooked) { + methods.forEach(function (m: any) { + const name = m.getName(); + const params = m.getParameterTypes(); + const retType = m.getReturnType().getName(); + if (params.length === 2 && retType === "[B" && name !== "encrypt" && !decryptHooked) { + try { + WvCrypto[name].overload(params[0].getName(), params[1].getName()).implementation = function (data: any, encoder: any) { + const result = this[name](data, encoder); + const esn = extractEsnFromBytes(result); + const strings = extractStringsFromBytes(result); + logMsl("WidevineCryptoContext.decrypt", data.length + "B -> " + result.length + "B" + (esn ? " sender=" + esn : "")); + logData("msl.widevine.decrypt", { + domain: mslCurrentDomain, + url: mslCurrentUrl, + sender: esn, + plaintext_b64: jbyteArrayToBase64(result), + ciphertext_size: data.length, + plaintext_size: result.length, + strings: strings + }); + return result; + }; + console.log("[+] Hooked WidevineCryptoContext." + name + " (decrypt fallback)"); + decryptHooked = true; + } catch (ex) { + console.log("[-] WidevineCryptoContext." + name + " decrypt hook failed: " + ex); + } + } + }); + } + + // sign: 'b' with 3 args returning byte[] + try { + WvCrypto.b.overload('[B', 'com.netflix.msl.io.MslEncoderFactory', 'com.netflix.msl.io.jOK').implementation = function (data: any, encoder: any, format: any) { + const result = this.b(data, encoder, format); + logMsl("WidevineCryptoContext.sign", "data:" + data.length + "B -> sig:" + result.length + "B"); + logData("msl.widevine.sign", { + domain: mslCurrentDomain, + url: mslCurrentUrl, + data_b64: jbyteArrayToBase64(data), + data_size: data.length, + signature_b64: jbyteArrayToBase64(result), + signature_size: result.length + }); + return result; + }; + console.log("[+] Hooked WidevineCryptoContext.b (sign, 3-arg)"); + } catch (e2) { + console.log("[-] WidevineCryptoContext.b(3) sign: " + e2); + // Fallback: enumerate 3-arg methods returning byte[] + methods.forEach(function (m: any) { + const name = m.getName(); + const params = m.getParameterTypes(); + const retType = m.getReturnType().getName(); + if (params.length === 3 && retType === "[B" && name !== "encrypt") { + try { + WvCrypto[name].overload(params[0].getName(), params[1].getName(), params[2].getName()).implementation = function (data: any, encoder: any, format: any) { + const result = this[name](data, encoder, format); + logMsl("WidevineCryptoContext.sign", "data:" + data.length + "B -> sig:" + result.length + "B"); + logData("msl.widevine.sign", { + data_b64: jbyteArrayToBase64(data), + data_size: data.length, + signature_b64: jbyteArrayToBase64(result), + signature_size: result.length + }); + return result; + }; + console.log("[+] Hooked WidevineCryptoContext." + name + " (sign fallback)"); + } catch (ex) { + console.log("[-] WidevineCryptoContext." + name + " sign hook failed: " + ex); + } + } + }); + } + + // verify: 'c' with 3 args returning boolean + try { + WvCrypto.c.overload('[B', '[B', 'com.netflix.msl.io.MslEncoderFactory').implementation = function (data: any, sig: any, encoder: any) { + const result = this.c(data, sig, encoder); + logMsl("WidevineCryptoContext.verify", "data:" + data.length + "B -> " + result); + logData("msl.widevine.verify", { + data_b64: jbyteArrayToBase64(data), + data_size: data.length, + verified: result + }); + return result; + }; + console.log("[+] Hooked WidevineCryptoContext.c (verify, 3-arg)"); + } catch (e2) { + console.log("[-] WidevineCryptoContext.c(3) verify: " + e2); + } + } catch (e) { + console.log("[-] WidevineCryptoContext: " + e); + } + + // ------------------------------------------------------- + // SymmetricCryptoContext -- 汎用対称暗号 + // Same obfuscation: encrypt, c(decrypt), b(sign), c(verify) + // ------------------------------------------------------- + try { + const SymCrypto = Java.use("com.netflix.msl.crypto.SymmetricCryptoContext"); + + SymCrypto.encrypt.implementation = function (data: any, encoder: any, format: any) { + const result = this.encrypt(data, encoder, format); + const esn = extractEsnFromBytes(data); + logMsl("SymmetricCryptoContext.encrypt", "data:" + data.length + "B" + (esn ? " sender=" + esn : "")); + logData("msl.symmetric.encrypt", { + sender: esn, + plaintext_size: data.length, + strings: extractStringsFromBytes(data) + }); + return result; + }; + console.log("[+] Hooked SymmetricCryptoContext.encrypt"); + + // Enumerate methods to find decrypt/sign/verify + const symMethods = SymCrypto.class.getDeclaredMethods(); + symMethods.forEach(function (m: any) { + const name = m.getName(); + const paramCount = m.getParameterTypes().length; + const retType = m.getReturnType().getName(); + if (name !== "encrypt") { + console.log("[*] SymmetricCryptoContext." + name + "(" + paramCount + ") -> " + retType); + } + }); + + // decrypt: 'c' with 2 args (byte[], MslEncoderFactory) -> byte[] + let symDecryptHooked = false; + try { + SymCrypto.c.overload('[B', 'com.netflix.msl.io.MslEncoderFactory').implementation = function (data: any, encoder: any) { + const result = this.c(data, encoder); + const esn = extractEsnFromBytes(result); + logMsl("SymmetricCryptoContext.decrypt", data.length + "B -> " + result.length + "B" + (esn ? " sender=" + esn : "")); + logData("msl.symmetric.decrypt", { + sender: esn, + ciphertext_size: data.length, + plaintext_size: result.length, + strings: extractStringsFromBytes(result) + }); + return result; + }; + console.log("[+] Hooked SymmetricCryptoContext.c (decrypt)"); + symDecryptHooked = true; + } catch (e2) { + console.log("[-] SymmetricCryptoContext.c(2): " + e2); + } + if (!symDecryptHooked) { + symMethods.forEach(function (m: any) { + const name = m.getName(); + const params = m.getParameterTypes(); + const retType = m.getReturnType().getName(); + if (params.length === 2 && retType === "[B" && name !== "encrypt" && !symDecryptHooked) { + try { + SymCrypto[name].overload(params[0].getName(), params[1].getName()).implementation = function (data: any, encoder: any) { + const result = this[name](data, encoder); + const esn = extractEsnFromBytes(result); + logMsl("SymmetricCryptoContext.decrypt", data.length + "B -> " + result.length + "B" + (esn ? " sender=" + esn : "")); + logData("msl.symmetric.decrypt", { + sender: esn, + ciphertext_size: data.length, + plaintext_size: result.length, + strings: extractStringsFromBytes(result) + }); + const ab = jbyteArrayToArrayBuffer(result); + if (ab && ab.byteLength > 0) { + try { processMslPlaintext(ab, "decrypt", "Symmetric"); } catch (e) { } + } + return result; + }; + console.log("[+] Hooked SymmetricCryptoContext." + name + " (decrypt fallback)"); + symDecryptHooked = true; + } catch (ex) { + console.log("[-] SymmetricCryptoContext." + name + " decrypt hook failed: " + ex); + } + } + }); + } + + // sign: 'b' with 3 args (byte[], MslEncoderFactory, jOK) -> byte[] + try { + SymCrypto.b.overload('[B', 'com.netflix.msl.io.MslEncoderFactory', 'com.netflix.msl.io.jOK').implementation = function (data: any, encoder: any, format: any) { + const result = this.b(data, encoder, format); + logMsl("SymmetricCryptoContext.sign", "data:" + data.length + "B -> sig:" + result.length + "B"); + logData("msl.symmetric.sign", { + data_size: data.length, + signature_size: result.length + }); + return result; + }; + console.log("[+] Hooked SymmetricCryptoContext.b (sign)"); + } catch (e2) { + console.log("[-] SymmetricCryptoContext.b(3) sign: " + e2); + } + + // verify: 'c' with 3 args (byte[], byte[], MslEncoderFactory) -> boolean + try { + SymCrypto.c.overload('[B', '[B', 'com.netflix.msl.io.MslEncoderFactory').implementation = function (data: any, sig: any, encoder: any) { + const result = this.c(data, sig, encoder); + logMsl("SymmetricCryptoContext.verify", "data:" + data.length + "B -> " + result); + logData("msl.symmetric.verify", { + data_size: data.length, + verified: result + }); + return result; + }; + console.log("[+] Hooked SymmetricCryptoContext.c (verify)"); + } catch (e2) { + console.log("[-] SymmetricCryptoContext.c(3) verify: " + e2); + } + } catch (e) { + console.log("[-] SymmetricCryptoContext: " + e); + } + + // ------------------------------------------------------- + // JsonWebEncryptionCryptoContext -- JWE暗号 + // wrap -> c(byte[], MslEncoderFactory, jOK) + // unwrap -> d(byte[], MslEncoderFactory) + // ------------------------------------------------------- + try { + const JweCrypto = Java.use("com.netflix.msl.crypto.JsonWebEncryptionCryptoContext"); + const jweMethods = JweCrypto.class.getDeclaredMethods(); + jweMethods.forEach(function (m: any) { + const name = m.getName(); + const paramCount = m.getParameterTypes().length; + const retType = m.getReturnType().getName(); + console.log("[*] JWECryptoContext." + name + "(" + paramCount + ") -> " + retType); + }); + + // wrap (encrypt): 3 args (byte[], MslEncoderFactory, jOK) -> byte[] + try { + JweCrypto.encrypt.implementation = function (data: any, encoder: any, format: any) { + const result = this.encrypt(data, encoder, format); + logMsl("JWECryptoContext.wrap", "data:" + data.length + "B -> " + result.length + "B"); + logData("msl.jwe.wrap", { + plaintext_size: data.length, + wrapped_size: result.length + }); + return result; + }; + console.log("[+] Hooked JWECryptoContext.encrypt (wrap)"); + } catch (e2) { + console.log("[-] JWECryptoContext.encrypt: " + e2); + // Fallback: try 'c' with 3 args -> byte[] + jweMethods.forEach(function (m: any) { + const name = m.getName(); + const params = m.getParameterTypes(); + const retType = m.getReturnType().getName(); + if (params.length === 3 && retType === "[B") { + try { + JweCrypto[name].overload(params[0].getName(), params[1].getName(), params[2].getName()).implementation = function (data: any, encoder: any, format: any) { + const result = this[name](data, encoder, format); + logMsl("JWECryptoContext.wrap", "data:" + data.length + "B -> " + result.length + "B"); + logData("msl.jwe.wrap", { + plaintext_size: data.length, + wrapped_size: result.length + }); + return result; + }; + console.log("[+] Hooked JWECryptoContext." + name + " (wrap fallback)"); + } catch (ex) { + console.log("[-] JWECryptoContext." + name + " wrap hook failed: " + ex); + } + } + }); + } + + // unwrap (decrypt): 2 args (byte[], MslEncoderFactory) -> byte[] + let jweDecryptHooked = false; + try { + JweCrypto.c.overload('[B', 'com.netflix.msl.io.MslEncoderFactory').implementation = function (data: any, encoder: any) { + const result = this.c(data, encoder); + logMsl("JWECryptoContext.unwrap", data.length + "B -> " + result.length + "B"); + logData("msl.jwe.unwrap", { + ciphertext_size: data.length, + plaintext_size: result.length + }); + return result; + }; + console.log("[+] Hooked JWECryptoContext.c (unwrap)"); + jweDecryptHooked = true; + } catch (e2) { + console.log("[-] JWECryptoContext.c(2): " + e2); + } + if (!jweDecryptHooked) { + // Fallback: try 'd' with 2 args, or enumerate + try { + JweCrypto.d.overload('[B', 'com.netflix.msl.io.MslEncoderFactory').implementation = function (data: any, encoder: any) { + const result = this.d(data, encoder); + logMsl("JWECryptoContext.unwrap", data.length + "B -> " + result.length + "B"); + logData("msl.jwe.unwrap", { + ciphertext_size: data.length, + plaintext_size: result.length + }); + return result; + }; + console.log("[+] Hooked JWECryptoContext.d (unwrap fallback)"); + jweDecryptHooked = true; + } catch (e3) { + console.log("[-] JWECryptoContext.d(2): " + e3); + } + } + if (!jweDecryptHooked) { + jweMethods.forEach(function (m: any) { + const name = m.getName(); + const params = m.getParameterTypes(); + const retType = m.getReturnType().getName(); + if (params.length === 2 && retType === "[B" && name !== "encrypt" && !jweDecryptHooked) { + try { + JweCrypto[name].overload(params[0].getName(), params[1].getName()).implementation = function (data: any, encoder: any) { + const result = this[name](data, encoder); + logMsl("JWECryptoContext.unwrap", data.length + "B -> " + result.length + "B"); + logData("msl.jwe.unwrap", { + ciphertext_size: data.length, + plaintext_size: result.length + }); + return result; + }; + console.log("[+] Hooked JWECryptoContext." + name + " (unwrap enum fallback)"); + jweDecryptHooked = true; + } catch (ex) { + console.log("[-] JWECryptoContext." + name + " unwrap hook failed: " + ex); + } + } + }); + } + + console.log("[+] JsonWebEncryptionCryptoContext hooks complete"); + } catch (e) { + console.log("[-] JWE: " + e); + } +} diff --git a/packages/frida/src/android/msl-state.ts b/packages/frida/src/android/msl-state.ts new file mode 100644 index 0000000..a25f351 --- /dev/null +++ b/packages/frida/src/android/msl-state.ts @@ -0,0 +1,8 @@ +// Track current MSL request context for correlating crypto events +export let mslCurrentUrl: string | null = null; +export let mslCurrentDomain: string | null = null; + +export function setMslContext(url: string | null, domain: string | null): void { + mslCurrentUrl = url; + mslCurrentDomain = domain; +} diff --git a/packages/frida/src/android/msl.ts b/packages/frida/src/android/msl.ts new file mode 100644 index 0000000..b833eb9 --- /dev/null +++ b/packages/frida/src/android/msl.ts @@ -0,0 +1,649 @@ +import { logData, logMsl } from "../common/utils"; +import { jbyteArrayToString, jbyteArrayToBase64 } from "./utils"; +import { setMslContext } from "./msl-state"; + +export function hookMSL(): void { + // ------------------------------------------------------- + // ApiHandlerImpl.apiRequest -- MSL APIリクエストのエントリポイント + // Signature: apiRequest(String url, byte[] body, Map headers, + // String userId, UserAuthenticationData auth, boolean, Object, List, boolean) + // ------------------------------------------------------- + try { + const ApiHandler = Java.use("com.netflix.msl.client.impl.handler.ApiHandlerImpl"); + // apiRequest は9引数メソッド + const methods = ApiHandler.class.getDeclaredMethods(); + methods.forEach(function (m: any) { + const name = m.getName(); + const paramCount = m.getParameterTypes().length; + console.log("[*] ApiHandlerImpl." + name + "(" + paramCount + ")"); + + // apiRequest (9 params) をフック + if (name === "apiRequest" && paramCount >= 5) { + try { + ApiHandler[name].implementation = function () { + const url = arguments[0] ? arguments[0].toString() : "?"; + const domainMatch = url.match(/^https?:\/\/([^\/\?:]+)/); + const domain = domainMatch ? domainMatch[1] : "msl.netflix.com"; + setMslContext(url, domain); + + // logblob / cl ログはノイズなのでスキップ + if (url.indexOf("/logblob/") !== -1 || url.indexOf("/log/android/cl/") !== -1) { + return this[name].apply(this, arguments); + } + const bodyBytes = arguments[1]; + const bodyStr = bodyBytes ? jbyteArrayToString(bodyBytes) : null; + const bodySize = bodyBytes ? bodyBytes.length : 0; + + // 3番目の引数: リクエストヘッダー Map + const reqHeaders: Record = {}; + try { + const headerMap = arguments[2]; + if (headerMap) { + const entrySet = headerMap.entrySet(); + const iter = entrySet.iterator(); + while (iter.hasNext()) { + const entry = iter.next(); + reqHeaders[entry.getKey().toString()] = entry.getValue().toString(); + } + } + } catch (he) { } + + // 5番目の引数: UserAuthenticationData auth + let userAuthData: any = null; + try { + const authObj = arguments[4]; + if (authObj) { + userAuthData = {}; + // getScheme() -> UserAuthenticationScheme + try { + const scheme = authObj.getScheme(); + userAuthData.scheme = scheme ? scheme.toString() : null; + } catch (_) { + // ProGuard: try common obfuscated names + try { userAuthData.scheme = authObj.a().toString(); } catch (_) {} + } + // toMslObject() / getAuthData() でJSON表現を取得 + try { + let mslObj: any = null; + // 標準メソッド名を試す + try { mslObj = authObj.toMslObject(null, null); } catch (_) {} + if (!mslObj) { + try { mslObj = authObj.getAuthData(null, null); } catch (_) {} + } + if (mslObj) { + userAuthData.mslObject = mslObj.toString(); + } + } catch (_) {} + // toString() でフォールバック + try { + userAuthData.toString = authObj.toString(); + } catch (_) {} + // クラス名を記録(ProGuard後の実クラスを確認するため) + userAuthData.className = authObj.getClass().getName(); + // 全フィールドを動的に読み取る + try { + const fields = authObj.getClass().getDeclaredFields(); + const fieldData: Record = {}; + for (let fi = 0; fi < fields.length; fi++) { + try { + fields[fi].setAccessible(true); + const fName = fields[fi].getName(); + const fVal = fields[fi].get(authObj); + fieldData[fName] = fVal ? fVal.toString() : null; + } catch (_) {} + } + userAuthData.fields = fieldData; + } catch (_) {} + // スーパークラスのフィールドも読み取る + try { + const superFields = authObj.getClass().getSuperclass().getDeclaredFields(); + const superFieldData: Record = {}; + for (let si = 0; si < superFields.length; si++) { + try { + superFields[si].setAccessible(true); + const sfName = superFields[si].getName(); + const sfVal = superFields[si].get(authObj); + superFieldData[sfName] = sfVal ? sfVal.toString() : null; + } catch (_) {} + } + userAuthData.superFields = superFieldData; + } catch (_) {} + logMsl("UserAuthData", JSON.stringify(userAuthData)); + } else { + logMsl("UserAuthData", "null (no auth)"); + } + } catch (authErr) { + logMsl("UserAuthData", "extraction error: " + authErr); + userAuthData = { error: authErr.toString() }; + } + + // 4番目の引数: userId + let userId: string | null = null; + try { + userId = arguments[3] ? arguments[3].toString() : null; + } catch (_) {} + + logMsl("ApiHandlerImpl.apiRequest", url + " (" + bodySize + "B, " + Object.keys(reqHeaders).length + " headers, userId=" + userId + ")"); + if (bodyStr) { + const preview = bodyStr.length > 300 ? bodyStr.substring(0, 300) + "..." : bodyStr; + console.log(" body: " + preview); + } + logData("msl.api", { + domain: domain, + url: url, + headers: reqHeaders, + body_size: bodySize, + userId: userId, + userauthdata: userAuthData, + params: bodyStr ? bodyStr.substring(0, 65536) : null + }); + return this[name].apply(this, arguments); + }; + console.log("[+] Hooked ApiHandlerImpl." + name); + } catch (e2) { + console.log("[-] ApiHandlerImpl." + name + " hook: " + e2); + } + } + }); + } catch (e) { + console.log("[-] ApiHandlerImpl: " + e); + } + + // ------------------------------------------------------- + // BaseHandler.processRequest -- MSLレスポンス処理 + // Reads full response body from MessageInputStream + // ------------------------------------------------------- + try { + const BaseHandler = Java.use("com.netflix.msl.client.impl.handler.BaseHandler"); + const methods = BaseHandler.class.getDeclaredMethods(); + methods.forEach(function (m: any) { + const name = m.getName(); + const paramCount = m.getParameterTypes().length; + const retType = m.getReturnType().getName(); + console.log("[*] BaseHandler." + name + "(" + paramCount + ") -> " + retType); + + // processRequest returns the parsed response (byte[] or String) + if (name === "processRequest" && paramCount === 1) { + try { + BaseHandler[name].implementation = function (request: any) { + const result = this[name](request); + try { + if (result) { + const cls = result.getClass(); + const allFields = cls.getDeclaredFields(); + + // デバッグ: フィールド構造をダンプ + const fieldInfo: string[] = []; + for (let di = 0; di < allFields.length; di++) { + try { + allFields[di].setAccessible(true); + const fn = allFields[di].getName(); + const ft = allFields[di].getType().getName(); + const fv = allFields[di].get(result); + fieldInfo.push(fn + ":" + ft + "=" + (fv ? "(" + (typeof fv) + ")" : "null")); + } catch (_) { + fieldInfo.push(allFields[di].getName() + ":?"); + } + } + // スーパークラスも + try { + const superFields = cls.getSuperclass().getDeclaredFields(); + for (let si = 0; si < superFields.length; si++) { + try { + superFields[si].setAccessible(true); + const fn = superFields[si].getName(); + const ft = superFields[si].getType().getName(); + const fv = superFields[si].get(result); + fieldInfo.push("super." + fn + ":" + ft + "=" + (fv ? "(" + (typeof fv) + ")" : "null")); + } catch (_) { } + } + } catch (_) { } + console.log("[DBG] processRequest result class=" + cls.getName() + " fields=[" + fieldInfo.join(", ") + "]"); + + // 動的フィールド探索: byte[] → body, Map → headers + let responseStr: string | null = null; + let domain = "msl.netflix.com"; + let originUrl: string | null = null; + const headers: Record = {}; + + for (let fi = 0; fi < allFields.length; fi++) { + try { + allFields[fi].setAccessible(true); + const fieldType = allFields[fi].getType().getName(); + const fieldVal = allFields[fi].get(result); + if (!fieldVal) continue; + + // byte[] → response body + if (fieldType === "[B" && !responseStr) { + const arrLen = fieldVal.length; + console.log("[DBG] byte[] field '" + allFields[fi].getName() + "' length=" + arrLen); + if (arrLen > 0) { + const str = jbyteArrayToString(fieldVal); + console.log("[DBG] jbyteArrayToString result: " + (str ? str.length + " chars, first50=" + str.substring(0, 50) : "null")); + if (str && str.length > 0) { + responseStr = str; + } + } + } + + // Map → headers + if (fieldType === "java.util.Map" || fieldType.indexOf("Map") !== -1) { + try { + const entrySet = fieldVal.entrySet(); + const iter = entrySet.iterator(); + while (iter.hasNext()) { + const entry = iter.next(); + const key = entry.getKey().toString(); + let val = entry.getValue().toString(); + if (val.startsWith("[") && val.endsWith("]")) { + val = val.substring(1, val.length - 1); + } + headers[key] = val; + } + if (headers["x-originating-url"]) { + originUrl = headers["x-originating-url"]; + const domainMatch = originUrl.match(/^https?:\/\/([^\/\?:]+)/); + if (domainMatch) domain = domainMatch[1]; + } + } catch (_me) { } + } + } catch (_fe) { } + } + + // スーパークラスのフィールドも探索 + if (!responseStr) { + try { + const superFields = cls.getSuperclass().getDeclaredFields(); + for (let si = 0; si < superFields.length; si++) { + try { + superFields[si].setAccessible(true); + const fieldType = superFields[si].getType().getName(); + const fieldVal = superFields[si].get(result); + if (!fieldVal) continue; + if (fieldType === "[B") { + const str = jbyteArrayToString(fieldVal); + if (str && str.length > 0) { + responseStr = str; + break; + } + } + } catch (_sfe) { } + } + } catch (_se) { } + } + + const bodySize = responseStr ? responseStr.length : 0; + const preview = responseStr ? (bodySize > 300 ? responseStr.substring(0, 300) + "..." : responseStr) : "(no body)"; + logMsl("processRequest.response", "(" + bodySize + "B) " + (originUrl || "") + ": " + preview); + + logData("msl.api.response", { + domain: domain, + url: originUrl, + headers: headers, + response: responseStr ? responseStr.substring(0, 262144) : null, + size: bodySize + }); + } + } catch (e2) { + console.log("[-] processRequest capture: " + e2); + } + return result; + }; + console.log("[+] Hooked BaseHandler.processRequest"); + } catch (e2) { + console.log("[-] BaseHandler.processRequest hook: " + e2); + } + } + }); + } catch (e) { + console.log("[-] BaseHandler: " + e); + } + + // ------------------------------------------------------- + // PayloadChunk -- MSLペイロードの復号後データ + // 全コンストラクタをフック + getData メソッドもフック + // ------------------------------------------------------- + try { + const PayloadChunk = Java.use("com.netflix.msl.msg.PayloadChunk"); + const ctors = PayloadChunk.class.getDeclaredConstructors(); + console.log("[*] PayloadChunk constructors: " + ctors.length); + ctors.forEach(function (c: any) { + const params = c.getParameterTypes(); + const sig = []; + for (let pi = 0; pi < params.length; pi++) sig.push(params[pi].getName()); + console.log("[*] PayloadChunk(" + params.length + "): " + sig.join(", ")); + }); + + // 全コンストラクタの $init をフック + PayloadChunk.$init.overloads.forEach(function (overload: any) { + const paramCount = overload.argumentTypes.length; + overload.implementation = function () { + // 7-arg: 6番目の引数が byte[] (ペイロードデータ) + if (paramCount === 7 && arguments[5]) { + try { + const payloadBytes = arguments[5]; + if (payloadBytes && payloadBytes.length > 0) { + const str = jbyteArrayToString(payloadBytes); + if (str && str.length > 0) { + const preview = str.length > 300 ? str.substring(0, 300) + "..." : str; + logMsl("PayloadChunk.init(7)", "(" + payloadBytes.length + "B): " + preview); + logData("msl.payload", { + domain: "msl.netflix.com", + size: payloadBytes.length, + body: str.substring(0, 65536) + }); + } + } + } catch (pe) { + console.log("[-] PayloadChunk 7-arg payload: " + pe); + } + } + + overload.apply(this, arguments); + + // 3-arg (parse ctor): コンストラクタ後にフィールドから読み取り + if (paramCount === 3) { + try { + const fields = PayloadChunk.class.getDeclaredFields(); + for (let fj = 0; fj < fields.length; fj++) { + try { + if (fields[fj].getType().getName() !== "[B") continue; + fields[fj].setAccessible(true); + const val = fields[fj].get(this); + if (val && val.length > 0) { + const str = jbyteArrayToString(val); + if (str && str.length > 0) { + const preview = str.length > 300 ? str.substring(0, 300) + "..." : str; + logMsl("PayloadChunk.init(3)", "(" + val.length + "B, field=" + fields[fj].getName() + "): " + preview); + logData("msl.payload", { + domain: "msl.netflix.com", + size: val.length, + body: str.substring(0, 65536) + }); + break; + } + } + } catch (_) { } + } + } catch (_) { } + } + }; + }); + console.log("[+] Hooked PayloadChunk all " + PayloadChunk.$init.overloads.length + " constructors"); + + // getData もフック (読み取り時のフォールバック) + try { + const getDataMethods = PayloadChunk.class.getDeclaredMethods(); + let getDataHooked = false; + getDataMethods.forEach(function (m: any) { + if (m.getReturnType().getName() === "[B" && m.getParameterTypes().length === 0 && !getDataHooked) { + const methodName = m.getName(); + try { + PayloadChunk[methodName].implementation = function () { + const data = this[methodName](); + if (data && data.length > 0) { + const str = jbyteArrayToString(data); + if (str && str.length > 0) { + const preview = str.length > 300 ? str.substring(0, 300) + "..." : str; + logMsl("PayloadChunk." + methodName, "(" + data.length + "B): " + preview); + logData("msl.payload", { domain: "msl.netflix.com", size: data.length, body: str.substring(0, 65536) }); + } + } + return data; + }; + console.log("[+] Hooked PayloadChunk." + methodName + " (getData)"); + getDataHooked = true; + } catch (_) { } + } + }); + } catch (_) { } + } catch (e) { + console.log("[-] PayloadChunk: " + e); + } + + // ------------------------------------------------------- + // MessageInputStream.read -- MSLレスポンスの読み取り (復号後) + // チャンクを蓄積して、read=-1で完全なペイロードをログ + // ------------------------------------------------------- + try { + const MessageInputStream = Java.use("com.netflix.msl.msg.MessageInputStream"); + const streamBuffers: Record = {}; + let streamSeq = 0; + + MessageInputStream.read.overload("[B", "int", "int").implementation = function (buf: any, off: any, len: any) { + // Assign a unique ID to this stream instance + if (!this.__msl_stream_id__) { + this.__msl_stream_id__ = ++streamSeq; + } + const sid = this.__msl_stream_id__; + + const bytesRead = this.read(buf, off, len); + if (bytesRead > 0) { + try { + const JavaString = Java.use("java.lang.String"); + const str = JavaString.$new(buf, off, bytesRead, "UTF-8"); + const s = str.toString(); + if (!streamBuffers[sid]) { + streamBuffers[sid] = ""; + } + streamBuffers[sid] += s; + } catch (e2) { } + } else if (bytesRead === -1) { + // Stream finished -- emit accumulated payload + const accumulated = streamBuffers[sid]; + if (accumulated && accumulated.length > 0 && accumulated.indexOf("{") !== -1) { + const preview = accumulated.length > 300 ? accumulated.substring(0, 300) + "..." : accumulated; + logMsl("MessageInputStream.complete", "(" + accumulated.length + "B): " + preview); + logData("msl.response.payload", { + domain: "msl.netflix.com", + size: accumulated.length, + body: accumulated.substring(0, 262144) + }); + } + delete streamBuffers[sid]; + } + return bytesRead; + }; + // Also hook close() to flush any remaining buffered data + try { + MessageInputStream.close.implementation = function () { + const sid = this.__msl_stream_id__; + if (sid && streamBuffers[sid]) { + const accumulated = streamBuffers[sid]; + if (accumulated.length > 0 && accumulated.indexOf("{") !== -1) { + const preview = accumulated.length > 300 ? accumulated.substring(0, 300) + "..." : accumulated; + logMsl("MessageInputStream.close", "(" + accumulated.length + "B): " + preview); + logData("msl.response.payload", { + domain: "msl.netflix.com", + size: accumulated.length, + body: accumulated.substring(0, 262144) + }); + } + delete streamBuffers[sid]; + } + return this.close(); + }; + } catch (e2) { + console.log("[-] MessageInputStream.close hook: " + e2); + } + + console.log("[+] Hooked MessageInputStream.read (chunked accumulator)"); + } catch (e) { + console.log("[-] MessageInputStream: " + e); + } + + // ------------------------------------------------------- + // MslControl.e -- MSLリクエスト送信 (request submit) + // ------------------------------------------------------- + try { + const MslControl = Java.use("com.netflix.msl.msg.MslControl"); + const methods = MslControl.class.getDeclaredMethods(); + methods.forEach(function (m: any) { + const name = m.getName(); + const paramCount = m.getParameterTypes().length; + if (name === "e" && paramCount === 3) { + console.log("[*] MslControl.e(3) -- primary request method"); + } + }); + } catch (e) { + console.log("[-] MslControl: " + e); + } + + // ------------------------------------------------------- + // AppbootHandlerImpl -- appboot ハンドラ + // ------------------------------------------------------- + try { + const AppbootHandler = Java.use("com.netflix.msl.client.impl.handler.AppbootHandlerImpl"); + const methods = AppbootHandler.class.getDeclaredMethods(); + methods.forEach(function (m: any) { + const name = m.getName(); + const paramCount = m.getParameterTypes().length; + const retType = m.getReturnType().getName(); + console.log("[*] AppbootHandler." + name + "(" + paramCount + ") -> " + retType); + + // Hook appbootExecute or doAppbootRequest to capture appboot response + if (name === "appbootExecute" && paramCount === 2) { + try { + AppbootHandler[name].implementation = function (arg0: any, arg1: any) { + const result = this[name](arg0, arg1); + try { + if (result) { + const s = result.toString(); + const preview = s.length > 300 ? s.substring(0, 300) + "..." : s; + logMsl("AppbootHandler.appbootExecute", "response (" + s.length + "B): " + preview); + logData("appboot.response", { + domain: "appboot.netflix.com", + response: s.substring(0, 65536), + size: s.length + }); + } + } catch (e2) { + console.log("[-] appbootExecute capture: " + e2); + } + return result; + }; + console.log("[+] Hooked AppbootHandler.appbootExecute"); + } catch (e2) { + console.log("[-] AppbootHandler.appbootExecute hook: " + e2); + } + } + }); + } catch (e) { + console.log("[-] AppbootHandler: " + e); + } + + // ------------------------------------------------------- + // UserAuthenticationData サブクラスの生成を追跡 + // 各認証スキームのコンストラクタをフックし、生成時のデータを記録 + // ------------------------------------------------------- + const authClasses = [ + { name: "EmailPasswordAuthenticationData", pkg: "com.netflix.msl.userauth.EmailPasswordAuthenticationData" }, + { name: "NetflixIdAuthenticationData", pkg: "com.netflix.msl.userauth.NetflixIdAuthenticationData" }, + { name: "UserIdTokenAuthenticationData", pkg: "com.netflix.msl.userauth.UserIdTokenAuthenticationData" }, + { name: "SsoTokenAuthenticationData", pkg: "com.netflix.msl.userauth.SsoTokenAuthenticationData" }, + { name: "SwitchProfileAuthenticationData", pkg: "com.netflix.msl.userauth.SwitchProfileAuthenticationData" }, + ]; + authClasses.forEach(function (cls) { + try { + const AuthClass = Java.use(cls.pkg); + // コンストラクタをフック + const ctors = AuthClass.class.getDeclaredConstructors(); + console.log("[*] " + cls.name + ": " + ctors.length + " constructor(s)"); + AuthClass.$init.overloads.forEach(function (overload: any) { + overload.implementation = function () { + const args: any[] = []; + for (let ai = 0; ai < arguments.length; ai++) { + try { + args.push(arguments[ai] ? arguments[ai].toString() : null); + } catch (_) { + args.push(""); + } + } + logMsl("AUTH." + cls.name, "created with " + arguments.length + " args: " + JSON.stringify(args)); + const result = this.$init.apply(this, arguments); + // 生成後のフィールドを読み取り + const fieldData: Record = {}; + try { + const fields = this.getClass().getDeclaredFields(); + for (let fi = 0; fi < fields.length; fi++) { + try { + fields[fi].setAccessible(true); + const fName = fields[fi].getName(); + const fVal = fields[fi].get(this); + fieldData[fName] = fVal ? fVal.toString() : null; + } catch (_) {} + } + } catch (_) {} + // スーパークラスのフィールドも + try { + const superFields = this.getClass().getSuperclass().getDeclaredFields(); + for (let si = 0; si < superFields.length; si++) { + try { + superFields[si].setAccessible(true); + const sfName = superFields[si].getName(); + const sfVal = superFields[si].get(this); + fieldData["super." + sfName] = sfVal ? sfVal.toString() : null; + } catch (_) {} + } + } catch (_) {} + logMsl("AUTH." + cls.name, "fields: " + JSON.stringify(fieldData)); + logData("msl.userauthdata", { + type: cls.name, + scheme: cls.name.replace("AuthenticationData", ""), + constructorArgs: args, + fields: fieldData + }); + return result; + }; + }); + console.log("[+] Hooked " + cls.name + " constructors"); + } catch (e: any) { + console.log("[-] " + cls.name + ": " + e.message); + // ProGuardで難読化されている可能性 -> クラス名を探索 + if (e.message && e.message.indexOf("ClassNotFoundException") !== -1) { + console.log("[*] " + cls.name + " not found (ProGuard?), trying base class scan..."); + } + } + }); + + // UserAuthenticationData 基底クラスのフック(サブクラスが見つからない場合の保険) + try { + const BaseUserAuth = Java.use("com.netflix.msl.userauth.UserAuthenticationData"); + console.log("[*] UserAuthenticationData base class found"); + // 全メソッドをリスト + const baseMethods = BaseUserAuth.class.getDeclaredMethods(); + baseMethods.forEach(function (m: any) { + console.log("[*] UserAuthenticationData." + m.getName() + "(" + m.getParameterTypes().length + ")"); + }); + // getScheme メソッドをフック(呼ばれるたびにスキーム名を記録) + try { + BaseUserAuth.getScheme.implementation = function () { + const scheme = this.getScheme.call(this); + logMsl("AUTH.getScheme", "-> " + scheme + " (class: " + this.getClass().getName() + ")"); + return scheme; + }; + console.log("[+] Hooked UserAuthenticationData.getScheme"); + } catch (_) { + // ProGuard: scheme取得メソッドが難読化されている場合 + console.log("[-] UserAuthenticationData.getScheme not found (ProGuard?)"); + } + } catch (e: any) { + console.log("[-] UserAuthenticationData base: " + e.message); + } + + // IosMslClient.makeUserAuthData に相当するAndroid版を探索 + // Android版では MslClient / MslControlImpl 等が該当する可能性 + try { + const MslClient = Java.use("com.netflix.msl.client.impl.MslClientImpl"); + const clientMethods = MslClient.class.getDeclaredMethods(); + clientMethods.forEach(function (m: any) { + const mName = m.getName(); + // userAuth関連メソッドを探す + if (mName.toLowerCase().indexOf("auth") !== -1 || mName.toLowerCase().indexOf("user") !== -1) { + console.log("[*] MslClientImpl." + mName + "(" + m.getParameterTypes().length + " params) -> " + m.getReturnType().getName()); + } + }); + } catch (e: any) { + console.log("[-] MslClientImpl: " + e.message); + } +} diff --git a/packages/frida/src/android/ssl-capture.ts b/packages/frida/src/android/ssl-capture.ts new file mode 100644 index 0000000..48ac895 --- /dev/null +++ b/packages/frida/src/android/ssl-capture.ts @@ -0,0 +1,186 @@ +import { logData } from "../common/utils"; + +// Parse HTTP/1.1 request line + Host header from text data +function parseHttpRequest(text: string): { method: string; url: string; host: string } | null { + const m = text.match(/^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+(\S+)\s+HTTP\/\d/); + if (!m) return null; + const method = m[1]; + const path = m[2]; + const hostMatch = text.match(/\r?\nHost:\s*(\S+)/i); + const host = hostMatch ? hostMatch[1] : "unknown"; + const url = "https://" + host + path; + return { method: method, url: url, host: host }; +} + +// Parse HTTP/1.1 response status line +function parseHttpResponse(text: string): { status: number; statusText: string } | null { + const m = text.match(/^HTTP\/[\d.]+\s+(\d+)\s*(.*)/); + if (!m) return null; + return { status: parseInt(m[1]), statusText: m[2] }; +} + +// Extract body from HTTP text (after blank line) +function extractBody(text: string): string | null { + let idx = text.indexOf("\r\n\r\n"); + if (idx === -1) idx = text.indexOf("\n\n"); + if (idx === -1) return null; + const body = text.substring(idx + (text[idx + 1] === '\n' ? 2 : 4)); + return body.length > 0 ? body : null; +} + +// Extract all HTTP headers from raw HTTP text +function extractHeaders(text: string): Record { + const headers: Record = {}; + let idx = text.indexOf("\r\n\r\n"); + if (idx === -1) idx = text.indexOf("\n\n"); + const headerBlock = idx !== -1 ? text.substring(0, idx) : text; + const lines = headerBlock.split(/\r?\n/); + // Skip first line (request/status line) + for (let i = 1; i < lines.length; i++) { + const colon = lines[i].indexOf(":"); + if (colon > 0) { + const key = lines[i].substring(0, colon).trim(); + const val = lines[i].substring(colon + 1).trim(); + headers[key] = val; + } + } + return headers; +} + +export function hookSSL(): void { + let connId = 0; + const sslConnMap: Record = {}; + // Track per-connection state: last request URL for matching responses + const connLastUrl: Record = {}; + const connLastMethod: Record = {}; + + // Android uses BoringSSL in libssl.so (or via Conscrypt/Cronet) + // Netflix may bundle its own SSL in a renamed or embedded library + let ssl_write: NativePointer | null = null; + const sslLibs = ["libssl.so", "libsscronet.so", "libcronet.so", "libconscrypt_jni.so", "libgmscore.so"]; + for (let i = 0; i < sslLibs.length && !ssl_write; i++) { + try { ssl_write = Module.findExportByName(sslLibs[i], "SSL_write"); } catch (e) { } + } + if (!ssl_write) { + try { ssl_write = Module.findExportByName(null, "SSL_write"); } catch (e) { } + } + if (!ssl_write) { + // Search all loaded modules for SSL_write + Process.enumerateModules().forEach(function (m) { + if (ssl_write) return; + try { + const exp = m.findExportByName("SSL_write"); + if (exp) { + console.log("[*] Found SSL_write in " + m.name); + ssl_write = exp; + } + } catch (e) { } + }); + } + + if (ssl_write) { + Interceptor.attach(ssl_write, { + onEnter: function (args) { + const ssl = args[0].toString(); + const buf = args[1]; + const len = args[2].toInt32(); + + if (!sslConnMap[ssl]) sslConnMap[ssl] = "conn_" + (connId++); + const connName = sslConnMap[ssl]; + + try { + const data = buf.readUtf8String(len); + const req = parseHttpRequest(data!); + if (req) { + connLastUrl[connName] = req.url; + connLastMethod[connName] = req.method; + const body = extractBody(data!); + const hdrs = extractHeaders(data!); + logData("http.request", { + domain: req.host, + method: req.method, + url: req.url, + headers: hdrs, + size: len, + body: body ? body.substring(0, 65536) : null + }); + } + } catch (e) { + // binary data -- log raw for non-HTTP/2 preface + const bytes = buf.readByteArray(Math.min(len, 4)); + const b = new Uint8Array(bytes!); + // Skip HTTP/2 binary frames (type byte at offset 3) + if (len > 9 && !(b[0] === 0x50 && b[1] === 0x52 && b[2] === 0x49)) { + logData("ssl.write", { + conn: connName, + size: len + }, buf.readByteArray(Math.min(len, 8192))!); + } + } + } + }); + console.log("[+] Hooked SSL_write"); + } else { + console.log("[-] SSL_write not found"); + } + + let ssl_read: NativePointer | null = null; + for (let j = 0; j < sslLibs.length && !ssl_read; j++) { + try { ssl_read = Module.findExportByName(sslLibs[j], "SSL_read"); } catch (e) { } + } + if (!ssl_read) { + try { ssl_read = Module.findExportByName(null, "SSL_read"); } catch (e) { } + } + if (!ssl_read) { + Process.enumerateModules().forEach(function (m) { + if (ssl_read) return; + try { + const exp = m.findExportByName("SSL_read"); + if (exp) { + console.log("[*] Found SSL_read in " + m.name); + ssl_read = exp; + } + } catch (e) { } + }); + } + + if (ssl_read) { + Interceptor.attach(ssl_read, { + onEnter: function (args) { + this.ssl = args[0].toString(); + this.buf = args[1]; + }, + onLeave: function (retval) { + const len = retval.toInt32(); + if (len <= 0) return; + + if (!sslConnMap[this.ssl]) sslConnMap[this.ssl] = "conn_" + (connId++); + const connName = sslConnMap[this.ssl]; + + try { + const data = this.buf.readUtf8String(len); + const resp = parseHttpResponse(data!); + if (resp) { + const body = extractBody(data!); + const hdrs = extractHeaders(data!); + const url = connLastUrl[connName] || ""; + const domain = url.match(/^https?:\/\/([^\/\?:]+)/) ? url.match(/^https?:\/\/([^\/\?:]+)/)![1] : "unknown"; + logData("http.response", { + domain: domain, + url: url, + status: resp.status, + headers: hdrs, + size: len, + body: body ? body.substring(0, 65536) : null + }); + } + } catch (e) { + // binary response -- skip logging individual HTTP/2 frames + } + } + }); + console.log("[+] Hooked SSL_read"); + } else { + console.log("[-] SSL_read not found"); + } +} diff --git a/packages/frida/src/android/ssl-pinning.ts b/packages/frida/src/android/ssl-pinning.ts new file mode 100644 index 0000000..335b0b2 --- /dev/null +++ b/packages/frida/src/android/ssl-pinning.ts @@ -0,0 +1,51 @@ +export function hookSSLPinning(): void { + // --- TrustManager 回避 --- + try { + const X509TrustManager = Java.use("javax.net.ssl.X509TrustManager"); + const SSLContext = Java.use("javax.net.ssl.SSLContext"); + const TrustManager = Java.registerClass({ + name: "com.netflix.frida.TrustManager", + implements: [X509TrustManager], + methods: { + checkClientTrusted: function (chain: any, authType: any) { }, + checkServerTrusted: function (chain: any, authType: any) { }, + getAcceptedIssuers: function () { return []; } + } + }); + const ctx = SSLContext.getInstance("TLS"); + ctx.init(null, [TrustManager.$new()], null); + SSLContext.setDefault(ctx); + console.log("[+] Bypassed TrustManager (global SSLContext)"); + } catch (e) { + console.log("[-] TrustManager bypass: " + e); + } + + // --- OkHttp CertificatePinner 回避 --- + try { + const CertificatePinner = Java.use("okhttp3.CertificatePinner"); + CertificatePinner.check.overload("java.lang.String", "java.util.List").implementation = function (hostname: any, peerCertificates: any) { + // do nothing + }; + console.log("[+] Bypassed OkHttp CertificatePinner.check(String, List)"); + } catch (e) { + console.log("[-] CertificatePinner List: " + e); + } + try { + const CertificatePinner = Java.use("okhttp3.CertificatePinner"); + CertificatePinner.check$okhttp.overload("java.lang.String", "kotlin.jvm.functions.Function0").implementation = function (hostname: any, peerCertificates: any) { + // do nothing + }; + console.log("[+] Bypassed OkHttp CertificatePinner.check$okhttp"); + } catch (e) { + console.log("[-] CertificatePinner okhttp: " + e); + } + + // --- Android WebViewClient SSL error 回避 --- + try { + const WebViewClient = Java.use("android.webkit.WebViewClient"); + WebViewClient.onReceivedSslError.implementation = function (view: any, handler: any, error: any) { + handler.proceed(); + }; + console.log("[+] Bypassed WebViewClient SSL error"); + } catch (e) { } +} diff --git a/packages/frida/src/android/storage-dump.ts b/packages/frida/src/android/storage-dump.ts new file mode 100644 index 0000000..ee40ce9 --- /dev/null +++ b/packages/frida/src/android/storage-dump.ts @@ -0,0 +1,245 @@ +// ── Android SharedPreferences + アプリストレージダンプ ── + +import { logData } from "../common/utils"; + +export function dumpStorage(): void { + dumpSharedPreferences(); + dumpAppFiles(); +} + +// ── SharedPreferences ── + +function dumpSharedPreferences(): void { + try { + const ActivityThread = Java.use("android.app.ActivityThread"); + const app = ActivityThread.currentApplication(); + const ctx = app.getApplicationContext(); + const appInfo = ctx.getApplicationInfo(); + const dataDir = appInfo.dataDir.value; + + console.log("[*] App dataDir: " + dataDir); + + // SharedPreferences XML ファイルを列挙 + const prefsDir = dataDir + "/shared_prefs"; + const File = Java.use("java.io.File"); + const prefsFile = File.$new(prefsDir); + + if (!prefsFile.exists()) { + console.log("[-] shared_prefs dir not found"); + return; + } + + const files = prefsFile.listFiles(); + if (!files) { + console.log("[-] No SharedPreferences files"); + return; + } + + console.log("[*] SharedPreferences: " + files.length + " files"); + + const keywords = ["netflix", "nf", "ale", "msl", "esn", "drm", "provision", "token", "session", "crypto", "key", "auth", "cookie", "profile", "cdm", "widevine"]; + + for (let i = 0; i < files.length; i++) { + const f = files[i]; + const fname = f.getName(); + console.log(" [SP:FILE] " + fname + " (" + f.length() + "B)"); + + // XML を直接読んでパース + try { + const filePath = prefsDir + "/" + fname; + const FileInputStream = Java.use("java.io.FileInputStream"); + const BufferedReader = Java.use("java.io.BufferedReader"); + const InputStreamReader = Java.use("java.io.InputStreamReader"); + + const fis = FileInputStream.$new(filePath); + const isr = InputStreamReader.$new(fis, "UTF-8"); + const br = BufferedReader.$new(isr); + let xmlContent = ""; + let line = br.readLine(); + while (line !== null) { + xmlContent += line.toString() + "\n"; + line = br.readLine(); + } + br.close(); + + // XML からキー/値を抽出 (行ベースパーサ) + const allEntries: Record = {}; + let matchCount = 0; + const lines = xmlContent.split("\n"); + let pendingTag = ""; + let pendingKey = ""; + let pendingVal = ""; + + for (let li = 0; li < lines.length; li++) { + const ln = lines[li]; + + // value (1行) + let m = ln.match(/<(string)\s+name="([^"]+)">(.*?)<\/\1>/); + if (m) { + allEntries[m[2]] = m[3].replace(/"/g, '"').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); + continue; + } + + // (複数行開始) + m = ln.match(/<(string)\s+name="([^"]+)">/); + if (m) { + pendingTag = m[1]; + pendingKey = m[2]; + pendingVal = ""; + continue; + } + + // (複数行終了) + if (pendingKey && ln.indexOf("") !== -1) { + const endIdx = ln.indexOf(""); + pendingVal += ln.substring(0, endIdx); + allEntries[pendingKey] = pendingVal.replace(/"/g, '"').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); + pendingKey = ""; + pendingTag = ""; + continue; + } + + // 複数行の途中 + if (pendingKey) { + pendingVal += ln + "\n"; + continue; + } + + // + m = ln.match(/<(int|long|float)\s+name="([^"]+)"\s+value="([^"]*)"\s*\/>/); + if (m) { allEntries[m[2]] = m[3]; continue; } + + // + m = ln.match(//); + if (m) { allEntries[m[1]] = m[2]; continue; } + } + + for (const key of Object.keys(allEntries)) { + const val = allEntries[key] || ""; + const keyLower = key.toLowerCase(); + const valLower = val.toLowerCase(); + if (keywords.some(kw => keyLower.indexOf(kw) !== -1 || valLower.indexOf(kw) !== -1)) { + matchCount++; + const display = val.length > 200 ? val.substring(0, 200) + "..." : val; + console.log(" [SP] " + key + " = " + display); + } + } + + logData("storage.sharedPreferences", { + file: fname, + total: Object.keys(allEntries).length, + matchCount: matchCount, + entries: allEntries, + }); + } catch (pe) { + console.log(" [-] Failed to read: " + pe); + } + } + } catch (e) { + console.log("[-] SharedPreferences dump: " + e); + } +} + +// ── アプリファイル探索 ── + +function dumpAppFiles(): void { + try { + const ActivityThread = Java.use("android.app.ActivityThread"); + const app = ActivityThread.currentApplication(); + const ctx = app.getApplicationContext(); + const appInfo = ctx.getApplicationInfo(); + const dataDir = appInfo.dataDir.value; + + const File = Java.use("java.io.File"); + const keywords = ["msl", "ale", "drm", "provision", "token", "session", "crypto", "key", "esn", "netflix", "widevine", "cdm"]; + + const searchDirs = [ + dataDir + "/files", + dataDir + "/cache", + dataDir + "/databases", + dataDir + "/app_webview", + dataDir + "/no_backup", + ]; + + const allFiles: { path: string; size: number; isDir: boolean }[] = []; + + for (const dir of searchDirs) { + try { + const dirFile = File.$new(dir); + if (!dirFile.exists() || !dirFile.isDirectory()) continue; + + listFilesRecursive(dirFile, keywords, allFiles, 2); + } catch (_) { } + } + + console.log("[*] App files: " + allFiles.length + " relevant files/dirs"); + for (const f of allFiles) { + const type = f.isDir ? "DIR " : "FILE"; + const sizeStr = f.isDir ? "" : " (" + f.size + "B)"; + console.log(" [FS:" + type + "] " + f.path + sizeStr); + } + + // 小さいテキストファイル (< 4KB) の中身を読む + const BufferedReader = Java.use("java.io.BufferedReader"); + const InputStreamReader = Java.use("java.io.InputStreamReader"); + const FileInputStream = Java.use("java.io.FileInputStream"); + + for (const f of allFiles) { + if (f.isDir || f.size === 0 || f.size > 4096) continue; + try { + const fis = FileInputStream.$new(f.path); + const isr = InputStreamReader.$new(fis, "UTF-8"); + const br = BufferedReader.$new(isr); + let content = ""; + let line = br.readLine(); + while (line !== null) { + content += line + "\n"; + line = br.readLine(); + } + br.close(); + + if (content.length > 0) { + const preview = content.length > 300 ? content.substring(0, 300) + "..." : content; + console.log(" [FS:CONTENT] " + f.path + ": " + preview); + logData("storage.file", { path: f.path, size: f.size, content: content.substring(0, 8192) }); + } + } catch (_) { } + } + + logData("storage.appFiles", { + dataDir: dataDir, + files: allFiles, + }); + } catch (e) { + console.log("[-] App files dump: " + e); + } +} + +function listFilesRecursive( + dir: any, + keywords: string[], + result: { path: string; size: number; isDir: boolean }[], + maxDepth: number, +): void { + if (maxDepth <= 0) return; + try { + const files = dir.listFiles(); + if (!files) return; + + for (let i = 0; i < files.length; i++) { + const f = files[i]; + const name = f.getName().toLowerCase(); + const isDir = f.isDirectory(); + const size = isDir ? 0 : f.length(); + const match = keywords.some(kw => name.indexOf(kw) !== -1); + + if (match) { + result.push({ path: f.getAbsolutePath(), size, isDir }); + } + + if (isDir) { + listFilesRecursive(f, keywords, result, maxDepth - 1); + } + } + } catch (_) { } +} diff --git a/packages/frida/src/android/utils.ts b/packages/frida/src/android/utils.ts new file mode 100644 index 0000000..5120889 --- /dev/null +++ b/packages/frida/src/android/utils.ts @@ -0,0 +1,71 @@ +import { bytesToBase64 } from "../common/utils"; + +// Java byte[] -> ArrayBuffer +export function jbyteArrayToArrayBuffer(jarray: any): ArrayBuffer | null { + if (!jarray) return null; + const len = jarray.length; + if (len === 0) return new ArrayBuffer(0); + const buf = new Uint8Array(len); + for (let i = 0; i < len; i++) { + buf[i] = jarray[i] & 0xff; + } + return buf.buffer; +} + +// Java byte[] -> base64 string +export function jbyteArrayToBase64(jarray: any): string | null { + if (!jarray) return null; + const len = jarray.length; + if (len === 0) return ""; + const buf = new Uint8Array(len); + for (let i = 0; i < len; i++) { + buf[i] = jarray[i] & 0xff; + } + return bytesToBase64(buf.buffer); +} + +// Java byte[] -> UTF-8 JS string (best effort) +export function jbyteArrayToString(jarray: any): string | null { + if (!jarray) return null; + try { + const String = Java.use("java.lang.String"); + // .toString() converts Java String -> JS string for JSON.stringify + return String.$new(jarray, "UTF-8").toString(); + } catch (e) { return null; } +} + +// Java byte[] から NFANDROID ESN を抽出 +export function extractEsnFromBytes(jarray: any): string | null { + if (!jarray) return null; + const len = jarray.length; + let str = ""; + for (let i = 0; i < len; i++) { + const b = jarray[i] & 0xff; + if (b >= 0x20 && b <= 0x7e) { + str += String.fromCharCode(b); + } else { + str += "\x00"; + } + } + const match = str.match(/NFANDROID1-[A-Z0-9=\-]+/); + return match ? match[0] : null; +} + +// Java byte[] から readable な ASCII 文字列を抽出 (8文字以上) +export function extractStringsFromBytes(jarray: any): string[] { + if (!jarray) return []; + const len = jarray.length; + const results: string[] = []; + let cur = ""; + for (let i = 0; i < len; i++) { + const b = jarray[i] & 0xff; + if (b >= 0x20 && b <= 0x7e) { + cur += String.fromCharCode(b); + } else { + if (cur.length >= 8) results.push(cur); + cur = ""; + } + } + if (cur.length >= 8) results.push(cur); + return results; +} diff --git a/packages/frida/src/android/widevine-drm.ts b/packages/frida/src/android/widevine-drm.ts new file mode 100644 index 0000000..9474370 --- /dev/null +++ b/packages/frida/src/android/widevine-drm.ts @@ -0,0 +1,168 @@ +import { logData, logDrm } from "../common/utils"; +import { jbyteArrayToBase64 } from "./utils"; + +export function hookWidevineDRM(): void { + // ------------------------------------------------------- + // NetflixMediaDrm -- Widevine MediaDrm ラッパー + // ------------------------------------------------------- + try { + const NfMediaDrm = Java.use("com.netflix.mediaclient.drm.NetflixMediaDrm"); + + // openSession + try { + const openMethods = NfMediaDrm.class.getDeclaredMethods(); + openMethods.forEach(function (m: any) { + const name = m.getName(); + if (name === "openSession" || name === "closeSession" || + name === "getKeyRequest" || name === "provideKeyResponse" || + name === "getPropertyByteArray" || name === "getPropertyString") { + console.log("[*] NetflixMediaDrm." + name + "(" + m.getParameterTypes().length + ")"); + } + }); + } catch (e) { } + } catch (e) { + console.log("[-] NetflixMediaDrm: " + e); + } + + // ------------------------------------------------------- + // MediaDrm API -- Android標準 DRM API + // ------------------------------------------------------- + try { + const MediaDrm = Java.use("android.media.MediaDrm"); + + // getKeyRequest -- DRMライセンスリクエスト + MediaDrm.getKeyRequest.overload("[B", "[B", "java.lang.String", "int", "java.util.HashMap").implementation = function (scope: any, init: any, mimeType: any, keyType: any, optParams: any) { + const result = this.getKeyRequest(scope, init, mimeType, keyType, optParams); + const reqData = result.getData(); + logDrm("MediaDrm.getKeyRequest type=" + keyType + " mime=" + mimeType + " reqSize=" + reqData.length); + logData("drm.keyRequest", { + keyType: keyType, + mimeType: mimeType, + request_b64: jbyteArrayToBase64(reqData), + request_size: reqData.length + }); + return result; + }; + console.log("[+] Hooked MediaDrm.getKeyRequest"); + + // provideKeyResponse -- DRMライセンスレスポンス + MediaDrm.provideKeyResponse.implementation = function (scope: any, response: any) { + logDrm("MediaDrm.provideKeyResponse scope=" + scope.length + "B response=" + response.length + "B"); + logData("drm.keyResponse", { + scope_b64: jbyteArrayToBase64(scope), + response_b64: jbyteArrayToBase64(response), + response_size: response.length + }); + return this.provideKeyResponse(scope, response); + }; + console.log("[+] Hooked MediaDrm.provideKeyResponse"); + + // openSession + MediaDrm.openSession.overload().implementation = function () { + const sessionId = this.openSession(); + logDrm("MediaDrm.openSession -> sessionId=" + sessionId.length + "B"); + logData("drm.openSession", { + sessionId_b64: jbyteArrayToBase64(sessionId) + }); + return sessionId; + }; + console.log("[+] Hooked MediaDrm.openSession"); + + // getPropertyByteArray -- デバイスID等 + MediaDrm.getPropertyByteArray.implementation = function (name: any) { + const result = this.getPropertyByteArray(name); + logDrm("MediaDrm.getPropertyByteArray('" + name + "') -> " + result.length + "B"); + logData("drm.property", { + name: name, + value_b64: jbyteArrayToBase64(result), + size: result.length + }); + return result; + }; + console.log("[+] Hooked MediaDrm.getPropertyByteArray"); + + // getPropertyString + MediaDrm.getPropertyString.implementation = function (name: any) { + const result = this.getPropertyString(name); + logDrm("MediaDrm.getPropertyString('" + name + "') -> '" + result + "'"); + logData("drm.propertyString", { name: name, value: result }); + return result; + }; + console.log("[+] Hooked MediaDrm.getPropertyString"); + } catch (e) { + console.log("[-] MediaDrm: " + e); + } + + // ------------------------------------------------------- + // MSLWidevineL3CryptoManagerImpl -- L3暗号マネージャ + // ------------------------------------------------------- + try { + const L3Mgr = Java.use("com.netflix.mediaclient.cryptomanager.impl.MSLWidevineL3CryptoManagerImpl"); + const methods = L3Mgr.class.getDeclaredMethods(); + methods.forEach(function (m: any) { + const name = m.getName(); + if (name.indexOf("encrypt") !== -1 || name.indexOf("decrypt") !== -1 || + name.indexOf("sign") !== -1 || name.indexOf("verify") !== -1 || + name.indexOf("wrap") !== -1 || name.indexOf("unwrap") !== -1 || + name.indexOf("provision") !== -1 || name.indexOf("Session") !== -1) { + console.log("[*] MSLWidevineL3CryptoManager." + name); + } + }); + } catch (e) { + console.log("[-] MSLWidevineL3CryptoManager: " + e); + } + + // ------------------------------------------------------- + // MSLWidevineL1CryptoManagerImpl -- L1暗号マネージャ + // ------------------------------------------------------- + try { + const L1Mgr = Java.use("com.netflix.mediaclient.cryptomanager.impl.MSLWidevineL1CryptoManagerImpl"); + const methods = L1Mgr.class.getDeclaredMethods(); + methods.forEach(function (m: any) { + const name = m.getName(); + if (name.indexOf("encrypt") !== -1 || name.indexOf("decrypt") !== -1 || + name.indexOf("sign") !== -1 || name.indexOf("verify") !== -1 || + name.indexOf("wrap") !== -1 || name.indexOf("unwrap") !== -1 || + name.indexOf("provision") !== -1 || name.indexOf("Session") !== -1) { + console.log("[*] MSLWidevineL1CryptoManager." + name); + } + }); + } catch (e) { + console.log("[-] MSLWidevineL1CryptoManager: " + e); + } + + // ------------------------------------------------------- + // BaseCryptoManager -- 共通暗号マネージャ (aesCbcEncrypt/Decrypt, hmacSha256) + // ------------------------------------------------------- + try { + const BaseCM = Java.use("com.netflix.mediaclient.cryptomanager.impl.BaseCryptoManager"); + const methods = BaseCM.class.getDeclaredMethods(); + methods.forEach(function (m: any) { + const name = m.getName(); + if (name === "aesCbcEncrypt" || name === "aesCbcDecrypt" || + name === "hmacSha256" || name === "hmacSha256Verify") { + console.log("[*] BaseCryptoManager." + name + "(" + m.getParameterTypes().length + ")"); + // フック: aesCbcEncrypt + if (name === "aesCbcEncrypt") { + try { + m.setAccessible(true); + // Dynamic hook via overload + } catch (e2) { } + } + } + }); + } catch (e) { + console.log("[-] BaseCryptoManager: " + e); + } + + // ------------------------------------------------------- + // CryptoProvider -- DRM暗号プロバイダ + // ------------------------------------------------------- + try { + const CryptoProvider = Java.use("com.netflix.mediaclient.crypto.api.CryptoProvider"); + const methods = CryptoProvider.class.getDeclaredMethods(); + methods.forEach(function (m: any) { + console.log("[*] CryptoProvider." + m.getName()); + }); + } catch (e) { } +} diff --git a/packages/frida/src/android/widevine-key-exchange.ts b/packages/frida/src/android/widevine-key-exchange.ts new file mode 100644 index 0000000..4136f9d --- /dev/null +++ b/packages/frida/src/android/widevine-key-exchange.ts @@ -0,0 +1,76 @@ +export function hookWidevineKeyExchange(): void { + // ------------------------------------------------------- + // WidevineKeyExchange -- MSLキー交換の実装 + // ------------------------------------------------------- + try { + const WvKeyEx = Java.use("com.netflix.msl.client.impl.WidevineKeyExchange"); + const methods = WvKeyEx.class.getDeclaredMethods(); + methods.forEach(function (m: any) { + const name = m.getName(); + console.log("[*] WidevineKeyExchange." + name + "(" + m.getParameterTypes().length + ")"); + }); + } catch (e) { + console.log("[-] WidevineKeyExchange: " + e); + } + + // ------------------------------------------------------- + // WidevineKeyRequestData / WidevineKeyResponseData + // ------------------------------------------------------- + try { + const WvKeyReq = Java.use("com.netflix.msl.keyx.WidevineKeyRequestData"); + const methods = WvKeyReq.class.getDeclaredMethods(); + methods.forEach(function (m: any) { + console.log("[*] WidevineKeyRequestData." + m.getName()); + }); + } catch (e) { } + + try { + const WvKeyResp = Java.use("com.netflix.msl.keyx.WidevineKeyResponseData"); + const methods = WvKeyResp.class.getDeclaredMethods(); + methods.forEach(function (m: any) { + console.log("[*] WidevineKeyResponseData." + m.getName()); + }); + } catch (e) { } + + // ------------------------------------------------------- + // DiffieHellmanExchange -- DHキー交換 + // ------------------------------------------------------- + try { + const DHExchange = Java.use("com.netflix.msl.keyx.DiffieHellmanExchange"); + const methods = DHExchange.class.getDeclaredMethods(); + methods.forEach(function (m: any) { + const name = m.getName(); + if (name.indexOf("generate") !== -1 || name.indexOf("derive") !== -1 || name.indexOf("Request") !== -1 || name.indexOf("Response") !== -1) { + console.log("[*] DiffieHellmanExchange." + name); + } + }); + } catch (e) { + console.log("[-] DiffieHellmanExchange: " + e); + } + + // ------------------------------------------------------- + // JsonWebEncryptionLadderExchange -- JWE ラダーキー交換 + // ------------------------------------------------------- + try { + const JweLadder = Java.use("com.netflix.msl.keyx.JsonWebEncryptionLadderExchange"); + const methods = JweLadder.class.getDeclaredMethods(); + methods.forEach(function (m: any) { + console.log("[*] JWELadderExchange." + m.getName()); + }); + } catch (e) { + console.log("[-] JWELadderExchange: " + e); + } + + // ------------------------------------------------------- + // AsymmetricWrappedExchange -- RSA/ECC ラップキー交換 + // ------------------------------------------------------- + try { + const AsymExchange = Java.use("com.netflix.msl.keyx.AsymmetricWrappedExchange"); + const methods = AsymExchange.class.getDeclaredMethods(); + methods.forEach(function (m: any) { + console.log("[*] AsymmetricWrappedExchange." + m.getName()); + }); + } catch (e) { + console.log("[-] AsymmetricWrappedExchange: " + e); + } +} diff --git a/packages/frida/src/chrome/cdm-host-callback.ts b/packages/frida/src/chrome/cdm-host-callback.ts new file mode 100644 index 0000000..11eeb2a --- /dev/null +++ b/packages/frida/src/chrome/cdm-host-callback.ts @@ -0,0 +1,79 @@ +/** + * CDM Host Callback Hook + * + * CDM がライセンスリクエストを生成すると、Host::OnSessionMessage コールバックで + * Chrome に challenge データが渡される。このコールバックをフックして + * challenge (SignedLicenseRequest protobuf) をキャプチャする。 + * + * Host_10 vtable layout: + * ... + * OnInitialized + * OnResolveKeyStatusPromise + * OnResolveNewSessionPromise + * OnResolvePromise + * OnRejectPromise + * OnSessionMessage ← ★ challenge がここで渡される + * OnSessionKeysChange ← ★ キーステータス変更通知 + * OnExpirationChange + * OnSessionClosed + * ... + * + * Note: Host vtable のオフセットは Chrome のバージョンによって変わる可能性がある。 + * ここでは heuristic に基づいてフックする。 + */ +import { logData, bytesToHex, bytesToBase64, SEP2, ts } from "../common/utils"; + +const MESSAGE_TYPE: Record = { + 0: "kLicenseRequest", + 1: "kLicenseRenewal", + 2: "kLicenseRelease", + 3: "kIndividualizationRequest", +}; + +const KEY_STATUS: Record = { + 0: "kUsable", + 1: "kInternalError", + 2: "kExpired", + 3: "kOutputRestricted", + 4: "kOutputDownscaled", + 5: "kStatusPending", + 6: "kReleased", +}; + +/** + * Chrome Helper の CDM Host コールバックをフックする代替手法。 + * CreateCdmInstance の第4引数 (GetCdmHostFunc) から Host ポインタを辿る + * のは複雑なので、代わりに CDM 内部の関数をパターンスキャンする。 + * + * ここではより実用的なアプローチとして、Chrome プロセスの + * OnSessionMessage 等のシンボルを探す。 + */ +export function hookHostCallbacks(): void { + // Chrome Framework 内のシンボルを探す + const chromeMod = Process.findModuleByName("Google Chrome Framework"); + if (!chromeMod) { + console.log("[-] Google Chrome Framework not found in this process"); + return; + } + + console.log("[*] Google Chrome Framework at " + chromeMod.base + " size=" + chromeMod.size); + + // CdmAdapter や MojoCdmService のシンボルを探す + const resolver = new ApiResolver("module"); + const patterns = [ + "exports:*!*OnSessionMessage*", + "exports:*!*OnSessionKeysChange*", + "exports:*!*CdmAdapter*", + ]; + + for (const pattern of patterns) { + try { + const matches = resolver.enumerateMatches(pattern); + for (const m of matches) { + console.log("[*] Found: " + m.name + " at " + m.address); + } + } catch (e) { + // stripped symbols, expected + } + } +} diff --git a/packages/frida/src/chrome/index.ts b/packages/frida/src/chrome/index.ts new file mode 100644 index 0000000..2746af4 --- /dev/null +++ b/packages/frida/src/chrome/index.ts @@ -0,0 +1,45 @@ +/** + * Chrome Widevine CDM L3 Hook - macOS + * + * Chrome のマルチプロセスアーキテクチャでは、CDM は Utility プロセス + * (Chrome Helper) 内で libwidevinecdm.dylib としてロードされる。 + * + * このスクリプトは CDM Interface v10 の以下をフックする: + * - CreateCdmInstance: CDM インスタンス生成 → vtable 取得 + * - SetServerCertificate: サービス証明書の設定 + * - CreateSessionAndGenerateRequest: ライセンスリクエスト (Challenge) 生成 + * - UpdateSession: ライセンスレスポンス処理 (キー取得) + * - CloseSession: セッション終了 + * - Decrypt: コンテンツ復号 + */ +import { SEP, ts } from "../common/utils"; +import { hookCreateCdmInstance } from "./widevine-cdm"; +import { extractPrivateKey, startPeriodicScan } from "./private-key-extractor"; + +// Python API 経由の場合、console.log の出力は on('message') に届かないことがある。 +// send() を使って全出力を Python ランナーに転送する。 +// _origLog は呼ばない (重複出力を防ぐ)。 +console.log = function (...args: any[]) { + const msg = args.map(a => (typeof a === "string" ? a : JSON.stringify(a))).join(" "); + send(msg); +}; + +function main(): void { + console.log(SEP); + console.log("[*] Chrome Widevine CDM L3 Hook - macOS (with Key Extraction)"); + console.log("[*] " + ts()); + console.log(SEP); + + hookCreateCdmInstance(); + + // CDM 初期化直後のスキャン (鍵が既にロード済みの場合に対応) + setTimeout(() => { + console.log("[*] Running initial memory scan for pre-loaded keys..."); + extractPrivateKey(); + }, 3000); + + // 定期スキャン (5秒間隔、鍵が見つかったら自動停止) + startPeriodicScan(5000); +} + +main(); diff --git a/packages/frida/src/chrome/key-scan-only.ts b/packages/frida/src/chrome/key-scan-only.ts new file mode 100644 index 0000000..cd6614c --- /dev/null +++ b/packages/frida/src/chrome/key-scan-only.ts @@ -0,0 +1,251 @@ +/** + * RSA Private Key Differential Scanner (Stalker-free) + * + * CDM プロセスにアタッチし、Interceptor/Stalker を一切使わず + * Memory.scan のみで RSA 秘密鍵を検出する。 + * + * 戦略: + * 1. アタッチ直後にベースラインスキャン → 既存の鍵を記録 + * 2. Python 側から RPC でスキャンを繰り返し実行 + * 3. ベースラインに無い「新出の鍵」のみを報告 + * + * これにより TLS セッション鍵等のノイズを排除し、 + * CDM がセッション生成時にデコードした鍵だけを捕捉できる。 + */ + +const CDM_MODULE = "libwidevinecdm.dylib"; +const PKCS8_PATTERN = "30 82 ?? ?? 02 01 00 30 0d 06 09 2a 86 48 86 f7 0d 01 01 01"; +const PKCS1_PATTERN = "30 82 ?? ?? 02 01 00 02 82"; + +const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +function bytesToHex(buf: ArrayBuffer): string { + const arr = new Uint8Array(buf); + let hex = ""; + for (let i = 0; i < arr.length; i++) { + const b = arr[i].toString(16); + hex += (b.length === 1 ? "0" : "") + b; + } + return hex; +} + +function bytesToBase64(buf: ArrayBuffer): string { + const arr = new Uint8Array(buf); + const len = arr.length; + let out = ""; + for (let i = 0; i < len; i += 3) { + const b0 = arr[i], b1 = i + 1 < len ? arr[i + 1] : 0, b2 = i + 2 < len ? arr[i + 2] : 0; + out += B64[b0 >> 2] + B64[((b0 & 3) << 4) | (b1 >> 4)]; + out += i + 1 < len ? B64[((b1 & 15) << 2) | (b2 >> 6)] : "="; + out += i + 2 < len ? B64[b2 & 63] : "="; + } + return out; +} + +// console.log → send() +console.log = function (...args: any[]) { + const msg = args.map(a => (typeof a === "string" ? a : JSON.stringify(a))).join(" "); + send(msg); +}; + +function derSequenceLength(ptr: NativePointer): number { + const tag = ptr.readU8(); + if (tag !== 0x30) return -1; + const lenByte = ptr.add(1).readU8(); + if (lenByte === 0x82) { + return 4 + ((ptr.add(2).readU8() << 8) | ptr.add(3).readU8()); + } else if (lenByte === 0x81) { + return 2 + ptr.add(2).readU8(); + } else if (lenByte < 0x80) { + return 2 + lenByte; + } + return -1; +} + +function validateKey(ptr: NativePointer, format: "pkcs8" | "pkcs1"): { valid: boolean; totalLen: number } { + try { + const totalLen = derSequenceLength(ptr); + if (totalLen < 600 || totalLen > 5000) return { valid: false, totalLen }; + if (ptr.add(4).readU8() !== 0x02 || ptr.add(5).readU8() !== 0x01 || ptr.add(6).readU8() !== 0x00) { + return { valid: false, totalLen }; + } + if (format === "pkcs8") { + if (ptr.add(9).readU8() !== 0x06) return { valid: false, totalLen }; + } else { + if (ptr.add(7).readU8() !== 0x02) return { valid: false, totalLen }; + } + return { valid: true, totalLen }; + } catch (_e) { + return { valid: false, totalLen: -1 }; + } +} + +// ─── 状態管理 ─── + +/** ベースラインの鍵 (ハッシュ的に先頭64バイトの hex を使う) */ +const baselineFingerprints: Set = new Set(); +let baselineCaptured = false; + +/** 全発見鍵のフィンガープリント → 完全データ */ +const allKeys: Map = new Map(); + +/** 新出鍵 (ベースラインに無い) */ +const newKeys: Map = new Map(); + +function fingerprint(ptr: NativePointer, len: number): string { + const fpLen = Math.min(len, 64); + return bytesToHex(ptr.readByteArray(fpLen) as ArrayBuffer); +} + +interface ScanResult { + found: number; + newFound: number; + totalNew: number; + keys: Array<{ format: string; location: string; length: number; key_b64: string; key_hex: string }>; +} + +function scanAllRanges(): ScanResult { + const cdmMod = Process.findModuleByName(CDM_MODULE); + if (!cdmMod) { + return { found: 0, newFound: 0, totalNew: newKeys.size, keys: [] }; + } + + const currentScanKeys: Array<{ format: string; location: string; length: number; key_b64: string; key_hex: string }> = []; + let found = 0; + let newFound = 0; + + function onKeyFound(address: NativePointer, totalLen: number, format: "pkcs8" | "pkcs1", label: string): void { + const fp = fingerprint(address, totalLen); + found++; + + if (allKeys.has(fp)) return; // 既知 + + const keyHex = bytesToHex(address.readByteArray(totalLen) as ArrayBuffer); + const keyB64 = bytesToBase64(address.readByteArray(totalLen) as ArrayBuffer); + const entry = { hex: keyHex, b64: keyB64, format, location: label, length: totalLen }; + allKeys.set(fp, entry); + + if (baselineCaptured && !baselineFingerprints.has(fp)) { + newFound++; + newKeys.set(fp, entry); + currentScanKeys.push({ format, location: label, length: totalLen, key_b64: keyB64, key_hex: keyHex }); + + console.log("======================================================================"); + console.log("[!!!] NEW RSA Private Key (" + format.toUpperCase() + ")"); + console.log(" Location: " + label + " @ " + address); + console.log(" Length: " + totalLen + " bytes"); + console.log("======================================================================"); + + send("@@LOG@@" + JSON.stringify({ + event: "cdm.privateKey", + format, location: label, + address: address.toString(), + length: totalLen, + key_hex: keyHex, + key_b64: keyB64, + is_new: true, + })); + } + } + + function scan(base: NativePointer, size: number, pattern: string, format: "pkcs8" | "pkcs1", label: string): void { + try { + Memory.scan(base, size, pattern, { + onMatch(address: NativePointer, _size: number) { + const { valid, totalLen } = validateKey(address, format); + if (valid) onKeyFound(address, totalLen, format, label); + }, + onComplete() {}, + }); + } catch (_e) {} + } + + // CDM モジュール + scan(cdmMod.base, cdmMod.size, PKCS8_PATTERN, "pkcs8", "cdm_module"); + scan(cdmMod.base, cdmMod.size, PKCS1_PATTERN, "pkcs1", "cdm_module"); + + // ヒープ + const ranges = Process.enumerateRanges("r--"); + const cdmBase = cdmMod.base; + const cdmEnd = cdmMod.base.add(cdmMod.size); + for (const range of ranges) { + if (range.base.compare(cdmBase) >= 0 && range.base.compare(cdmEnd) < 0) continue; + if (range.size < 1024 || range.size > 100 * 1024 * 1024) continue; + scan(range.base, range.size, PKCS8_PATTERN, "pkcs8", "heap"); + scan(range.base, range.size, PKCS1_PATTERN, "pkcs1", "heap"); + } + + return { found, newFound, totalNew: newKeys.size, keys: currentScanKeys }; +} + +// ─── RPC エクスポート ─── + +rpc.exports = { + /** + * ベースラインスキャン: 現時点のメモリ上の鍵を全て記録し、 + * 以降のスキャンではこれらを除外する。 + */ + captureBaseline(): { baselineCount: number } { + const result = scanAllRanges(); + for (const fp of allKeys.keys()) { + baselineFingerprints.add(fp); + } + baselineCaptured = true; + console.log("[*] Baseline captured: " + baselineFingerprints.size + " existing key(s)"); + return { baselineCount: baselineFingerprints.size }; + }, + + /** + * 差分スキャン: ベースライン以降に出現した新しい鍵を返す。 + */ + scan(): ScanResult { + if (!baselineCaptured) { + console.log("[!] Baseline not captured yet, call captureBaseline() first"); + return { found: 0, newFound: 0, totalNew: 0, keys: [] }; + } + return scanAllRanges(); + }, + + /** + * 蓄積された全ての新出鍵を返す。 + */ + getNewKeys(): Array<{ format: string; location: string; length: number; key_b64: string; key_hex: string }> { + const result: Array<{ format: string; location: string; length: number; key_b64: string; key_hex: string }> = []; + for (const entry of newKeys.values()) { + result.push({ + format: entry.format, + location: entry.location, + length: entry.length, + key_b64: entry.b64, + key_hex: entry.hex, + }); + } + return result; + }, + + getModuleInfo(): string { + const cdmMod = Process.findModuleByName(CDM_MODULE); + if (!cdmMod) return "not loaded"; + return JSON.stringify({ + base: cdmMod.base.toString(), + size: cdmMod.size, + path: cdmMod.path, + }); + }, +}; + +// ─── 起動メッセージ ─── + +const cdmMod = Process.findModuleByName(CDM_MODULE); +if (cdmMod) { + console.log("[+] " + CDM_MODULE + " at " + cdmMod.base + " (" + cdmMod.size + " bytes)"); + const vAddr = cdmMod.findExportByName("GetCdmVersion"); + if (vAddr) { + try { + const fn = new NativeFunction(vAddr, "pointer", []); + console.log("[*] CDM Version: " + (fn() as NativePointer).readUtf8String()); + } catch (_e) {} + } +} else { + console.log("[-] " + CDM_MODULE + " not loaded yet"); +} diff --git a/packages/frida/src/chrome/private-key-extractor.ts b/packages/frida/src/chrome/private-key-extractor.ts new file mode 100644 index 0000000..e199ff9 --- /dev/null +++ b/packages/frida/src/chrome/private-key-extractor.ts @@ -0,0 +1,237 @@ +/** + * RSA Private Key Extractor for Widevine CDM (L3) + * + * CDM が CreateSessionAndGenerateRequest で challenge を生成する際、 + * RSA 秘密鍵をメモリ上に展開して署名に使用する。 + * このモジュールはメモリ上の DER エンコードされた RSA 秘密鍵を + * パターンスキャンで検出・ダンプする。 + * + * 検出パターン: + * PKCS#8: 30 82 xx xx 02 01 00 30 0d 06 09 2a 86 48 86 f7 0d 01 01 01 + * PKCS#1: 30 82 xx xx 02 01 00 02 82 (version=0, modulus) + */ +import { logData, bytesToHex, bytesToBase64, SEP2 } from "../common/utils"; + +const CDM_MODULE = "libwidevinecdm.dylib"; + +/** PKCS#8 RSA 秘密鍵の先頭パターン (RSA OID: 1.2.840.113549.1.1.1) */ +const PKCS8_PATTERN = "30 82 ?? ?? 02 01 00 30 0d 06 09 2a 86 48 86 f7 0d 01 01 01"; + +/** PKCS#1 RSAPrivateKey の先頭パターン (version=0, modulus follows) */ +const PKCS1_PATTERN = "30 82 ?? ?? 02 01 00 02 82"; + +let extractedKeys: string[] = []; +let scanInProgress = false; + +/** + * DER SEQUENCE の全長を先頭2バイト (length field) から計算する。 + * 30 82 XX YY → total = 4 + (XX << 8 | YY) + */ +function derSequenceLength(ptr: NativePointer): number { + const tag = ptr.readU8(); + if (tag !== 0x30) return -1; + + const lenByte = ptr.add(1).readU8(); + if (lenByte === 0x82) { + // 2-byte length + const hi = ptr.add(2).readU8(); + const lo = ptr.add(3).readU8(); + return 4 + ((hi << 8) | lo); + } else if (lenByte === 0x81) { + // 1-byte length + return 2 + ptr.add(2).readU8(); + } else if (lenByte < 0x80) { + // short form + return 2 + lenByte; + } + return -1; +} + +/** + * 候補アドレスが有効な RSA 秘密鍵かどうかを簡易検証する。 + * - DER SEQUENCE の長さが妥当 (RSA-2048: ~1200B, RSA-4096: ~2400B) + * - version フィールドが 0 + */ +function validateRSAKey(ptr: NativePointer, format: "pkcs8" | "pkcs1"): { valid: boolean; totalLen: number } { + try { + const totalLen = derSequenceLength(ptr); + // RSA-2048 PKCS#8 ≈ 1218B, RSA-2048 PKCS#1 ≈ 1192B + // RSA-4096 would be ~2400B + if (totalLen < 600 || totalLen > 5000) { + return { valid: false, totalLen }; + } + + if (format === "pkcs8") { + // Verify: 02 01 00 (version=0), then AlgorithmIdentifier with RSA OID + const v = ptr.add(4).readU8(); // 02 + const vl = ptr.add(5).readU8(); // 01 + const vv = ptr.add(6).readU8(); // 00 + if (v !== 0x02 || vl !== 0x01 || vv !== 0x00) { + return { valid: false, totalLen }; + } + // Check RSA OID at offset 9: 06 09 2a 86 48 86 f7 0d 01 01 01 + const oid = ptr.add(9).readU8(); + if (oid !== 0x06) { + return { valid: false, totalLen }; + } + } else { + // PKCS#1: version=0, then modulus INTEGER + const v = ptr.add(4).readU8(); // 02 + const vl = ptr.add(5).readU8(); // 01 + const vv = ptr.add(6).readU8(); // 00 + if (v !== 0x02 || vl !== 0x01 || vv !== 0x00) { + return { valid: false, totalLen }; + } + // Next should be modulus: 02 82 (for 2048-bit: 02 82 01 01) + const mTag = ptr.add(7).readU8(); + if (mTag !== 0x02) { + return { valid: false, totalLen }; + } + } + + return { valid: true, totalLen }; + } catch (_e) { + return { valid: false, totalLen: -1 }; + } +} + +/** + * 指定されたメモリ範囲で RSA 秘密鍵をスキャンする。 + */ +function scanRange( + base: NativePointer, + size: number, + pattern: string, + format: "pkcs8" | "pkcs1", + label: string, +): void { + try { + Memory.scan(base, size, pattern, { + onMatch(address: NativePointer, _size: number) { + const { valid, totalLen } = validateRSAKey(address, format); + if (!valid) return; + + const keyHex = bytesToHex(address.readByteArray(totalLen) as ArrayBuffer); + if (extractedKeys.indexOf(keyHex) !== -1) return; // duplicate + extractedKeys.push(keyHex); + + const keyB64 = bytesToBase64(address.readByteArray(totalLen) as ArrayBuffer); + + console.log(SEP2); + console.log("[!!!] RSA Private Key FOUND (" + format.toUpperCase() + ")"); + console.log(" Location: " + label + " @ " + address); + console.log(" Length: " + totalLen + " bytes"); + console.log(" First 32 bytes: " + bytesToHex(address.readByteArray(32) as ArrayBuffer)); + console.log(SEP2); + + logData("cdm.privateKey", { + format: format, + location: label, + address: address.toString(), + length: totalLen, + key_hex: keyHex, + key_b64: keyB64, + }); + }, + onComplete() { + // silent + }, + }); + } catch (_e) { + // Access denied or invalid range, skip + } +} + +/** + * CDM モジュールのメモリ範囲をスキャンする。 + */ +function scanCdmModule(): void { + const cdmMod = Process.findModuleByName(CDM_MODULE); + if (!cdmMod) return; + + console.log("[*] Scanning CDM module for RSA private keys..."); + console.log(" Base: " + cdmMod.base + " Size: " + cdmMod.size); + + // モジュール全体をスキャン (セクション単位だと漏れる可能性があるため) + scanRange(cdmMod.base, cdmMod.size, PKCS8_PATTERN, "pkcs8", "cdm_module"); + scanRange(cdmMod.base, cdmMod.size, PKCS1_PATTERN, "pkcs1", "cdm_module"); +} + +/** + * プロセスのヒープ領域をスキャンする。 + * CDM が動的にアロケートしたバッファに鍵がある場合に有効。 + */ +function scanHeap(): void { + console.log("[*] Scanning process memory ranges for RSA private keys..."); + + const ranges = Process.enumerateRanges("r--"); + let scanned = 0; + const cdmMod = Process.findModuleByName(CDM_MODULE); + const cdmBase = cdmMod ? cdmMod.base : ptr(0); + const cdmEnd = cdmMod ? cdmMod.base.add(cdmMod.size) : ptr(0); + + for (const range of ranges) { + // CDM モジュール自体は既にスキャン済み + if (cdmMod && + range.base.compare(cdmBase) >= 0 && + range.base.compare(cdmEnd) < 0) { + continue; + } + + // 小さすぎるレンジはスキップ + if (range.size < 1024) continue; + // 大きすぎるレンジもスキップ (効率のため) + if (range.size > 100 * 1024 * 1024) continue; + + scanRange(range.base, range.size, PKCS8_PATTERN, "pkcs8", "heap"); + scanRange(range.base, range.size, PKCS1_PATTERN, "pkcs1", "heap"); + scanned++; + } + + console.log("[*] Scanned " + scanned + " memory ranges"); +} + +/** + * RSA 秘密鍵のメモリスキャンを実行する。 + * CreateSessionAndGenerateRequest の後に呼び出すことを想定。 + */ +export function extractPrivateKey(): void { + if (scanInProgress) { + console.log("[*] Scan already in progress, skipping"); + return; + } + scanInProgress = true; + + console.log("[*] Starting RSA private key extraction..."); + + scanCdmModule(); + scanHeap(); + + scanInProgress = false; + + if (extractedKeys.length > 0) { + console.log("[+] Total unique keys found: " + extractedKeys.length); + } else { + console.log("[-] No RSA private keys found in this scan."); + console.log("[*] Will retry on next CreateSessionAndGenerateRequest call."); + } +} + +/** + * 定期的にスキャンを行う (CDM が遅延で鍵をロードする場合に対応)。 + */ +export function startPeriodicScan(intervalMs: number): void { + console.log("[*] Starting periodic key scan every " + intervalMs + "ms"); + const timer = setInterval(() => { + if (extractedKeys.length > 0) { + console.log("[+] Key already extracted, stopping periodic scan"); + clearInterval(timer); + return; + } + extractPrivateKey(); + }, intervalMs); +} + +export function getExtractedKeyCount(): number { + return extractedKeys.length; +} diff --git a/packages/frida/src/chrome/widevine-cdm.ts b/packages/frida/src/chrome/widevine-cdm.ts new file mode 100644 index 0000000..59cd464 --- /dev/null +++ b/packages/frida/src/chrome/widevine-cdm.ts @@ -0,0 +1,404 @@ +/** + * Widevine CDM Interface v10/v11 Hook - Stalker ベース + * + * ★ Stalker を使う理由: + * Interceptor.attach/replace は対象関数のコードを書き換えるため、 + * CDM の VerifyCdmHost_0 コード整合性検証に失敗する。 + * Stalker はオリジナルコードを一切変更せず、JIT コンパイルした + * コピー上でスレッドを実行しながら callout でデータをキャプチャする。 + * + * 戦略: + * 1. CDM エクスポート関数のアドレスを取得 (読み取りのみ) + * 2. 全スレッドを Stalker.follow して、CDM 関数への call を監視 + * 3. CreateCdmInstance の呼び出しと戻り値をキャプチャ + * 4. 戻り値から CDM インスタンスの vtable を読み取り + * 5. vtable 内の各メソッドアドレスも Stalker で監視 + */ +import { logData, bytesToHex, bytesToBase64, SEP, SEP2, ts } from "../common/utils"; +import { extractPrivateKey, startPeriodicScan, getExtractedKeyCount } from "./private-key-extractor"; + +const CDM_MODULE = "libwidevinecdm.dylib"; + +function findExport(moduleName: string, exportName: string): NativePointer | null { + const mod = Process.findModuleByName(moduleName); + if (!mod) return null; + return mod.findExportByName(exportName); +} + +const INIT_DATA_TYPE: Record = { 0: "Cenc", 1: "Keyids", 2: "WebM" }; +const SESSION_TYPE: Record = { 0: "Temporary", 1: "PersistentLicense" }; +const CDM_STATUS: Record = { + 0: "kSuccess", 1: "kNeedMoreData", 2: "kNoKey", + 3: "kInitializationError", 4: "kDecryptError", 5: "kDecodeError", + 6: "kDeferredInitialization", 7: "kInvalidState", 8: "kSessionNotFound", +}; + +function readBuf(ptr: NativePointer, size: number): ArrayBuffer { + return ptr.readByteArray(size) as ArrayBuffer; +} + +function getVtableEntry(vt: NativePointer, index: number): NativePointer { + return vt.add(index * Process.pointerSize).readPointer(); +} + +// 監視対象アドレス +let createCdmAddr: NativePointer | null = null; +let verifyCdmAddr: NativePointer | null = null; +let initModuleAddr: NativePointer | null = null; + +// vtable メソッドアドレス (CreateCdmInstance 完了後に設定) +let vtableAddrs: Record = {}; +let vtableInstalled = false; + +// Stalker で follow 中のスレッド +const followedThreads: Set = new Set(); + +// Decrypt カウンタ +let decryptCount = 0; + +export function hookCreateCdmInstance(): void { + const cdmMod = Process.findModuleByName(CDM_MODULE); + if (!cdmMod) { + console.log("[-] " + CDM_MODULE + " not loaded. Waiting..."); + const wait = setInterval(() => { + if (Process.findModuleByName(CDM_MODULE)) { + clearInterval(wait); + console.log("[+] " + CDM_MODULE + " loaded"); + setup(); + } + }, 500); + return; + } + console.log("[+] " + CDM_MODULE + " at " + cdmMod.base); + setup(); +} + +function setup(): void { + // バージョン取得 (副作用なし) + const versionAddr = findExport(CDM_MODULE, "GetCdmVersion"); + if (versionAddr) { + try { + const fn = new NativeFunction(versionAddr, "pointer", []); + const ver = (fn() as NativePointer).readUtf8String(); + console.log("[*] CDM Version: " + ver); + logData("cdm.version", { version: ver }); + } catch (_e) { /* ignore */ } + } + + // エクスポートアドレスを記録 + createCdmAddr = findExport(CDM_MODULE, "CreateCdmInstance"); + verifyCdmAddr = findExport(CDM_MODULE, "VerifyCdmHost_0"); + initModuleAddr = findExport(CDM_MODULE, "InitializeCdmModule_4"); + + console.log("[*] Targets:"); + console.log(" CreateCdmInstance: " + createCdmAddr); + console.log(" VerifyCdmHost_0: " + verifyCdmAddr); + console.log(" InitializeCdmModule_4: " + initModuleAddr); + + if (!createCdmAddr) { + console.log("[-] CreateCdmInstance not found"); + return; + } + + // 全スレッドを Stalker で follow + followAllThreads(); + + console.log("[+] Stalker active. Open DRM content in Chrome."); +} + +function followAllThreads(): void { + const threads = Process.enumerateThreads(); + console.log("[*] Following " + threads.length + " threads with Stalker..."); + + for (const thread of threads) { + followThread(thread.id); + } + + // 新しいスレッドもキャッチするためにポーリング + setInterval(() => { + const current = Process.enumerateThreads(); + for (const t of current) { + if (!followedThreads.has(t.id)) { + followThread(t.id); + } + } + }, 1000); +} + +function followThread(threadId: number): void { + if (followedThreads.has(threadId)) return; + followedThreads.add(threadId); + + try { + Stalker.follow(threadId, { + transform: function (iterator: StalkerArm64Iterator) { + let instruction = iterator.next(); + while (instruction !== null) { + const addr = instruction.address; + + // CreateCdmInstance の先頭をヒット + if (createCdmAddr && addr.equals(createCdmAddr)) { + iterator.putCallout(onCreateCdmInstanceEntry); + } + + // VerifyCdmHost_0 の先頭 + if (verifyCdmAddr && addr.equals(verifyCdmAddr)) { + iterator.putCallout(onVerifyCdmHostEntry); + } + + // InitializeCdmModule_4 の先頭 + if (initModuleAddr && addr.equals(initModuleAddr)) { + iterator.putCallout(onInitModuleEntry); + } + + // vtable メソッドの先頭 + if (vtableInstalled) { + for (const name in vtableAddrs) { + if (addr.equals(vtableAddrs[name])) { + // クロージャで name をキャプチャ + const methodName = name; + iterator.putCallout(function (ctx: CpuContext) { + onVtableMethodEntry(methodName, ctx); + }); + break; + } + } + } + + iterator.keep(); + instruction = iterator.next(); + } + } + }); + } catch (_e) { + followedThreads.delete(threadId); + } +} + +// ─── Stalker callout ハンドラ ─── + +function onInitModuleEntry(ctx: CpuContext): void { + console.log("[CDM] InitializeCdmModule_4()"); + logData("cdm.initModule", {}); +} + +function onVerifyCdmHostEntry(ctx: CpuContext): void { + console.log("[CDM] VerifyCdmHost_0()"); + logData("cdm.verifyHost", { note: "entry detected via Stalker" }); +} + +function onCreateCdmInstanceEntry(ctx: CpuContext): void { + // arm64: x0=interface_version, x1=key_system, x2=key_system_len + const arm64ctx = ctx as Arm64CpuContext; + const ifVer = arm64ctx.x0.toInt32(); + + let keySys = ""; + try { + keySys = arm64ctx.x1.readUtf8String() || ""; + } catch (_e) { keySys = "(unreadable)"; } + + console.log(SEP); + console.log("[CDM] CreateCdmInstance"); + console.log(" interface_version: " + ifVer); + console.log(" key_system: " + keySys); + logData("cdm.createInstance", { interface_version: ifVer, key_system: keySys }); + + // CreateCdmInstance の ret 命令にもコールアウトを仕掛けたいが、 + // Stalker の transform は関数全体に及ぶので、 + // 代わりに短いポーリングで戻り値 (x0) を監視する。 + // → 実用的な方法: CreateCdmInstance の呼び出し後、 + // 呼び出し元に戻ったタイミングで x0 を読む。 + // + // Stalker では ret のアドレスを特定するのが難しいため、 + // 別スレッドでポーリングして CDM インスタンスを探す。 + if (!vtableInstalled) { + console.log("[*] Waiting for CreateCdmInstance to return..."); + setTimeout(function () { + scanForVtable(); + }, 500); + } +} + +function onVtableMethodEntry(name: string, ctx: CpuContext): void { + const arm64ctx = ctx as Arm64CpuContext; + + if (name === "Initialize") { + // x1=allow_distinctive, x2=allow_persistent, x3=use_hw_secure + const hwSecure = arm64ctx.x3.toInt32() !== 0; + console.log("[CDM] Initialize hw_secure=" + hwSecure + (hwSecure ? " (L1)" : " (L3)")); + logData("cdm.initialize", { + use_hw_secure_codecs: hwSecure, + drm_level: hwSecure ? "L1" : "L3", + }); + } else if (name === "SetServerCertificate") { + // x1=promise_id, x2=cert_data, x3=cert_size + const certSize = arm64ctx.x3.toInt32(); + console.log("[CDM] SetServerCertificate cert_size=" + certSize); + if (certSize > 0 && certSize < 65536) { + try { + logData("cdm.setServerCertificate", { + cert_size: certSize, + cert_b64: bytesToBase64(readBuf(arm64ctx.x2, certSize)), + }); + } catch (_e) { /* ignore */ } + } + } else if (name === "CreateSessionAndGenerateRequest") { + // x1=promise_id, x2=session_type, x3=init_data_type, x4=init_data, x5=init_data_size + const sessType = arm64ctx.x2.toInt32(); + const initType = arm64ctx.x3.toInt32(); + const initSize = arm64ctx.x5.toInt32(); + console.log(SEP); + console.log("[CDM] CreateSessionAndGenerateRequest"); + console.log(" session=" + (SESSION_TYPE[sessType] || sessType) + + " type=" + (INIT_DATA_TYPE[initType] || initType) + " size=" + initSize); + if (initSize > 0 && initSize < 65536) { + try { + const buf = readBuf(arm64ctx.x4, initSize); + console.log(" PSSH: " + bytesToHex(buf)); + logData("cdm.createSession", { + session_type: SESSION_TYPE[sessType] || sessType, + init_data_type: INIT_DATA_TYPE[initType] || initType, + init_data_size: initSize, + init_data_b64: bytesToBase64(buf), + init_data_hex: bytesToHex(buf), + }); + } catch (_e) { /* ignore */ } + } + // セッション生成後に RSA 秘密鍵をスキャン + if (getExtractedKeyCount() === 0) { + setTimeout(() => { + console.log("[*] Triggering RSA private key scan after CreateSession..."); + extractPrivateKey(); + }, 1000); + } + } else if (name === "UpdateSession") { + // x1=promise_id, x2=session_id, x3=session_id_size, x4=response, x5=response_size + const sidSize = arm64ctx.x3.toInt32(); + const respSize = arm64ctx.x5.toInt32(); + let sid = ""; + if (sidSize > 0 && sidSize < 256) { + try { sid = arm64ctx.x2.readUtf8String(sidSize) || ""; } catch (_e) { } + } + console.log(SEP); + console.log("[CDM] UpdateSession session=" + sid + " response_size=" + respSize); + if (respSize > 0 && respSize < 1048576) { + try { + const b64 = bytesToBase64(readBuf(arm64ctx.x4, respSize)); + console.log(" response: " + b64.substring(0, 200) + (b64.length > 200 ? "..." : "")); + logData("cdm.updateSession", { + session_id: sid, response_size: respSize, response_b64: b64, + }); + } catch (_e) { /* ignore */ } + } + } else if (name === "CloseSession") { + const sidSize = arm64ctx.x3.toInt32(); + let sid = ""; + if (sidSize > 0 && sidSize < 256) { + try { sid = arm64ctx.x2.readUtf8String(sidSize) || ""; } catch (_e) { } + } + console.log("[CDM] CloseSession session=" + sid); + logData("cdm.closeSession", { session_id: sid }); + } else if (name === "Decrypt") { + decryptCount++; + if (decryptCount <= 5 || decryptCount % 500 === 0) { + // x1=InputBuffer*, x2=DecryptedBlock* + try { + const inBuf = arm64ctx.x1; + const dataSize = inBuf.add(8).readU32(); + const encScheme = inBuf.add(12).readU32(); + const keyIdPtr = inBuf.add(16).readPointer(); + const keyIdSize = inBuf.add(24).readU32(); + const schemes = ["Unencrypted", "Cenc", "Cbcs"]; + const keyId = (keyIdSize > 0 && keyIdSize <= 32) + ? bytesToHex(readBuf(keyIdPtr, keyIdSize)) : ""; + console.log("[CDM] Decrypt #" + decryptCount + " " + + (schemes[encScheme] || encScheme) + + " size=" + dataSize + " key=" + keyId); + logData("cdm.decrypt", { + count: decryptCount, data_size: dataSize, + encryption_scheme: schemes[encScheme] || encScheme, + key_id: keyId || null, + }); + } catch (_e) { + console.log("[CDM] Decrypt #" + decryptCount); + } + } + } +} + +// ─── vtable スキャン (CreateCdmInstance 後に実行) ─── + +function scanForVtable(): void { + const cdmMod = Process.findModuleByName(CDM_MODULE); + if (!cdmMod) return; + + const modStart = cdmMod.base; + const modEnd = cdmMod.base.add(cdmMod.size); + const step = Process.pointerSize; + const sections = cdmMod.enumerateSections(); + + for (const section of sections) { + const sid = (section as any).id || ""; + if (!sid.includes("__const") && !sid.includes("__data")) continue; + + const base = (section as any).address as NativePointer; + const size = (section as any).size as number; + if (!base || size < step * 20) continue; + + for (let off = 0; off < size - step * 20; off += step) { + const cand = base.add(off); + let count = 0; + for (let i = 0; i < 20; i++) { + const e = cand.add(i * step).readPointer(); + if (e.compare(modStart) >= 0 && e.compare(modEnd) < 0) count++; + else break; + } + // CDM Interface v10=20 entries, v11 もおそらく20前後 + if (count < 15) continue; + + // vtable として登録 + console.log("[+] CDM vtable at " + cand + " (" + count + " entries)"); + registerVtableMethods(cand); + + // Stalker を再 follow して新しいアドレスを監視 + refollowAllThreads(); + return; + } + } + console.log("[-] vtable not found after CreateCdmInstance"); +} + +function registerVtableMethods(vt: NativePointer): void { + const names = [ + "Destructor", "Initialize", "GetStatusForPolicy", + "SetServerCertificate", "CreateSessionAndGenerateRequest", + "LoadSession", "UpdateSession", "CloseSession", + "RemoveSession", "TimerExpired", "Decrypt", + ]; + const cdmMod = Process.findModuleByName(CDM_MODULE); + console.log(SEP2); + for (let i = 0; i < names.length; i++) { + const entry = getVtableEntry(vt, i); + const off = cdmMod ? "+" + entry.sub(cdmMod.base).toString(16) : "?"; + console.log(" [" + i + "] " + names[i] + " (" + off + ")"); + + // Stalker で監視する対象を登録 + if (i >= 1 && i <= 10 && i !== 2 && i !== 5 && i !== 8 && i !== 9) { + vtableAddrs[names[i]] = entry; + } + } + console.log(SEP2); + vtableInstalled = true; + console.log("[+] Registered " + Object.keys(vtableAddrs).length + " vtable methods for Stalker"); +} + +function refollowAllThreads(): void { + // 既存の follow を解除して再 follow (新しい transform で) + for (const tid of followedThreads) { + try { Stalker.unfollow(tid); } catch (_e) { } + } + followedThreads.clear(); + followAllThreads(); +} + +export function hookCdmVtable(_a: NativePointer, _b: NativePointer): void {} diff --git a/packages/frida/src/common/base64.ts b/packages/frida/src/common/base64.ts new file mode 100644 index 0000000..e4f22e5 --- /dev/null +++ b/packages/frida/src/common/base64.ts @@ -0,0 +1,79 @@ +// ── Base64 / Base64URL デコードユーティリティ ── + +const B64_LOOKUP: Record = {}; +const B64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; +for (let i = 0; i < B64_CHARS.length; i++) B64_LOOKUP[B64_CHARS[i]] = i; + +export function base64ToBytes(b64: string): Uint8Array | null { + try { + const clean = b64.replace(/[\r\n\s]/g, "").replace(/=+$/, ""); + const len = clean.length; + const outLen = (len * 3) >> 2; + const out = new Uint8Array(outLen); + let j = 0; + for (let i = 0; i < len; i += 4) { + const a = B64_LOOKUP[clean[i]] ?? 0; + const b = B64_LOOKUP[clean[i + 1]] ?? 0; + const c = i + 2 < len ? (B64_LOOKUP[clean[i + 2]] ?? 0) : 0; + const d = i + 3 < len ? (B64_LOOKUP[clean[i + 3]] ?? 0) : 0; + out[j++] = (a << 2) | (b >> 4); + if (i + 2 < len) out[j++] = ((b & 15) << 4) | (c >> 2); + if (i + 3 < len) out[j++] = ((c & 3) << 6) | d; + } + return out.slice(0, j); + } catch { + return null; + } +} + +export function base64ToString(b64: string): string | null { + const bytes = base64ToBytes(b64); + if (!bytes) return null; + return utf8Decode(bytes); +} + +export function base64urlToBytes(b64url: string): Uint8Array | null { + const b64 = b64url.replace(/-/g, "+").replace(/_/g, "/"); + const pad = (4 - (b64.length % 4)) % 4; + return base64ToBytes(b64 + "=".repeat(pad)); +} + +export function utf8Decode(bytes: Uint8Array): string { + let str = ""; + for (let i = 0; i < bytes.length; i++) { + const b = bytes[i]; + if (b < 0x80) { + str += String.fromCharCode(b); + } else if (b < 0xc0) { + str += "?"; + } else if (b < 0xe0) { + const b2 = bytes[++i] & 0x3f; + str += String.fromCharCode(((b & 0x1f) << 6) | b2); + } else if (b < 0xf0) { + const b2 = bytes[++i] & 0x3f; + const b3 = bytes[++i] & 0x3f; + str += String.fromCharCode(((b & 0x0f) << 12) | (b2 << 6) | b3); + } else { + const b2 = bytes[++i] & 0x3f; + const b3 = bytes[++i] & 0x3f; + const b4 = bytes[++i] & 0x3f; + const cp = ((b & 0x07) << 18) | (b2 << 12) | (b3 << 6) | b4; + if (cp > 0xffff) { + str += String.fromCharCode(0xd800 + ((cp - 0x10000) >> 10)); + str += String.fromCharCode(0xdc00 + ((cp - 0x10000) & 0x3ff)); + } else { + str += String.fromCharCode(cp); + } + } + } + return str; +} + +export function bytesToHexStr(bytes: Uint8Array): string { + let hex = ""; + for (let i = 0; i < bytes.length; i++) { + const b = bytes[i].toString(16); + hex += (b.length === 1 ? "0" : "") + b; + } + return hex; +} diff --git a/packages/frida/src/common/lzw.ts b/packages/frida/src/common/lzw.ts new file mode 100644 index 0000000..7a70480 --- /dev/null +++ b/packages/frida/src/common/lzw.ts @@ -0,0 +1,62 @@ +// ── LZW Decoder (Netflix MSL variant) ── + +import { base64ToBytes, utf8Decode } from "./base64"; + +export function decodeLZW(input: string | Uint8Array): string | null { + try { + let bytes: Uint8Array; + if (typeof input === "string") { + const decoded = base64ToBytes(input); + if (!decoded) return null; + bytes = decoded; + } else { + bytes = input; + } + if (bytes.length === 0) return null; + + let bitPos = 0; + const totalBits = bytes.length * 8; + + function readBits(n: number): number { + if (bitPos + n > totalBits) return -1; + let val = 0; + for (let i = 0; i < n; i++) { + const byteIdx = (bitPos + i) >> 3; + const bitIdx = 7 - ((bitPos + i) & 7); + if (bytes[byteIdx] & (1 << bitIdx)) val |= 1 << (n - 1 - i); + } + bitPos += n; + return val; + } + + const dict: number[][] = []; + for (let i = 0; i < 256; i++) dict[i] = [i]; + + let bits = 8; + const output: number[] = []; + let code = readBits(bits); + if (code === -1 || !dict[code]) return null; + let prev = dict[code]; + for (let i = 0; i < prev.length; i++) output.push(prev[i]); + + while (true) { + if (dict.length === 1 << bits) bits++; + code = readBits(bits); + if (code === -1) break; + let entry: number[]; + if (code < dict.length) { + entry = dict[code]; + } else if (code === dict.length) { + entry = prev.concat([prev[0]]); + } else { + break; + } + for (let i = 0; i < entry.length; i++) output.push(entry[i]); + dict.push(prev.concat([entry[0]])); + prev = entry; + } + return utf8Decode(new Uint8Array(output)); + } catch { + return null; + } +} diff --git a/packages/frida/src/common/msl-processor.ts b/packages/frida/src/common/msl-processor.ts new file mode 100644 index 0000000..b113c74 --- /dev/null +++ b/packages/frida/src/common/msl-processor.ts @@ -0,0 +1,586 @@ +// ── MSL メッセージプロセッサー ── +// Chrome extension と同等のログを Frida 環境で出力する +// - MSL エンベロープのデコード (headerdata, payload, servicetokens, useridtoken) +// - ALE 鍵の抽出 (keyx.scheme=CLEAR) +// - ESN の抽出・追跡 +// - マニフェストの抽出 (video/audio/text tracks) + +import { base64ToString, base64urlToBytes, bytesToHexStr, utf8Decode } from "./base64"; +import { decodeLZW } from "./lzw"; +import { logData } from "./utils"; + +// ──────────────────────────────────────────────────────────── +// Utility +// ──────────────────────────────────────────────────────────── + +function tryParseJSON(text: string | null): any { + if (!text) return null; + try { return JSON.parse(text); } catch { return null; } +} + +function tryDecodeB64(str: string): string | null { + return base64ToString(str); +} + +// ──────────────────────────────────────────────────────────── +// ESN Tracking +// ──────────────────────────────────────────────────────────── + +const IOS_ESN_RE = /^NF[A-Z0-9]+-/i; + +interface EsnData { + esn: string | null; + capturedAt: string | null; +} + +const esnState: EsnData = { esn: null, capturedAt: null }; + +export function maybeUpdateEsn(value: string): void { + if (!value || !IOS_ESN_RE.test(value)) return; + if (esnState.esn === value) return; + esnState.esn = value; + esnState.capturedAt = new Date().toISOString(); + logData("esn.detected", { + esn: value, + }); +} + +export function maybeUpdateEsnFromHeader(headers: Record): void { + const esn = headers["x-netflix.esn"] || headers["X-Netflix.esn"] || headers["X-Netflix-ESN"]; + if (esn) maybeUpdateEsn(esn); +} + +// ──────────────────────────────────────────────────────────── +// MSL Envelope Decoder +// ──────────────────────────────────────────────────────────── + +function decodeChunkData(dataStr: string, compressionalgo: string | null): any { + if (!dataStr) return null; + if (compressionalgo === "LZW") { + const decompressed = decodeLZW(dataStr); + if (decompressed) return tryParseJSON(decompressed) ?? decompressed; + } + const inner = tryDecodeB64(dataStr); + if (inner) return tryParseJSON(inner) ?? inner; + return null; +} + +interface DecodedMSL { + envelope: any; + header: any; + useridtoken: any; + servicetokens: any[]; + payload: any; + payloads: any[]; + sender: string | null; +} + +function deepDecodeMSL(obj: any): DecodedMSL { + const result: DecodedMSL = { + envelope: obj, + header: null, + useridtoken: null, + servicetokens: [], + payload: null, + payloads: [], + sender: null, + }; + + if (!obj || typeof obj !== "object") return result; + + const compress = obj.compressionalgo ?? null; + + // sender (ESN) + if (typeof obj.sender === "string") { + result.sender = obj.sender; + } + + // headerdata → base64 decode → JSON parse + if (typeof obj.headerdata === "string") { + const hdr = tryParseJSON(tryDecodeB64(obj.headerdata)); + if (hdr && typeof hdr === "object") { + result.header = hdr; + if (typeof hdr.sender === "string" && !result.sender) { + result.sender = hdr.sender; + } + } + } + + // payload → base64 decode → JSON (MSLPayloadChunk) → data decode + if (typeof obj.payload === "string") { + const chunkRaw = tryParseJSON(tryDecodeB64(obj.payload)); + if (chunkRaw && typeof chunkRaw === "object") { + if (chunkRaw.data) { + const algo = chunkRaw.compressionalgo ?? compress; + result.payload = decodeChunkData(chunkRaw.data, algo); + } + } + } + + // data field (used in some MSL envelope formats) + if (typeof obj.data === "string" && obj.messageid !== undefined) { + result.payload = result.payload ?? decodeChunkData(obj.data, compress); + } + + // payloads[] → each chunk decoded + if (Array.isArray(obj.payloads)) { + result.payloads = obj.payloads.map((p: any) => { + if (typeof p === "string") { + const chunkRaw = tryParseJSON(tryDecodeB64(p)); + if (chunkRaw && typeof chunkRaw === "object" && chunkRaw.data) { + const algo = chunkRaw.compressionalgo ?? compress; + return { _chunk: chunkRaw, _data: decodeChunkData(chunkRaw.data, algo) }; + } + return chunkRaw ?? p; + } + return p; + }); + } + + // servicetokens[] → tokendata decode → servicedata decode + if (Array.isArray(obj.servicetokens)) { + result.servicetokens = obj.servicetokens.map((st: any) => { + if (st && typeof st.tokendata === "string") { + const tdRaw = tryParseJSON(tryDecodeB64(st.tokendata)); + if (tdRaw && typeof tdRaw === "object") { + const decoded: any = { ...tdRaw }; + if (tdRaw.servicedata) { + const sd = tryDecodeB64(tdRaw.servicedata); + decoded._servicedata_decoded = sd ? tryParseJSON(sd) ?? sd : null; + } + return decoded; + } + } + return st; + }); + } + + // useridtoken → tokendata decode + if (obj.useridtoken && typeof obj.useridtoken === "object") { + if (typeof obj.useridtoken.tokendata === "string") { + result.useridtoken = tryParseJSON(tryDecodeB64(obj.useridtoken.tokendata)); + } + } + + return result; +} + +// ──────────────────────────────────────────────────────────── +// ALE Key Extraction +// ──────────────────────────────────────────────────────────── + +interface AleKeys { + encryptionKey: string; + hmacKey: string; + kid: string; + jweToken: string; + scheme: string; + rawKeyHex: string; + capturedAt: string; +} + +function extractAleKeys(payload: any): AleKeys | null { + if (!payload || typeof payload !== "object") return null; + + // provisionResponse field in the result + const provResponse = payload.provisionResponse; + if (!provResponse || typeof provResponse !== "string") return null; + + // iOS: base64 エンコード, Android: 直接 JSON + let tokenObj = tryParseJSON(tryDecodeB64(provResponse)); + if (!tokenObj) tokenObj = tryParseJSON(provResponse); + if (!tokenObj || typeof tokenObj !== "object") return null; + + const keyx = tokenObj.keyx; + if (!keyx || !keyx.data) return null; + + // scheme=CLEAR: data.key (平文鍵), scheme=RSA-OAEP-256: data.wrappedkey (ラップ鍵) + const rawKey = keyx.data.key || keyx.data.wrappedkey; + if (!rawKey) return null; + + const keyBytes = base64urlToBytes(rawKey); + if (!keyBytes || keyBytes.length < 16) return null; + + const hmacHex = bytesToHexStr(keyBytes.slice(0, 16)); + const aesHex = bytesToHexStr(keyBytes.slice(16, 32)); + + // JWE header info + const jweToken = tokenObj.token || ""; + let jweAlg = "?"; + let jweEnc = "?"; + if (jweToken) { + try { + const parts = jweToken.split("."); + if (parts.length === 5) { + const hdrStr = tryDecodeB64(parts[0].replace(/-/g, "+").replace(/_/g, "/")); + const hdr = tryParseJSON(hdrStr); + if (hdr) { + jweAlg = hdr.alg ?? "?"; + jweEnc = hdr.enc ?? "?"; + } + } + } catch { /* ignore */ } + } + + logData("ale.keys", { + hmacKey: hmacHex, + encryptionKey: aesHex, + kid: keyx.kid, + scheme: keyx.scheme, + jweAlg: jweAlg, + jweEnc: jweEnc, + rawKeyHex: bytesToHexStr(keyBytes), + }); + + return { + encryptionKey: aesHex, + hmacKey: hmacHex, + kid: keyx.kid, + jweToken: jweToken, + scheme: keyx.scheme, + rawKeyHex: bytesToHexStr(keyBytes), + capturedAt: new Date().toISOString(), + }; +} + +// ──────────────────────────────────────────────────────────── +// Manifest Extraction +// ──────────────────────────────────────────────────────────── + +function formatKID(hex: string): string | null { + if (!hex || hex.length !== 32) return hex || null; + return hex.slice(0, 8) + "-" + hex.slice(8, 12) + "-" + hex.slice(12, 16) + "-" + hex.slice(16, 20) + "-" + hex.slice(20); +} + +function extractManifest(payload: any): void { + if (!payload || typeof payload !== "object") return; + + const rawResult = payload.result ?? payload; + if (!rawResult || typeof rawResult !== "object") return; + if (!rawResult.video_tracks && !rawResult.audio_tracks) return; + + const movieId = rawResult.movieId != null ? String(rawResult.movieId) : null; + const duration = rawResult.duration ?? null; + + // Video tracks + const videoTracks: any[] = []; + if (Array.isArray(rawResult.video_tracks)) { + for (const vt of rawResult.video_tracks) { + const streams: any[] = []; + if (Array.isArray(vt.streams)) { + for (const s of vt.streams) { + streams.push({ + res_w: s.res_w, + res_h: s.res_h, + bitrate: s.bitrate, + size: s.size, + vmaf: s.vmaf, + content_profile: s.content_profile, + downloadable_id: s.downloadable_id, + kid: formatKID(s.drmHeaderId ?? ""), + }); + } + } + videoTracks.push({ + trackType: vt.trackType, + track_id: vt.track_id, + maxWidth: vt.maxWidth, + maxHeight: vt.maxHeight, + streams: streams, + }); + } + } + + // Audio tracks + const audioTracks: any[] = []; + if (Array.isArray(rawResult.audio_tracks)) { + for (const at of rawResult.audio_tracks) { + const streams: any[] = []; + if (Array.isArray(at.streams)) { + for (const s of at.streams) { + streams.push({ + bitrate: s.bitrate, + size: s.size, + content_profile: s.content_profile, + downloadable_id: s.downloadable_id, + }); + } + } + audioTracks.push({ + language: at.language, + languageDescription: at.languageDescription, + channels: at.channels, + trackType: at.trackType, + track_id: at.track_id, + streams: streams, + }); + } + } + + // Text tracks + const textTracks: any[] = []; + if (Array.isArray(rawResult.timedtexttracks)) { + for (const tt of rawResult.timedtexttracks) { + if (tt.isNoneTrack) continue; + textTracks.push({ + language: tt.language, + languageDescription: tt.languageDescription, + trackType: tt.trackType, + downloadableId: tt.downloadableId, + }); + } + } + + const totalVideo = videoTracks.reduce((n: number, t: any) => n + (t.streams ? t.streams.length : 0), 0); + const totalAudio = audioTracks.reduce((n: number, t: any) => n + (t.streams ? t.streams.length : 0), 0); + + logData("manifest", { + movieId: movieId, + duration: duration, + videoStreams: totalVideo, + audioStreams: totalAudio, + textTracks: textTracks.length, + videoTracks: videoTracks, + audioTracks: audioTracks, + textTracks_detail: textTracks, + }); + + // KID table (video streams grouped by resolution and KID) + const kidRows: any[] = []; + for (const vt of videoTracks) { + let lastKid = ""; + for (const s of vt.streams) { + const boundary = s.kid !== lastKid && lastKid !== ""; + kidRows.push({ + res: s.res_w + "x" + s.res_h, + bitrate: s.bitrate, + kid: s.kid, + content_profile: s.content_profile, + boundary: boundary, + }); + lastKid = s.kid || lastKid; + } + } + + if (kidRows.length > 0) { + logData("manifest.kidTable", { + movieId: movieId, + rows: kidRows, + }); + } +} + +// ──────────────────────────────────────────────────────────── +// Chunk Accumulator (multi-part manifest reassembly) +// ──────────────────────────────────────────────────────────── + +let chunkState: { chunks: string[]; msgId: number | null } = { chunks: [], msgId: null }; + +function accumulateChunks(envelope: any, decodedPayload: any): void { + // Skip if already a manifest + if (decodedPayload && typeof decodedPayload === "object") { + const pd = decodedPayload as Record; + if (pd.result && typeof pd.result === "object" && pd.result.video_tracks) return; + } + + const msgId = envelope.messageid as number | undefined; + const data = envelope.data; + const endofmsg = envelope.endofmsg as boolean | undefined; + + if (!data || typeof data !== "string") return; + if (msgId !== chunkState.msgId) { + chunkState.chunks = []; + chunkState.msgId = msgId ?? null; + } + chunkState.chunks.push(data); + + if (endofmsg) { + try { + const algo = (envelope.compressionalgo as string) || "LZW"; + const combined = chunkState.chunks + .map((d: string) => { + if (algo === "LZW") return decodeLZW(d) || ""; + return tryDecodeB64(d) || ""; + }) + .join(""); + const parsed = tryParseJSON(combined); + if (parsed && parsed.result) { + if (parsed.result.video_tracks || parsed.result.audio_tracks) { + extractManifest(parsed); + } + } + } catch (e) { + console.log("[-] Manifest chunk reassembly error: " + e); + } + chunkState.chunks = []; + chunkState.msgId = null; + } +} + +// ──────────────────────────────────────────────────────────── +// Public API: Process decrypted MSL plaintext +// ──────────────────────────────────────────────────────────── + +let mslSeq = 0; + +export function processMslPlaintext( + plaintextBytes: ArrayBuffer, + direction: "encrypt" | "decrypt", + algorithm: string, +): void { + const bytes = new Uint8Array(plaintextBytes); + + // バイナリデータ (CBOR 等) を早期にスキップ + // JSON は '{' (0x7b) または '[' (0x5b) で始まる + // CBOR MSL エンベロープも内部に JSON を含むことがあるので、 + // JSON 開始文字を探す + let jsonStart = -1; + for (let i = 0; i < Math.min(bytes.length, 256); i++) { + if (bytes[i] === 0x7b || bytes[i] === 0x5b) { + jsonStart = i; + break; + } + } + if (jsonStart === -1) return; // JSON が見つからない → バイナリデータ + + let text: string; + try { + // JSON 部分のみをデコード (先頭のバイナリヘッダをスキップ) + text = utf8Decode(bytes.slice(jsonStart)); + // サロゲートペア等の不正文字を除去 + text = text.replace(/[\uD800-\uDFFF]/g, "?"); + } catch { + return; + } + + // JSON 末尾以降のゴミを除去 (CBOR フッタ) + const lastBrace = text.lastIndexOf("}"); + const lastBracket = text.lastIndexOf("]"); + const jsonEnd = Math.max(lastBrace, lastBracket); + if (jsonEnd > 0) { + text = text.substring(0, jsonEnd + 1); + } + + const json = tryParseJSON(text); + if (!json || typeof json !== "object") { + // Non-JSON plaintext + if (text.length > 0 && text.length < 65536) { + mslSeq++; + logData("msl.message", { + seq: mslSeq, + direction: direction, + algorithm: algorithm, + size: text.length, + format: "text", + data: text.substring(0, 8192), + }); + } + return; + } + + // Decode MSL envelope + const decoded = deepDecodeMSL(json); + + // ESN extraction + if (decoded.sender) maybeUpdateEsn(decoded.sender); + + // Build summary for console + let summary = ""; + if (decoded.payload && typeof decoded.payload === "object") { + const pd = decoded.payload; + if (pd.method) summary = " method=" + pd.method; + else if (pd.url) summary = " url=" + pd.url; + } + + // コンソール出力は抑制 (@@LOG@@ でログファイルに記録される) + + // Log decoded MSL message + mslSeq++; + logData("msl.message", { + seq: mslSeq, + direction: direction, + algorithm: algorithm, + size: text.length, + format: "json", + envelope: json, + header: decoded.header, + useridtoken: decoded.useridtoken, + servicetokens: decoded.servicetokens.length > 0 ? decoded.servicetokens : null, + payload: decoded.payload, + payloads: decoded.payloads.length > 0 ? decoded.payloads : null, + }); + + // Process decrypted payloads + if (direction === "decrypt" && decoded.payload && typeof decoded.payload === "object") { + const pd = decoded.payload; + const result = pd.result ?? pd; + + // Manifest detection + if (result && typeof result === "object" && (result.video_tracks || result.audio_tracks)) { + try { extractManifest(pd); } catch (e) { + console.log("[-] extractManifest error: " + e); + } + } + + // ALE key detection + try { + extractAleKeys(result); + } catch (e) { + console.log("[-] extractAleKeys error: " + e); + } + } + + // Chunk accumulation for multi-part manifests + if (direction === "decrypt") { + accumulateChunks(json, decoded.payload); + } +} + +// ──────────────────────────────────────────────────────────── +// Public API: Process MSL API response (from IosMslClient) +// ──────────────────────────────────────────────────────────── + +export function processMslApiResponse(url: string, responseStr: string | null): void { + if (!responseStr) return; + + const json = tryParseJSON(responseStr); + if (!json || typeof json !== "object") return; + + // If the response itself is an MSL envelope, decode it + if (json.headerdata || json.payload || json.payloads) { + const decoded = deepDecodeMSL(json); + if (decoded.sender) maybeUpdateEsn(decoded.sender); + + if (decoded.payload && typeof decoded.payload === "object") { + const pd = decoded.payload; + const result = pd.result ?? pd; + + // Manifest + if (result && typeof result === "object" && (result.video_tracks || result.audio_tracks)) { + try { extractManifest(pd); } catch (e) { + console.log("[-] extractManifest from API response: " + e); + } + } + + // ALE keys + try { extractAleKeys(result); } catch (e) { + console.log("[-] extractAleKeys from API response: " + e); + } + } + return; + } + + // Direct response object (not MSL-wrapped) + // Check for manifest + if (json.result && typeof json.result === "object") { + const result = json.result; + if (result.video_tracks || result.audio_tracks) { + try { extractManifest(json); } catch (e) { + console.log("[-] extractManifest from direct response: " + e); + } + } + } + + // Check for ALE keys + try { extractAleKeys(json.result ?? json); } catch (e) { /* ignore */ } +} diff --git a/packages/frida/src/common/utils.ts b/packages/frida/src/common/utils.ts new file mode 100644 index 0000000..7cc6f1e --- /dev/null +++ b/packages/frida/src/common/utils.ts @@ -0,0 +1,65 @@ +export const SEP = "======================================================================"; +export const SEP2 = "----------------------------------------------------------------------"; + +export function ts(): string { + return new Date().toISOString(); +} + +export function bytesToHex(buf: ArrayBuffer): string { + const arr = new Uint8Array(buf); + let hex = ""; + for (let i = 0; i < arr.length; i++) { + const b = arr[i].toString(16); + hex += (b.length === 1 ? "0" : "") + b; + } + return hex; +} + +const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +export function bytesToBase64(buf: ArrayBuffer): string { + const arr = new Uint8Array(buf); + const len = arr.length; + let out = ""; + for (let i = 0; i < len; i += 3) { + const b0 = arr[i], b1 = i + 1 < len ? arr[i + 1] : 0, b2 = i + 2 < len ? arr[i + 2] : 0; + out += B64[b0 >> 2] + B64[((b0 & 3) << 4) | (b1 >> 4)]; + out += i + 1 < len ? B64[((b1 & 15) << 2) | (b2 >> 6)] : "="; + out += i + 2 < len ? B64[b2 & 63] : "="; + } + return out; +} + +export function logData(event: string, info?: Record, data?: ArrayBuffer): void { + const payload: Record = { event: event, ts: ts() }; + if (info) { + for (const k in info) payload[k] = info[k]; + } + if (data) { + payload.data_hex = bytesToHex(data); + payload.data_size = data.byteLength; + } + console.log("@@LOG@@" + JSON.stringify(payload)); +} + +// ── 共通コンソールロガー ── + +export function logMsl(operation: string, detail: string): void { + console.log("[MSL] " + operation + " " + detail); +} + +export function logDrm(detail: string): void { + console.log("[DRM] " + detail); +} + +export function logAle(detail: string): void { + console.log("[ALE] " + detail); +} + +export function logHttpReq(method: string, url: string, bodySize: number, headerCount: number): void { + console.log(" > " + method + " " + url + " (" + bodySize + "B, " + headerCount + " headers)"); +} + +export function logHttpResp(status: number, url: string, bodySize: number, headerCount: number): void { + console.log(" < " + status + " " + url + " (" + bodySize + "B, " + headerCount + " headers)"); +} diff --git a/packages/frida/src/frida-env.d.ts b/packages/frida/src/frida-env.d.ts new file mode 100644 index 0000000..ba946b3 --- /dev/null +++ b/packages/frida/src/frida-env.d.ts @@ -0,0 +1,23 @@ +// Frida ObjC bridge type declarations +declare namespace ObjC { + const available: boolean; + const classes: Record; + function Object(handle: any): any; + class Block { + constructor(handle: any); + implementation: any; + } +} + +// Frida Java bridge type declarations +declare namespace Java { + function perform(fn: () => void): void; + function use(className: string): any; + function registerClass(spec: any): any; +} + +// Frida ApiResolver +declare class ApiResolver { + constructor(type: string); + enumerateMatches(query: string): Array<{ name: string; address: NativePointer }>; +} diff --git a/packages/frida/src/ios/ale.ts b/packages/frida/src/ios/ale.ts new file mode 100644 index 0000000..0303345 --- /dev/null +++ b/packages/frida/src/ios/ale.ts @@ -0,0 +1,181 @@ +import { bytesToBase64, logData, logAle } from "../common/utils"; + +// ── iOS ALE フック ── +// Nbp.framework の AleManager / ALE モジュールの ObjC/Swift クラスをフック +// MslClient ネイティブではなく Nbp の Swift 実装 + +export function hookALE(): void { + if (typeof ObjC === 'undefined' || !ObjC.available) return; + + hookAleManager(); + hookAleService(); + hookPBOClient(); +} + +// ── AleManager (Nbp) ── + +function hookAleManager(): void { + // Swift クラスの ObjC 名: _TtC3Nbp10AleManager + const clsName = "_TtC3Nbp10AleManager"; + try { + const cls = ObjC.classes[clsName]; + if (!cls) { + console.log("[-] " + clsName + " not found"); + return; + } + + const methods = cls.$ownMethods; + console.log("[*] AleManager methods (" + methods.length + "):"); + methods.forEach(function (m: string) { + console.log(" " + m); + }); + + // 全メソッドをフック + methods.forEach(function (m: string) { + try { + const impl = cls[m]; + if (!impl) return; + Interceptor.attach(impl.implementation, { + onEnter: function (args) { + logAle("AleManager" + m); + } + }); + } catch (e) { } + }); + + console.log("[+] Hooked AleManager (" + methods.length + " methods)"); + } catch (e) { + console.log("[-] AleManager: " + e); + } +} + +// ── AleService / AleSession (ALE module) ── + +function hookAleService(): void { + const classNames = [ + "_TtC3ALE10AleService", + "_TtC3ALE10AleSession", + "_TtC3ALE7AleAuto", + "_TtC3ALE16KeyExchangeClear", + "_TtC3ALE18KeyExchangeRsaOaep", + ]; + + for (const clsName of classNames) { + try { + const cls = ObjC.classes[clsName]; + if (!cls) continue; + + const shortName = clsName.replace("_TtC3ALE", "ALE.").replace("_TtC3Nbp", "Nbp."); + const methods = cls.$ownMethods; + console.log("[*] " + shortName + " methods (" + methods.length + "):"); + methods.forEach(function (m: string) { + console.log(" " + m); + }); + + methods.forEach(function (m: string) { + try { + const impl = cls[m]; + if (!impl) return; + Interceptor.attach(impl.implementation, { + onEnter: function (args) { + logAle(shortName + m); + // createSession / getProvisioningRequest の引数/戻り値をキャプチャ + if (m.indexOf("createSession") !== -1 || m.indexOf("provisionResponse") !== -1) { + try { + // Swift String 引数は args[2] (self=args[0], _cmd=args[1]) + if (args[2] && !args[2].isNull()) { + const str = new ObjC.Object(args[2]).toString(); + if (str && str.length > 0) { + const preview = str.length > 300 ? str.substring(0, 300) + "..." : str; + logAle(shortName + ".createSession response: " + preview); + logData("ale.createSession", { + response: str.substring(0, 65536) + }); + } + } + } catch (e) { } + } + if (m.indexOf("getProvisioningRequest") !== -1) { + logData("ale.provisionRequest", {}); + } + }, + onLeave: function (retval) { + if (m.indexOf("getProvisioningRequest") !== -1) { + try { + if (retval && !retval.isNull()) { + const str = new ObjC.Object(retval).toString(); + if (str && str.length > 0) { + const preview = str.length > 300 ? str.substring(0, 300) + "..." : str; + logAle(shortName + ".getProvisioningRequest -> " + preview); + logData("ale.provisionRequest", { + request: str.substring(0, 65536) + }); + } + } + } catch (e) { } + } + } + }); + } catch (e) { } + }); + + if (methods.length > 0) { + console.log("[+] Hooked " + shortName + " (" + methods.length + " methods)"); + } + } catch (e) { } + } +} + +// ── PBOClient (PlayapiClient) — aleProvision ルーティング監視 ── + +function hookPBOClient(): void { + try { + const PBORequest = ObjC.classes.PBORequest; + if (!PBORequest) { + console.log("[-] PBORequest not found"); + return; + } + + // +[PBORequest stringForAction:] をフック + try { + const resolver = new ApiResolver("objc"); + resolver.enumerateMatches("+[PBORequest stringForAction:]").forEach(function (match) { + Interceptor.attach(match.address, { + onLeave: function (retval) { + if (retval && !retval.isNull()) { + const actionStr = new ObjC.Object(retval).toString(); + if (actionStr === "aleProvision") { + logAle("PBORequest.stringForAction -> aleProvision"); + } + } + } + }); + }); + console.log("[+] Hooked PBORequest.stringForAction:"); + } catch (e) { } + + // PBOClient.sendRequest:callback: をフック + try { + const PBOClient = ObjC.classes.PBOClient; + if (PBOClient) { + const resolver = new ApiResolver("objc"); + resolver.enumerateMatches("-[PBOClient sendRequest:callback:]").forEach(function (match) { + Interceptor.attach(match.address, { + onEnter: function (args) { + try { + const request = new ObjC.Object(args[2]); + const desc = request.toString(); + if (desc.indexOf("aleProvision") !== -1 || desc.indexOf("Provision") !== -1) { + logAle("PBOClient.sendRequest: " + desc.substring(0, 200)); + } + } catch (e) { } + } + }); + }); + console.log("[+] Hooked PBOClient.sendRequest:callback:"); + } + } catch (e) { } + } catch (e) { + console.log("[-] PBOClient hooks: " + e); + } +} diff --git a/packages/frida/src/ios/anti-detection.ts b/packages/frida/src/ios/anti-detection.ts new file mode 100644 index 0000000..30052bc --- /dev/null +++ b/packages/frida/src/ios/anti-detection.ts @@ -0,0 +1,185 @@ +// ── Netflix iOS アンチ検出バイパス ── +// spawn モードで動作させるために、以下を無効化: +// 1. objc_setHook_getClass (NFNetworkState) — ObjC ランタイムフックの競合回避 +// 2. task_set_exception_ports (Nbp) — Mach 例外ポート奪取の阻止 +// 3. __dyld_register_func_for_add_image — frida-agent.dylib 検出の抑制 +// +// これらは全て __mod_init_func / +load で実行されるため、 +// スクリプトロード直後(コンストラクタ実行前)に Interceptor.replace で NOP 化する + +export function installAntiDetectionBypass(): void { + console.log("[*] Installing anti-detection bypass..."); + + bypassObjcSetHookGetClass(); + bypassTaskSetExceptionPorts(); + bypassDyldRegisterAddImage(); + bypassTermination(); + + console.log("[+] Anti-detection bypass installed"); +} + +// ── 1. objc_setHook_getClass NOP 化 ── +// NFNetworkState が installGetClassHook_untrusted() で ObjC クラス解決をフック +// Frida の ObjC ブリッジと競合するため、フック登録自体を無効化 + +function bypassObjcSetHookGetClass(): void { + try { + const addr = findExport("libobjc.A.dylib", "objc_setHook_getClass"); + if (!addr) { + console.log("[-] objc_setHook_getClass not found"); + return; + } + // replace ではなく attach で監視のみ (NOP 化すると依存する初期化が壊れる) + Interceptor.attach(addr, { + onEnter: function (_args) { + console.log("[MONITOR] objc_setHook_getClass called"); + } + }); + console.log("[+] Monitoring objc_setHook_getClass"); + } catch (e) { + console.log("[-] objc_setHook_getClass bypass: " + e); + } +} + +// ── 2. task_set_exception_ports ── +// 監視のみ (NOP 化すると他に影響する可能性) + +function bypassTaskSetExceptionPorts(): void { + try { + const addr = findExport("libsystem_kernel.dylib", "task_set_exception_ports"); + if (!addr) { + console.log("[-] task_set_exception_ports not found"); + return; + } + Interceptor.attach(addr, { + onEnter: function (args) { + console.log("[MONITOR] task_set_exception_ports(mask=" + args[1] + ", behavior=" + args[3] + ")"); + } + }); + console.log("[+] Monitoring task_set_exception_ports"); + } catch (e) { + console.log("[-] task_set_exception_ports bypass: " + e); + } +} + +// ── 3. __dyld_register_func_for_add_image コールバック抑制 ── +// NFRCW, NFNetworkState, Nbp が dylib ロード通知を受け取り、 +// frida-agent.dylib を検出する +// コールバック登録自体を NOP 化 + +function bypassDyldRegisterAddImage(): void { + // attach 時の Interceptor.attach で登録をログしつつ通す + // spawn 時はフレームワーク初期化で呼ばれるが、frida-agent は既にロード済みなので + // 新規 dylib ロード通知は問題にならないはず + // → replace ではなく attach にして副作用を最小化 + const targets = ["_dyld_register_func_for_add_image", "_dyld_register_func_for_remove_image"]; + + for (const symName of targets) { + try { + const addr = findExport("libdyld.dylib", symName) + || findExport("libSystem.B.dylib", symName); + if (!addr) continue; + + Interceptor.attach(addr, { + onEnter: function (_args) { + console.log("[MONITOR] " + symName + " called"); + } + }); + console.log("[+] Monitoring " + symName); + } catch (e) { + console.log("[-] " + symName + " monitor: " + e); + } + } +} + +// ── 4. 終了関数フック — クラッシュ原因の特定 ── + +function bypassTermination(): void { + const funcs = ["abort", "_exit", "exit"]; + for (const name of funcs) { + try { + const addr = findExport(null, name); + if (!addr) continue; + Interceptor.attach(addr, { + onEnter: function (args) { + const code = name === "abort" ? "" : " code=" + args[0].toInt32(); + console.log("[CRASH] " + name + "()" + code + " called!"); + console.log(" backtrace:\n" + Thread.backtrace(this.context, Backtracer.ACCURATE) + .map(DebugSymbol.fromAddress).join("\n")); + } + }); + console.log("[+] Monitoring " + name + "()"); + } catch (e) { } + } + + // kill (self) + try { + const addr = findExport(null, "kill"); + if (addr) { + Interceptor.attach(addr, { + onEnter: function (args) { + const pid = args[0].toInt32(); + const sig = args[1].toInt32(); + if (pid === Process.id || pid === 0) { + console.log("[CRASH] kill(self, " + sig + ")!"); + console.log(" backtrace:\n" + Thread.backtrace(this.context, Backtracer.ACCURATE) + .map(DebugSymbol.fromAddress).join("\n")); + } + } + }); + } + } catch (e) { } + + // raise + try { + const addr = findExport(null, "raise"); + if (addr) { + Interceptor.attach(addr, { + onEnter: function (args) { + console.log("[CRASH] raise(" + args[0].toInt32() + ")!"); + console.log(" backtrace:\n" + Thread.backtrace(this.context, Backtracer.ACCURATE) + .map(DebugSymbol.fromAddress).join("\n")); + } + }); + } + } catch (e) { } + + // __pthread_kill + try { + const addr = findExport(null, "__pthread_kill"); + if (addr) { + Interceptor.attach(addr, { + onEnter: function (args) { + const sig = args[1].toInt32(); + if (sig === 6 || sig === 9) { // SIGABRT or SIGKILL + console.log("[CRASH] __pthread_kill(sig=" + sig + ")!"); + console.log(" backtrace:\n" + Thread.backtrace(this.context, Backtracer.ACCURATE) + .map(DebugSymbol.fromAddress).join("\n")); + } + } + }); + } + } catch (e) { } +} + +// ── ヘルパー ── + +function findExport(moduleName: string | null, symbolName: string): NativePointer | null { + try { + if (moduleName) { + const mod = Process.findModuleByName(moduleName); + if (!mod) return null; + for (const exp of mod.enumerateExports()) { + if (exp.name === symbolName) return exp.address; + } + } else { + // 全モジュールから検索 + for (const mod of Process.enumerateModules()) { + for (const exp of mod.enumerateExports()) { + if (exp.name === symbolName) return exp.address; + } + } + } + } catch (e) { } + return null; +} diff --git a/packages/frida/src/ios/crypto.ts b/packages/frida/src/ios/crypto.ts new file mode 100644 index 0000000..ee07946 --- /dev/null +++ b/packages/frida/src/ios/crypto.ts @@ -0,0 +1,84 @@ +import { logData } from "../common/utils"; + +export function hookCrypto(): void { + // CCCrypt + const ccCrypt = Module.findExportByName("libcommonCrypto.dylib", "CCCrypt"); + if (ccCrypt) { + Interceptor.attach(ccCrypt, { + onEnter: function (args) { + this.op = args[0]; + this.alg = args[1]; + this.options = args[2]; + this.key = args[3]; + this.keyLength = args[4]; + this.iv = args[5]; + this.dataIn = args[6]; + this.dataInLength = args[7]; + this.dataOut = args[8]; + this.dataOutAvailable = args[9]; + this.dataOutMoved = args[10]; + + const opName = this.op == 0 ? "encrypt" : "decrypt"; + const algName = (["AES", "DES", "3DES", "CAST", "RC4", "RC2", "Blowfish"] as string[])[this.alg.toInt32()] || "unknown"; + const keyLen = this.keyLength.toInt32(); + const dataLen = this.dataInLength.toInt32(); + + console.log("CCCrypt(" + opName + " alg:" + algName + " keyLen:" + keyLen + " dataLen:" + dataLen + ")"); + + const keyBytes = ptr(this.key).readByteArray(keyLen); + const ivBytes = ptr(this.iv).readByteArray(keyLen); + logData("CCCrypt." + opName, { alg: algName, keyLen: keyLen, dataLen: dataLen }, keyBytes!); + logData("CCCrypt." + opName + ".iv", { alg: algName, size: keyLen }, ivBytes!); + + if (this.op == 0) { + const plainBytes = ptr(this.dataIn).readByteArray(Math.min(dataLen, 4096)); + logData("CCCrypt.encrypt.plaintext", { size: dataLen }, plainBytes!); + console.log("key:"); + console.log(hexdump(ptr(this.key), { length: keyLen, header: true, ansi: false })); + console.log("iv:"); + console.log(hexdump(ptr(this.iv), { length: keyLen, header: true, ansi: false })); + } + }, + onLeave: function (retval) { + const outLen = Memory.readUInt(this.dataOutMoved); + if (this.op == 1) { + const decrypted = ptr(this.dataOut).readByteArray(Math.min(outLen, 4096)); + logData("CCCrypt.decrypt.plaintext", { size: outLen }, decrypted!); + console.log("CCCrypt decrypt dataOut:"); + console.log(hexdump(ptr(this.dataOut), { length: Math.min(outLen, 512), header: true, ansi: false })); + } else { + console.log("CCCrypt encrypt dataOut (" + outLen + " bytes)"); + } + } + }); + console.log("[+] Hooked CCCrypt"); + } + + // SecKeyEncrypt + const secKeyEnc = Module.findExportByName("Security", "SecKeyEncrypt"); + if (secKeyEnc) { + Interceptor.attach(secKeyEnc, { + onEnter: function (args) { + console.log("SecKeyEncrypt()=" + args[2].readCString() + "="); + console.log("SecKeyEncrypt called from:\n" + + Thread.backtrace(this.context, Backtracer.ACCURATE) + .map(DebugSymbol.fromAddress).join("\n")); + } + }); + console.log("[+] Hooked SecKeyEncrypt"); + } + + // SecKeyRawSign + const secKeySign = Module.findExportByName("Security", "SecKeyRawSign"); + if (secKeySign) { + Interceptor.attach(secKeySign, { + onEnter: function (args) { + console.log("SecKeyRawSign()=" + args[2].readCString() + "="); + console.log("SecKeyRawSign called from:\n" + + Thread.backtrace(this.context, Backtracer.ACCURATE) + .map(DebugSymbol.fromAddress).join("\n")); + } + }); + console.log("[+] Hooked SecKeyRawSign"); + } +} diff --git a/packages/frida/src/ios/detect-antifrida.ts b/packages/frida/src/ios/detect-antifrida.ts new file mode 100644 index 0000000..215f111 --- /dev/null +++ b/packages/frida/src/ios/detect-antifrida.ts @@ -0,0 +1,346 @@ +// ── Netflix iOS の Frida / Jailbreak 検出メカニズム調査 ── +// attach モードで実行し、検出・終了処理を特定する + +export function investigateDetection(): void { + // ObjC が使えなくても C 関数フックは可能 (spawn 直後対応) + + // 最優先: ptrace ブロック (spawn 直後に呼ばれる可能性) + hookPtrace(); + + // 終了系関数 (exit, abort, kill, raise, pthread_kill) + hookTerminationFunctions(); + + // sysctl (デバッガ検出) + hookSysctl(); + + // dlopen / dyld (Frida 検出) + hookDynamicLoading(); + + // ObjC が使える場合のみ + if (typeof ObjC !== 'undefined' && ObjC.available) { + hookFileExistenceChecks(); + } +} + +function hookTerminationFunctions(): void { + // exit + try { + const exitPtr = Module.findExportByName(null, "exit"); + if (exitPtr) { + Interceptor.attach(exitPtr, { + onEnter: function (args) { + const code = args[0].toInt32(); + console.log("[DETECT] exit(" + code + ") called!"); + console.log(" backtrace:\n" + Thread.backtrace(this.context, Backtracer.ACCURATE) + .map(DebugSymbol.fromAddress).join("\n")); + } + }); + } + } catch (e) { } + + // _exit + try { + const _exitPtr = Module.findExportByName(null, "_exit"); + if (_exitPtr) { + Interceptor.attach(_exitPtr, { + onEnter: function (args) { + console.log("[DETECT] _exit(" + args[0].toInt32() + ") called!"); + console.log(" backtrace:\n" + Thread.backtrace(this.context, Backtracer.ACCURATE) + .map(DebugSymbol.fromAddress).join("\n")); + } + }); + } + } catch (e) { } + + // abort + try { + const abortPtr = Module.findExportByName(null, "abort"); + if (abortPtr) { + Interceptor.attach(abortPtr, { + onEnter: function () { + console.log("[DETECT] abort() called!"); + console.log(" backtrace:\n" + Thread.backtrace(this.context, Backtracer.ACCURATE) + .map(DebugSymbol.fromAddress).join("\n")); + } + }); + } + } catch (e) { } + + // kill (自プロセスへの SIGKILL) + try { + const killPtr = Module.findExportByName(null, "kill"); + if (killPtr) { + Interceptor.attach(killPtr, { + onEnter: function (args) { + const pid = args[0].toInt32(); + const sig = args[1].toInt32(); + if (pid === Process.id || pid === 0) { + console.log("[DETECT] kill(" + pid + ", " + sig + ") self-kill!"); + console.log(" backtrace:\n" + Thread.backtrace(this.context, Backtracer.ACCURATE) + .map(DebugSymbol.fromAddress).join("\n")); + } + } + }); + } + } catch (e) { } + + // raise (SIGKILL / SIGABRT) + try { + const raisePtr = Module.findExportByName(null, "raise"); + if (raisePtr) { + Interceptor.attach(raisePtr, { + onEnter: function (args) { + const sig = args[0].toInt32(); + console.log("[DETECT] raise(" + sig + ")!"); + console.log(" backtrace:\n" + Thread.backtrace(this.context, Backtracer.ACCURATE) + .map(DebugSymbol.fromAddress).join("\n")); + } + }); + } + } catch (e) { } + + // pthread_kill + try { + const pthreadKillPtr = Module.findExportByName(null, "pthread_kill"); + if (pthreadKillPtr) { + Interceptor.attach(pthreadKillPtr, { + onEnter: function (args) { + const sig = args[1].toInt32(); + if (sig === 9 || sig === 6) { // SIGKILL or SIGABRT + console.log("[DETECT] pthread_kill(thread, " + sig + ")!"); + console.log(" backtrace:\n" + Thread.backtrace(this.context, Backtracer.ACCURATE) + .map(DebugSymbol.fromAddress).join("\n")); + } + } + }); + } + } catch (e) { } + + // __pthread_kill (低レベル) + try { + const __pthreadKillPtr = Module.findExportByName(null, "__pthread_kill"); + if (__pthreadKillPtr) { + Interceptor.attach(__pthreadKillPtr, { + onEnter: function (args) { + const sig = args[1].toInt32(); + console.log("[DETECT] __pthread_kill(thread, " + sig + ")"); + if (sig === 9 || sig === 6) { + console.log(" backtrace:\n" + Thread.backtrace(this.context, Backtracer.ACCURATE) + .map(DebugSymbol.fromAddress).join("\n")); + } + } + }); + } + } catch (e) { } + + // signal handler registration + try { + const signalPtr = Module.findExportByName(null, "signal"); + if (signalPtr) { + Interceptor.attach(signalPtr, { + onEnter: function (args) { + const sig = args[0].toInt32(); + console.log("[DETECT] signal(" + sig + ", handler)"); + } + }); + } + } catch (e) { } + + // NSException raise (ObjC 例外) + try { + if (typeof ObjC !== 'undefined' && ObjC.available) { + const resolver = new ApiResolver("objc"); + resolver.enumerateMatches("+[NSException raise:format:]").forEach(function (match) { + Interceptor.attach(match.address, { + onEnter: function (args) { + const name = new ObjC.Object(args[2]).toString(); + const reason = new ObjC.Object(args[3]).toString(); + console.log("[DETECT] NSException raise: " + name + " reason: " + reason); + console.log(" backtrace:\n" + Thread.backtrace(this.context, Backtracer.ACCURATE) + .map(DebugSymbol.fromAddress).join("\n")); + } + }); + }); + } + } catch (e) { } + + console.log("[+] Hooked termination functions (exit, _exit, abort, kill, raise, pthread_kill, signal)"); +} + +function hookFileExistenceChecks(): void { + // Jailbreak 検出でよく使われるファイルパス + const jbPaths = [ + "/Applications/Cydia.app", + "/Library/MobileSubstrate", + "/usr/sbin/sshd", + "/etc/apt", + "/usr/bin/ssh", + "/private/var/lib/apt", + "/private/var/lib/cydia", + "/private/var/tmp/cydia.log", + "/usr/libexec/sftp-server", + "/var/jb", + "/var/LIB", + "frida", + "substrate", + "cycript", + ]; + + // NSFileManager fileExistsAtPath: + try { + const resolver = new ApiResolver("objc"); + resolver.enumerateMatches("-[NSFileManager fileExistsAtPath:]").forEach(function (match) { + Interceptor.attach(match.address, { + onEnter: function (args) { + const path = new ObjC.Object(args[2]).toString(); + const pathLower = path.toLowerCase(); + if (jbPaths.some(jb => pathLower.indexOf(jb.toLowerCase()) !== -1)) { + console.log("[DETECT] fileExistsAtPath: " + path); + console.log(" backtrace:\n" + Thread.backtrace(this.context, Backtracer.ACCURATE) + .map(DebugSymbol.fromAddress).join("\n")); + } + } + }); + }); + console.log("[+] Hooked NSFileManager fileExistsAtPath:"); + } catch (e) { } + + // access() C function + try { + const accessPtr = Module.findExportByName(null, "access"); + if (accessPtr) { + Interceptor.attach(accessPtr, { + onEnter: function (args) { + const path = args[0].readUtf8String(); + if (path) { + const pathLower = path.toLowerCase(); + if (jbPaths.some(jb => pathLower.indexOf(jb.toLowerCase()) !== -1)) { + console.log("[DETECT] access('" + path + "')"); + } + } + } + }); + } + } catch (e) { } + + // stat() + try { + const statPtr = Module.findExportByName(null, "stat"); + if (statPtr) { + Interceptor.attach(statPtr, { + onEnter: function (args) { + const path = args[0].readUtf8String(); + if (path) { + const pathLower = path.toLowerCase(); + if (jbPaths.some(jb => pathLower.indexOf(jb.toLowerCase()) !== -1)) { + console.log("[DETECT] stat('" + path + "')"); + } + } + } + }); + } + } catch (e) { } + + // open() for reading jailbreak files + try { + const openPtr = Module.findExportByName(null, "open"); + if (openPtr) { + Interceptor.attach(openPtr, { + onEnter: function (args) { + const path = args[0].readUtf8String(); + if (path) { + const pathLower = path.toLowerCase(); + if (jbPaths.some(jb => pathLower.indexOf(jb.toLowerCase()) !== -1)) { + console.log("[DETECT] open('" + path + "')"); + } + } + } + }); + } + } catch (e) { } +} + +function hookDynamicLoading(): void { + // dlopen - Frida ライブラリ検出 + try { + const dlopenPtr = Module.findExportByName(null, "dlopen"); + if (dlopenPtr) { + Interceptor.attach(dlopenPtr, { + onEnter: function (args) { + const path = args[0].readUtf8String(); + if (path && (path.toLowerCase().indexOf("frida") !== -1 || + path.toLowerCase().indexOf("substrate") !== -1 || + path.toLowerCase().indexOf("cycript") !== -1)) { + console.log("[DETECT] dlopen('" + path + "')"); + } + } + }); + } + } catch (e) { } + + // _dyld_get_image_name - モジュール列挙 + try { + const getImageNamePtr = Module.findExportByName(null, "_dyld_get_image_name"); + if (getImageNamePtr) { + Interceptor.attach(getImageNamePtr, { + onEnter: function (args) { + this._idx = args[0].toInt32(); + }, + onLeave: function (retval) { + if (!retval.isNull()) { + const name = retval.readUtf8String(); + if (name && name.toLowerCase().indexOf("frida") !== -1) { + console.log("[DETECT] _dyld_get_image_name(" + this._idx + ") -> " + name); + console.log(" backtrace:\n" + Thread.backtrace(this.context, Backtracer.ACCURATE) + .map(DebugSymbol.fromAddress).join("\n")); + } + } + } + }); + } + } catch (e) { } +} + +function hookSysctl(): void { + // sysctl - デバッガ検出 (P_TRACED flag) + try { + const sysctlPtr = Module.findExportByName(null, "sysctl"); + if (sysctlPtr) { + Interceptor.attach(sysctlPtr, { + onEnter: function (args) { + // CTL_KERN=1, KERN_PROC=14, KERN_PROC_PID=1 + const mib = args[0]; + const name0 = mib.readInt(); + const name1 = mib.add(4).readInt(); + if (name0 === 1 && name1 === 14) { + console.log("[DETECT] sysctl(KERN_PROC) - debugger check"); + console.log(" backtrace:\n" + Thread.backtrace(this.context, Backtracer.ACCURATE) + .map(DebugSymbol.fromAddress).join("\n")); + } + } + }); + } + } catch (e) { } +} + +function hookPtrace(): void { + // ptrace(PT_DENY_ATTACH, ...) - アンチデバッグ + try { + const ptracePtr = Module.findExportByName(null, "ptrace"); + if (ptracePtr) { + Interceptor.attach(ptracePtr, { + onEnter: function (args) { + const request = args[0].toInt32(); + if (request === 31) { // PT_DENY_ATTACH + console.log("[DETECT] ptrace(PT_DENY_ATTACH) blocked!"); + console.log(" backtrace:\n" + Thread.backtrace(this.context, Backtracer.ACCURATE) + .map(DebugSymbol.fromAddress).join("\n")); + // ブロック: 何もせず return 0 + args[0] = ptr(0); + } + } + }); + console.log("[+] Hooked ptrace (PT_DENY_ATTACH bypass)"); + } + } catch (e) { } +} diff --git a/packages/frida/src/ios/http.ts b/packages/frida/src/ios/http.ts new file mode 100644 index 0000000..b7f4625 --- /dev/null +++ b/packages/frida/src/ios/http.ts @@ -0,0 +1,542 @@ +import { logData, logHttpReq, logHttpResp } from "../common/utils"; +import { maybeUpdateEsnFromHeader } from "../common/msl-processor"; + +function domainOf(urlStr: string): string { + const m = urlStr.match(/^https?:\/\/([^\/\?:]+)/); + return m ? m[1] : "unknown"; +} + +function isLocal(urlStr: string): boolean { + return /^https?:\/\/(192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.|127\.|localhost|0\.0\.0\.0)/.test(urlStr); +} + +export function hookObjCTrace(): void { + if (typeof ObjC === 'undefined' || !ObjC.available) return; + + const lastUrlByThread: Record = {}; + + // +[NSURL URLWithString:] + try { + const resolver = new ApiResolver("objc"); + resolver.enumerateMatches("+[NSURL URLWithString:]").forEach(function (match) { + Interceptor.attach(match.address, { + onEnter: function (args) { + const url = new ObjC.Object(args[2]).toString(); + lastUrlByThread[this.threadId] = url; + if (isLocal(url)) return; + const domain = domainOf(url); + logData("url", { domain: domain, url: url }); + } + }); + }); + console.log("[+] Hooked +[NSURL URLWithString:]"); + } catch (e) { console.log("[-] NSURL: " + e); } + + // -[NSMutableURLRequest setHTTPBody:] + try { + const resolver = new ApiResolver("objc"); + resolver.enumerateMatches("-[NSMutableURLRequest setHTTPBody:]").forEach(function (match) { + Interceptor.attach(match.address, { + onEnter: function (args) { + try { + const req = new ObjC.Object(args[0]); + let url = ""; + try { + const reqUrl = req.URL(); + if (reqUrl && !reqUrl.isNull()) url = reqUrl.absoluteString().toString(); + } catch (e) { } + if (!url) url = lastUrlByThread[this.threadId] || ""; + if (isLocal(url)) return; + const data = new ObjC.Object(args[2]); + const domain = domainOf(url); + let bodyStr: string | null = null; + let bodySize = 0; + + let method = "POST"; + try { method = req.HTTPMethod().toString(); } catch (e) { } + + try { bodySize = data.length(); } catch (e) { } + + const str = ObjC.classes.NSString.alloc().initWithData_encoding_(data, 4); + if (str && !str.isNull()) { + bodyStr = str.toString(); + } + + let contentType: string | null = null; + try { + const ct = req.valueForHTTPHeaderField_("Content-Type"); + if (ct && !ct.isNull()) contentType = ct.toString(); + } catch (e) { } + + // Extract all headers for ESN detection + const headers: Record = {}; + try { + const allHeaders = req.allHTTPHeaderFields(); + if (allHeaders && !allHeaders.isNull()) { + const keys = allHeaders.allKeys(); + const count = keys.count(); + for (let i = 0; i < count; i++) { + const k = keys.objectAtIndex_(i).toString(); + const v = allHeaders.objectForKey_(keys.objectAtIndex_(i)).toString(); + headers[k] = v; + } + } + } catch (e) { } + + maybeUpdateEsnFromHeader(headers); + + logData("http.request", { + domain: domain, + method: method, + url: url, + content_type: contentType, + size: bodySize, + body: bodyStr ? bodyStr.substring(0, 8192) : null, + headers: Object.keys(headers).length > 0 ? headers : undefined + }); + logHttpReq(method, url, bodySize, Object.keys(headers).length); + } catch (e) { } + } + }); + }); + console.log("[+] Hooked -[NSMutableURLRequest setHTTPBody:]"); + } catch (e) { console.log("[-] setHTTPBody: " + e); } + + // -[NSMutableURLRequest setHTTPBodyStream:] + try { + const resolver = new ApiResolver("objc"); + resolver.enumerateMatches("-[NSMutableURLRequest setHTTPBodyStream:]").forEach(function (match) { + Interceptor.attach(match.address, { + onEnter: function (args) { + try { + const req = new ObjC.Object(args[0]); + const stream = new ObjC.Object(args[2]); + let url = ""; + try { + const reqUrl = req.URL(); + if (reqUrl && !reqUrl.isNull()) url = reqUrl.absoluteString().toString(); + } catch (e) { } + if (!url) url = lastUrlByThread[this.threadId] || ""; + if (isLocal(url)) return; + const domain = domainOf(url); + + let bodyStr: string | null = null; + let bodySize = 0; + try { + const data = stream.valueForKey_("_data"); + if (data && !data.isNull()) { + bodySize = data.length(); + const str = ObjC.classes.NSString.alloc().initWithData_encoding_(data, 4); + if (str && !str.isNull()) bodyStr = str.toString(); + } + } catch (e) { } + + if (!bodyStr) { + try { + stream.open(); + const bufSize = 65536; + const buf = Memory.alloc(bufSize); + const bytesRead = stream.read_maxLength_(buf, bufSize); + if (bytesRead > 0) { + bodySize = bytesRead; + const nsData = ObjC.classes.NSData.dataWithBytes_length_(buf, bytesRead); + const str = ObjC.classes.NSString.alloc().initWithData_encoding_(nsData, 4); + if (str && !str.isNull()) bodyStr = str.toString(); + } + stream.close(); + } catch (e) { } + } + + let method = "POST"; + try { method = req.HTTPMethod().toString(); } catch (e) { } + let contentType: string | null = null; + try { + const ct = req.valueForHTTPHeaderField_("Content-Type"); + if (ct && !ct.isNull()) contentType = ct.toString(); + } catch (e) { } + + logData("http.request", { + domain: domain, + method: method, + url: url, + content_type: contentType, + size: bodySize, + body: bodyStr ? bodyStr.substring(0, 8192) : null, + via: "bodyStream" + }); + } catch (e) { } + } + }); + }); + console.log("[+] Hooked -[NSMutableURLRequest setHTTPBodyStream:]"); + } catch (e) { console.log("[-] setHTTPBodyStream: " + e); } + + // uploadTaskWithRequest:fromData:completionHandler: + try { + const resolver = new ApiResolver("objc"); + resolver.enumerateMatches("-[NSURLSession uploadTaskWithRequest:fromData:completionHandler:]").forEach(function (match) { + Interceptor.attach(match.address, { + onEnter: function (args) { + try { + const req = new ObjC.Object(args[2]); + const data = new ObjC.Object(args[3]); + let url = ""; + try { + const reqUrl = req.URL(); + if (reqUrl && !reqUrl.isNull()) url = reqUrl.absoluteString().toString(); + } catch (e) { } + if (isLocal(url)) return; + const domain = domainOf(url); + + let bodyStr: string | null = null; + let bodySize = 0; + if (data && !data.isNull()) { + try { bodySize = data.length(); } catch (e) { } + try { + const str = ObjC.classes.NSString.alloc().initWithData_encoding_(data, 4); + if (str && !str.isNull()) bodyStr = str.toString(); + } catch (e) { } + } + + let method = "POST"; + try { method = req.HTTPMethod().toString(); } catch (e) { } + let contentType: string | null = null; + try { + const ct = req.valueForHTTPHeaderField_("Content-Type"); + if (ct && !ct.isNull()) contentType = ct.toString(); + } catch (e) { } + + logData("http.request", { + domain: domain, + method: method, + url: url, + content_type: contentType, + size: bodySize, + body: bodyStr ? bodyStr.substring(0, 8192) : null, + via: "uploadTask" + }); + } catch (e) { } + } + }); + }); + console.log("[+] Hooked uploadTaskWithRequest:fromData:completionHandler:"); + } catch (e) { console.log("[-] uploadTask: " + e); } + + // dataTaskWithRequest:completionHandler: (request + response) + try { + const resolver = new ApiResolver("objc"); + resolver.enumerateMatches("-[NSURLSession dataTaskWithRequest:completionHandler:]").forEach(function (match) { + Interceptor.attach(match.address, { + onEnter: function (args) { + try { + const req = new ObjC.Object(args[2]); + let url = ""; + try { + const reqUrl = req.URL(); + if (reqUrl && !reqUrl.isNull()) url = reqUrl.absoluteString().toString(); + } catch (e) { } + if (isLocal(url)) return; + const domain = domainOf(url); + if (url.indexOf("netflix") === -1) return; + + let reqBodyStr: string | null = null; + let reqBodySize = 0; + try { + const httpBody = req.HTTPBody(); + if (httpBody && !httpBody.isNull()) { + reqBodySize = httpBody.length(); + const str = ObjC.classes.NSString.alloc().initWithData_encoding_(httpBody, 4); + if (str && !str.isNull()) reqBodyStr = str.toString(); + } + } catch (e) { } + + if (!reqBodyStr) { + try { + const bodyStream = req.HTTPBodyStream(); + if (bodyStream && !bodyStream.isNull()) { + try { + const sData = bodyStream.valueForKey_("_data"); + if (sData && !sData.isNull()) { + reqBodySize = sData.length(); + const str = ObjC.classes.NSString.alloc().initWithData_encoding_(sData, 4); + if (str && !str.isNull()) reqBodyStr = str.toString(); + } + } catch (e) { } + } + } catch (e) { } + } + + let method = "GET"; + try { method = req.HTTPMethod().toString(); } catch (e) { } + let contentType: string | null = null; + try { + const ct = req.valueForHTTPHeaderField_("Content-Type"); + if (ct && !ct.isNull()) contentType = ct.toString(); + } catch (e) { } + + // Extract headers for ESN detection + const reqHeaders: Record = {}; + try { + const allHeaders = req.allHTTPHeaderFields(); + if (allHeaders && !allHeaders.isNull()) { + const keys = allHeaders.allKeys(); + const count = keys.count(); + for (let i = 0; i < count; i++) { + const k = keys.objectAtIndex_(i).toString(); + const v = allHeaders.objectForKey_(keys.objectAtIndex_(i)).toString(); + reqHeaders[k] = v; + } + } + } catch (e) { } + + maybeUpdateEsnFromHeader(reqHeaders); + + if (reqBodyStr && (method === "POST" || method === "PUT" || method === "PATCH")) { + logData("http.request", { + domain: domain, + method: method, + url: url, + content_type: contentType, + size: reqBodySize, + body: reqBodyStr.substring(0, 8192), + headers: Object.keys(reqHeaders).length > 0 ? reqHeaders : undefined, + via: "dataTask" + }); + logHttpReq(method, url, reqBodySize, Object.keys(reqHeaders).length); + } + + const cb = new ObjC.Block(args[3]); + const origImpl = cb.implementation; + const capturedUrl = url; + const capturedDomain = domain; + + cb.implementation = function (data: any, response: any, error: any) { + try { + let bodyStr: string | null = null; + if (data && !data.isNull()) { + const nsData = ObjC.Object(data); + try { + const str = ObjC.classes.NSString.alloc().initWithData_encoding_(nsData, 4); + if (str && !str.isNull()) bodyStr = str.toString(); + } catch (e) { } + } + + let statusCode = 0; + const respHeaders: Record = {}; + if (response && !response.isNull()) { + try { statusCode = ObjC.Object(response).statusCode(); } catch (e) { } + try { + const hdrs = ObjC.Object(response).allHeaderFields(); + if (hdrs && !hdrs.isNull()) { + const keys = hdrs.allKeys(); + const count = keys.count(); + for (let i = 0; i < count; i++) { + const k = keys.objectAtIndex_(i).toString(); + const v = hdrs.objectForKey_(keys.objectAtIndex_(i)).toString(); + respHeaders[k] = v; + } + } + } catch (e) { } + } + + maybeUpdateEsnFromHeader(respHeaders); + + let errStr: string | null = null; + if (error && !error.isNull()) { + try { errStr = ObjC.Object(error).toString(); } catch (e) { } + } + + logData("http.response", { + domain: capturedDomain, + url: capturedUrl, + status: statusCode, + size: bodyStr ? bodyStr.length : 0, + body: bodyStr ? bodyStr.substring(0, 65536) : null, + responseHeaders: Object.keys(respHeaders).length > 0 ? respHeaders : undefined, + error: errStr + }); + logHttpResp(statusCode, capturedUrl, bodyStr ? bodyStr.length : 0, Object.keys(respHeaders).length); + } catch (e) { } + origImpl(data, response, error); + }; + } catch (e) { } + } + }); + }); + console.log("[+] Hooked dataTaskWithRequest:completionHandler: (request+response)"); + } catch (e) { console.log("[-] dataTask: " + e); } + + // uploadTaskWithStreamedRequest: + try { + const resolver = new ApiResolver("objc"); + resolver.enumerateMatches("-[NSURLSession uploadTaskWithStreamedRequest:]").forEach(function (match) { + Interceptor.attach(match.address, { + onEnter: function (args) { + try { + const req = new ObjC.Object(args[2]); + let url = ""; + try { + const reqUrl = req.URL(); + if (reqUrl && !reqUrl.isNull()) url = reqUrl.absoluteString().toString(); + } catch (e) { } + if (isLocal(url)) return; + if (url.indexOf("netflix") === -1) return; + const domain = domainOf(url); + + let method = "POST"; + try { method = req.HTTPMethod().toString(); } catch (e) { } + let contentType: string | null = null; + try { + const ct = req.valueForHTTPHeaderField_("Content-Type"); + if (ct && !ct.isNull()) contentType = ct.toString(); + } catch (e) { } + + let reqBodyStr: string | null = null; + let reqBodySize = 0; + try { + const httpBody = req.HTTPBody(); + if (httpBody && !httpBody.isNull()) { + reqBodySize = httpBody.length(); + const str = ObjC.classes.NSString.alloc().initWithData_encoding_(httpBody, 4); + if (str && !str.isNull()) reqBodyStr = str.toString(); + } + } catch (e) { } + + logData("http.request", { + domain: domain, + method: method, + url: url, + content_type: contentType, + size: reqBodySize, + body: reqBodyStr ? reqBodyStr.substring(0, 8192) : null, + via: "streamedUpload" + }); + } catch (e) { } + } + }); + }); + console.log("[+] Hooked uploadTaskWithStreamedRequest:"); + } catch (e) { console.log("[-] streamedUpload: " + e); } + + // NSURLSession delegate: didReceiveData + didCompleteWithError + const mslResponseBuffers: Record = {}; + const mslResponseUrls: Record = {}; + + try { + const resolver = new ApiResolver("objc"); + resolver.enumerateMatches("-[* URLSession:dataTask:didReceiveData:]").forEach(function (match) { + if (match.name.indexOf("NF") === -1 && match.name.indexOf("Netflix") === -1 && match.name.indexOf("Msl") === -1 && match.name.indexOf("Osprey") === -1) return; + Interceptor.attach(match.address, { + onEnter: function (args) { + try { + const task = new ObjC.Object(args[3]); + const data = new ObjC.Object(args[4]); + let url = ""; + try { + const req = task.originalRequest(); + if (req && !req.isNull()) { + const reqUrl = req.URL(); + if (reqUrl && !reqUrl.isNull()) url = reqUrl.absoluteString().toString(); + } + } catch (e) { } + if (!url || url.indexOf("netflix") === -1) return; + + const taskId = task.taskIdentifier(); + const key = url + "#" + taskId; + + if (url.indexOf("/msl/") !== -1 || url.indexOf("/license") !== -1 || url.indexOf("/manifest") !== -1) { + if (!mslResponseBuffers[key]) { + mslResponseBuffers[key] = ObjC.classes.NSMutableData.alloc().init(); + mslResponseUrls[key] = url; + } + mslResponseBuffers[key].appendData_(data); + } + } catch (e) { } + } + }); + console.log("[+] didReceiveData: " + match.name); + }); + } catch (e) { console.log("[-] didReceiveData: " + e); } + + try { + const resolver = new ApiResolver("objc"); + resolver.enumerateMatches("-[* URLSession:task:didCompleteWithError:]").forEach(function (match) { + if (match.name.indexOf("NF") === -1 && match.name.indexOf("Netflix") === -1 && match.name.indexOf("Msl") === -1 && match.name.indexOf("Osprey") === -1) return; + Interceptor.attach(match.address, { + onEnter: function (args) { + try { + const task = new ObjC.Object(args[3]); + const error = args[4]; + let url = ""; + try { + const req = task.originalRequest(); + if (req && !req.isNull()) { + const reqUrl = req.URL(); + if (reqUrl && !reqUrl.isNull()) url = reqUrl.absoluteString().toString(); + } + } catch (e) { } + if (!url) return; + + const taskId = task.taskIdentifier(); + const key = url + "#" + taskId; + const buf = mslResponseBuffers[key]; + if (!buf) return; + + let statusCode = 0; + const delegateRespHeaders: Record = {}; + try { + const resp = task.response(); + if (resp && !resp.isNull()) { + statusCode = resp.statusCode(); + try { + const hdrs = resp.allHeaderFields(); + if (hdrs && !hdrs.isNull()) { + const keys = hdrs.allKeys(); + const count = keys.count(); + for (let i = 0; i < count; i++) { + const k = keys.objectAtIndex_(i).toString(); + const v = hdrs.objectForKey_(keys.objectAtIndex_(i)).toString(); + delegateRespHeaders[k] = v; + } + } + } catch (e) { } + } + } catch (e) { } + + maybeUpdateEsnFromHeader(delegateRespHeaders); + + let errStr: string | null = null; + if (error && !error.isNull()) { + try { errStr = ObjC.Object(error).toString(); } catch (e) { } + } + + let bodyStr: string | null = null; + const bodySize = buf.length(); + try { + const str = ObjC.classes.NSString.alloc().initWithData_encoding_(buf, 4); + if (str && !str.isNull()) bodyStr = str.toString(); + } catch (e) { } + + const domain = domainOf(url); + + logData("http.response", { + domain: domain, + url: url, + status: statusCode, + size: bodyStr ? bodyStr.length : bodySize, + body: bodyStr ? bodyStr.substring(0, 65536) : null, + responseHeaders: Object.keys(delegateRespHeaders).length > 0 ? delegateRespHeaders : undefined, + error: errStr, + via: "delegate" + }); + logHttpResp(statusCode, url, bodyStr ? bodyStr.length : bodySize, Object.keys(delegateRespHeaders).length); + + delete mslResponseBuffers[key]; + delete mslResponseUrls[key]; + } catch (e) { } + } + }); + console.log("[+] didCompleteWithError: " + match.name); + }); + } catch (e) { console.log("[-] didCompleteWithError: " + e); } +} diff --git a/packages/frida/src/ios/index.ts b/packages/frida/src/ios/index.ts new file mode 100644 index 0000000..98f159f --- /dev/null +++ b/packages/frida/src/ios/index.ts @@ -0,0 +1,57 @@ +import { hookSSLPinning } from "./ssl-pinning"; +import { hookMSL } from "./msl"; +import { hookObjCTrace } from "./http"; +import { hookMslCrypto } from "./msl-crypto"; +import { hookALE } from "./ale"; +import { dumpStorage, forceAleProvision } from "./storage-dump"; +import { traceMslModule } from "./msl-trace"; + +console.log("[*] Netflix iOS Hook starting..."); + +// ── Hook 有効化フラグ ── +const ENABLE_SSL_PINNING = false; +const ENABLE_MSL = true; +const ENABLE_HTTP = true; +const ENABLE_MSL_CRYPTO = true; +const ENABLE_ALE = true; + +// Phase 1: ObjC ランタイム初期化を待ってからフック +setTimeout(function () { + try { dumpStorage(); } catch (e) { console.log("[-] dumpStorage: " + e); } + if (ENABLE_SSL_PINNING) try { hookSSLPinning(); } catch (e) { console.log("[-] hookSSLPinning: " + e); } + if (ENABLE_HTTP) try { hookObjCTrace(); } catch (e) { console.log("[-] hookObjCTrace: " + e); } + if (ENABLE_MSL) try { hookMSL(); } catch (e) { console.log("[-] hookMSL: " + e); } + console.log("[*] Phase 1 done (storage dump + ObjC hooks)"); + + // Phase 2: MslClient + Nbp のロードを待ってフック + function tryHookNative(): boolean { + const mslMod = Process.findModuleByName("MslClient"); + if (!mslMod) return false; + console.log("[*] MslClient loaded, installing hooks..."); + if (ENABLE_MSL_CRYPTO) try { hookMslCrypto(); } catch (e) { console.log("[-] hookMslCrypto: " + e); } + if (ENABLE_ALE) try { hookALE(); } catch (e) { console.log("[-] hookALE: " + e); } + console.log("[*] Netflix Helper Ready."); + + // 全フック完了後に aleProvision を強制トリガー + setTimeout(function () { + try { forceAleProvision(); } catch (e) { console.log("[-] forceAleProvision: " + e); } + }, 2000); + + return true; + } + + if (!tryHookNative()) { + console.log("[*] Waiting for MslClient module..."); + const interval = setInterval(function () { + if (tryHookNative()) { + clearInterval(interval); + } + }, 500); + setTimeout(function () { + clearInterval(interval); + if (!Process.findModuleByName("MslClient")) { + console.log("[-] MslClient not loaded after 30s"); + } + }, 30000); + } +}, 1000); diff --git a/packages/frida/src/ios/investigate.ts b/packages/frida/src/ios/investigate.ts new file mode 100644 index 0000000..a4fdecc --- /dev/null +++ b/packages/frida/src/ios/investigate.ts @@ -0,0 +1,5 @@ +import { investigateDetection } from "./detect-antifrida"; + +console.log("[*] Anti-Frida investigation script loaded"); +investigateDetection(); +console.log("[*] All detection hooks installed. Monitoring..."); diff --git a/packages/frida/src/ios/msl-crypto.ts b/packages/frida/src/ios/msl-crypto.ts new file mode 100644 index 0000000..6b626b1 --- /dev/null +++ b/packages/frida/src/ios/msl-crypto.ts @@ -0,0 +1,237 @@ +import { bytesToBase64, logData, logMsl } from "../common/utils"; +import { processMslPlaintext } from "../common/msl-processor"; + +// ── Apple ARM64 ABI for C++ shared_ptr ── +// shared_ptr は non-trivial type なので間接渡し: +// args[N] = shared_ptr* (スタック上の shared_ptr へのポインタ) +// [args[N]+0] = __ptr_ (T* = vector*) +// [args[N]+8] = __cntrl_ (control block*) +// +// 戻り値 (shared_ptr) は x8 レジスタの sret で返される: +// x8 = sret バッファへのポインタ +// [x8+0] = __ptr_, [x8+8] = __cntrl_ +// +// vector layout: [begin_ptr, end_ptr, capacity_ptr] + +function readVecFromSharedPtrPtr(sharedPtrPtr: NativePointer): { ptr: NativePointer; size: number; bytes: ArrayBuffer } | null { + try { + if (sharedPtrPtr.isNull()) return null; + const vecPtr = sharedPtrPtr.readPointer(); // __ptr_ = vector* + if (vecPtr.isNull()) return null; + const begin = vecPtr.readPointer(); + const end = vecPtr.add(Process.pointerSize).readPointer(); + if (begin.isNull()) return null; + const size = end.sub(begin).toInt32(); + if (size <= 0 || size > 4 * 1024 * 1024) return null; + return { ptr: begin, size, bytes: begin.readByteArray(size)! }; + } catch (e) { return null; } +} + +// 生の vector ポインタから読む (低レベル関数用) +function readVecDirect(vecPtr: NativePointer): { ptr: NativePointer; size: number; bytes: ArrayBuffer } | null { + try { + if (vecPtr.isNull()) return null; + const begin = vecPtr.readPointer(); + const end = vecPtr.add(Process.pointerSize).readPointer(); + if (begin.isNull()) return null; + const size = end.sub(begin).toInt32(); + if (size <= 0 || size > 4 * 1024 * 1024) return null; + return { ptr: begin, size, bytes: begin.readByteArray(size)! }; + } catch (e) { return null; } +} + +export function hookMslCrypto(): void { + const mod = Process.findModuleByName("MslClient"); + if (!mod) { + console.log("[-] MslClient module not loaded"); + return; + } + + console.log("[*] MslClient module: " + mod.name + " base=" + mod.base + " size=" + mod.size); + + let exports = mod.enumerateExports(); + console.log("[*] MslClient exports: " + exports.length); + if (exports.length === 0) { + exports = mod.enumerateSymbols() as ModuleExportDetails[]; + console.log("[*] MslClient symbols: " + exports.length); + } + + let hooked = 0; + + exports.forEach(function (sym) { + if (sym.type !== 'function') return; + const name = sym.name; + + // ============================================================ + // IosSessionCryptoContext (薄いラッパー) + // ABI: x0=this, x1=shared_ptr* data (間接), x2=shared_ptr* encoder (間接) + // x8=sret (戻り値バッファ, IosCryptoContext に透過的に渡される) + // 戻り値: sret に shared_ptr が書かれる + // ============================================================ + + if (name.indexOf("IosSessionCryptoContext") !== -1) { + + // encrypt + if (name.indexOf("encrypt") !== -1 && name.indexOf("decrypt") === -1) { + Interceptor.attach(sym.address, { + onEnter: function (args) { + this._sret = (this.context as any).x8; + this._input = readVecFromSharedPtrPtr(args[1]); + }, + onLeave: function (_retval) { + const output = readVecFromSharedPtrPtr(this._sret); + const input = this._input; + logMsl("SessionCryptoContext.encrypt", "plain:" + (input ? input.size : 0) + "B -> cipher:" + (output ? output.size : 0) + "B"); + logData("msl.aesCbcEncrypt", { + plaintext_b64: input ? bytesToBase64(input.bytes) : null, + ciphertext_b64: output ? bytesToBase64(output.bytes) : null, + plaintext_size: input ? input.size : 0, + ciphertext_size: output ? output.size : 0, + }); + if (input && input.size > 0) { + try { processMslPlaintext(input.bytes, "encrypt", "AES-CBC"); } catch (e) { } + } + } + }); + console.log("[+] Hooked (Session) " + sym.name); + hooked++; + } + + // decrypt + if (name.indexOf("decrypt") !== -1) { + Interceptor.attach(sym.address, { + onEnter: function (args) { + this._sret = (this.context as any).x8; + this._input = readVecFromSharedPtrPtr(args[1]); + }, + onLeave: function (_retval) { + const output = readVecFromSharedPtrPtr(this._sret); + const input = this._input; + logMsl("SessionCryptoContext.decrypt", "cipher:" + (input ? input.size : 0) + "B -> plain:" + (output ? output.size : 0) + "B"); + logData("msl.aesCbcDecrypt", { + ciphertext_b64: input ? bytesToBase64(input.bytes) : null, + plaintext_b64: output ? bytesToBase64(output.bytes) : null, + ciphertext_size: input ? input.size : 0, + plaintext_size: output ? output.size : 0, + }); + if (output && output.size > 0) { + try { processMslPlaintext(output.bytes, "decrypt", "AES-CBC"); } catch (e) { } + } + } + }); + console.log("[+] Hooked (Session) " + sym.name); + hooked++; + } + + // sign + if (name.indexOf("sign") !== -1 && name.indexOf("Signature") === -1) { + Interceptor.attach(sym.address, { + onEnter: function (args) { + this._sret = (this.context as any).x8; + this._data = readVecFromSharedPtrPtr(args[1]); + }, + onLeave: function (_retval) { + const sig = readVecFromSharedPtrPtr(this._sret); + logMsl("SessionCryptoContext.sign", "data:" + (this._data ? this._data.size : 0) + "B -> sig:" + (sig ? sig.size : 0) + "B"); + logData("msl.hmacSha256", { + data_b64: this._data ? bytesToBase64(this._data.bytes) : null, + signature_b64: sig ? bytesToBase64(sig.bytes) : null, + data_size: this._data ? this._data.size : 0, + }); + } + }); + console.log("[+] Hooked (Session) " + sym.name); + hooked++; + } + + // verify — returns bool, no sret + if (name.indexOf("verify") !== -1) { + Interceptor.attach(sym.address, { + onEnter: function (args) { + this._data = readVecFromSharedPtrPtr(args[1]); + this._sig = readVecFromSharedPtrPtr(args[2]); + }, + onLeave: function (retval) { + const result = retval.toInt32() !== 0; + logMsl("SessionCryptoContext.verify", "data:" + (this._data ? this._data.size : 0) + "B -> " + result); + logData("msl.hmacVerify", { + data_b64: this._data ? bytesToBase64(this._data.bytes) : null, + signature_b64: this._sig ? bytesToBase64(this._sig.bytes) : null, + data_size: this._data ? this._data.size : 0, + result: result, + }); + } + }); + console.log("[+] Hooked (Session) " + sym.name); + hooked++; + } + + return; + } + + // ── 低レベル関数 (生 const vector& ポインタ) ── + + if (name.indexOf('aesKwUnwrap') !== -1) { + Interceptor.attach(sym.address, { + onEnter: function (args) { + this._out = args[2]; + this._kek = readVecDirect(args[0]); + this._wrapped = readVecDirect(args[1]); + }, + onLeave: function (_retval) { + const unwrapped = readVecDirect(this._out); + logMsl("aesKwUnwrap", "wrapped:" + (this._wrapped ? this._wrapped.size : 0) + "B -> unwrapped:" + (unwrapped ? unwrapped.size : 0) + "B"); + logData("msl.aesKwUnwrap", { + kek_b64: this._kek ? bytesToBase64(this._kek.bytes) : null, + wrapped_key_b64: this._wrapped ? bytesToBase64(this._wrapped.bytes) : null, + unwrapped_key_b64: unwrapped ? bytesToBase64(unwrapped.bytes) : null, + }); + } + }); + console.log("[+] Hooked " + sym.name); + hooked++; + } + + if (name.indexOf('dhComputeSharedSecret') !== -1) { + Interceptor.attach(sym.address, { + onEnter: function (args) { + this._out = args[3]; + this._pub = readVecDirect(args[1]); + }, + onLeave: function (_retval) { + const shared = readVecDirect(this._out); + logMsl("dhComputeSharedSecret", "pub:" + (this._pub ? this._pub.size : 0) + "B -> shared:" + (shared ? shared.size : 0) + "B"); + logData("msl.dhSharedSecret", { + pub_key_b64: this._pub ? bytesToBase64(this._pub.bytes) : null, + shared_secret_b64: shared ? bytesToBase64(shared.bytes) : null, + }); + } + }); + console.log("[+] Hooked " + sym.name); + hooked++; + } + + if (name.indexOf('rsaEncrypt') !== -1 || name.indexOf('rsaDecrypt') !== -1) { + const fnName = name.indexOf('rsaEncrypt') !== -1 ? "rsaEncrypt" : "rsaDecrypt"; + Interceptor.attach(sym.address, { + onEnter: function (args) { + this._fnName = fnName; + this._out = args[3]; + this._input = readVecDirect(args[1]); + }, + onLeave: function (_retval) { + const output = readVecDirect(this._out); + logMsl(this._fnName, "input:" + (this._input ? this._input.size : 0) + "B -> output:" + (output ? output.size : 0) + "B"); + logData("msl." + this._fnName, { + input_b64: this._input ? bytesToBase64(this._input.bytes) : null, + output_b64: output ? bytesToBase64(output.bytes) : null, + }); + } + }); + console.log("[+] Hooked " + sym.name); + hooked++; + } + }); + + console.log("[+] Hooked " + hooked + " MSL crypto functions"); +} diff --git a/packages/frida/src/ios/msl-trace.ts b/packages/frida/src/ios/msl-trace.ts new file mode 100644 index 0000000..8953101 --- /dev/null +++ b/packages/frida/src/ios/msl-trace.ts @@ -0,0 +1,116 @@ +// ── MslClient モジュール内の暗号関連関数を網羅的にトレースする ── +// どの関数が実際に呼ばれているか調査用 + +export function traceMslModule(): void { + const mod = Process.findModuleByName("MslClient"); + if (!mod) { + console.log("[-] MslClient module not loaded"); + return; + } + + console.log("[*] MslClient: base=" + mod.base + " size=" + mod.size); + + const keywords = [ + "encrypt", "decrypt", "Encrypt", "Decrypt", + "cipher", "Cipher", + "aes", "Aes", "AES", + "hmac", "Hmac", "HMAC", + "sign", "Sign", + "wrap", "Wrap", + "key", "Key", + "rsa", "Rsa", "RSA", + "dh", "DH", + "provision", "Provision", + "session", "Session", + "token", "Token", + ]; + + let exports = mod.enumerateExports(); + if (exports.length === 0) { + exports = mod.enumerateSymbols() as ModuleExportDetails[]; + } + + const traced: string[] = []; + + exports.forEach(function (sym) { + if (sym.type !== 'function') return; + const name = sym.name; + + // キーワードに一致する関数をトレース + const match = keywords.some(kw => name.indexOf(kw) !== -1); + if (!match) return; + + // 既知の大量呼び出し関数はスキップ + if (name.indexOf("__cxa_") !== -1 || name.indexOf("operator") !== -1) return; + + try { + Interceptor.attach(sym.address, { + onEnter: function (_args) { + console.log("[TRACE] " + name); + } + }); + traced.push(name); + } catch (e) { + // attach 失敗は無視 + } + }); + + console.log("[*] Tracing " + traced.length + " crypto-related functions in MslClient"); + + // ObjC クラスも調査 + if (typeof ObjC !== 'undefined' && ObjC.available) { + const cryptoClasses = [ + "IosMslCryptoContext", + "IosMdxCryptoContext", + "IosCryptoContext", + "MslCryptoContext", + "NfCryptoContext", + "NFCryptoContext", + "AesCbcCryptoContext", + "SymmetricCryptoContext", + ]; + + cryptoClasses.forEach(function (clsName) { + try { + const cls = ObjC.classes[clsName]; + if (cls) { + const methods = cls.$ownMethods; + console.log("[*] Found ObjC class: " + clsName + " (" + methods.length + " methods)"); + methods.forEach(function (m: string) { + console.log(" " + m); + }); + } + } catch (e) { } + }); + + // "Crypto" を含むクラスを広く検索 + try { + const resolver = new ApiResolver("objc"); + const matches = resolver.enumerateMatches("-[*Crypto* encrypt*]"); + matches.forEach(function (match) { + console.log("[*] ObjC encrypt method: " + match.name); + try { + Interceptor.attach(match.address, { + onEnter: function (_args) { + console.log("[TRACE-OBJC] " + match.name); + } + }); + } catch (e) { } + }); + + const decMatches = resolver.enumerateMatches("-[*Crypto* decrypt*]"); + decMatches.forEach(function (match) { + console.log("[*] ObjC decrypt method: " + match.name); + try { + Interceptor.attach(match.address, { + onEnter: function (_args) { + console.log("[TRACE-OBJC] " + match.name); + } + }); + } catch (e) { } + }); + } catch (e) { + console.log("[-] ObjC crypto search: " + e); + } + } +} diff --git a/packages/frida/src/ios/msl.ts b/packages/frida/src/ios/msl.ts new file mode 100644 index 0000000..fb71372 --- /dev/null +++ b/packages/frida/src/ios/msl.ts @@ -0,0 +1,173 @@ +import { logData, logMsl } from "../common/utils"; +import { processMslApiResponse, maybeUpdateEsn } from "../common/msl-processor"; + +function objcToJsonStr(obj: ObjC.Object | NativePointer, maxLen?: number): string | null { + if (!obj || (obj as NativePointer).isNull()) return null; + if (maxLen === undefined || maxLen === null) maxLen = 65536; + const respObj = ObjC.Object(obj); + + function truncate(s: string): string { + if (maxLen! > 0 && s.length > maxLen!) return s.substring(0, maxLen!); + return s; + } + + try { + if (respObj.isKindOfClass_(ObjC.classes.NSDictionary) || + respObj.isKindOfClass_(ObjC.classes.NSArray)) { + const jsonData = ObjC.classes.NSJSONSerialization.dataWithJSONObject_options_error_(respObj, 1, NULL); + if (jsonData) { + const jsonStr = ObjC.classes.NSString.alloc().initWithData_encoding_(jsonData, 4); + if (jsonStr && !jsonStr.isNull()) return truncate(jsonStr.toString()); + } + } + } catch (e) { } + + try { + if (respObj.isKindOfClass_(ObjC.classes.NSData)) { + const str = ObjC.classes.NSString.alloc().initWithData_encoding_(respObj, 4); + if (str && !str.isNull()) return truncate(str.toString()); + } + } catch (e) { } + + try { + return truncate(respObj.toString()); + } catch (e) { } + return null; +} + +export function hookMSL(): void { + if (typeof ObjC === 'undefined' || !ObjC.available) return; + + // IosMslClient sendAPIRequest (リクエストのみキャプチャ、コールバック書き換えなし) + // レスポンスは http.ts (HTTP層) と msl-crypto.ts (復号後平文) で取得する + try { + const sendAPI = ObjC.classes.IosMslClient["- sendAPIRequest:extraHeaders:params:userAuthData:requestOptions:callback:"]; + if (sendAPI) { + Interceptor.attach(sendAPI.implementation, { + onEnter: function (args) { + try { + const apiPath = ObjC.Object(args[2]).toString(); + const params = ObjC.Object(args[4]); + + let paramsStr: string | null = null; + if (params && !params.isNull()) { + try { + const jsonData = ObjC.classes.NSJSONSerialization.dataWithJSONObject_options_error_(params, 1, NULL); + if (jsonData) { + const jsonStr = ObjC.classes.NSString.alloc().initWithData_encoding_(jsonData, 4); + paramsStr = jsonStr.toString(); + } + } catch (e) { + paramsStr = params.toString(); + } + } + + const m = apiPath.match(/^https?:\/\/([^\/\?:]+)/); + const domain = m ? m[1] : "msl.netflix.com"; + + // ESN extraction from userAuthData + try { + const userAuthData = ObjC.Object(args[5]); + if (userAuthData && !userAuthData.isNull()) { + const authStr = objcToJsonStr(userAuthData, 4096); + if (authStr) { + const parsed = JSON.parse(authStr); + if (parsed && typeof parsed.sender === "string") maybeUpdateEsn(parsed.sender); + } + } + } catch (e) { } + + logData("msl.api", { + domain: domain, + url: apiPath, + params: paramsStr + }); + logMsl("IosMslClient.sendAPIRequest", apiPath + " (" + (paramsStr ? paramsStr.length : 0) + "B)"); + } catch (e) { + console.log("[-] sendAPIRequest onEnter: " + e); + } + } + }); + console.log("[+] Hooked IosMslClient sendAPIRequest (request only)"); + } + } catch (e) { + console.log("[-] IosMslClient: " + e); + } + + // _handleAppbootResponse + try { + const handleAppboot = ObjC.classes.IosMslClient["- _handleAppbootResponse:error:timeoutMS:"]; + if (handleAppboot) { + Interceptor.attach(handleAppboot.implementation, { + onEnter: function (args) { + try { + const respStr = objcToJsonStr(args[2]); + let errStr: string | null = null; + if (args[3] && !args[3].isNull()) { + try { errStr = ObjC.Object(args[3]).toString(); } catch (e) { } + } + + logData("appboot.response", { + domain: "appboot.netflix.com", + response: respStr, + error: errStr + }); + logMsl("appboot.response", "(" + (respStr ? respStr.length : 0) + "B)" + (errStr ? " error=" + errStr : "")); + } catch (e) { + console.log("[-] appboot response capture: " + e); + } + } + }); + console.log("[+] Hooked _handleAppbootResponse"); + } + } catch (e) { + console.log("[-] _handleAppbootResponse: " + e); + } + + // IosMdxCryptoContext + try { + const cls = ObjC.classes.IosMdxCryptoContext; + if (cls) { + const enc = cls["- encrypt:"]; + if (enc) { + Interceptor.attach(enc.implementation, { + onEnter: function (args) { + try { + const data = ObjC.Object(args[2]); + const dataStr = data.toString().substring(0, 8192); + logData("msl.encrypt.input", { + domain: "msl.netflix.com", + data: dataStr + }); + // Try to extract ESN from encrypt input + try { + const parsed = JSON.parse(dataStr); + if (parsed && typeof parsed.sender === "string") maybeUpdateEsn(parsed.sender); + } catch (e) { } + } catch (e) { } + } + }); + console.log("[+] Hooked MdxCrypto encrypt"); + } + + const dec = cls["- decrypt:"]; + if (dec) { + Interceptor.attach(dec.implementation, { + onLeave: function (retval) { + try { + const data = ObjC.Object(retval); + const dataStr = data.toString().substring(0, 8192); + logData("msl.decrypt.output", { + domain: "msl.netflix.com", + data: dataStr + }); + // Process decrypted output for manifest/ALE/ESN + try { processMslApiResponse("msl.decrypt", dataStr); } catch (e) { } + } catch (e) { } + } + }); + console.log("[+] Hooked MdxCrypto decrypt"); + } + } + } catch (e) { } +} diff --git a/packages/frida/src/ios/search-strings.ts b/packages/frida/src/ios/search-strings.ts new file mode 100644 index 0000000..4b95ede --- /dev/null +++ b/packages/frida/src/ios/search-strings.ts @@ -0,0 +1,87 @@ +// Netflix バイナリ内の ALE/provision 関連文字列を検索 + +function searchModuleStrings(modName: string | null, keywords: string[]): void { + let mod: Module | null = null; + if (modName) { + mod = Process.findModuleByName(modName); + if (!mod) { + console.log("[-] Module not found: " + modName); + return; + } + } else { + mod = Process.enumerateModules()[0]; // main binary + } + + console.log("[*] Searching " + mod.name + " (base=" + mod.base + " size=" + mod.size + ")"); + + const base = mod.base; + const size = mod.size; + + for (const kw of keywords) { + const kwBytes = []; + for (let i = 0; i < kw.length; i++) { + kwBytes.push(kw.charCodeAt(i)); + } + + // Memory.scan for the keyword + const pattern = kwBytes.map(b => b.toString(16).padStart(2, '0')).join(" "); + const results: NativePointer[] = []; + + Memory.scan(base, size, pattern, { + onMatch: function (address, _size) { + results.push(address); + }, + onComplete: function () { + if (results.length > 0) { + console.log(" [FOUND] \"" + kw + "\" x" + results.length + " in " + mod!.name); + for (let i = 0; i < Math.min(results.length, 5); i++) { + // Read surrounding context + try { + const str = results[i].readUtf8String(200); + const preview = str ? str.substring(0, 100) : ""; + const offset = results[i].sub(base); + console.log(" @" + offset + ": " + preview); + } catch (e) { + console.log(" @" + results[i].sub(base)); + } + } + } + } + }); + } +} + +const keywords = [ + "aleProvision", + "AleProvision", + "getProxyEsn", + "ProxyEsn", + "proxyEsn", + "provisionResponse", + "AleService", + "AleSession", + "AleCrypto", + "/aleProvision", + "ale.provision", + "CLEAR", + "RSA-OAEP", + "keyx", +]; + +console.log("[*] String search starting..."); + +// Main binary +searchModuleStrings(null, keywords); + +// MslClient +searchModuleStrings("MslClient", keywords); + +// Netflix framework +const modules = Process.enumerateModules(); +for (const m of modules) { + if (m.name.indexOf("Netflix") !== -1 && m.name !== "Netflix") { + searchModuleStrings(m.name, keywords); + } +} + +console.log("[*] String search complete."); diff --git a/packages/frida/src/ios/ssl-capture.ts b/packages/frida/src/ios/ssl-capture.ts new file mode 100644 index 0000000..ad8ea77 --- /dev/null +++ b/packages/frida/src/ios/ssl-capture.ts @@ -0,0 +1,77 @@ +import { SEP, SEP2, ts } from "../common/utils"; + +export function hookSSL(): void { + let connId = 0; + const sslConnMap: Record = {}; + + let ssl_write: NativePointer | null = null; + try { + ssl_write = Module.findExportByName("libboringssl.dylib", "SSL_write"); + } catch (e) { } + if (!ssl_write) { + try { ssl_write = Module.findExportByName(null, "SSL_write"); } catch (e) { } + } + + if (ssl_write) { + Interceptor.attach(ssl_write, { + onEnter: function (args) { + const ssl = args[0].toString(); + const buf = args[1]; + const len = args[2].toInt32(); + + if (!sslConnMap[ssl]) sslConnMap[ssl] = "conn_" + (connId++); + + try { + const data = buf.readUtf8String(len); + console.log("\n" + SEP); + console.log("[" + ts() + "] >>> WRITE " + sslConnMap[ssl] + " (" + len + " bytes)"); + console.log(SEP); + console.log(data); + } catch (e) { + console.log("\n[" + ts() + "] >>> WRITE " + sslConnMap[ssl] + " (" + len + " bytes, binary)"); + console.log(hexdump(buf, { length: Math.min(len, 512), ansi: false })); + } + } + }); + console.log("[+] Hooked SSL_write"); + } else { + console.log("[-] SSL_write not found"); + } + + let ssl_read: NativePointer | null = null; + try { + ssl_read = Module.findExportByName("libboringssl.dylib", "SSL_read"); + } catch (e) { } + if (!ssl_read) { + try { ssl_read = Module.findExportByName(null, "SSL_read"); } catch (e) { } + } + + if (ssl_read) { + Interceptor.attach(ssl_read, { + onEnter: function (args) { + this.ssl = args[0].toString(); + this.buf = args[1]; + }, + onLeave: function (retval) { + const len = retval.toInt32(); + if (len <= 0) return; + + if (!sslConnMap[this.ssl]) sslConnMap[this.ssl] = "conn_" + (connId++); + + try { + const data = this.buf.readUtf8String(len); + console.log("\n" + SEP2); + console.log("[" + ts() + "] <<< READ " + sslConnMap[this.ssl] + " (" + len + " bytes)"); + console.log(SEP2); + console.log(data); + } catch (e) { + console.log("\n[" + ts() + "] <<< READ " + sslConnMap[this.ssl] + " (" + len + " bytes, binary)"); + console.log(hexdump(this.buf, { length: Math.min(len, 512), ansi: false })); + } + } + }); + console.log("[+] Hooked SSL_read"); + } else { + console.log("[-] SSL_read not found"); + } +} diff --git a/packages/frida/src/ios/ssl-pinning.ts b/packages/frida/src/ios/ssl-pinning.ts new file mode 100644 index 0000000..af6ac64 --- /dev/null +++ b/packages/frida/src/ios/ssl-pinning.ts @@ -0,0 +1,172 @@ +export function hookSSLPinning(): void { + if (typeof ObjC === 'undefined' || !ObjC.available) { + console.log("[-] ObjC not available, skipping SSL pinning bypass"); + return; + } + + // ── 1. SecTrustEvaluateWithError (iOS 12+) ── + // bool SecTrustEvaluateWithError(SecTrustRef trust, CFErrorRef *error) + // error に NULL を書き込まないと呼び出し元が不正なエラーを参照してクラッシュする + try { + const SecTrustEvaluateWithError = Module.findExportByName("Security", "SecTrustEvaluateWithError"); + if (SecTrustEvaluateWithError) { + Interceptor.attach(SecTrustEvaluateWithError, { + onEnter: function (args) { + this._errorPtr = args[1]; // CFErrorRef *error + }, + onLeave: function (retval) { + // error ポインタが渡されていれば NULL をセット (エラーなし) + if (this._errorPtr && !this._errorPtr.isNull()) { + this._errorPtr.writePointer(ptr(0)); + } + retval.replace(ptr(1)); // true = 検証成功 + } + }); + console.log("[+] Hooked SecTrustEvaluateWithError (always returns true)"); + } + } catch (e) { + console.log("[-] SecTrustEvaluateWithError: " + e); + } + + // ── 2. SecTrustEvaluate (legacy) ── + // OSStatus SecTrustEvaluate(SecTrustRef trust, SecTrustResultType *result) + try { + const SecTrustEvaluate = Module.findExportByName("Security", "SecTrustEvaluate"); + if (SecTrustEvaluate) { + Interceptor.attach(SecTrustEvaluate, { + onEnter: function (args) { + this._resultPtr = args[1]; + }, + onLeave: function (retval) { + // kSecTrustResultProceed = 1 + if (this._resultPtr && !this._resultPtr.isNull()) { + this._resultPtr.writeU32(1); + } + retval.replace(ptr(0)); // errSecSuccess + } + }); + console.log("[+] Hooked SecTrustEvaluate (always succeeds)"); + } + } catch (e) { + console.log("[-] SecTrustEvaluate: " + e); + } + + // ── 3. SecTrustGetTrustResult ── + try { + const SecTrustGetTrustResult = Module.findExportByName("Security", "SecTrustGetTrustResult"); + if (SecTrustGetTrustResult) { + Interceptor.attach(SecTrustGetTrustResult, { + onEnter: function (args) { + this._resultPtr = args[1]; + }, + onLeave: function (retval) { + if (this._resultPtr && !this._resultPtr.isNull()) { + // kSecTrustResultProceed = 1 + this._resultPtr.writeU32(1); + } + retval.replace(ptr(0)); // errSecSuccess + } + }); + console.log("[+] Hooked SecTrustGetTrustResult"); + } + } catch (e) { + console.log("[-] SecTrustGetTrustResult: " + e); + } + + // ── 4. NSURLSession delegate (completionHandler block) ── + // NF/Netflix/Osprey 以外のクラスも対象にする + try { + const resolver = new ApiResolver("objc"); + const matches = resolver.enumerateMatches("-[* URLSession:didReceiveChallenge:completionHandler:]"); + let count = 0; + matches.forEach(function (match) { + // Netflix 関連クラスのみ (システムクラスを除外) + const name = match.name; + if (name.indexOf("NF") === -1 && name.indexOf("Netflix") === -1 && + name.indexOf("Osprey") === -1 && name.indexOf("Argo") === -1) return; + + Interceptor.attach(match.address, { + onEnter: function (args) { + try { + const challenge = new ObjC.Object(args[3]); + const serverTrust = challenge.protectionSpace().serverTrust(); + const cred = ObjC.classes.NSURLCredential.credentialForTrust_(serverTrust); + + // ObjC.Block で型シグネチャを明示 + const handler = new ObjC.Block(args[4], { + retType: 'void', + argTypes: ['int', 'object'] + }); + // NSURLSessionAuthChallengeUseCredential = 0 + handler(0, cred); + console.log("[*] SSL pinning bypassed: " + name); + } catch (e) { + // フォールバック: invoke pointer を直接呼ぶ + try { + const challenge = new ObjC.Object(args[3]); + const serverTrust = challenge.protectionSpace().serverTrust(); + const cred = ObjC.classes.NSURLCredential.credentialForTrust_(serverTrust); + const blockPtr = args[4]; + // Block layout: isa, flags, reserved, invoke, descriptor + const invokePtr = blockPtr.add(Process.pointerSize * 2).readPointer(); + const invoke = new NativeFunction(invokePtr, 'void', ['pointer', 'int', 'pointer']); + invoke(blockPtr, 0, cred.handle); + console.log("[*] SSL pinning bypassed (invoke): " + name); + } catch (e2) { + console.log("[-] SSL pinning bypass failed: " + name + " " + e2); + } + } + } + }); + count++; + }); + console.log("[+] Hooked " + count + " didReceiveChallenge delegates"); + } catch (e) { + console.log("[-] didReceiveChallenge: " + e); + } + + // ── 5. URLSession:task:didReceiveChallenge:completionHandler: (per-task) ── + try { + const resolver = new ApiResolver("objc"); + const matches = resolver.enumerateMatches("-[* URLSession:task:didReceiveChallenge:completionHandler:]"); + let count = 0; + matches.forEach(function (match) { + const name = match.name; + if (name.indexOf("NF") === -1 && name.indexOf("Netflix") === -1 && + name.indexOf("Osprey") === -1 && name.indexOf("Argo") === -1) return; + + Interceptor.attach(match.address, { + onEnter: function (args) { + try { + const challenge = new ObjC.Object(args[4]); + const serverTrust = challenge.protectionSpace().serverTrust(); + const cred = ObjC.classes.NSURLCredential.credentialForTrust_(serverTrust); + const handler = new ObjC.Block(args[5], { + retType: 'void', + argTypes: ['int', 'object'] + }); + handler(0, cred); + console.log("[*] SSL pinning bypassed (per-task): " + name); + } catch (e) { + try { + const challenge = new ObjC.Object(args[4]); + const serverTrust = challenge.protectionSpace().serverTrust(); + const cred = ObjC.classes.NSURLCredential.credentialForTrust_(serverTrust); + const blockPtr = args[5]; + const invokePtr = blockPtr.add(Process.pointerSize * 2).readPointer(); + const invoke = new NativeFunction(invokePtr, 'void', ['pointer', 'int', 'pointer']); + invoke(blockPtr, 0, cred.handle); + console.log("[*] SSL pinning bypassed (per-task invoke): " + name); + } catch (e2) { + console.log("[-] SSL pinning bypass (per-task) failed: " + name + " " + e2); + } + } + } + }); + count++; + }); + if (count > 0) console.log("[+] Hooked " + count + " per-task didReceiveChallenge delegates"); + } catch (e) { + console.log("[-] per-task didReceiveChallenge: " + e); + } +} diff --git a/packages/frida/src/ios/storage-dump.ts b/packages/frida/src/ios/storage-dump.ts new file mode 100644 index 0000000..f2b8477 --- /dev/null +++ b/packages/frida/src/ios/storage-dump.ts @@ -0,0 +1,515 @@ +// ── iOS UserDefaults + Keychain ダンプ ── +// 起動時に Netflix 関連のストレージ内容を表示 + +import { logData } from "../common/utils"; + +export function dumpStorage(): void { + if (typeof ObjC === 'undefined' || !ObjC.available) return; + + dumpUserDefaults(); + dumpKeychain(); + dumpSandbox(); +} + +// ── aleProvision 強制トリガー ── +// IosMslClient のインスタンスを探して _retrieveProxyESN を呼び出す +// これにより getProxyEsn → aleProvision が発火する + +export function forceAleProvision(): void { + if (typeof ObjC === 'undefined' || !ObjC.available) return; + + try { + // IosMslClient のインスタンスを ObjC ヒープから検索 + ObjC.choose(ObjC.classes.IosMslClient, { + onMatch: function (instance) { + console.log("[*] Found IosMslClient instance: " + instance); + + // didAppboot を false にリセット → appboot が再実行される + try { + instance.setDidAppboot_(false); + console.log("[+] Reset didAppboot = false"); + } catch (e) { + console.log("[-] setDidAppboot: " + e); + } + + // _retrieveProxyESN を呼び出し → getProxyEsn → aleProvision + try { + // ObjC メッセージ送信 + const sel = ObjC.selector("_retrieveProxyESN"); + const method = instance.methodForSelector_(sel); + if (method && !method.isNull()) { + const fn = new NativeFunction(method, "void", ["pointer", "pointer"]); + fn(instance.handle, sel); + console.log("[+] Called _retrieveProxyESN via msgSend"); + logData("proxyEsn.forceExpired", { method: "_retrieveProxyESN" }); + } else { + console.log("[-] _retrieveProxyESN method not found"); + } + } catch (e) { + console.log("[-] _retrieveProxyESN: " + e); + } + + // appboot も再実行 + try { + const sel2 = ObjC.selector("checkOnAppboot:"); + const method2 = instance.methodForSelector_(sel2); + if (method2 && !method2.isNull()) { + const fn2 = new NativeFunction(method2, "void", ["pointer", "pointer", "pointer"]); + fn2(instance.handle, sel2, NULL); + console.log("[+] Called checkOnAppboot:"); + } + } catch (e) { + console.log("[-] checkOnAppboot: " + e); + } + }, + onComplete: function () { + console.log("[*] IosMslClient search complete"); + } + }); + } catch (e) { + console.log("[-] forceAleProvision: " + e); + } +} + +// ── ストレージ全消去 ── +// UserDefaults + Keychain の Netflix 関連データを全削除 +// → アプリ初回起動と同じ状態 → getProxyEsn → aleProvision が強制発火 + +function forceProxyEsnExpired(): void { + clearUserDefaults(); + clearKeychain(); +} + +function clearUserDefaults(): void { + try { + const defaults = ObjC.classes.NSUserDefaults.standardUserDefaults(); + const dict = defaults.dictionaryRepresentation(); + const keys = dict.allKeys(); + const count = keys.count(); + + // Netflix 関連のキーを全削除 + const keywords = ["netflix", "nf", "esn", "cdm", "msl", "ale", "drm", "provision", "token", "bf"]; + let removed = 0; + + for (let i = 0; i < count; i++) { + const key = keys.objectAtIndex_(i).toString(); + const keyLower = key.toLowerCase(); + if (keywords.some(kw => keyLower.indexOf(kw) !== -1)) { + defaults.removeObjectForKey_(keys.objectAtIndex_(i)); + removed++; + } + } + + defaults.synchronize(); + console.log("[+] UserDefaults: removed " + removed + " Netflix-related keys"); + logData("storage.clear.userDefaults", { removed: removed }); + } catch (e) { + console.log("[-] clearUserDefaults: " + e); + } +} + +function clearKeychain(): void { + try { + // SecItemCopyMatching と SecItemDelete を取得 + const secMod = Process.findModuleByName("Security"); + if (!secMod) { console.log("[-] Security module not found"); return; } + + let copyAddr: NativePointer | null = null; + let deleteAddr: NativePointer | null = null; + for (const exp of secMod.enumerateExports()) { + if (exp.name === "SecItemCopyMatching") copyAddr = exp.address; + if (exp.name === "SecItemDelete") deleteAddr = exp.address; + } + if (!copyAddr || !deleteAddr) { console.log("[-] SecItem functions not found"); return; } + + const SecItemCopyMatching = new NativeFunction(copyAddr, "int32", ["pointer", "pointer"]); + const SecItemDelete = new NativeFunction(deleteAddr, "int32", ["pointer"]); + + const nsStr = ObjC.classes.NSString; + const nsNum = ObjC.classes.NSNumber; + const secClasses = ["genp", "inet"]; + let totalRemoved = 0; + + for (const cls of secClasses) { + // まず全アイテムを取得 + const query = ObjC.classes.NSMutableDictionary.alloc().init(); + query.setObject_forKey_(nsStr.stringWithString_(cls), nsStr.stringWithString_("class")); + query.setObject_forKey_(nsNum.numberWithBool_(1), nsStr.stringWithString_("r_Attributes")); + query.setObject_forKey_(nsStr.stringWithString_("m_LimitAll"), nsStr.stringWithString_("m_Limit")); + + const resultPtr = Memory.alloc(Process.pointerSize); + resultPtr.writePointer(ptr(0)); + const status = SecItemCopyMatching(query.handle, resultPtr); + if (status !== 0) continue; + + const items = new ObjC.Object(resultPtr.readPointer()); + if (!items || items.isNull()) continue; + + const count = items.count(); + for (let i = 0; i < count; i++) { + try { + const item = items.objectAtIndex_(i); + const agrp = item.objectForKey_(nsStr.stringWithString_("agrp")); + if (!agrp) continue; + const agrpStr = agrp.toString().toLowerCase(); + if (agrpStr.indexOf("netflix") === -1) continue; + + // このアイテムを削除 + const delQuery = ObjC.classes.NSMutableDictionary.alloc().init(); + delQuery.setObject_forKey_(nsStr.stringWithString_(cls), nsStr.stringWithString_("class")); + // service + account で特定 + const svc = item.objectForKey_(nsStr.stringWithString_("svce")); + const acct = item.objectForKey_(nsStr.stringWithString_("acct")); + if (svc) delQuery.setObject_forKey_(svc, nsStr.stringWithString_("svce")); + if (acct) delQuery.setObject_forKey_(acct, nsStr.stringWithString_("acct")); + + const delStatus = SecItemDelete(delQuery.handle); + if (delStatus === 0) { + totalRemoved++; + const acctStr = acct ? acct.toString() : "?"; + console.log("[KC:DEL] " + cls + " acct=" + acctStr); + } + } catch (_) { } + } + } + + console.log("[+] Keychain: deleted " + totalRemoved + " Netflix items"); + logData("storage.clear.keychain", { removed: totalRemoved }); + } catch (e) { + console.log("[-] clearKeychain: " + e); + } +} + +// ── UserDefaults ── + +function dumpUserDefaults(): void { + try { + const NSUserDefaults = ObjC.classes.NSUserDefaults; + const defaults = NSUserDefaults.standardUserDefaults(); + const dict = defaults.dictionaryRepresentation(); + const keys = dict.allKeys(); + const count = keys.count(); + + console.log("[*] UserDefaults: " + count + " keys"); + + const allEntries: Record = {}; + const keywords = ["netflix", "nf", "ale", "msl", "esn", "drm", "provision", "token", "session", "crypto", "key", "auth", "cookie", "profile", "user"]; + let matchCount = 0; + + for (let i = 0; i < count; i++) { + const key = keys.objectAtIndex_(i).toString(); + + try { + const val = dict.objectForKey_(keys.objectAtIndex_(i)); + if (!val || val.isNull()) continue; + + let valStr: string; + const className = val.$className || ""; + + if (className === "NSData" || className === "__NSCFData") { + const len = val.length(); + const str = ObjC.classes.NSString.alloc().initWithData_encoding_(val, 4); + if (str && !str.isNull()) { + valStr = str.toString(); + } else { + // base64 で保存 + const b64 = val.base64EncodedStringWithOptions_(0); + valStr = b64 && !b64.isNull() ? "b64:" + b64.toString() : ""; + } + } else { + valStr = val.toString(); + } + + allEntries[key] = valStr; + + // コンソールにはフィルタ済みのみ表示 + const keyLower = key.toLowerCase(); + if (keywords.some(kw => keyLower.indexOf(kw) !== -1)) { + matchCount++; + const display = valStr.length > 200 ? valStr.substring(0, 200) + "..." : valStr; + console.log(" [UD] " + key + " = " + display); + } + } catch (e) { + allEntries[key] = ""; + } + } + + logData("storage.userDefaults", { + total: count, + matchCount: matchCount, + entries: allEntries + }); + + console.log("[+] UserDefaults: " + matchCount + " Netflix-related / " + count + " total keys"); + } catch (e) { + console.log("[-] UserDefaults dump: " + e); + } +} + +// ── Keychain ── + +function dumpKeychain(): void { + try { + console.log("[*] Keychain dump starting..."); + console.log("[*] NSMutableDictionary: " + !!ObjC.classes.NSMutableDictionary); + console.log("[*] NSString: " + !!ObjC.classes.NSString); + console.log("[*] NSNumber: " + !!ObjC.classes.NSNumber); + console.log("[*] numberWithBool_: " + typeof ObjC.classes.NSNumber.numberWithBool_); + console.log("[*] numberWithInt_: " + typeof ObjC.classes.NSNumber.numberWithInt_); + // Security framework から SecItemCopyMatching を探す + let secAddr: NativePointer | null = null; + const secMod = Process.findModuleByName("Security"); + if (secMod) { + const exports = secMod.enumerateExports(); + for (let i = 0; i < exports.length; i++) { + if (exports[i].name === "SecItemCopyMatching") { + secAddr = exports[i].address; + break; + } + } + } + console.log("[*] SecItemCopyMatching addr: " + secAddr); + if (!secAddr || secAddr.isNull()) { + console.log("[-] SecItemCopyMatching not found"); + return; + } + const SecItemCopyMatching = new NativeFunction(secAddr, "int32", ["pointer", "pointer"]); + + const secClasses = ["genp", "inet"]; + const classNames = ["GenericPassword", "InternetPassword"]; + + let totalFound = 0; + + for (let ci = 0; ci < secClasses.length; ci++) { + try { + const query = ObjC.classes.NSMutableDictionary.alloc().init(); + const nsStr = ObjC.classes.NSString; + const nsNum = ObjC.classes.NSNumber; + + query.setObject_forKey_(nsStr.stringWithString_(secClasses[ci]), nsStr.stringWithString_("class")); + query.setObject_forKey_(nsNum.numberWithBool_(1), nsStr.stringWithString_("r_Attributes")); + query.setObject_forKey_(nsNum.numberWithBool_(1), nsStr.stringWithString_("r_Data")); + query.setObject_forKey_(nsStr.stringWithString_("m_LimitAll"), nsStr.stringWithString_("m_Limit")); + + console.log("[*] KC query " + classNames[ci] + ": " + query.toString().substring(0, 200)); + + const resultPtr = Memory.alloc(Process.pointerSize); + resultPtr.writePointer(ptr(0)); + const status = SecItemCopyMatching(query.handle, resultPtr); + + if (status !== 0) { + if (status !== -25300) { + console.log(" [KC] " + classNames[ci] + ": status=" + status); + } + continue; + } + + const resultObj = new ObjC.Object(resultPtr.readPointer()); + if (!resultObj || resultObj.isNull()) continue; + + // NSArray of NSDictionary + const itemCount = resultObj.count(); + + for (let i = 0; i < itemCount; i++) { + try { + const item = resultObj.objectAtIndex_(i); + const service = safeStr(item, "svce"); + const account = safeStr(item, "acct"); + const label = safeStr(item, "labl"); + const accessGroup = safeStr(item, "agrp"); + + const combined = (service + " " + account + " " + label + " " + accessGroup).toLowerCase(); + const keywords = ["netflix", "nf", "ale", "msl", "esn", "drm", "provision", "com.netflix"]; + if (!keywords.some(kw => combined.indexOf(kw) !== -1)) continue; + + totalFound++; + + let dataStr = ""; + let dataB64 = ""; + let dataSize = 0; + try { + const vData = item.objectForKey_(ObjC.classes.NSString.stringWithString_("v_Data")); + if (vData && !vData.isNull()) { + dataSize = vData.length(); + // UTF-8 で読めるか試す + const str = ObjC.classes.NSString.alloc().initWithData_encoding_(vData, 4); + if (str && !str.isNull()) { + dataStr = str.toString(); + } + // base64 エンコード (全データ保存) + if (dataSize > 0) { + const b64 = vData.base64EncodedStringWithOptions_(0); + if (b64 && !b64.isNull()) { + dataB64 = b64.toString(); + } + } + } + } catch (_) { } + + const display = dataStr || ("<" + dataSize + " bytes>"); + console.log(" [KC:" + classNames[ci] + "] svc=" + service + " acct=" + account + " " + dataSize + "B"); + console.log(" " + (dataStr ? dataStr.substring(0, 200) : "b64=" + dataB64.substring(0, 80) + "...")); + + logData("storage.keychain", { + class: classNames[ci], + service: service, + account: account, + label: label, + accessGroup: accessGroup, + size: dataSize, + data: dataStr || null, + data_b64: dataB64, + }); + } catch (_) { } + } + } catch (ce) { + console.log(" [KC] " + classNames[ci] + ": " + ce); + } + } + + console.log("[+] Keychain: " + totalFound + " Netflix-related items"); + } catch (e) { + console.log("[-] Keychain dump: " + e); + } +} + +// ── サンドボックスファイル探索 ── + +function dumpSandbox(): void { + try { + const NSFileManager = ObjC.classes.NSFileManager; + const fm = NSFileManager.defaultManager(); + + // ホームディレクトリ: NSTemporaryDirectory の親から推定、または既知パス + let homeDir = ""; + try { + // Library/Preferences のパスから逆算 + const paths = ObjC.classes.NSSearchPathForDirectoriesInDomains + ? null // これは C 関数なので使えない + : null; + // NSBundle.mainBundle.bundlePath → /var/containers/Bundle/Application/UUID/Netflix.app + // データは /var/containers/Data/Application/UUID/ にある + // fm.URLsForDirectory_inDomains_(5, 1) = NSDocumentDirectory, NSUserDomainMask + // NSLibraryDirectory = 5 in some versions, NSDocumentDirectory = 9 + // Try multiple directory types + for (const dirType of [9, 5]) { // NSDocumentDirectory, NSLibraryDirectory + try { + const urls = fm.URLsForDirectory_inDomains_(dirType, 1); + if (urls && urls.count() > 0) { + const p = urls.objectAtIndex_(0).path().toString(); + // Strip trailing /Documents or /Library + homeDir = p.replace(/\/(Documents|Library)$/, ""); + if (homeDir) break; + } + } catch (_) { } + } + } catch (e) { + console.log("[-] Home dir detection: " + e); + } + if (!homeDir) { + console.log("[-] Could not determine home directory"); + return; + } + console.log("[*] App home: " + homeDir); + + const searchDirs = [ + homeDir + "/Documents", + homeDir + "/Library", + homeDir + "/Library/Caches", + homeDir + "/Library/Application Support", + homeDir + "/Library/Preferences", + homeDir + "/tmp", + ]; + + const allFiles: { path: string; size: number; isDir: boolean }[] = []; + + for (const dir of searchDirs) { + try { + listDirRecursive(fm, dir, allFiles, 2); + } catch (_) { } + } + + // 結果表示 + console.log("[*] Sandbox: " + allFiles.length + " relevant files/dirs"); + for (const f of allFiles) { + const type = f.isDir ? "DIR " : "FILE"; + const sizeStr = f.isDir ? "" : " (" + f.size + "B)"; + console.log(" [FS:" + type + "] " + f.path + sizeStr); + } + + // 小さいファイル (< 4KB) の中身を読む + for (const f of allFiles) { + if (f.isDir || f.size === 0 || f.size > 4096) continue; + try { + const nsPath = ObjC.classes.NSString.stringWithString_(f.path); + const data = ObjC.classes.NSData.dataWithContentsOfFile_(nsPath); + if (!data || data.isNull()) continue; + + let content = ""; + const str = ObjC.classes.NSString.alloc().initWithData_encoding_(data, 4); + if (str && !str.isNull()) { + content = str.toString(); + } else { + // plist 試行 + try { + const plist = ObjC.classes.NSPropertyListSerialization.propertyListWithData_options_format_error_(data, 0, NULL, NULL); + if (plist && !plist.isNull()) content = plist.toString(); + } catch (_) { } + } + if (content) { + const preview = content.length > 300 ? content.substring(0, 300) + "..." : content; + console.log(" [FS:CONTENT] " + f.path + ": " + preview); + logData("storage.file", { path: f.path, size: f.size, content: content.substring(0, 8192) }); + } + } catch (_) { } + } + + logData("storage.sandbox", { + home: homeDir, + files: allFiles, + }); + } catch (e) { + console.log("[-] Sandbox dump: " + e); + } +} + +function listDirRecursive( + fm: ObjC.Object, dir: string, + result: { path: string; size: number; isDir: boolean }[], + maxDepth: number +): void { + if (maxDepth <= 0) return; + try { + const nsDir = ObjC.classes.NSString.stringWithString_(dir); + const contents = fm.contentsOfDirectoryAtPath_error_(nsDir, NULL); + if (!contents || contents.isNull()) return; + const count = contents.count(); + for (let i = 0; i < count; i++) { + const name = contents.objectAtIndex_(i).toString(); + const fullPath = dir + "/" + name; + let size = 0; + let isDir = false; + try { + const attrs = fm.attributesOfItemAtPath_error_(ObjC.classes.NSString.stringWithString_(fullPath), NULL); + if (attrs && !attrs.isNull()) { + const ft = safeStr(attrs, "NSFileType"); + isDir = ft === "NSFileTypeDirectory"; + if (!isDir) { + const s = attrs.objectForKey_(ObjC.classes.NSString.stringWithString_("NSFileSize")); + if (s) size = parseInt(s.toString()) || 0; + } + } + } catch (_) { } + result.push({ path: fullPath, size, isDir }); + if (isDir) listDirRecursive(fm, fullPath, result, maxDepth - 1); + } + } catch (_) { } +} + +function safeStr(dict: ObjC.Object, key: string): string { + try { + const val = dict.objectForKey_(ObjC.classes.NSString.stringWithString_(key)); + if (val && !val.isNull()) return val.toString(); + } catch (e) { } + return ""; +} diff --git a/packages/frida/tsconfig.json b/packages/frida/tsconfig.json new file mode 100644 index 0000000..5b9eade --- /dev/null +++ b/packages/frida/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "node", + "strict": false, + "esModuleInterop": true, + "outDir": "./dist", + "rootDir": "./src", + "declaration": false, + "sourceMap": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/mitmproxy/msl_decoder.py b/packages/mitmproxy/msl_decoder.py new file mode 100644 index 0000000..66950e0 --- /dev/null +++ b/packages/mitmproxy/msl_decoder.py @@ -0,0 +1,468 @@ +"""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"{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]} [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() diff --git a/packages/mitmproxy/netflix_ios_capture.py b/packages/mitmproxy/netflix_ios_capture.py new file mode 100644 index 0000000..a93bb31 --- /dev/null +++ b/packages/mitmproxy/netflix_ios_capture.py @@ -0,0 +1,267 @@ +""" +Netflix Raw Capture — mitmproxy addon + +通信を一切改変せず、リクエスト/レスポンスの生データを保存する。 +User-Agent / URL からプラットフォーム (ios, android, chrome) を自動判別。 + +使い方: + mitmdump -p 8080 --set stream_large_bodies=0 \ + -s packages/mitmproxy/netflix_ios_capture.py + + デバイス側で Wi-Fi プロキシを <このマシンのIP>:8080 に設定し、 + http://mitm.it から CA 証明書をインストールする。 + +保存先: raws/// +""" + +from __future__ import annotations + +import json +import logging +import re +from datetime import datetime, timezone +from pathlib import Path + +from mitmproxy import http, tls + + +# ── 設定 ── +BASE_DIR = Path(__file__).resolve().parent.parent.parent / "raws" + +# ── TLS パススルー (SSL pinning 回避不可なホスト) ── +TLS_PASSTHROUGH_HOSTS = { + "gateway.icloud.com", + "mesu.apple.com", +} + +# サフィックスマッチ (*.icloud.com, *.apple.com 等) +TLS_PASSTHROUGH_SUFFIXES = ( + ".icloud.com", + ".apple.com", + ".googleapis.com", + ".gstatic.com", +) + + +# ── ログ抑制 (非対象ドメインの connect/disconnect/TLS エラーを非表示) ── +_INTERCEPT_DOMAINS = ("netflix.com", "nflxext.com", "nflxso.net", "nflximg.net", "nflxvideo.net") +_HOST_RE = re.compile(r"([a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?\.[a-zA-Z]{2,})(?::\d+)?") +_NOISE_KEYWORDS = ( + "client connect", + "client disconnect", + "server connect", + "server disconnect", + "handshake failed", + "does not trust the proxy", + "disconnected during the handshake", +) + + +def _should_intercept(hostname: str) -> bool: + return any(hostname == d or hostname.endswith(f".{d}") for d in _INTERCEPT_DOMAINS) + + +class _NoiseFilter(logging.Filter): + """非対象ドメインの接続系ログを抑制する.""" + + def filter(self, record: logging.LogRecord) -> bool: + msg = record.getMessage() + msg_lower = msg.lower() + if not any(kw in msg_lower for kw in _NOISE_KEYWORDS): + return True + hosts = _HOST_RE.findall(msg) + if not hosts: + # ホスト名なし (bare "client connect" 等) → 抑制 + return False + return any(_should_intercept(h) for h in hosts) + + +def _detect_platform(flow: http.HTTPFlow) -> str: + ua = flow.request.headers.get("User-Agent", "") + url = flow.request.pretty_url + + # iOS + if "Darwin/" in ua or "CFNetwork/" in ua: + return "ios" + if "ios.prod." in url or "/iosui/" in url or "/iosplatform/" in url: + return "ios" + + # Android + if "okhttp/" in ua or "Cronet/" in ua: + return "android" + if "android" in ua.lower(): + return "android" + + # Chrome / browser + if "Chrome/" in ua or "Mozilla/" in ua: + return "chrome" + + return "unknown" + + +def _output_dir(platform: str) -> Path: + return BASE_DIR / platform / datetime.now(timezone.utc).strftime("%Y%m%d") + + +def _ts() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%S-%f")[:-3] + "Z" + + +def _classify(url: str) -> str: + patterns = [ + ("pbo_manifests", "pbo_manifests"), + ("pbo_license", "pbo_license"), + ("pbo_tokens", "pbo_tokens"), + ("licensedmanifest", "licensedmanifest"), + ("playapi/ios/manifest", "ios_manifest"), + ("playapi/ios/logblob", "ios_logblob"), + ("/msl_v1/", "msl"), + ("/msl/", "msl"), + ("/license", "license"), + ("/events", "events"), + ("getProxyEsn", "getProxyEsn"), + ("pathEvaluator", "pathEvaluator"), + ("graphql", "graphql"), + ("/iosui/", "iosui"), + ("/appboot/", "appboot"), + ("/config", "config"), + ("/metadata", "metadata"), + ("/shakti", "shakti"), + ("/api/", "api"), + ] + for pattern, name in patterns: + if pattern in url: + return name + return "other" + + +def _safe_json(obj: object) -> str: + return json.dumps(obj, ensure_ascii=False, indent=2, default=str) + + +def _write(path: Path, data: bytes | str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + if isinstance(data, str): + path.write_text(data, encoding="utf-8") + else: + path.write_bytes(data) + + +def _append(path: Path, data: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "a", encoding="utf-8") as f: + f.write(data) + + +# ── シーケンス番号 ── +_seq = 0 + + +class NetflixCapture: + def tls_clienthello(self, data: tls.ClientHelloData) -> None: + """TLS パススルー: 指定ホストは MITM せずそのまま通す。""" + if data.context.server.address: + host = data.context.server.address[0] + if host in TLS_PASSTHROUGH_HOSTS or host.endswith(TLS_PASSTHROUGH_SUFFIXES): + data.ignore_connection = True + + def response(self, flow: http.HTTPFlow) -> None: + """レスポンス受信時に呼ばれる。通信は改変しない。""" + global _seq + + url = flow.request.pretty_url + if "netflix.com" not in url and "netflix.net" not in url: + return + + _seq += 1 + seq = _seq + now = _ts() + platform = _detect_platform(flow) + endpoint = _classify(url) + out = _output_dir(platform) + + # ── リクエスト生ボディ ── + req_body = flow.request.raw_content + if req_body: + _write(out / "raw" / f"req_{seq}_{endpoint}_{now}.bin", req_body) + + # ── レスポンス生ボディ ── + res_body = flow.response.raw_content if flow.response else b"" + if res_body: + _write(out / "raw" / f"res_{seq}_{endpoint}_{now}.bin", res_body) + + # ── ヘッダー + メタデータ ── + meta = { + "seq": seq, + "ts": datetime.now(timezone.utc).isoformat(), + "url": url, + "method": flow.request.method, + "platform": platform, + "endpoint": endpoint, + "statusCode": flow.response.status_code if flow.response else None, + "requestHeaders": dict(flow.request.headers), + "responseHeaders": dict(flow.response.headers) if flow.response else {}, + "requestBodySize": len(req_body) if req_body else 0, + "responseBodySize": len(res_body) if res_body else 0, + } + _write(out / "headers" / f"{seq}_{endpoint}_{now}.json", _safe_json(meta)) + + # ── Cookie ── + cookie_header = flow.request.headers.get("Cookie", "") + if cookie_header: + lines = [] + cookie_obj = {} + for c in cookie_header.split(";"): + c = c.strip() + if "=" in c: + name, val = c.split("=", 1) + lines.append(f".netflix.com\tTRUE\t/\tTRUE\t0\t{name}\t{val}") + cookie_obj[name] = val + _write(out / "cookies" / "cookies.txt", "\n".join(lines) + "\n") + _write(out / "cookies" / "cookies.json", _safe_json(cookie_obj)) + + # ── Set-Cookie ── + set_cookie = ( + flow.response.headers.get("Set-Cookie", "") if flow.response else "" + ) + if set_cookie: + _append( + out / "cookies" / "set_cookies.log", + f"{datetime.now(timezone.utc).isoformat()} {url}\n{set_cookie}\n\n", + ) + + # ── JSON レスポンス ── + if res_body: + try: + parsed = json.loads(res_body) + _write( + out / "json" / f"res_{seq}_{endpoint}_{now}.json", + _safe_json(parsed), + ) + except (json.JSONDecodeError, UnicodeDecodeError): + pass + + # ── JSONL ログ ── + log_entry = { + "seq": seq, + "ts": datetime.now(timezone.utc).isoformat(), + "url": url, + "method": flow.request.method, + "platform": platform, + "endpoint": endpoint, + "statusCode": flow.response.status_code if flow.response else None, + "requestBodySize": len(req_body) if req_body else 0, + "responseBodySize": len(res_body) if res_body else 0, + } + _append(out / "capture_log.jsonl", json.dumps(log_entry) + "\n") + + +def load(loader): + """addon ロード時にログフィルタを登録する.""" + noise_filter = _NoiseFilter() + for name in ("mitmproxy.proxy", "mitmproxy.proxy.layers", "mitmproxy.proxy.layers.tls"): + logging.getLogger(name).addFilter(noise_filter) + logging.getLogger().addFilter(noise_filter) + + +addons = [NetflixCapture()] diff --git a/packages/mitmproxy/netflix_msl_capture.py b/packages/mitmproxy/netflix_msl_capture.py new file mode 100644 index 0000000..c742a34 --- /dev/null +++ b/packages/mitmproxy/netflix_msl_capture.py @@ -0,0 +1,827 @@ +""" +Netflix MSL Capture — mitmproxy addon + +Proxyman スクリプト (netflix-msl-capture.js + NetflixMSLParser.js) の +mitmproxy 移植版。通信を一切改変せずに MSL メッセージをデコードし、 +マニフェスト・ALE 鍵・ESN・KID テーブルを抽出・保存する。 + +使い方: + mitmdump --listen-port 9080 --set block_global=false \ + -s packages/mitmproxy/netflix_msl_capture.py + +保存先: raws/// +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import logging +import struct +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from cryptography.hazmat.primitives.padding import PKCS7 +from mitmproxy import ctx, http + +logger = logging.getLogger(__name__) + +# ── 設定 ── +BASE_DIR = Path(__file__).resolve().parent.parent.parent / "raws" + + +# ════════════════════════════════════════════════════════════════ +# Base64 helpers +# ════════════════════════════════════════════════════════════════ + + +def b64_decode(s: str) -> bytes | None: + try: + return base64.b64decode(s) + except Exception: + return None + + +def b64url_decode(s: str) -> bytes | None: + try: + return base64.urlsafe_b64decode(s + "=" * (4 - len(s) % 4)) + except Exception: + return None + + +# ════════════════════════════════════════════════════════════════ +# LZW Decoder (Netflix MSL variant) +# ════════════════════════════════════════════════════════════════ + + +def decode_lzw(data_b64: str) -> str | None: + raw = b64_decode(data_b64) + if not raw: + return None + try: + data = list(raw) + if not data: + return None + + bit_pos = 0 + total_bits = len(data) * 8 + + def read_bits(n: int) -> int: + nonlocal bit_pos + if bit_pos + n > total_bits: + return -1 + val = 0 + for i in range(n): + byte_idx = (bit_pos + i) >> 3 + bit_idx = 7 - ((bit_pos + i) & 7) + if data[byte_idx] & (1 << bit_idx): + val |= 1 << (n - 1 - i) + bit_pos += n + return val + + dictionary: list[list[int]] = [list([i]) for i in range(256)] + bits = 8 + output: list[int] = [] + + code = read_bits(bits) + if code == -1 or code >= len(dictionary): + return None + prev = dictionary[code] + output.extend(prev) + + while True: + if len(dictionary) == 1 << bits: + bits += 1 + code = read_bits(bits) + if code == -1: + break + if code < len(dictionary): + entry = dictionary[code] + elif code == len(dictionary): + entry = prev + [prev[0]] + else: + break + output.extend(entry) + dictionary.append(prev + [entry[0]]) + prev = entry + + return bytes(output).decode("utf-8", errors="replace") + except Exception: + return None + + +# ════════════════════════════════════════════════════════════════ +# JSON helpers +# ════════════════════════════════════════════════════════════════ + + +def try_parse_json(text: str | bytes | None) -> Any: + if not text: + return None + try: + if isinstance(text, bytes): + text = text.decode("utf-8", errors="replace") + return json.loads(text) + except (json.JSONDecodeError, UnicodeDecodeError): + return None + + +def safe_json(obj: Any) -> str: + return json.dumps(obj, ensure_ascii=False, indent=2, default=str) + + +# ════════════════════════════════════════════════════════════════ +# AES-CBC Decryption +# ════════════════════════════════════════════════════════════════ + + +def decrypt_aes_cbc(data_b64: str, key_hex: str) -> str | None: + """MSL format: base64(IV[16] || ciphertext)""" + if not data_b64 or not key_hex: + return None + try: + raw = base64.b64decode(data_b64) + if len(raw) < 17: + return None + iv = raw[:16] + ciphertext = raw[16:] + key = bytes.fromhex(key_hex) + cipher = Cipher(algorithms.AES(key), modes.CBC(iv)) + decryptor = cipher.decryptor() + padded = decryptor.update(ciphertext) + decryptor.finalize() + unpadder = PKCS7(128).unpadder() + plaintext = unpadder.update(padded) + unpadder.finalize() + return plaintext.decode("utf-8", errors="replace") + except Exception: + return None + + +# ════════════════════════════════════════════════════════════════ +# MSL Envelope Decoder +# ════════════════════════════════════════════════════════════════ + + +class MSLDecoder: + def __init__(self) -> None: + self.encryption_key: str | None = None + self.hmac_key: str | None = None + + def set_keys(self, enc_key: str, hmac_key: str) -> None: + self.encryption_key = enc_key + self.hmac_key = hmac_key + + def decode_chunk_data(self, data_str: str, compress: str | None = None) -> Any: + if not data_str: + return None + + # 1. LZW + if compress == "LZW": + decompressed = decode_lzw(data_str) + if decompressed: + return try_parse_json(decompressed) or decompressed + + # 2. Base64 + inner = b64_decode(data_str) + if inner: + parsed = try_parse_json(inner) + if parsed: + return parsed + try: + text = inner.decode("utf-8", errors="replace") + if text and ord(text[0]) >= 0x20: + return text + except Exception: + pass + + # 3. AES-CBC (if ALE keys available) + if self.encryption_key: + decrypted = decrypt_aes_cbc(data_str, self.encryption_key) + if decrypted: + if compress == "LZW": + decompressed = decode_lzw(decrypted) + if decompressed: + return try_parse_json(decompressed) or decompressed + return try_parse_json(decrypted) or decrypted + + return None + + def deep_decode(self, obj: dict) -> dict: + if not obj or not isinstance(obj, dict): + return obj + + decoded = dict(obj) + compress = decoded.get("compressionalgo") + + # headerdata + if isinstance(decoded.get("headerdata"), str): + hdr_bytes = b64_decode(decoded["headerdata"]) + hdr = try_parse_json(hdr_bytes) + if hdr and isinstance(hdr, dict): + decoded["_headerdata_decoded"] = hdr + + # payload (single) + if isinstance(decoded.get("payload"), str): + chunk_bytes = b64_decode(decoded["payload"]) + chunk = try_parse_json(chunk_bytes) + if chunk: + decoded["_payload_decoded"] = chunk + if chunk.get("data"): + algo = chunk.get("compressionalgo") or compress + decoded["_payload_data"] = self.decode_chunk_data( + chunk["data"], algo + ) + + # data field (payload chunk format) + if isinstance(decoded.get("data"), str) and "messageid" in decoded: + decoded["_data_decoded"] = self.decode_chunk_data(decoded["data"], compress) + + # payloads array + if isinstance(decoded.get("payloads"), list): + payloads_decoded = [] + for p in decoded["payloads"]: + if isinstance(p, str): + chunk_bytes = b64_decode(p) + chunk = try_parse_json(chunk_bytes) + if chunk and chunk.get("data"): + algo = chunk.get("compressionalgo") or compress + inner = self.decode_chunk_data(chunk["data"], algo) + payloads_decoded.append({"_chunk": chunk, "_data": inner}) + else: + payloads_decoded.append(chunk or p) + else: + payloads_decoded.append(p) + decoded["_payloads_decoded"] = payloads_decoded + + # servicetokens + if isinstance(decoded.get("servicetokens"), list): + tokens_decoded = [] + for st in decoded["servicetokens"]: + if isinstance(st, dict) and isinstance(st.get("tokendata"), str): + td_bytes = b64_decode(st["tokendata"]) + td = try_parse_json(td_bytes) + if td: + result = dict(td) + if td.get("servicedata"): + sd_bytes = b64_decode(td["servicedata"]) + result["_servicedata_decoded"] = ( + try_parse_json(sd_bytes) if sd_bytes else None + ) + tokens_decoded.append(result) + else: + tokens_decoded.append(st) + else: + tokens_decoded.append(st) + decoded["_servicetokens_decoded"] = tokens_decoded + + # useridtoken + uit = decoded.get("useridtoken") + if isinstance(uit, dict) and isinstance(uit.get("tokendata"), str): + uit_bytes = b64_decode(uit["tokendata"]) + decoded["_useridtoken_decoded"] = try_parse_json(uit_bytes) + + return decoded + + +# ════════════════════════════════════════════════════════════════ +# MSL Body Parser +# ════════════════════════════════════════════════════════════════ + + +def parse_msl_body(body: bytes | str) -> list[dict]: + if not body: + return [] + if isinstance(body, bytes): + try: + body = body.decode("utf-8", errors="replace") + except Exception: + return [] + + single = try_parse_json(body) + if single: + return [single] if isinstance(single, dict) else [] + + messages = [] + for line in body.split("\n"): + line = line.strip() + if not line: + continue + parsed = try_parse_json(line) + if parsed and isinstance(parsed, dict): + messages.append(parsed) + return messages + + +# ════════════════════════════════════════════════════════════════ +# Extractors +# ════════════════════════════════════════════════════════════════ + + +def extract_decoded_payload(expanded: dict) -> Any: + return ( + expanded.get("_data_decoded") + or expanded.get("_payload_data") + or expanded.get("_payload_decoded") + ) + + +def format_drm_header_id(hex_str: str) -> str | None: + if not hex_str or len(hex_str) != 32: + return hex_str or None + return ( + f"{hex_str[:8]}-{hex_str[8:12]}-{hex_str[12:16]}" + f"-{hex_str[16:20]}-{hex_str[20:]}" + ) + + +def extract_manifest(payload: dict) -> dict | None: + if not payload or not isinstance(payload, dict): + return None + raw = payload.get("result", payload) + if not raw or (not raw.get("video_tracks") and not raw.get("audio_tracks")): + return None + + manifest: dict[str, Any] = { + "movieId": str(raw["movieId"]) if raw.get("movieId") is not None else None, + "duration": raw.get("duration"), + "servers": raw.get("servers", []), + "videoTracks": [], + "audioTracks": [], + "textTracks": [], + } + + for vt in raw.get("video_tracks", []): + track = { + "trackType": vt.get("trackType"), + "track_id": vt.get("track_id"), + "maxWidth": vt.get("maxWidth"), + "maxHeight": vt.get("maxHeight"), + "drmHeader": ( + { + "bytes": vt["drmHeader"].get("bytes"), + "keyId": vt["drmHeader"].get("keyId"), + } + if vt.get("drmHeader") + else None + ), + "streams": [ + { + "res_w": s.get("res_w"), + "res_h": s.get("res_h"), + "bitrate": s.get("bitrate"), + "size": s.get("size"), + "vmaf": s.get("vmaf"), + "content_profile": s.get("content_profile"), + "downloadable_id": s.get("downloadable_id"), + "kid": format_drm_header_id(s.get("drmHeaderId", "")), + "urls": s.get("urls", []), + } + for s in vt.get("streams", []) + ], + } + manifest["videoTracks"].append(track) + + for at in raw.get("audio_tracks", []): + track = { + "language": at.get("language"), + "languageDescription": at.get("languageDescription"), + "channels": at.get("channels"), + "trackType": at.get("trackType"), + "track_id": at.get("track_id"), + "streams": [ + { + "bitrate": s.get("bitrate"), + "size": s.get("size"), + "content_profile": s.get("content_profile"), + "downloadable_id": s.get("downloadable_id"), + "urls": s.get("urls", []), + } + for s in at.get("streams", []) + ], + } + manifest["audioTracks"].append(track) + + for tt in raw.get("timedtexttracks", []): + if tt.get("isNoneTrack"): + continue + manifest["textTracks"].append( + { + "language": tt.get("language"), + "languageDescription": tt.get("languageDescription"), + "trackType": tt.get("trackType"), + "downloadableId": tt.get("downloadableId"), + "urls": tt.get("ttDownloadables"), + } + ) + + return manifest + + +def extract_ale_keys(payload: dict) -> dict | None: + if not payload or not isinstance(payload, dict): + return None + prov = payload.get("provisionResponse") + if not prov: + return None + + token_obj = try_parse_json(prov) if isinstance(prov, str) else prov + if not token_obj or not isinstance(token_obj, dict): + return None + keyx = token_obj.get("keyx") + if not keyx or not keyx.get("data", {}).get("key"): + return None + + key_bytes = b64url_decode(keyx["data"]["key"]) + if not key_bytes or len(key_bytes) < 32: + return None + + hmac_hex = key_bytes[:16].hex() + aes_hex = key_bytes[16:32].hex() + + # JWE header + jwe_token = token_obj.get("token", "") + jwe_alg = "?" + jwe_enc = "?" + if jwe_token: + parts = jwe_token.split(".") + if len(parts) == 5: + hdr_bytes = b64url_decode(parts[0]) + hdr = try_parse_json(hdr_bytes) + if hdr: + jwe_alg = hdr.get("alg", "?") + jwe_enc = hdr.get("enc", "?") + + return { + "encryptionKey": aes_hex, + "hmacKey": hmac_hex, + "kid": keyx.get("kid", ""), + "jweToken": jwe_token, + "scheme": keyx.get("scheme", ""), + "rawKeyHex": key_bytes.hex(), + "jweAlg": jwe_alg, + "jweEnc": jwe_enc, + "capturedAt": datetime.now(timezone.utc).isoformat(), + } + + +def extract_esn_from_headers(headers: dict) -> dict | None: + esn = None + for key in ("x-netflix.esn", "X-Netflix.esn", "X-Netflix.Esn", "X-NETFLIX.ESN"): + if key in headers: + esn = headers[key] + break + if not esn: + return None + parts = esn.split("|") + return { + "esn": esn, + "prv": parts[0] if parts else None, + "pxa": parts[1] if len(parts) >= 2 else None, + } + + +def extract_esn_from_sender(sender: str) -> dict | None: + if not sender: + return None + return {"esn": sender, "prv": sender, "pxa": None} + + +def build_kid_table(manifest: dict) -> list[dict]: + rows = [] + for vt in manifest.get("videoTracks", []): + sorted_streams = sorted( + vt.get("streams", []), key=lambda s: s.get("bitrate", 0) + ) + prev_kid = None + for s in sorted_streams: + kid = s.get("kid") + boundary = prev_kid is not None and kid != prev_kid + rows.append( + { + "res_w": s.get("res_w"), + "res_h": s.get("res_h"), + "bitrate": s.get("bitrate"), + "kid": kid, + "kid_short": (kid[:8] + "..." if kid else "-"), + "content_profile": s.get("content_profile"), + "boundary": boundary, + } + ) + prev_kid = kid + return rows + + +# ════════════════════════════════════════════════════════════════ +# File I/O +# ════════════════════════════════════════════════════════════════ + + +def _ts() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%S-%f")[:-3] + "Z" + + +def _write(path: Path, data: bytes | str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + if isinstance(data, str): + path.write_text(data, encoding="utf-8") + else: + path.write_bytes(data) + + +def _append(path: Path, data: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "a", encoding="utf-8") as f: + f.write(data) + + +def _detect_platform(flow: http.HTTPFlow) -> str: + ua = flow.request.headers.get("User-Agent", "") + url = flow.request.pretty_url + if "Darwin/" in ua or "CFNetwork/" in ua: + return "ios" + if "ios.prod." in url or "/iosui/" in url or "/iosplatform/" in url: + return "ios" + if "okhttp/" in ua or "Cronet/" in ua: + return "android" + if "android" in ua.lower(): + return "android" + if "Chrome/" in ua or "Mozilla/" in ua: + return "chrome" + return "unknown" + + +def _classify(url: str) -> str: + patterns = [ + ("pbo_manifests", "manifest_msl"), + ("pbo_license", "license"), + ("pbo_tokens", "ale_provision"), + ("licensedmanifest", "licensedmanifest"), + ("playapi/ios/manifest", "ios_manifest"), + ("/events", "events"), + ("getProxyEsn", "getProxyEsn"), + ("pathEvaluator", "pathEvaluator"), + ("graphql", "graphql"), + ("/config", "config"), + ("/msl_v1/", "msl"), + ("/msl/", "msl"), + ] + for pattern, name in patterns: + if pattern in url: + return name + return "other" + + +def _output_dir(platform: str) -> Path: + return BASE_DIR / platform / datetime.now(timezone.utc).strftime("%Y%m%d") + + +# ════════════════════════════════════════════════════════════════ +# mitmproxy addon +# ════════════════════════════════════════════════════════════════ + + +class NetflixMSLCapture: + def __init__(self) -> None: + self.seq = 0 + self.decoder = MSLDecoder() + self.captured_esn: str | None = None + + def response(self, flow: http.HTTPFlow) -> None: + url = flow.request.pretty_url + if "netflix.com" not in url and "netflix.net" not in url: + return + + self.seq += 1 + seq = self.seq + now = _ts() + ts = datetime.now(timezone.utc).isoformat() + platform = _detect_platform(flow) + endpoint = _classify(url) + out = _output_dir(platform) + + req_body = flow.request.raw_content + res_body = flow.response.raw_content if flow.response else b"" + + # ── ESN from headers ── + esn_info = extract_esn_from_headers(dict(flow.request.headers)) + if not esn_info and flow.response: + esn_info = extract_esn_from_headers(dict(flow.response.headers)) + if esn_info: + self.captured_esn = esn_info["esn"] + + # ── Raw bodies ── + if req_body: + _write(out / "raw" / f"req_{seq}_{endpoint}_{now}.bin", req_body) + if res_body: + _write(out / "raw" / f"res_{seq}_{endpoint}_{now}.bin", res_body) + + # ── Headers ── + _write( + out / "headers" / f"{seq}_{endpoint}_{now}.json", + safe_json( + { + "seq": seq, + "ts": ts, + "url": url, + "method": flow.request.method, + "platform": platform, + "endpoint": endpoint, + "statusCode": flow.response.status_code if flow.response else None, + "requestHeaders": dict(flow.request.headers), + "responseHeaders": dict(flow.response.headers) + if flow.response + else {}, + } + ), + ) + + # ── Cookies ── + cookie_header = flow.request.headers.get("Cookie", "") + if cookie_header: + lines = [] + for c in cookie_header.split(";"): + c = c.strip() + if "=" in c: + name, val = c.split("=", 1) + lines.append(f".netflix.com\tTRUE\t/\tTRUE\t0\t{name}\t{val}") + _write(out / "cookies" / "cookies.txt", "\n".join(lines) + "\n") + + set_cookie = ( + flow.response.headers.get("Set-Cookie", "") if flow.response else "" + ) + if set_cookie: + _append(out / "cookies" / "set_cookies.log", f"{ts} {set_cookie}\n") + + # ── MSL Request decode ── + if req_body: + req_messages = parse_msl_body(req_body) + if req_messages: + all_decoded = [] + for msg in req_messages: + expanded = self.decoder.deep_decode(msg) + all_decoded.append(expanded) + if msg.get("sender"): + esn = extract_esn_from_sender(msg["sender"]) + if esn: + self.captured_esn = esn["esn"] + + _write( + out / "msl" / f"req_{seq}_{endpoint}_{now}.json", + safe_json( + { + "seq": seq, + "direction": "request", + "endpoint": endpoint, + "ts": ts, + "url": url, + "messages": all_decoded, + } + ), + ) + + # ── MSL Response decode ── + found_manifest = None + found_ale_keys = None + + if res_body: + res_messages = parse_msl_body(res_body) + if res_messages: + all_decoded = [] + for msg in res_messages: + expanded = self.decoder.deep_decode(msg) + all_decoded.append(expanded) + decoded_payload = extract_decoded_payload(expanded) + + if msg.get("sender"): + esn = extract_esn_from_sender(msg["sender"]) + if esn: + self.captured_esn = esn["esn"] + + if decoded_payload and isinstance(decoded_payload, dict): + # Manifest + manifest = extract_manifest(decoded_payload) + if manifest: + found_manifest = manifest + + # ALE keys + ale = extract_ale_keys( + decoded_payload.get("result", decoded_payload) + ) + if ale: + found_ale_keys = ale + self.decoder.set_keys(ale["encryptionKey"], ale["hmacKey"]) + + _write( + out / "msl" / f"res_{seq}_{endpoint}_{now}.json", + safe_json( + { + "seq": seq, + "direction": "response", + "ts": ts, + "url": url, + "statusCode": ( + flow.response.status_code if flow.response else None + ), + "messages": all_decoded, + } + ), + ) + + # ── Manifest save ── + if found_manifest: + movie_id = found_manifest.get("movieId", "unknown") + _write( + out / "manifests" / f"manifest_{movie_id}_{now}.json", + safe_json(found_manifest), + ) + + kid_table = build_kid_table(found_manifest) + if kid_table: + _write( + out / "manifests" / f"kid_table_{movie_id}_{now}.json", + safe_json(kid_table), + ) + + lines = [f"# KID Table — movieId: {movie_id}\n"] + lines.append("| Resolution | Bitrate | KID | Profile |") + lines.append("|------------|---------|-----|---------|") + for row in kid_table: + if row.get("boundary"): + lines.append("|---|---|---|---|") + br = row.get("bitrate", 0) + br_kbps = f"{br // 1000}" if br and br > 10000 else str(br) + lines.append( + f"| {row['res_w']}x{row['res_h']}" + f" | {br_kbps} kbps" + f" | {row['kid_short']}" + f" | {row['content_profile']} |" + ) + _write( + out / "manifests" / f"kid_table_{movie_id}.md", + "\n".join(lines) + "\n", + ) + + video_count = sum( + len(vt.get("streams", [])) + for vt in found_manifest.get("videoTracks", []) + ) + audio_count = sum( + len(at.get("streams", [])) + for at in found_manifest.get("audioTracks", []) + ) + logger.info( + "[MSL] Manifest: movieId=%s video=%d audio=%d", + movie_id, + video_count, + audio_count, + ) + + # ── ALE keys save ── + if found_ale_keys: + _append( + out / "keys" / "ale_keys.jsonl", + json.dumps(found_ale_keys) + "\n", + ) + kid = found_ale_keys.get("kid", str(seq)) + _write( + out / "keys" / f"ale_{kid}_{now}.json", + safe_json(found_ale_keys), + ) + logger.info( + "[MSL] ALE Keys: scheme=%s kid=%s\n" + " HMAC-SHA256: %s\n" + " AES-CBC: %s", + found_ale_keys["scheme"], + found_ale_keys["kid"], + found_ale_keys["hmacKey"], + found_ale_keys["encryptionKey"], + ) + + # ── ESN save ── + if self.captured_esn: + _write(out / "esn.txt", self.captured_esn + "\n") + + # ── JSONL log ── + log_entry: dict[str, Any] = { + "seq": seq, + "ts": ts, + "url": url, + "platform": platform, + "endpoint": endpoint, + "statusCode": flow.response.status_code if flow.response else None, + "esn": self.captured_esn, + } + if found_manifest: + log_entry["manifestDetected"] = True + log_entry["movieId"] = found_manifest.get("movieId") + if found_ale_keys: + log_entry["aleKeysDetected"] = True + log_entry["aleScheme"] = found_ale_keys.get("scheme") + + _append(out / "capture_log.jsonl", json.dumps(log_entry) + "\n") + + +addons = [NetflixMSLCapture()] diff --git a/packages/proxyman/README.md b/packages/proxyman/README.md new file mode 100644 index 0000000..e5b7ae7 --- /dev/null +++ b/packages/proxyman/README.md @@ -0,0 +1,122 @@ +# Netflix MSL Capture — Proxyman Script + Addon + +Netflix の MSL (Media Service Layer) トラフィックをキャプチャ・解析する Proxyman スクリプト。 + +## 機能 + +- **MSL メッセージデコード**: base64 / LZW エンコードされたエンベロープをデコード +- **リクエストボディ解析**: MSL リクエストのパラメータをデコード・保存 (マニフェスト要求の詳細が見える) +- **マニフェスト抽出**: 動画/音声トラック情報 (解像度, ビットレート, コンテンツプロファイル, DRM KID) +- **HTTP マニフェスト API キャプチャ**: `/playapi/cadmium/manifest/1` (非 MSL) をキャプチャ +- **licensedmanifest キャプチャ**: `/msl/playapi/cadmium/licensedmanifest/1` の MSL エンベロープ解析 +- **ALE 鍵抽出**: CLEAR スキームの provisioning レスポンスから HMAC/AES 鍵を検出 +- **ESN 取得**: デバイス識別子 (Electronic Serial Number) をヘッダー / sender から抽出 +- **KID テーブル生成**: 解像度ごとの DRM Key ID 一覧を Markdown テーブルで出力 +- **全トラフィックログ**: JSONL 形式で全 MSL 通信を記録 + +## 前提条件 + +- **Proxyman v3.6.2 以上** (`writeToFile` / `readFromFile` API が必要) +- Proxyman の SSL Proxying が `*.netflix.com` に対して有効であること + +## インストール + +### 1. アドオンの配置 + +```bash +# Proxyman のカスタムアドオンフォルダを開く +# Proxyman > More > Documentations > Open Custom Addons Folder +# または直接: +cp addons/NetflixMSLParser.js \ + ~/Library/Application\ Support/com.proxyman.NSProxy/users/NetflixMSLParser.js +``` + +### 2. スクリプトの設定 + +#### スクリプト A: MSL トラフィック (既存) + +1. Proxyman を開く +2. **Script Menu** > **Script List** (`Option + Cmd + I`) +3. **+** ボタンで新規スクリプト作成 +4. **URL Matching Rule**: `*netflix.com/nq/msl_v1/*` +5. `netflix-msl-capture.js` の内容をスクリプトエディタに貼り付け +6. **Enable on Request** ✓ にチェック +7. **Enable on Response** ✓ にチェック + +#### スクリプト B: HTTP マニフェスト API (StreamFab 等) + +1. **+** ボタンでもう一つ新規スクリプト作成 +2. **URL Matching Rule**: `*netflix.com/*manifest*` +3. `netflix-manifest-http-capture.js` の内容をスクリプトエディタに貼り付け +4. **Enable on Request** ✓ にチェック +5. **Enable on Response** ✓ にチェック + +### 3. 出力ディレクトリ + +デフォルトでは `~/Desktop/netflix-msl-capture/` に自動作成されます。 +スクリプト冒頭の `OUTPUT_DIR` で変更可能: + +```javascript +const OUTPUT_DIR = "~/Desktop/netflix-msl-capture"; +``` + +## 出力ファイル構成 + +``` +~/Desktop/netflix-msl-capture/ +├── capture_log.jsonl # 全キャプチャの JSONL ログ +├── esn.txt # 最後にキャプチャした ESN +├── raw/ # 生のリクエスト/レスポンスボディ +│ ├── request_1_manifest_msl_...bin # MSL リクエスト +│ ├── response_1_2026-...bin # MSL レスポンス +│ ├── http_request_1_manifest_http_...bin # HTTP manifest リクエスト +│ └── http_response_1_manifest_http_...bin # HTTP manifest レスポンス +├── msl/ # デコード済み MSL メッセージ +│ ├── request_1_manifest_msl_...json # MSL リクエスト (デコード済み) +│ ├── response_2_2026-...json +│ └── http_response_1_licensedmanifest_http_...json # licensedmanifest (MSL) +├── manifests/ # マニフェスト +│ ├── manifest_81234567_2026-...json # MSL 経由マニフェスト +│ ├── http_manifest_81234567_manifest_http_...json # HTTP API マニフェスト +│ ├── request_params_1_manifest_msl_...json # MSL マニフェスト要求パラメータ +│ ├── http_request_1_manifest_http_...json # HTTP マニフェスト要求パラメータ +│ ├── kid_table_81234567_2026-...json +│ └── kid_table_81234567.md +├── keys/ # ALE 鍵 +│ ├── ale_keys.jsonl +│ └── ale_KID_2026-...json +├── headers/ # HTTP ヘッダー +└── cookies/ # Cookie +``` + +## Chrome 拡張機能との違い + +| 機能 | Chrome 拡張 | Proxyman | +|------|:----------:|:-------:| +| HTTP トラフィックキャプチャ | ✓ | ✓ | +| MSL エンベロープデコード | ✓ | ✓ | +| マニフェスト抽出 | ✓ | ✓ (CLEAR のみ) | +| ALE 鍵抽出 | ✓ | ✓ (CLEAR のみ) | +| ESN 取得 | ✓ | ✓ | +| KID テーブル | ✓ | ✓ | +| Web Crypto API フック | ✓ | ✗ | +| EME セッション監視 | ✓ | ✗ | +| 暗号化ペイロードの復号 | ✓ | ✗ | +| プロファイルオーバーライド | ✓ | ✗ | +| 浮動パネル UI | ✓ | ✗ (Proxyman UI で確認) | + +## トラブルシューティング + +### `writeToFile` が動作しない +- Proxyman v3.6.2 以上か確認: **Proxyman > About Proxyman** +- ファイルパスに `~` を使用可(Proxyman が展開) + +### MSL ペイロードがデコードできない +- SSL Proxying が有効か確認: **Certificate > Install Certificate on this Mac** +- Netflix アプリが Proxyman の CA 証明書を信頼しているか確認 +- 暗号化スキームが CLEAR でない場合、ペイロード内容は暗号化されたまま(エンベロープの headerdata は読める) + +### アドオンが見つからない +- `@users/` フォルダにファイルがあるか確認 +- ファイル名が正確に `NetflixMSLParser.js` であるか確認 +- Proxyman を再起動 diff --git a/packages/proxyman/addons/NetflixMSLParser.js b/packages/proxyman/addons/NetflixMSLParser.js new file mode 100644 index 0000000..f9e8489 --- /dev/null +++ b/packages/proxyman/addons/NetflixMSLParser.js @@ -0,0 +1,630 @@ +/** + { + "name": "Netflix MSL Parser", + "description": "Netflix MSL (Media Service Layer) message parser — decodes envelopes, extracts manifests, ALE keys, ESN", + "author": "frida-project", + "tags": "netflix,msl,drm,widevine,manifest" + } +**/ + +// ════════════════════════════════════════════════════════════════ +// Base64 helpers +// ════════════════════════════════════════════════════════════════ + +function b64Decode(str) { + try { + // Proxyman の JS 環境では atob が使える + return atob(str); + } catch (e) { + return null; + } +} + +function b64urlDecode(b64url) { + var pad = (4 - (b64url.length % 4)) % 4; + var b64 = b64url.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat(pad); + return b64Decode(b64); +} + +function b64urlToBytes(b64url) { + var decoded = b64urlDecode(b64url); + if (!decoded) return null; + var bytes = []; + for (var i = 0; i < decoded.length; i++) { + bytes.push(decoded.charCodeAt(i)); + } + return bytes; +} + +function bytesToHex(bytes) { + return bytes + .map(function (b) { + return ("0" + b.toString(16)).slice(-2); + }) + .join(""); +} + +// ════════════════════════════════════════════════════════════════ +// LZW Decoder (Netflix MSL variant) +// ════════════════════════════════════════════════════════════════ + +function decodeLZW(input) { + try { + var decoded = b64Decode(input); + if (!decoded) return null; + var bytes = []; + for (var i = 0; i < decoded.length; i++) { + bytes.push(decoded.charCodeAt(i)); + } + if (bytes.length === 0) return null; + + var bitPos = 0; + var totalBits = bytes.length * 8; + + function readBits(n) { + if (bitPos + n > totalBits) return -1; + var val = 0; + for (var i = 0; i < n; i++) { + var byteIdx = (bitPos + i) >> 3; + var bitIdx = 7 - ((bitPos + i) & 7); + if (bytes[byteIdx] & (1 << bitIdx)) val |= 1 << (n - 1 - i); + } + bitPos += n; + return val; + } + + var dict = []; + for (var i = 0; i < 256; i++) dict[i] = [i]; + + var bits = 8; + var output = []; + var code = readBits(bits); + if (code === -1 || !dict[code]) return null; + var prev = dict[code]; + for (var i = 0; i < prev.length; i++) output.push(prev[i]); + + while (true) { + if (dict.length === 1 << bits) bits++; + code = readBits(bits); + if (code === -1) break; + var entry; + if (code < dict.length) { + entry = dict[code]; + } else if (code === dict.length) { + entry = prev.concat([prev[0]]); + } else { + break; + } + for (var i = 0; i < entry.length; i++) output.push(entry[i]); + dict.push(prev.concat([entry[0]])); + prev = entry; + } + + // UTF-8 decode + var result = ""; + for (var i = 0; i < output.length; i++) { + result += String.fromCharCode(output[i]); + } + return result; + } catch (e) { + return null; + } +} + +// ════════════════════════════════════════════════════════════════ +// JSON helpers +// ════════════════════════════════════════════════════════════════ + +function tryParseJSON(text) { + if (!text) return null; + try { + return JSON.parse(text); + } catch (e) { + return null; + } +} + +function safeStringify(obj) { + try { + return JSON.stringify(obj, null, 2); + } catch (e) { + return String(obj); + } +} + +// ════════════════════════════════════════════════════════════════ +// AES-CBC 復号 (CryptoJS) +// ════════════════════════════════════════════════════════════════ + +var CryptoJS = require("@addons/CryptoJS.js"); + +// 現在の ALE 鍵を保持 +var _currentKeys = { + encryptionKey: null, // hex string (AES-CBC) + hmacKey: null, // hex string (HMAC-SHA256) +}; + +function setDecryptionKeys(encKeyHex, hmacKeyHex) { + _currentKeys.encryptionKey = encKeyHex; + _currentKeys.hmacKey = hmacKeyHex; +} + +function getDecryptionKeys() { + return _currentKeys; +} + +/** + * AES-CBC で MSL ペイロードを復号 + * MSL の暗号化フォーマット: base64(IV[16] || ciphertext) + */ +function decryptAesCbc(dataB64, keyHex) { + if (!dataB64 || !keyHex) return null; + try { + var raw = b64Decode(dataB64); + if (!raw || raw.length < 17) return null; // IV(16) + 最低1ブロック + + // IV = 先頭16バイト, ciphertext = 残り + var ivWords = CryptoJS.enc.Latin1.parse(raw.substring(0, 16)); + var ciphertextStr = raw.substring(16); + var ciphertext = CryptoJS.enc.Latin1.parse(ciphertextStr); + var key = CryptoJS.enc.Hex.parse(keyHex); + + var decrypted = CryptoJS.AES.decrypt( + { ciphertext: ciphertext }, + key, + { iv: ivWords, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 } + ); + + var plaintext = decrypted.toString(CryptoJS.enc.Utf8); + if (!plaintext) return null; + return plaintext; + } catch (e) { + return null; + } +} + +// ════════════════════════════════════════════════════════════════ +// MSL Envelope Decoder +// ════════════════════════════════════════════════════════════════ + +function decodeChunkData(dataStr, compressionalgo) { + if (!dataStr) return null; + + // 1. LZW 圧縮の場合 + if (compressionalgo === "LZW") { + var decompressed = decodeLZW(dataStr); + if (decompressed) return tryParseJSON(decompressed) || decompressed; + } + + // 2. 通常の base64 デコード + var inner = b64Decode(dataStr); + if (inner) { + var parsed = tryParseJSON(inner); + if (parsed) return parsed; + // 印字可能テキストならそのまま返す + if (inner.length > 0 && inner.charCodeAt(0) >= 0x20) return inner; + } + + // 3. AES-CBC 復号を試みる (ALE 鍵がある場合) + if (_currentKeys.encryptionKey) { + var decrypted = decryptAesCbc(dataStr, _currentKeys.encryptionKey); + if (decrypted) { + // 復号後が LZW 圧縮されている場合 + if (compressionalgo === "LZW") { + var decompDecrypted = decodeLZW(decrypted); + if (decompDecrypted) return tryParseJSON(decompDecrypted) || decompDecrypted; + } + return tryParseJSON(decrypted) || decrypted; + } + } + + return null; +} + +/** + * MSL メッセージのデコード + * HTTP 本文の JSON を受け取り、base64 エンコードされたフィールドをデコードする + */ +function deepDecodeMSL(obj) { + if (!obj || typeof obj !== "object") return obj; + + var decoded = {}; + var keys = Object.keys(obj); + for (var i = 0; i < keys.length; i++) { + decoded[keys[i]] = obj[keys[i]]; + } + + var compress = decoded.compressionalgo || null; + + // headerdata: base64 → JSON + if (typeof decoded.headerdata === "string") { + var hdrText = b64Decode(decoded.headerdata); + var hdr = tryParseJSON(hdrText); + if (hdr && typeof hdr === "object") { + decoded._headerdata_decoded = hdr; + } + } + + // payload: base64 → JSON chunk → data + if (typeof decoded.payload === "string") { + var chunkText = b64Decode(decoded.payload); + var chunk = tryParseJSON(chunkText); + if (chunk) { + decoded._payload_decoded = chunk; + if (chunk.data) { + var algo = chunk.compressionalgo || compress; + decoded._payload_data = decodeChunkData(chunk.data, algo); + } + } + } + + // data field (payload chunk format) + if (typeof decoded.data === "string" && decoded.messageid !== undefined) { + decoded._data_decoded = decodeChunkData(decoded.data, compress); + } + + // payloads array + if (Array.isArray(decoded.payloads)) { + decoded._payloads_decoded = decoded.payloads.map(function (p) { + if (typeof p === "string") { + var chunkText = b64Decode(p); + var chunk = tryParseJSON(chunkText); + if (chunk && chunk.data) { + var algo = chunk.compressionalgo || compress; + var inner = decodeChunkData(chunk.data, algo); + return { _chunk: chunk, _data: inner }; + } + return chunk || p; + } + return p; + }); + } + + // servicetokens + if (Array.isArray(decoded.servicetokens)) { + decoded._servicetokens_decoded = decoded.servicetokens.map(function (st) { + if (typeof st.tokendata === "string") { + var tdText = b64Decode(st.tokendata); + var td = tryParseJSON(tdText); + if (td) { + var result = {}; + var tdKeys = Object.keys(td); + for (var i = 0; i < tdKeys.length; i++) result[tdKeys[i]] = td[tdKeys[i]]; + if (td.servicedata) { + var sd = b64Decode(td.servicedata); + result._servicedata_decoded = sd ? tryParseJSON(sd) || sd : null; + } + return result; + } + } + return st; + }); + } + + // useridtoken + if (decoded.useridtoken && typeof decoded.useridtoken === "object") { + if (typeof decoded.useridtoken.tokendata === "string") { + var uitText = b64Decode(decoded.useridtoken.tokendata); + decoded._useridtoken_decoded = tryParseJSON(uitText); + } + } + + return decoded; +} + +/** + * デコード済みペイロードの抽出 + */ +function extractDecodedPayload(expanded) { + return ( + expanded._data_decoded || + expanded._payload_data || + expanded._payload_decoded || + null + ); +} + +// ════════════════════════════════════════════════════════════════ +// Manifest Extractor +// ════════════════════════════════════════════════════════════════ + +function formatDrmHeaderId(hex) { + if (!hex || hex.length !== 32) return hex || null; + return ( + hex.slice(0, 8) + "-" + hex.slice(8, 12) + "-" + + hex.slice(12, 16) + "-" + hex.slice(16, 20) + "-" + hex.slice(20) + ); +} + +/** + * デコード済みペイロードからマニフェスト情報を抽出 + */ +function extractManifestData(payload) { + if (!payload || typeof payload !== "object") return null; + + var rawResult = payload.result || payload; + if (!rawResult) return null; + if (!rawResult.video_tracks && !rawResult.audio_tracks) return null; + + var manifest = { + movieId: rawResult.movieId != null ? String(rawResult.movieId) : null, + duration: rawResult.duration || null, + servers: rawResult.servers || [], + videoTracks: [], + audioTracks: [], + textTracks: [], + }; + + if (rawResult.video_tracks) { + manifest.videoTracks = rawResult.video_tracks.map(function (vt) { + return { + trackType: vt.trackType, + track_id: vt.track_id, + maxWidth: vt.maxWidth, + maxHeight: vt.maxHeight, + drmHeader: vt.drmHeader + ? { bytes: vt.drmHeader.bytes, keyId: vt.drmHeader.keyId } + : null, + streams: (vt.streams || []).map(function (s) { + return { + res_w: s.res_w, + res_h: s.res_h, + bitrate: s.bitrate, + size: s.size, + vmaf: s.vmaf, + content_profile: s.content_profile, + downloadable_id: s.downloadable_id, + kid: formatDrmHeaderId(s.drmHeaderId || ""), + urls: s.urls || [], + }; + }), + }; + }); + } + + if (rawResult.audio_tracks) { + manifest.audioTracks = rawResult.audio_tracks.map(function (at) { + return { + language: at.language, + languageDescription: at.languageDescription, + channels: at.channels, + trackType: at.trackType, + track_id: at.track_id, + streams: (at.streams || []).map(function (s) { + return { + bitrate: s.bitrate, + size: s.size, + content_profile: s.content_profile, + downloadable_id: s.downloadable_id, + urls: s.urls || [], + }; + }), + }; + }); + } + + if (rawResult.timedtexttracks) { + manifest.textTracks = rawResult.timedtexttracks + .filter(function (tt) { + return !tt.isNoneTrack; + }) + .map(function (tt) { + return { + language: tt.language, + languageDescription: tt.languageDescription, + trackType: tt.trackType, + downloadableId: tt.downloadableId, + urls: tt.ttDownloadables, + }; + }); + } + + return manifest; +} + +// ════════════════════════════════════════════════════════════════ +// ALE Key Extractor +// ════════════════════════════════════════════════════════════════ + +/** + * MSL provision レスポンスから ALE 鍵を抽出 + * keyx.scheme=CLEAR の場合、keyx.data.key に 32 バイトの鍵素材が含まれる + * bytes[0:16] = HMAC-SHA256 鍵 + * bytes[16:32] = AES-CBC 暗号鍵 + */ +function extractAleKeys(payload) { + if (!payload || typeof payload !== "object") return null; + + // provisionResponse を探す + var provResponse = payload.provisionResponse; + if (!provResponse) return null; + + var tokenObj; + if (typeof provResponse === "string") { + tokenObj = tryParseJSON(provResponse); + } else { + tokenObj = provResponse; + } + if (!tokenObj || !tokenObj.keyx) return null; + + var keyx = tokenObj.keyx; + if (!keyx.data || !keyx.data.key) return null; + + var keyBytes = b64urlToBytes(keyx.data.key); + if (!keyBytes || keyBytes.length < 32) return null; + + var hmacHex = bytesToHex(keyBytes.slice(0, 16)); + var aesHex = bytesToHex(keyBytes.slice(16, 32)); + + // JWE header + var jweToken = tokenObj.token || ""; + var jweAlg = "?"; + var jweEnc = "?"; + if (jweToken) { + try { + var parts = jweToken.split("."); + if (parts.length === 5) { + var hdrDecoded = b64urlDecode(parts[0]); + var hdr = tryParseJSON(hdrDecoded); + if (hdr) { + jweAlg = hdr.alg || "?"; + jweEnc = hdr.enc || "?"; + } + } + } catch (e) { + /* ignore */ + } + } + + return { + encryptionKey: aesHex, + hmacKey: hmacHex, + kid: keyx.kid || "", + jweToken: jweToken, + scheme: keyx.scheme || "", + rawKeyHex: bytesToHex(keyBytes), + jweAlg: jweAlg, + jweEnc: jweEnc, + capturedAt: new Date().toISOString(), + }; +} + +// ════════════════════════════════════════════════════════════════ +// ESN Extractor +// ════════════════════════════════════════════════════════════════ + +function extractEsnFromHeaders(headers) { + if (!headers) return null; + var esn = + headers["x-netflix.esn"] || + headers["X-Netflix.esn"] || + headers["X-Netflix.Esn"] || + headers["X-NETFLIX.ESN"]; + if (!esn) return null; + + var prv = null; + var pxa = null; + var parts = esn.split("|"); + if (parts.length >= 1) prv = parts[0]; + if (parts.length >= 2) pxa = parts[1]; + + return { esn: esn, prv: prv, pxa: pxa }; +} + +function extractEsnFromSender(sender) { + if (!sender || typeof sender !== "string") return null; + return { esn: sender, prv: sender, pxa: null }; +} + +// ════════════════════════════════════════════════════════════════ +// HTTP Body Parser +// MSL エンドポイントの本文は複数の JSON オブジェクトが連結されている場合がある +// ════════════════════════════════════════════════════════════════ + +function parseMSLBody(body) { + if (!body) return []; + + // Proxyman が自動パースしてオブジェクトになっている場合 + if (typeof body === "object" && !Array.isArray(body)) { + return [body]; + } + if (Array.isArray(body)) { + return body; + } + + // 文字列の場合 + if (typeof body !== "string") { + // Uint8Array 等 → 文字列変換を試みる + try { + body = String(body); + } catch (e) { + return []; + } + } + + // 単一 JSON の場合 + var single = tryParseJSON(body); + if (single) return [single]; + + // 複数 JSON の連結(改行区切り) + var messages = []; + var lines = body.split("\n"); + for (var i = 0; i < lines.length; i++) { + var line = lines[i].trim(); + if (!line) continue; + var parsed = tryParseJSON(line); + if (parsed) messages.push(parsed); + } + return messages; +} + +// ════════════════════════════════════════════════════════════════ +// KID Table Builder +// ════════════════════════════════════════════════════════════════ + +function buildKIDTable(manifest) { + if (!manifest || !manifest.videoTracks) return []; + var rows = []; + manifest.videoTracks.forEach(function (vt) { + var sortedStreams = vt.streams.slice().sort(function (a, b) { + return a.bitrate - b.bitrate; + }); + var prevKid = null; + sortedStreams.forEach(function (s) { + var boundary = prevKid !== null && s.kid !== prevKid; + rows.push({ + res_w: s.res_w, + res_h: s.res_h, + bitrate: s.bitrate, + kid: s.kid, + kid_short: s.kid ? s.kid.slice(0, 8) + "..." : "-", + content_profile: s.content_profile, + boundary: boundary, + }); + prevKid = s.kid; + }); + }); + return rows; +} + +// ════════════════════════════════════════════════════════════════ +// Format helpers +// ════════════════════════════════════════════════════════════════ + +function formatSize(bytes) { + if (!bytes || bytes <= 0) return "?"; + if (bytes < 1024) return bytes + " B"; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(0) + " KB"; + if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(0) + " MB"; + return (bytes / (1024 * 1024 * 1024)).toFixed(2) + " GB"; +} + +function timestamp() { + return new Date().toISOString().replace(/[:.]/g, "-"); +} + +// ════════════════════════════════════════════════════════════════ +// Exports +// ════════════════════════════════════════════════════════════════ + +exports.b64Decode = b64Decode; +exports.b64urlDecode = b64urlDecode; +exports.b64urlToBytes = b64urlToBytes; +exports.bytesToHex = bytesToHex; +exports.decodeLZW = decodeLZW; +exports.tryParseJSON = tryParseJSON; +exports.safeStringify = safeStringify; +exports.deepDecodeMSL = deepDecodeMSL; +exports.extractDecodedPayload = extractDecodedPayload; +exports.extractManifestData = extractManifestData; +exports.extractAleKeys = extractAleKeys; +exports.extractEsnFromHeaders = extractEsnFromHeaders; +exports.extractEsnFromSender = extractEsnFromSender; +exports.parseMSLBody = parseMSLBody; +exports.buildKIDTable = buildKIDTable; +exports.formatSize = formatSize; +exports.timestamp = timestamp; +exports.decodeChunkData = decodeChunkData; +exports.setDecryptionKeys = setDecryptionKeys; +exports.getDecryptionKeys = getDecryptionKeys; +exports.decryptAesCbc = decryptAesCbc; diff --git a/packages/proxyman/netflix-ios-raw-capture.js b/packages/proxyman/netflix-ios-raw-capture.js new file mode 100644 index 0000000..f04237e --- /dev/null +++ b/packages/proxyman/netflix-ios-raw-capture.js @@ -0,0 +1,13 @@ +/** + * Netflix iOS Raw Capture — Proxyman Script + * + * URL Matching Rule: *netflix.com* + */ + +function onRequest(context, url, request) { + return request; +} + +function onResponse(context, url, request, response) { + return response; +} diff --git a/packages/proxyman/netflix-manifest-http-capture.js b/packages/proxyman/netflix-manifest-http-capture.js new file mode 100644 index 0000000..c23ccf7 --- /dev/null +++ b/packages/proxyman/netflix-manifest-http-capture.js @@ -0,0 +1,378 @@ +/** + * Netflix HTTP Manifest Capture — Proxyman Script + * + * URL Matching Rule: *netflix.com/*manifest* + * + * StreamFab 等が使う非 MSL マニフェスト API をキャプチャする。 + * MSL 経由のマニフェスト (/nq/msl_v1/) は netflix-msl-capture.js が担当するため、 + * このスクリプトでは /nq/msl_v1/ を含む URL をスキップする。 + * + * マッチする URL: + * - /playapi/cadmium/manifest/1 + * - /msl/playapi/cadmium/licensedmanifest/1 + * + * 保存先: ~/Desktop/netflix-msl-capture/ (MSL スクリプトと共通) + * + * 【Proxyman 設定】 + * 1. Script Menu > Script List (Opt+Cmd+I) + * 2. 新規スクリプト作成 + * 3. URL Matching Rule: *netflix.com/*manifest* + * 4. このスクリプトの内容を貼り付け + * 5. Enable on Request ✓ (オン) / Enable on Response ✓ (オン) + */ + +// ── アドオン読み込み ── +const { + deepDecodeMSL, + extractDecodedPayload, + extractManifestData, + extractEsnFromHeaders, + parseMSLBody, + buildKIDTable, + safeStringify, + timestamp, + formatSize, + tryParseJSON, + b64Decode, + setDecryptionKeys, + getDecryptionKeys, + decryptAesCbc, +} = require("@users/NetflixMSLParser.js"); + +// ── 設定 ── +const OUTPUT_DIR = "~/Desktop/netflix-msl-capture"; +const LOG_FILE = OUTPUT_DIR + "/capture_log.jsonl"; + +// ── sharedState 初期化 ── +if (sharedState._httpManifestSeq === undefined) sharedState._httpManifestSeq = 0; + +// ════════════════════════════════════════════════════════════════ +// ヘルパー: URL パラメータをパース +// ════════════════════════════════════════════════════════════════ + +function parseQueryString(url) { + var params = {}; + var idx = url.indexOf("?"); + if (idx === -1) return params; + var qs = url.substring(idx + 1); + var pairs = qs.split("&"); + for (var i = 0; i < pairs.length; i++) { + var kv = pairs[i].split("="); + var key = decodeURIComponent(kv[0]); + var val = kv.length > 1 ? decodeURIComponent(kv.slice(1).join("=")) : ""; + params[key] = val; + } + return params; +} + +// ════════════════════════════════════════════════════════════════ +// ヘルパー: エンドポイント種別判定 +// ════════════════════════════════════════════════════════════════ + +function classifyUrl(url) { + if (url.indexOf("licensedmanifest") !== -1) return "licensedmanifest_http"; + if (url.indexOf("/playapi/cadmium/manifest") !== -1) return "manifest_http"; + return "manifest_unknown"; +} + +// ════════════════════════════════════════════════════════════════ +// onRequest — リクエストボディ・パラメータのキャプチャ +// ════════════════════════════════════════════════════════════════ + +function onRequest(context, url, request) { + // /nq/msl_v1/ は netflix-msl-capture.js が担当 → スキップ + if (url.indexOf("/nq/msl_v1/") !== -1) return request; + + sharedState._httpManifestSeq++; + var seq = sharedState._httpManifestSeq; + var ts = new Date().toISOString(); + var endpoint = classifyUrl(url); + var queryParams = parseQueryString(url); + + // ESN 抽出 + var esnInfo = extractEsnFromHeaders(request.headers); + if (esnInfo) { + sharedState._capturedESN = esnInfo.esn; + } + + // リクエストボディのパース + var rawBody = request.body || ""; + var bodyParsed = null; + var bodyParams = {}; + + if (rawBody) { + // 生ボディ保存 + writeToFile( + rawBody, + OUTPUT_DIR + "/raw/http_request_" + seq + "_" + endpoint + "_" + timestamp() + ".bin" + ); + + if (typeof rawBody === "object") { + bodyParsed = rawBody; + } else if (typeof rawBody === "string") { + // JSON ボディ + bodyParsed = tryParseJSON(rawBody); + if (!bodyParsed) { + // URL-encoded form + var pairs = rawBody.split("&"); + for (var i = 0; i < pairs.length; i++) { + var kv = pairs[i].split("="); + try { + bodyParams[decodeURIComponent(kv[0])] = kv.length > 1 ? decodeURIComponent(kv.slice(1).join("=")) : ""; + } catch (e) { + bodyParams[kv[0]] = kv.slice(1).join("="); + } + } + } + } + + // MSL envelope の可能性もチェック (licensedmanifest は MSL ラップされている場合がある) + if (endpoint === "licensedmanifest_http") { + var mslMessages = parseMSLBody(rawBody); + if (mslMessages.length > 0) { + var allDecoded = []; + mslMessages.forEach(function (msg) { + allDecoded.push(deepDecodeMSL(msg)); + }); + writeToFile( + safeStringify({ + seq: seq, + direction: "request", + endpoint: endpoint, + ts: ts, + url: url, + messages: allDecoded, + }), + OUTPUT_DIR + "/msl/http_request_" + seq + "_" + endpoint + "_" + timestamp() + ".json" + ); + } + } + } + + // リクエスト情報を構造化して保存 + var requestCapture = { + seq: seq, + direction: "request", + endpoint: endpoint, + ts: ts, + url: url, + method: request.method || "POST", + queryParams: queryParams, + headers: request.headers, + esn: (esnInfo && esnInfo.esn) || sharedState._capturedESN || null, + body: bodyParsed || (Object.keys(bodyParams).length > 0 ? bodyParams : null), + // StreamFab 判定用: User-Agent, クライアントタイプ + userAgent: (request.headers || {})["User-Agent"] || (request.headers || {})["user-agent"] || null, + clienttype: queryParams.clienttype || null, + browsername: queryParams.browsername || null, + browserversion: queryParams.browserversion || null, + osname: queryParams.osname || null, + osversion: queryParams.osversion || null, + }; + + writeToFile( + safeStringify(requestCapture), + OUTPUT_DIR + "/manifests/http_request_" + seq + "_" + endpoint + "_" + timestamp() + ".json" + ); + + console.log( + "[HTTP-Manifest] REQUEST #" + seq + " " + endpoint + + " client=" + (requestCapture.clienttype || "?") + + " browser=" + (requestCapture.browsername || "?") + "/" + (requestCapture.browserversion || "?") + + " os=" + (requestCapture.osname || "?") + + " esn=" + (requestCapture.esn || "?") + ); + + // ログ + writeToFile( + JSON.stringify({ + seq: seq, + type: "http_manifest.request", + endpoint: endpoint, + ts: ts, + url: url, + esn: requestCapture.esn, + clienttype: requestCapture.clienttype, + browsername: requestCapture.browsername, + }) + "\n", + LOG_FILE, + { appendFile: true } + ); + + request.comment = "[HTTP-MF-REQ] #" + seq + " " + endpoint; + return request; +} + +// ════════════════════════════════════════════════════════════════ +// onResponse — マニフェストレスポンスの解析 +// ════════════════════════════════════════════════════════════════ + +function onResponse(context, url, request, response) { + // /nq/msl_v1/ は netflix-msl-capture.js が担当 → スキップ + if (url.indexOf("/nq/msl_v1/") !== -1) return response; + + var seq = sharedState._httpManifestSeq || 0; + var ts = new Date().toISOString(); + var endpoint = classifyUrl(url); + + var rawBody = response.body || response.rawBody || ""; + if (!rawBody) return response; + + // 生レスポンスボディ保存 + writeToFile( + rawBody, + OUTPUT_DIR + "/raw/http_response_" + seq + "_" + endpoint + "_" + timestamp() + ".bin" + ); + + var manifest = null; + var responseData = null; + + if (endpoint === "licensedmanifest_http") { + // licensedmanifest: MSL エンベロープの可能性が高い + var mslMessages = parseMSLBody(rawBody); + if (mslMessages.length > 0) { + var allDecoded = []; + mslMessages.forEach(function (msg) { + var expanded = deepDecodeMSL(msg); + allDecoded.push(expanded); + var decodedPayload = extractDecodedPayload(expanded); + if (decodedPayload && typeof decodedPayload === "object") { + manifest = extractManifestData(decodedPayload); + responseData = decodedPayload; + } + }); + + writeToFile( + safeStringify({ + seq: seq, + direction: "response", + endpoint: endpoint, + ts: ts, + url: url, + statusCode: response.statusCode, + messages: allDecoded, + decryptionAvailable: !!getDecryptionKeys().encryptionKey, + }), + OUTPUT_DIR + "/msl/http_response_" + seq + "_" + endpoint + "_" + timestamp() + ".json" + ); + + if (!manifest && !responseData) { + console.log( + "[HTTP-Manifest] licensedmanifest #" + seq + + " — payload encrypted (AES-CBC). ALE keys " + + (getDecryptionKeys().encryptionKey ? "available but decryption failed" : "NOT available") + + ". Raw body saved." + ); + } + } else { + // JSON レスポンスの可能性 + responseData = typeof rawBody === "string" ? tryParseJSON(rawBody) : rawBody; + } + } else { + // manifest API: 通常の JSON レスポンス + if (typeof rawBody === "object") { + responseData = rawBody; + } else if (typeof rawBody === "string") { + responseData = tryParseJSON(rawBody); + } + + if (responseData) { + // manifest API のレスポンスは result にマニフェストが入っている場合がある + manifest = extractManifestData(responseData); + } + } + + // マニフェスト検出時の保存 + if (manifest) { + var movieId = manifest.movieId || "unknown"; + + // マニフェスト本体 + writeToFile( + safeStringify(manifest), + OUTPUT_DIR + "/manifests/http_manifest_" + movieId + "_" + endpoint + "_" + timestamp() + ".json" + ); + + // KID テーブル + var kidTable = buildKIDTable(manifest); + if (kidTable.length > 0) { + writeToFile( + safeStringify(kidTable), + OUTPUT_DIR + "/manifests/http_kid_table_" + movieId + "_" + endpoint + "_" + timestamp() + ".json" + ); + + // KID が全て null かチェック + var hasAnyKid = kidTable.some(function (row) { return !!row.kid; }); + + var readable = "# KID Table — movieId: " + movieId + " (source: " + endpoint + ")\n\n"; + if (!hasAnyKid) { + readable += "> **Note:** manifest API (`/playapi/cadmium/manifest/1`) には DRM Key ID が含まれない。\n"; + readable += "> KID は `licensedmanifest` からのみ取得可能。\n\n"; + } + readable += "| Resolution | Bitrate | KID | Profile |\n"; + readable += "|------------|---------|-----|----------|\n"; + kidTable.forEach(function (row) { + if (row.boundary) readable += "|---|---|---|---|\n"; + readable += + "| " + row.res_w + "x" + row.res_h + + " | " + (row.bitrate > 10000 ? (row.bitrate / 1000).toFixed(0) : row.bitrate) + " kbps" + + " | " + row.kid_short + + " | " + row.content_profile + + " |\n"; + }); + writeToFile(readable, OUTPUT_DIR + "/manifests/http_kid_table_" + movieId + "_" + endpoint + ".md"); + } + + var videoCount = 0; + var audioCount = 0; + manifest.videoTracks.forEach(function (vt) { videoCount += vt.streams.length; }); + manifest.audioTracks.forEach(function (at) { audioCount += at.streams.length; }); + + console.log( + "[HTTP-Manifest] MANIFEST detected #" + seq + + ": movieId=" + movieId + + " video=" + videoCount + + " audio=" + audioCount + + " source=" + endpoint + ); + } + + // レスポンス全体も保存(マニフェスト以外のフィールドも含む) + if (responseData && !manifest) { + writeToFile( + safeStringify({ + seq: seq, + direction: "response", + endpoint: endpoint, + ts: ts, + url: url, + statusCode: response.statusCode, + data: responseData, + }), + OUTPUT_DIR + "/manifests/http_response_" + seq + "_" + endpoint + "_" + timestamp() + ".json" + ); + } + + // ログ + writeToFile( + JSON.stringify({ + seq: seq, + type: "http_manifest.response", + endpoint: endpoint, + ts: ts, + url: url, + statusCode: response.statusCode, + manifestDetected: !!manifest, + movieId: manifest ? manifest.movieId : null, + }) + "\n", + LOG_FILE, + { appendFile: true } + ); + + // レスポンスにコメント付与 + var commentParts = ["[HTTP-MF] #" + seq + " " + endpoint]; + if (manifest) commentParts.push("MANIFEST(id=" + manifest.movieId + ")"); + response.comment = commentParts.join(" "); + response.color = manifest ? "#2196F3" : "#FF9800"; + + return response; +} diff --git a/packages/proxyman/netflix-msl-capture.js b/packages/proxyman/netflix-msl-capture.js new file mode 100644 index 0000000..d1443aa --- /dev/null +++ b/packages/proxyman/netflix-msl-capture.js @@ -0,0 +1,405 @@ +/** + * Netflix MSL Capture — Proxyman Script + * + * URL Matching Rule: *netflix.com/nq/msl_v1/* + * + * MSL エンドポイントへの全リクエスト/レスポンスをキャプチャし、 + * MSL メッセージのデコード・復号、マニフェスト抽出、ALE 鍵抽出、ESN 取得を行う。 + * + * 保存先: ~/Desktop/netflix-msl-capture/ + * + * 【Proxyman 設定】 + * 1. Script Menu > Script List (Opt+Cmd+I) + * 2. 新規スクリプト作成 + * 3. URL Matching Rule: *netflix.com/nq/msl_v1/* + * 4. このスクリプトの内容を貼り付け + * 5. Enable on Request ✓ (オン) / Enable on Response ✓ (オン) + */ + +// ── アドオン読み込み ── +const { + deepDecodeMSL, + extractDecodedPayload, + extractManifestData, + extractAleKeys, + extractEsnFromHeaders, + extractEsnFromSender, + parseMSLBody, + buildKIDTable, + safeStringify, + timestamp, + formatSize, + setDecryptionKeys, + getDecryptionKeys, +} = require("@users/NetflixMSLParser.js"); + +// ── 設定 ── +const OUTPUT_DIR = "~/Desktop/netflix-msl-capture"; +const SAVE_RAW_BODIES = true; +const SAVE_DECODED_MSL = true; +const SAVE_MANIFEST = true; +const SAVE_ALE_KEYS = true; +const SAVE_HEADERS = true; +const SAVE_COOKIES = true; +const SAVE_REQUEST_BODIES = true; +const LOG_FILE = OUTPUT_DIR + "/capture_log.jsonl"; + +// ── sharedState 初期化 ── +if (sharedState._mslSeq === undefined) sharedState._mslSeq = 0; +if (sharedState._capturedManifests === undefined) sharedState._capturedManifests = 0; +if (sharedState._capturedAleKeys === undefined) sharedState._capturedAleKeys = 0; +if (sharedState._capturedESN === undefined) sharedState._capturedESN = ""; + +// ── 前回キャプチャ済みの ALE 鍵があれば復号鍵としてセット ── +if (sharedState._aleEncKey && sharedState._aleHmacKey) { + setDecryptionKeys(sharedState._aleEncKey, sharedState._aleHmacKey); +} + +// ════════════════════════════════════════════════════════════════ +// onRequest — リクエストボディの解析・保存 +// ════════════════════════════════════════════════════════════════ + +function onRequest(context, url, request) { + if (!SAVE_REQUEST_BODIES) return request; + + sharedState._mslReqSeq = (sharedState._mslReqSeq || 0) + 1; + var reqSeq = sharedState._mslReqSeq; + var ts = new Date().toISOString(); + + var rawBody = request.body || ""; + if (!rawBody) return request; + + // URL からエンドポイント種別を判別 + var endpoint = "unknown"; + if (url.indexOf("pbo_manifests") !== -1) endpoint = "manifest_msl"; + else if (url.indexOf("pbo_licenses") !== -1) endpoint = "license"; + else if (url.indexOf("pbo_tokens") !== -1) endpoint = "ale_provision"; + else if (url.indexOf("licensedmanifest") !== -1) endpoint = "licensedmanifest"; + else if (url.indexOf("/events") !== -1) endpoint = "events"; + else if (url.indexOf("getProxyEsn") !== -1) endpoint = "getProxyEsn"; + else if (url.indexOf("/config") !== -1) endpoint = "config"; + else if (url.indexOf("pathEvaluator") !== -1) endpoint = "pathEvaluator"; + else if (url.indexOf("graphql") !== -1) endpoint = "graphql"; + + // 生のリクエストボディを保存 + var rawFile = OUTPUT_DIR + "/raw/request_" + reqSeq + "_" + endpoint + "_" + timestamp() + ".bin"; + writeToFile(rawBody, rawFile); + + // MSL メッセージとしてパース・デコード + var mslMessages = parseMSLBody(rawBody); + if (mslMessages.length > 0) { + var allDecoded = []; + mslMessages.forEach(function (msg) { + var expanded = deepDecodeMSL(msg); + allDecoded.push(expanded); + + // sender から ESN + if (msg.sender) { + var esn = extractEsnFromSender(msg.sender); + if (esn) sharedState._capturedESN = esn.esn; + } + }); + + // デコード済みリクエストを保存 + var decodedFile = OUTPUT_DIR + "/msl/request_" + reqSeq + "_" + endpoint + "_" + timestamp() + ".json"; + writeToFile( + safeStringify({ + seq: reqSeq, + direction: "request", + endpoint: endpoint, + ts: ts, + url: url, + requestHeaders: request.headers, + messages: allDecoded, + }), + decodedFile + ); + + // マニフェスト関連リクエストの場合、パラメータを詳細にログ + if (endpoint === "manifest_msl" || endpoint === "licensedmanifest") { + var manifestParams = null; + allDecoded.forEach(function (expanded) { + var payload = extractDecodedPayload(expanded); + if (payload && typeof payload === "object") { + manifestParams = { + endpoint: endpoint, + url: (payload.url || payload.body && payload.body.url || null), + params: payload.params || payload.body && payload.body.params || null, + esn: sharedState._capturedESN || null, + drmType: payload.drmType || null, + profiles: payload.profiles || null, + languages: payload.languages || null, + showAllSubDubTracks: payload.showAllSubDubTracks, + }; + } + }); + + if (manifestParams) { + var paramsFile = OUTPUT_DIR + "/manifests/request_params_" + reqSeq + "_" + endpoint + "_" + timestamp() + ".json"; + writeToFile(safeStringify(manifestParams), paramsFile); + console.log( + "[MSL-Capture] Manifest REQUEST detected: endpoint=" + endpoint + + " esn=" + (manifestParams.esn || "?") + + " url=" + (manifestParams.url || "?") + ); + } + } + } + + // リクエストログ + var logEntry = { + seq: reqSeq, + type: "http.request", + endpoint: endpoint, + ts: ts, + url: url, + esn: sharedState._capturedESN || null, + mslMessageCount: mslMessages.length, + }; + writeToFile(JSON.stringify(logEntry) + "\n", LOG_FILE, { appendFile: true }); + + // リクエストにコメント付与 + var commentParts = ["[MSL-REQ] #" + reqSeq + " " + endpoint]; + request.comment = commentParts.join(" "); + + return request; +} + +// ════════════════════════════════════════════════════════════════ +// onResponse — キャプチャ+解析+復号 +// ════════════════════════════════════════════════════════════════ + +function onResponse(context, url, request, response) { + sharedState._mslSeq++; + var seq = sharedState._mslSeq; + var ts = new Date().toISOString(); + + // ESN 抽出(リクエスト+レスポンスヘッダー) + var esnInfo = + extractEsnFromHeaders(request.headers) || + extractEsnFromHeaders(response.headers); + if (esnInfo) { + sharedState._capturedESN = esnInfo.esn; + } + + // リクエストの Cookie を保存 + var cookies = (request.headers || {})["Cookie"] || (request.headers || {})["cookie"] || ""; + if (cookies && SAVE_COOKIES) { + var netscapeLines = cookies.split(";").map(function (c) { + var parts = c.trim().split("="); + var name = parts.shift(); + var value = parts.join("="); + return ".netflix.com\tTRUE\t/\tTRUE\t0\t" + name + "\t" + value; + }); + writeToFile(netscapeLines.join("\n") + "\n", OUTPUT_DIR + "/cookies/cookies.txt"); + } + + // リクエスト+レスポンスヘッダーを保存 + if (SAVE_HEADERS) { + var headerFile = + OUTPUT_DIR + "/headers/response_" + seq + "_" + timestamp() + ".json"; + writeToFile( + safeStringify({ + seq: seq, + ts: ts, + url: url, + statusCode: response.statusCode, + requestHeaders: request.headers, + responseHeaders: response.headers, + }), + headerFile + ); + } + + // Set-Cookie を保存 + var setCookie = + response.headers["Set-Cookie"] || + response.headers["set-cookie"] || + ""; + if (setCookie && SAVE_COOKIES) { + writeToFile( + ts + " " + setCookie + "\n", + OUTPUT_DIR + "/cookies/set_cookies.log", + { appendFile: true } + ); + } + + var logEntry = { + seq: seq, + type: "http.response", + ts: ts, + url: url, + statusCode: response.statusCode, + esn: sharedState._capturedESN || null, + }; + + // ── レスポンス本文の解析 ── + var rawBody = response.body || response.rawBody || ""; + var foundManifest = null; + var foundAleKeys = null; + + if (rawBody) { + // 生の本文を保存 + if (SAVE_RAW_BODIES) { + var rawFile = + OUTPUT_DIR + "/raw/response_" + seq + "_" + timestamp() + ".bin"; + writeToFile(rawBody, rawFile); + } + + // MSL メッセージとしてパース + var mslMessages = parseMSLBody(rawBody); + if (mslMessages.length > 0) { + logEntry.mslMessageCount = mslMessages.length; + + var allDecoded = []; + + mslMessages.forEach(function (msg, idx) { + var expanded = deepDecodeMSL(msg); + allDecoded.push(expanded); + var decodedPayload = extractDecodedPayload(expanded); + + // sender から ESN + if (msg.sender) { + var esn = extractEsnFromSender(msg.sender); + if (esn) sharedState._capturedESN = esn.esn; + } + + // ── マニフェスト検出 ── + if (decodedPayload && typeof decodedPayload === "object") { + var manifest = extractManifestData(decodedPayload); + if (manifest) { + foundManifest = manifest; + sharedState._capturedManifests++; + logEntry.manifestDetected = true; + logEntry.movieId = manifest.movieId; + + var videoCount = 0; + var audioCount = 0; + manifest.videoTracks.forEach(function (vt) { + videoCount += vt.streams.length; + }); + manifest.audioTracks.forEach(function (at) { + audioCount += at.streams.length; + }); + logEntry.videoStreams = videoCount; + logEntry.audioStreams = audioCount; + + console.log( + "[MSL-Capture] Manifest detected: movieId=" + + manifest.movieId + + " video=" + + videoCount + + " audio=" + + audioCount + ); + } + + // ── ALE 鍵検出 → 復号鍵としてセット ── + var aleResult = extractAleKeys( + decodedPayload.result || decodedPayload + ); + if (aleResult) { + foundAleKeys = aleResult; + sharedState._capturedAleKeys++; + logEntry.aleKeysDetected = true; + logEntry.aleScheme = aleResult.scheme; + + // 復号鍵をセット (以降の MSL メッセージで AES-CBC 復号が有効に) + setDecryptionKeys(aleResult.encryptionKey, aleResult.hmacKey); + sharedState._aleEncKey = aleResult.encryptionKey; + sharedState._aleHmacKey = aleResult.hmacKey; + + console.log( + "[MSL-Capture] ALE Keys detected → decryption enabled:" + + "\n HMAC-SHA256: " + aleResult.hmacKey + + "\n AES-CBC: " + aleResult.encryptionKey + + "\n KID: " + aleResult.kid + + "\n Scheme: " + aleResult.scheme + ); + } + } + }); + + // デコード済み MSL の保存 + if (SAVE_DECODED_MSL) { + var decodedFile = + OUTPUT_DIR + "/msl/response_" + seq + "_" + timestamp() + ".json"; + writeToFile( + safeStringify({ + seq: seq, + direction: "response", + ts: ts, + url: url, + statusCode: response.statusCode, + messages: allDecoded, + }), + decodedFile + ); + } + } + } + + // ── マニフェストの個別保存 ── + if (foundManifest && SAVE_MANIFEST) { + var movieId = foundManifest.movieId || "unknown"; + var manifestFile = + OUTPUT_DIR + "/manifests/manifest_" + movieId + "_" + timestamp() + ".json"; + writeToFile(safeStringify(foundManifest), manifestFile); + + // KID テーブルも保存 + var kidTable = buildKIDTable(foundManifest); + if (kidTable.length > 0) { + var kidFile = + OUTPUT_DIR + "/manifests/kid_table_" + movieId + "_" + timestamp() + ".json"; + writeToFile(safeStringify(kidTable), kidFile); + + var readable = "# KID Table — movieId: " + movieId + "\n\n"; + readable += "| Resolution | Bitrate | KID | Profile |\n"; + readable += "|------------|---------|-----|----------|\n"; + kidTable.forEach(function (row) { + if (row.boundary) readable += "|---|---|---|---|\n"; + // bitrate: bps (>10000) なら kbps に変換、既に kbps ならそのまま + var bitrateKbps = row.bitrate > 10000 ? (row.bitrate / 1000).toFixed(0) : row.bitrate; + readable += + "| " + row.res_w + "x" + row.res_h + + " | " + bitrateKbps + " kbps" + + " | " + row.kid_short + + " | " + row.content_profile + + " |\n"; + }); + writeToFile(readable, OUTPUT_DIR + "/manifests/kid_table_" + movieId + ".md"); + } + } + + // ── ALE 鍵の個別保存 ── + if (foundAleKeys && SAVE_ALE_KEYS) { + writeToFile( + JSON.stringify(foundAleKeys) + "\n", + OUTPUT_DIR + "/keys/ale_keys.jsonl", + { appendFile: true } + ); + writeToFile( + safeStringify(foundAleKeys), + OUTPUT_DIR + "/keys/ale_" + (foundAleKeys.kid || seq) + "_" + timestamp() + ".json" + ); + } + + // ── ESN の保存 ── + if (sharedState._capturedESN) { + logEntry.esn = sharedState._capturedESN; + writeToFile(sharedState._capturedESN + "\n", OUTPUT_DIR + "/esn.txt"); + } + + // JSONL ログ書き込み + writeToFile(JSON.stringify(logEntry) + "\n", LOG_FILE, { appendFile: true }); + + // レスポンスにコメント付与 + var commentParts = ["[MSL] #" + seq]; + if (foundManifest) + commentParts.push("MANIFEST(id=" + foundManifest.movieId + ")"); + if (foundAleKeys) commentParts.push("ALE-KEYS"); + response.comment = commentParts.join(" "); + response.color = foundManifest || foundAleKeys ? "#4CAF50" : "#9E9E9E"; + + return response; +}