diff --git a/packages/frida/hook_appboot_dh.js b/packages/frida/hook_appboot_dh.js index 8455c03..eee485f 100644 --- a/packages/frida/hook_appboot_dh.js +++ b/packages/frida/hook_appboot_dh.js @@ -191,7 +191,8 @@ if (fn.DH_generate_key) { var pBuf = Memory.alloc(pBytes); BN_bn2bin(p, pBuf); g_dhP = hex(pBuf, pBytes); - console.log("[DH] p (" + pBytes + "B) = " + g_dhP.substring(0, 16) + "..."); + // Log the full DH prime p (128B = 1024-bit) + console.log("[DH] p (" + pBytes + "B) = " + g_dhP); } if (!g.isNull() && BN_num_bits && BN_bn2bin) { @@ -258,17 +259,23 @@ if (fn.BN_mod_exp) { console.log(" exp: " + expBits + " bits"); console.log(" mod: " + mBits + " bits"); - // Dump base (first 32 bytes) + // Dump base and exponent (full bytes) if (BN_bn2bin) { var aBytes = (aBits + 7) >> 3; var aBuf = Memory.alloc(aBytes); BN_bn2bin(this.a, aBuf); - console.log(" base hex: " + hex(aBuf, Math.min(aBytes, 32)) + (aBytes > 32 ? "..." : "")); + console.log(" base hex: " + hex(aBuf, aBytes)); var expBytes = (expBits + 7) >> 3; var expBuf = Memory.alloc(expBytes); BN_bn2bin(this.p_exp, expBuf); - console.log(" exp hex: " + hex(expBuf, Math.min(expBytes, 32)) + (expBytes > 32 ? "..." : "")); + console.log(" exp hex: " + hex(expBuf, expBytes)); + + // Also dump the full modulus (DH prime p, 128B) + var mBytes = (mBits + 7) >> 3; + var mBuf = Memory.alloc(mBytes); + BN_bn2bin(this.m, mBuf); + console.log(" mod hex: " + hex(mBuf, mBytes)); } } } @@ -280,7 +287,7 @@ if (fn.BN_mod_exp) { var rBuf = Memory.alloc(rBytes); BN_bn2bin(this.r, rBuf); var resultHex = hex(rBuf, rBytes); - console.log("[BN_mod_exp] result (" + rBytes + "B) = " + resultHex.substring(0, 64) + (rBytes > 32 ? "..." : "")); + console.log("[BN_mod_exp] result (" + rBytes + "B) = " + resultHex); // Store as potential DH shared secret g_dhSharedSecret = resultHex; @@ -432,7 +439,7 @@ function computeViaBnModExp() { BN_bn2bin(rBN, rBuf); var result = new Uint8Array(rBuf.readByteArray(rLen)); - console.log("[+] BN_mod_exp result (" + rLen + "B) = " + bytesToHex(result).substring(0, 64) + "..."); + console.log("[+] BN_mod_exp result (" + rLen + "B) = " + bytesToHex(result)); BN_free(privBN); BN_free(pubBN); BN_free(pBN); BN_free(rBN); if (BN_CTX_free_fn && !ctx.isNull()) BN_CTX_free_fn(ctx); diff --git a/packages/frida/hook_entityauth_capture.js b/packages/frida/hook_entityauth_capture.js new file mode 100644 index 0000000..e3a21ba --- /dev/null +++ b/packages/frida/hook_entityauth_capture.js @@ -0,0 +1,618 @@ +/** + * hook_entityauth_capture.js — entity_auth_data 未知値の実行時キャプチャ + * + * 目的: MSL entity_auth_data に含まれる以下の3値を実行時にキャプチャする + * 1. devicetoken (216B protobuf) — Nbp.framework 生成 + * 2. apphmac (32B HMAC-SHA256) — NFWebCrypto.framework HMAC + * 3. device_key_data (~6576B) — ファイル / NSUserDefaults / SQLite から読み込み + * + * 使い方: + * frida -H host.docker.internal:27042 -n Argo -l hook_entityauth_capture.js + * + * 注意: + * - Netflix は standalone spawn で JB 検知して落ちるため、必ず attach で使うこと + * - appboot 発生後に entity_auth_data が組み立てられるタイミングでキャプチャされる + */ + +"use strict"; + +// ===== Utility ===== + +function ts() { + return new Date().toISOString(); +} + +function hex(ptr, len) { + if (!ptr || ptr.isNull() || len <= 0) return "(null)"; + try { + return Array.from(new Uint8Array(ptr.readByteArray(len))) + .map(function (b) { return ("0" + b.toString(16)).slice(-2); }) + .join(""); + } catch (e) { + return "(unreadable:" + e.message + ")"; + } +} + +function hexShort(ptr, len) { + if (len <= 64) return hex(ptr, len); + return hex(ptr, 32) + "..." + hex(ptr.add(len - 16), 16) + " (" + len + "B)"; +} + +function hexArr(arr) { + return Array.from(arr) + .map(function (b) { return ("0" + b.toString(16)).slice(-2); }) + .join(""); +} + +function banner(title) { + var line = "=".repeat(60); + console.log("\n" + line); + console.log(" " + title); + console.log(" " + ts()); + console.log(line); +} + +// ===== Module wait helper ===== + +function waitForModule(name, cb) { + var m = Process.findModuleByName(name); + if (m) { cb(m); return; } + var iv = setInterval(function () { + var m2 = Process.findModuleByName(name); + if (m2) { clearInterval(iv); cb(m2); } + }, 200); +} + +// ===== Captured state (accessible via RPC) ===== + +var captured = { + devicetoken: null, // hex string (216B protobuf) + apphmac: null, // hex string (32B HMAC-SHA256) + deviceKeyData: null, // hex string (~6576B) + hmacCandidates: [], // all 32B HMAC outputs seen near entity_auth assembly + fileReads: [], // large file reads (>= 1KB) +}; + +// ===== 1. devicetoken capture — Nbp.framework ===== +// +// Strategy: +// a. Enumerate all exports/symbols in Nbp that match token/credential/Device +// b. Hook promising candidates and log 216B-sized return buffers +// c. Hook ObjC methods on NflxPinnedCertEvaluator / IosMslClient that return NSData + +waitForModule("Nbp", function (nbp) { + banner("Nbp.framework loaded — scanning for devicetoken symbols"); + console.log("[*] Nbp base=" + nbp.base + " size=" + nbp.size); + + // ---- Enumerate exports matching token/credential/device patterns ---- + var tokenPatterns = ["token", "Token", "credential", "Credential", "DeviceToken", + "device", "Device", "attestation", "Attestation", "regist", "Register"]; + + var candidateExports = []; + var candidateSymbols = []; + + try { + nbp.enumerateExports().forEach(function (exp) { + for (var i = 0; i < tokenPatterns.length; i++) { + if (exp.name.indexOf(tokenPatterns[i]) !== -1) { + candidateExports.push(exp); + console.log("[NBP-EXP] " + exp.type + " " + exp.name + " @ " + exp.address); + break; + } + } + }); + } catch (e) { + console.log("[-] enumerateExports error: " + e.message); + } + + try { + nbp.enumerateSymbols().forEach(function (sym) { + if (sym.type !== "function") return; + for (var i = 0; i < tokenPatterns.length; i++) { + if (sym.name.indexOf(tokenPatterns[i]) !== -1) { + candidateSymbols.push(sym); + console.log("[NBP-SYM] " + sym.type + " " + sym.name + " @ " + sym.address); + break; + } + } + }); + } catch (e) { + console.log("[-] enumerateSymbols error: " + e.message); + } + + // ---- Hook all candidates: watch for 216B buffer returns ---- + var TARGET_TOKEN_LEN = 216; + + function tryHookTokenCandidate(name, addr) { + try { + Interceptor.attach(addr, { + onEnter: function (args) { + // Common pattern: output buffer pointer as first or second arg + this.arg0 = args[0]; + this.arg1 = args[1]; + this.arg2 = args[2]; + }, + onLeave: function (retval) { + // Check if retval looks like a pointer to NSData or raw buffer + if (!retval || retval.isNull()) return; + + // Try reading as length-prefixed buffer (NSData: [ptr isa][ptr bytes][size_t length]) + try { + var lenOffset = 2 * Process.pointerSize; + var dataLen = retval.add(lenOffset).readUSize(); + if (dataLen === TARGET_TOKEN_LEN) { + var bytesPtr = retval.add(Process.pointerSize).readPointer(); + if (!bytesPtr.isNull()) { + var h = hex(bytesPtr, TARGET_TOKEN_LEN); + banner("devicetoken CANDIDATE (NSData path) — " + name); + console.log("[+] length = " + dataLen); + console.log("[+] bytes = " + h); + captured.devicetoken = h; + } + } + } catch (e2) {} + + // Try reading retval directly as 216B buffer + try { + var h2 = hex(retval, TARGET_TOKEN_LEN); + // Sanity: first byte should be 0x0a (protobuf field 1 type 2) + var first = retval.readU8(); + if (first === 0x0a || first === 0x08 || first === 0x12) { + banner("devicetoken CANDIDATE (raw ptr path) — " + name); + console.log("[+] bytes = " + h2); + if (!captured.devicetoken) captured.devicetoken = h2; + } + } catch (e3) {} + } + }); + console.log("[+] Hooked NBP: " + name); + } catch (e) { + console.log("[-] Failed to hook NBP " + name + ": " + e.message); + } + } + + candidateExports.forEach(function (exp) { + if (exp.type === "function") tryHookTokenCandidate(exp.name, exp.address); + }); + candidateSymbols.forEach(function (sym) { + tryHookTokenCandidate(sym.name, sym.address); + }); + + // Also dump ALL exports so we can review them offline + console.log("\n[NBP] Full export list:"); + try { + nbp.enumerateExports().forEach(function (exp) { + console.log(" [NBP-ALL] " + exp.type + " " + exp.name); + }); + } catch (e) { + console.log("[-] " + e.message); + } +}); + +// ===== ObjC hooks for devicetoken via IosMslClient / NflxPinnedCertEvaluator ===== + +setTimeout(function () { + if (!ObjC.available) { + console.log("[-] ObjC runtime not available"); + return; + } + + banner("ObjC scan for devicetoken / device_key_data methods"); + + var TARGET_TOKEN_LEN = 216; + var TARGET_DKD_LEN_MIN = 6000; + var TARGET_DKD_LEN_MAX = 7200; + + // Scan all loaded ObjC classes for methods that return NSData and match our patterns + var classPatterns = ["Nflx", "Netflix", "Msl", "MSL", "Device", "Token", + "Credential", "Auth", "Attestation", "Boot", "Key"]; + var methodPatterns = ["token", "Token", "credential", "Credential", "deviceKey", + "DeviceKey", "keyData", "KeyData", "attestation", "deviceData"]; + + ObjC.enumerateLoadedClasses(function (name) { + var matchClass = false; + for (var i = 0; i < classPatterns.length; i++) { + if (name.indexOf(classPatterns[i]) !== -1) { matchClass = true; break; } + } + if (!matchClass) return; + + try { + var cls = ObjC.classes[name]; + if (!cls) return; + + cls.$ownMethods.forEach(function (method) { + var matchMethod = false; + for (var j = 0; j < methodPatterns.length; j++) { + if (method.indexOf(methodPatterns[j]) !== -1) { matchMethod = true; break; } + } + if (!matchMethod) return; + + console.log("[OBJC-CAND] " + name + " " + method); + + try { + var impl = cls[method]; + if (!impl) return; + + Interceptor.attach(impl.implementation, { + onLeave: function (retval) { + if (!retval || retval.isNull()) return; + try { + // Check if it's NSData + var isData = retval.isKindOfClass_ + ? retval.isKindOfClass_(ObjC.classes.NSData) + : false; + + // Try ObjC API + var obj = new ObjC.Object(retval); + var className = obj.$className; + + if (className === "NSData" || className === "__NSCFData" || + className === "NSMutableData" || className === "_NSInlineData") { + + var dataLen = obj.length(); + if (dataLen === TARGET_TOKEN_LEN) { + var bytesPtr = obj.bytes(); + var h = hex(bytesPtr, TARGET_TOKEN_LEN); + banner("devicetoken via ObjC: " + name + " " + method); + console.log("[+] length = " + dataLen); + console.log("[+] bytes = " + h); + captured.devicetoken = h; + + } else if (dataLen >= TARGET_DKD_LEN_MIN && dataLen <= TARGET_DKD_LEN_MAX) { + var bytesPtr2 = obj.bytes(); + var h2 = hex(bytesPtr2, dataLen); + banner("device_key_data via ObjC: " + name + " " + method); + console.log("[+] length = " + dataLen); + console.log("[+] bytes = " + h2); + captured.deviceKeyData = h2; + } + } + } catch (e) {} + } + }); + console.log("[+] ObjC hooked: " + name + " " + method); + } catch (e) { + console.log("[-] ObjC hook failed: " + name + " " + method + ": " + e.message); + } + }); + } catch (e) {} + }); +}, 500); + +// ===== 2. apphmac capture — NFWebCrypto.framework HMAC ===== +// +// Hook NFWebCrypto HMAC function. Filter for 32B outputs that occur in the +// timeframe when entity_auth_data is being assembled (near MslClient calls). +// Log: key bytes, data bytes, output hash for all candidates. +// +// Known candidates: +// HMAC(PSK, devicetoken) +// HMAC(enc_key_0, ESN) +// HMAC(sign_key, payload) + +waitForModule("NFWebCrypto", function (nfwc) { + banner("NFWebCrypto.framework loaded — hooking HMAC for apphmac capture"); + console.log("[*] NFWebCrypto base=" + nfwc.base + " size=" + nfwc.size); + + var pHMAC = nfwc.findExportByName("HMAC"); + if (!pHMAC) { + console.log("[-] HMAC not found in NFWebCrypto"); + return; + } + console.log("[+] HMAC @ " + pHMAC); + + // HMAC(const EVP_MD *evp_md, + // const void *key, int key_len, + // const unsigned char *data, size_t data_len, + // unsigned char *md, unsigned int *md_len) + Interceptor.attach(pHMAC, { + onEnter: function (args) { + this.evp_md = args[0]; + this.key = args[1]; + this.keyLen = args[2].toInt32(); + this.data = args[3]; + this.dataLen = args[4].toUInt32(); + this.md = args[5]; + this.mdLenPtr = args[6]; + }, + onLeave: function (retval) { + if (retval.isNull()) return; + + var outLen = (this.mdLenPtr && !this.mdLenPtr.isNull()) + ? this.mdLenPtr.readU32() : 32; + + // Only care about HMAC-SHA256 (32B output) for apphmac + if (outLen !== 32) return; + + var keyHex = hex(this.key, Math.min(this.keyLen, 64)); + var dataHex = hexShort(this.data, Math.min(this.dataLen, 256)); + var outHex = hex(retval, 32); + + var entry = { + ts: ts(), + keyLen: this.keyLen, + key: keyHex, + dataLen: this.dataLen, + data: dataHex, + out: outHex, + }; + captured.hmacCandidates.push(entry); + + // Check if output matches 32B and data size matches devicetoken (216B) + var isTokenHmac = (this.dataLen === 216); + // Check if data size matches ESN-like (variable, usually 20-40B) + var isEsnHmac = (this.dataLen >= 20 && this.dataLen <= 50); + + var label = "HMAC-SHA256"; + if (isTokenHmac) label += " [DEVICETOKEN-SIZE DATA]"; + if (isEsnHmac) label += " [ESN-SIZE DATA]"; + + console.log("\n[" + ts() + "] " + label); + console.log(" key (" + this.keyLen + "B) = " + keyHex); + console.log(" data (" + this.dataLen + "B) = " + dataHex); + console.log(" output (32B) = " + outHex); + + // Heuristic: if dataLen == 216 this is likely HMAC(PSK, devicetoken) = apphmac + if (isTokenHmac) { + banner("apphmac CANDIDATE — HMAC(key, 216B_data)"); + console.log(" [CANDIDATE] key = " + keyHex); + console.log(" [CANDIDATE] data = " + dataHex); + console.log(" [CANDIDATE] hmac = " + outHex); + captured.apphmac = outHex; + } + + // Backtrace to identify caller context + var bt = Thread.backtrace(this.context, Backtracer.ACCURATE).slice(0, 5); + console.log(" backtrace:"); + bt.forEach(function (addr) { + var sym = DebugSymbol.fromAddress(addr); + console.log(" " + addr + " " + sym); + }); + } + }); + console.log("[+] HMAC hooked in NFWebCrypto"); +}); + +// ===== 3. device_key_data capture ===== +// +// Strategy A: NSData dataWithContentsOfFile: — watch all file reads >= 1KB +// Strategy B: NSUserDefaults objectForKey: — watch large NSData values +// Strategy C: sqlite3_step — watch for large BLOB column reads + +setTimeout(function () { + if (!ObjC.available) return; + + banner("device_key_data capture — file / NSUserDefaults / SQLite hooks"); + + var TARGET_DKD_LEN_MIN = 4000; // be generous — might be base64 or padded + var TARGET_DKD_LEN_MAX = 10000; + + // ---- Strategy A: NSData dataWithContentsOfFile: ---- + try { + var NSData = ObjC.classes.NSData; + if (NSData) { + var dataWithContents = NSData["+ dataWithContentsOfFile:"]; + if (dataWithContents) { + Interceptor.attach(dataWithContents.implementation, { + onEnter: function (args) { + // args[2] is the NSString path (index 0=self, 1=sel, 2=path) + try { + this.path = new ObjC.Object(args[2]).toString(); + } catch (e) { + this.path = "(unknown)"; + } + }, + onLeave: function (retval) { + if (!retval || retval.isNull()) return; + try { + var obj = new ObjC.Object(retval); + var dataLen = obj.length(); + var entry = { ts: ts(), path: this.path, size: dataLen }; + captured.fileReads.push(entry); + console.log("[FILE] " + ts() + " dataWithContentsOfFile: " + this.path + " → " + dataLen + "B"); + + if (dataLen >= TARGET_DKD_LEN_MIN && dataLen <= TARGET_DKD_LEN_MAX) { + var bytesPtr = obj.bytes(); + var h = hex(bytesPtr, dataLen); + banner("device_key_data CANDIDATE — file read"); + console.log(" path = " + this.path); + console.log(" length = " + dataLen); + console.log(" bytes = " + h.slice(0, 128) + "..."); + captured.deviceKeyData = h; + } + } catch (e) {} + } + }); + console.log("[+] NSData dataWithContentsOfFile: hooked"); + } + + // Also hook dataWithContentsOfFile:options:error: + var dataWithContentsOpts = NSData["+ dataWithContentsOfFile:options:error:"]; + if (dataWithContentsOpts) { + Interceptor.attach(dataWithContentsOpts.implementation, { + onEnter: function (args) { + try { + this.path = new ObjC.Object(args[2]).toString(); + } catch (e) { + this.path = "(unknown)"; + } + }, + onLeave: function (retval) { + if (!retval || retval.isNull()) return; + try { + var obj = new ObjC.Object(retval); + var dataLen = obj.length(); + console.log("[FILE] " + ts() + " dataWithContentsOfFile:options:error: " + this.path + " → " + dataLen + "B"); + if (dataLen >= TARGET_DKD_LEN_MIN && dataLen <= TARGET_DKD_LEN_MAX) { + var bytesPtr = obj.bytes(); + var h = hex(bytesPtr, dataLen); + banner("device_key_data CANDIDATE — file read (options:error:)"); + console.log(" path = " + this.path); + console.log(" length = " + dataLen); + console.log(" bytes = " + h.slice(0, 128) + "..."); + captured.deviceKeyData = h; + } + } catch (e) {} + } + }); + console.log("[+] NSData dataWithContentsOfFile:options:error: hooked"); + } + } + } catch (e) { + console.log("[-] NSData file hook error: " + e.message); + } + + // ---- Strategy B: NSUserDefaults objectForKey: ---- + try { + var NSUserDefaults = ObjC.classes.NSUserDefaults; + if (NSUserDefaults) { + var objectForKey = NSUserDefaults["- objectForKey:"]; + if (objectForKey) { + Interceptor.attach(objectForKey.implementation, { + onEnter: function (args) { + try { + this.key = new ObjC.Object(args[2]).toString(); + } catch (e) { + this.key = "(unknown)"; + } + }, + onLeave: function (retval) { + if (!retval || retval.isNull()) return; + try { + var obj = new ObjC.Object(retval); + var className = obj.$className; + if (className === "NSData" || className === "__NSCFData" || + className === "NSMutableData" || className === "_NSInlineData") { + var dataLen = obj.length(); + console.log("[DEFAULTS] " + ts() + " objectForKey: " + this.key + " → NSData " + dataLen + "B"); + if (dataLen >= TARGET_DKD_LEN_MIN && dataLen <= TARGET_DKD_LEN_MAX) { + var bytesPtr = obj.bytes(); + var h = hex(bytesPtr, dataLen); + banner("device_key_data CANDIDATE — NSUserDefaults"); + console.log(" key = " + this.key); + console.log(" length = " + dataLen); + console.log(" bytes = " + h.slice(0, 128) + "..."); + captured.deviceKeyData = h; + } + } + } catch (e) {} + } + }); + console.log("[+] NSUserDefaults objectForKey: hooked"); + } + } + } catch (e) { + console.log("[-] NSUserDefaults hook error: " + e.message); + } + + // ---- Strategy C: sqlite3_step — watch for large BLOB column reads ---- + // We hook sqlite3_column_blob + sqlite3_column_bytes after sqlite3_step + var sqlite3Mods = ["libsqlite3.dylib", "libsqlite3.0.dylib"]; + var sqlite3 = null; + Process.enumerateModules().forEach(function (m) { + if (m.name.indexOf("sqlite3") !== -1) sqlite3 = m; + }); + + if (sqlite3) { + var pStep = sqlite3.findExportByName("sqlite3_step"); + var pColBlob = sqlite3.findExportByName("sqlite3_column_blob"); + var pColBytes = sqlite3.findExportByName("sqlite3_column_bytes"); + + if (pStep && pColBlob && pColBytes) { + var colBlob = new NativeFunction(pColBlob, 'pointer', ['pointer', 'int']); + var colBytes = new NativeFunction(pColBytes, 'int', ['pointer', 'int']); + + Interceptor.attach(pStep, { + onLeave: function (retval) { + // SQLITE_ROW = 100 + if (retval.toInt32() !== 100) return; + var stmt = this.context.x0; // arm64: first arg stays in x0 + // Scan first 10 columns + for (var col = 0; col < 10; col++) { + try { + var blobLen = colBytes(stmt, col); + if (blobLen >= TARGET_DKD_LEN_MIN && blobLen <= TARGET_DKD_LEN_MAX) { + var blobPtr = colBlob(stmt, col); + if (blobPtr && !blobPtr.isNull()) { + var h = hex(blobPtr, blobLen); + banner("device_key_data CANDIDATE — SQLite BLOB"); + console.log(" column = " + col); + console.log(" length = " + blobLen); + console.log(" bytes = " + h.slice(0, 128) + "..."); + captured.deviceKeyData = h; + } + } + } catch (e) {} + } + } + }); + console.log("[+] sqlite3_step hooked (BLOB size filter " + + TARGET_DKD_LEN_MIN + "-" + TARGET_DKD_LEN_MAX + "B)"); + } else { + console.log("[-] sqlite3 step/column functions not found"); + } + } else { + console.log("[-] libsqlite3 not found"); + } +}, 300); + +// ===== RPC exports ===== + +rpc.exports = { + getCaptured: function () { + return JSON.stringify({ + devicetoken: captured.devicetoken, + apphmac: captured.apphmac, + deviceKeyData: captured.deviceKeyData + ? captured.deviceKeyData.slice(0, 64) + "... (" + (captured.deviceKeyData.length / 2) + "B)" + : null, + hmacCandidates: captured.hmacCandidates.length, + fileReads: captured.fileReads.length, + }, null, 2); + }, + getHmacCandidates: function () { + return JSON.stringify(captured.hmacCandidates, null, 2); + }, + getDeviceKeyData: function () { + return captured.deviceKeyData || null; + }, + getDeviceToken: function () { + return captured.devicetoken || null; + }, + getAppHmac: function () { + return captured.apphmac || null; + }, + getFileReads: function () { + return JSON.stringify(captured.fileReads, null, 2); + }, +}; + +// Make callable from REPL +var global = this; +global.getCaptured = function () { + console.log(rpc.exports.getCaptured()); +}; +global.getHmacCandidates = function () { + console.log(rpc.exports.getHmacCandidates()); +}; +global.getDeviceToken = function () { + console.log(rpc.exports.getDeviceToken()); +}; +global.getDeviceKeyData = function () { + var d = rpc.exports.getDeviceKeyData(); + if (d) console.log(d.slice(0, 256) + "... (" + (d.length / 2) + "B)"); + else console.log("(not captured yet)"); +}; + +console.log("\n=== hook_entityauth_capture.js loaded ==="); +console.log("Targets:"); +console.log(" 1. devicetoken (216B) — Nbp.framework token symbols"); +console.log(" 2. apphmac (32B) — NFWebCrypto HMAC (dataLen==216 filter)"); +console.log(" 3. device_key_data — file / NSUserDefaults / SQLite (4-10KB filter)"); +console.log("RPC commands:"); +console.log(" getCaptured() — summary of all captured values"); +console.log(" getHmacCandidates() — all 32B HMAC calls seen"); +console.log(" getDeviceToken() — devicetoken hex"); +console.log(" getDeviceKeyData() — device_key_data hex"); +console.log(""); +console.log("[*] Navigate to Netflix homescreen to trigger entity_auth_data assembly"); diff --git a/packages/frida/hook_nfwebcrypto_keys.js b/packages/frida/hook_nfwebcrypto_keys.js new file mode 100644 index 0000000..e1b2751 --- /dev/null +++ b/packages/frida/hook_nfwebcrypto_keys.js @@ -0,0 +1,415 @@ +/** + * hook_nfwebcrypto_keys.js — NFWebCrypto 署名検証鍵の実行時ダンプ + * + * 目的: appboot 署名検証で使用される以下の2鍵をメモリスキャンとフックで捕捉する + * 1. kAppBootKey — RSA-4096 公開鍵 (SPKI DER, ~550B) + * 2. kAppBootEccKey — ECDSA P-256 公開鍵 (SPKI DER, 91B) + * + * 手法: + * A. メモリスキャン: NFWebCrypto の .data/.rodata セクションを DER マーカーでスキャン + * B. importKey フック: "ABKP" / "ABECCKP" ハンドル名を持つ呼び出しをキャプチャ + * C. EVP_PKEY_set1_RSA / d2i_RSAPublicKey / d2i_PUBKEY フック: key import 経路 + * D. SecKeyCreateWithData / SecItemAdd フック: Keychain 経由の場合 + * + * 使い方: + * frida -H host.docker.internal:27042 -n Argo -l hook_nfwebcrypto_keys.js + * + * 注意: + * - 鍵は静的に .rodata に埋め込まれている可能性が高い + * - メモリスキャンは NFWebCrypto ロード直後に一度だけ実行する + */ + +"use strict"; + +// ===== Utility ===== + +function ts() { + return new Date().toISOString(); +} + +function hex(ptr, len) { + if (!ptr || ptr.isNull() || len <= 0) return "(null)"; + try { + return Array.from(new Uint8Array(ptr.readByteArray(len))) + .map(function (b) { return ("0" + b.toString(16)).slice(-2); }) + .join(""); + } catch (e) { + return "(unreadable:" + e.message + ")"; + } +} + +function banner(title) { + var line = "=".repeat(60); + console.log("\n" + line); + console.log(" " + title); + console.log(" " + ts()); + console.log(line); +} + +// ===== DER marker bytes ===== + +// RSA-4096 SPKI DER prefix (SubjectPublicKeyInfo wrapping rsaEncryption OID): +// SEQUENCE { SEQUENCE { OID rsaEncryption, NULL }, BIT STRING } +// 30 82 02 22 — SEQUENCE, length 0x0222 = 546 bytes (for RSA-4096 SPKI) +// 30 0d 06 09 2a 86 48 86 f7 0d 01 01 01 05 00 +// The 4-byte outer SEQUENCE tag+len varies for different key sizes; +// we search for the OID bytes which are always present. +var RSA_OID_BYTES = [0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01, 0x05, 0x00]; + +// ECDSA P-256 SPKI DER (91 bytes total, fixed): +// 30 59 30 13 06 07 2a 86 48 ce 3d 02 01 06 08 2a 86 48 ce 3d 03 01 07 03 42 00 04 ... +var P256_SPKI_PREFIX = [0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, + 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, + 0x03, 0x01, 0x07]; + +// RSA-4096 SPKI full prefix (outer SEQUENCE length for 4096-bit key is 0x0222): +var RSA4096_SPKI_PREFIX = [0x30, 0x82, 0x02, 0x22].concat(RSA_OID_BYTES); + +// ===== Captured state ===== + +var captured = { + kAppBootKey: null, // RSA-4096 SPKI hex + kAppBootEccKey: null, // ECDSA P-256 SPKI hex +}; + +// ===== Memory scan helper ===== + +function findPattern(baseAddr, size, patternBytes) { + var results = []; + var patLen = patternBytes.length; + + // Read in 4KB chunks to avoid large allocations + var CHUNK = 4096; + var offset = 0; + + while (offset < size) { + var readLen = Math.min(CHUNK + patLen, size - offset); + try { + var chunk = new Uint8Array(baseAddr.add(offset).readByteArray(readLen)); + for (var i = 0; i < chunk.length - patLen; i++) { + var match = true; + for (var j = 0; j < patLen; j++) { + if (chunk[i + j] !== patternBytes[j]) { match = false; break; } + } + if (match) { + results.push(baseAddr.add(offset + i)); + } + } + } catch (e) { + // Skip unreadable pages + } + offset += CHUNK; + } + return results; +} + +// Parse DER SEQUENCE length at ptr (may be short or long form) +// Returns { totalLen, headerLen } or null +function parseDerSequenceLen(ptr) { + try { + var tag = ptr.readU8(); + if (tag !== 0x30) return null; + var b1 = ptr.add(1).readU8(); + if (b1 < 0x80) { + return { totalLen: 2 + b1, headerLen: 2 }; + } else if (b1 === 0x81) { + var len = ptr.add(2).readU8(); + return { totalLen: 3 + len, headerLen: 3 }; + } else if (b1 === 0x82) { + var hi = ptr.add(2).readU8(); + var lo = ptr.add(3).readU8(); + var len2 = (hi << 8) | lo; + return { totalLen: 4 + len2, headerLen: 4 }; + } + return null; + } catch (e) { + return null; + } +} + +// ===== Main scan function ===== + +function scanNFWebCryptoForKeys(nfwc) { + banner("Scanning NFWebCrypto memory for RSA-4096 and ECDSA P-256 keys"); + console.log("[*] NFWebCrypto base=" + nfwc.base + " size=" + nfwc.size); + + // ---- Scan for ECDSA P-256 SPKI (91B, exact prefix match) ---- + console.log("[*] Scanning for ECDSA P-256 SPKI prefix..."); + var eccMatches = findPattern(nfwc.base, nfwc.size, P256_SPKI_PREFIX); + console.log("[*] P-256 prefix hits: " + eccMatches.length); + + eccMatches.forEach(function (addr) { + var info = parseDerSequenceLen(addr); + var keyLen = info ? info.totalLen : 91; // P-256 SPKI is always 91B + var h = hex(addr, keyLen); + banner("kAppBootEccKey — ECDSA P-256 SPKI @ " + addr); + console.log(" address = " + addr); + console.log(" length = " + keyLen); + console.log(" hex = " + h); + captured.kAppBootEccKey = h; + }); + + // ---- Scan for RSA-4096 SPKI (search by full prefix including outer SEQUENCE) ---- + console.log("[*] Scanning for RSA-4096 SPKI prefix..."); + var rsaMatches = findPattern(nfwc.base, nfwc.size, RSA4096_SPKI_PREFIX); + console.log("[*] RSA-4096 prefix hits: " + rsaMatches.length); + + rsaMatches.forEach(function (addr) { + var info = parseDerSequenceLen(addr); + if (!info) { + console.log("[-] Could not parse DER at " + addr); + return; + } + var h = hex(addr, info.totalLen); + banner("kAppBootKey — RSA-4096 SPKI @ " + addr); + console.log(" address = " + addr); + console.log(" totalLen = " + info.totalLen); + console.log(" hex = " + h); + captured.kAppBootKey = h; + }); + + // ---- Broader RSA OID scan (catches RSA-2048 or different SEQUENCE length) ---- + if (rsaMatches.length === 0) { + console.log("[*] Trying broader RSA OID scan..."); + var rsaOidMatches = findPattern(nfwc.base, nfwc.size, RSA_OID_BYTES); + console.log("[*] RSA OID hits: " + rsaOidMatches.length); + + rsaOidMatches.forEach(function (oidAddr) { + // Walk back up to 4 bytes to find the outer SEQUENCE tag + var seqAddr = oidAddr.sub(4); + var info = parseDerSequenceLen(seqAddr); + if (!info) { + seqAddr = oidAddr.sub(2); + info = parseDerSequenceLen(seqAddr); + } + if (!info) return; + + // Sanity: SPKI for RSA-4096 should be 550 bytes or so + if (info.totalLen < 100 || info.totalLen > 700) return; + + var h = hex(seqAddr, info.totalLen); + banner("kAppBootKey CANDIDATE (RSA OID scan) @ " + seqAddr); + console.log(" address = " + seqAddr); + console.log(" totalLen = " + info.totalLen); + console.log(" hex = " + h); + if (!captured.kAppBootKey) captured.kAppBootKey = h; + }); + } +} + +// ===== Hook-based capture ===== + +function hookNFWebCryptoImportKey(nfwc) { + // Hook d2i_PUBKEY — parses SubjectPublicKeyInfo DER → EVP_PKEY* + // EVP_PKEY *d2i_PUBKEY(EVP_PKEY **a, const unsigned char **pp, long length) + var pD2iPubkey = nfwc.findExportByName("d2i_PUBKEY"); + if (pD2iPubkey) { + Interceptor.attach(pD2iPubkey, { + onEnter: function (args) { + this.ppData = args[1]; + this.len = args[2].toInt32(); + }, + onLeave: function (retval) { + if (retval.isNull() || this.len <= 0) return; + // pp was advanced past the parsed data; read from saved pointer + try { + var dataPtr = this.ppData.readPointer().sub(this.len); + var h = hex(dataPtr, this.len); + banner("d2i_PUBKEY called — possible kAppBootKey or kAppBootEccKey import"); + console.log(" length = " + this.len); + console.log(" hex = " + h.slice(0, 256) + (h.length > 256 ? "..." : "")); + // Distinguish by size: P-256 = 91B, RSA-4096 ~= 550B + if (this.len === 91) { + captured.kAppBootEccKey = h; + } else if (this.len >= 400 && this.len <= 700) { + captured.kAppBootKey = h; + } + } catch (e) { + console.log("[-] d2i_PUBKEY data read error: " + e.message); + } + } + }); + console.log("[+] d2i_PUBKEY hooked"); + } else { + console.log("[-] d2i_PUBKEY not found in NFWebCrypto"); + } + + // Hook d2i_RSAPublicKey / d2i_RSA_PUBKEY + ["d2i_RSAPublicKey", "d2i_RSA_PUBKEY"].forEach(function (name) { + var p = nfwc.findExportByName(name); + if (!p) { console.log("[-] " + name + " not found"); return; } + Interceptor.attach(p, { + onEnter: function (args) { + this.ppData = args[1]; + this.len = args[2].toInt32(); + }, + onLeave: function (retval) { + if (retval.isNull() || this.len <= 0) return; + try { + var dataPtr = this.ppData.readPointer().sub(this.len); + var h = hex(dataPtr, this.len); + banner(name + " called — kAppBootKey import"); + console.log(" length = " + this.len); + console.log(" hex = " + h.slice(0, 256) + (h.length > 256 ? "..." : "")); + if (this.len >= 400) captured.kAppBootKey = h; + } catch (e) {} + } + }); + console.log("[+] " + name + " hooked"); + }); + + // Hook EVP_PKEY_set1_RSA + var pSetRSA = nfwc.findExportByName("EVP_PKEY_set1_RSA"); + if (pSetRSA) { + Interceptor.attach(pSetRSA, { + onEnter: function (args) { + // args[1] = RSA* — try to serialize it + this.rsa = args[1]; + }, + onLeave: function (retval) { + if (retval.toInt32() !== 1) return; + // Can't easily dump RSA* without i2d_RSAPublicKey — log the pointer + console.log("[EVP_PKEY_set1_RSA] RSA* = " + this.rsa + + " (use i2d_RSAPublicKey to dump if needed)"); + } + }); + console.log("[+] EVP_PKEY_set1_RSA hooked"); + } + + // Hook EVP_DigestVerifyInit — catches signature verification setup + // This is called once per verify operation on appboot response + var pDVI = nfwc.findExportByName("EVP_DigestVerifyInit"); + if (pDVI) { + Interceptor.attach(pDVI, { + onEnter: function (args) { + // args: (EVP_MD_CTX*, EVP_PKEY_CTX**, EVP_MD*, ENGINE*, EVP_PKEY*) + this.pkey = args[4]; + }, + onLeave: function (retval) { + if (retval.toInt32() !== 1) return; + console.log("[EVP_DigestVerifyInit] pkey=" + this.pkey + " (appboot sig verify initiated)"); + // Attempt to dump the public key via i2d_PUBKEY if available + var pI2d = nfwc.findExportByName("i2d_PUBKEY"); + if (pI2d && this.pkey && !this.pkey.isNull()) { + try { + var i2dPubkey = new NativeFunction(pI2d, 'int', ['pointer', 'pointer']); + var ppOut = Memory.alloc(Process.pointerSize); + ppOut.writePointer(ptr(0)); + var len = i2dPubkey(this.pkey, ppOut); + if (len > 0) { + var outPtr = ppOut.readPointer(); + var h = hex(outPtr, len); + banner("kAppBootKey/kAppBootEccKey via EVP_DigestVerifyInit + i2d_PUBKEY"); + console.log(" length = " + len); + console.log(" hex = " + h); + if (len === 91) captured.kAppBootEccKey = h; + else if (len >= 400) captured.kAppBootKey = h; + } + } catch (e) { + console.log("[-] i2d_PUBKEY dump error: " + e.message); + } + } + } + }); + console.log("[+] EVP_DigestVerifyInit hooked"); + } + + // Hook EVP_DigestVerify — the final verify call, pkey already set + var pDV = nfwc.findExportByName("EVP_DigestVerify"); + if (pDV) { + Interceptor.attach(pDV, { + onEnter: function (args) { + // args: (EVP_MD_CTX*, unsigned char* sig, size_t siglen, + // unsigned char* tbs, size_t tbslen) + this.sigLen = args[2].toUInt32(); + this.tbsLen = args[4].toUInt32(); + this.sig = args[1]; + this.tbs = args[3]; + }, + onLeave: function (retval) { + var result = retval.toInt32(); + console.log("[EVP_DigestVerify] sigLen=" + this.sigLen + + " tbsLen=" + this.tbsLen + " result=" + result); + if (this.sigLen > 0 && this.sigLen <= 512) { + console.log(" sig = " + hex(this.sig, this.sigLen)); + } + } + }); + console.log("[+] EVP_DigestVerify hooked"); + } +} + +// ===== Wait for NFWebCrypto and run ===== + +function waitForModule(name, cb) { + var m = Process.findModuleByName(name); + if (m) { cb(m); return; } + var iv = setInterval(function () { + var m2 = Process.findModuleByName(name); + if (m2) { clearInterval(iv); cb(m2); } + }, 200); +} + +waitForModule("NFWebCrypto", function (nfwc) { + // Run memory scan first (keys may be in static rodata) + scanNFWebCryptoForKeys(nfwc); + + // Then install import/verify hooks to catch runtime key loading + hookNFWebCryptoImportKey(nfwc); + + // Also scan for string "ABKP" / "ABECCKP" handle names in the module + banner("Scanning for ABKP / ABECCKP handle strings"); + var abkpBytes = [0x41, 0x42, 0x4b, 0x50, 0x00]; // "ABKP\0" + var abeccBytes = [0x41, 0x42, 0x45, 0x43, 0x43, 0x4b, 0x50, 0x00]; // "ABECCKP\0" + + var abkpHits = findPattern(nfwc.base, nfwc.size, abkpBytes); + var abeccHits = findPattern(nfwc.base, nfwc.size, abeccBytes); + + console.log("[*] \"ABKP\" string hits: " + abkpHits.length); + abkpHits.forEach(function (a) { console.log(" @ " + a); }); + + console.log("[*] \"ABECCKP\" string hits: " + abeccHits.length); + abeccHits.forEach(function (a) { console.log(" @ " + a); }); +}); + +// ===== RPC exports ===== + +rpc.exports = { + getKeys: function () { + return JSON.stringify({ + kAppBootKey: captured.kAppBootKey + ? captured.kAppBootKey.slice(0, 64) + "... (" + (captured.kAppBootKey.length / 2) + "B)" + : null, + kAppBootEccKey: captured.kAppBootEccKey || null, + }, null, 2); + }, + getKAppBootKey: function () { + return captured.kAppBootKey || null; + }, + getKAppBootEccKey: function () { + return captured.kAppBootEccKey || null; + }, + rescan: function () { + var nfwc = Process.findModuleByName("NFWebCrypto"); + if (nfwc) scanNFWebCryptoForKeys(nfwc); + else console.log("[-] NFWebCrypto not loaded"); + }, +}; + +// REPL shortcuts +var global = this; +global.getKeys = function () { console.log(rpc.exports.getKeys()); }; +global.rescan = function () { rpc.exports.rescan(); }; + +console.log("\n=== hook_nfwebcrypto_keys.js loaded ==="); +console.log("Targets:"); +console.log(" kAppBootKey — RSA-4096 SPKI (ABKP handle)"); +console.log(" kAppBootEccKey — ECDSA P-256 SPKI (ABECCKP handle)"); +console.log("Actions:"); +console.log(" Memory scan runs immediately on NFWebCrypto load"); +console.log(" d2i_PUBKEY / EVP_DigestVerifyInit hooks capture runtime import"); +console.log("RPC:"); +console.log(" getKeys() — summary of captured keys"); +console.log(" getKAppBootKey() — RSA-4096 hex (full)"); +console.log(" getKAppBootEccKey() — P-256 hex (full)"); +console.log(" rescan() — re-run memory scan");