From 27c71a3ad22a09074249203349d06c3debeffa2a Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 28 Aug 2026 17:19:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(huya):=20hook=E9=98=B6=E6=AE=B5=E7=BB=93?= =?UTF-8?q?=E8=AE=BA=20-=20NativeBridge=E6=8E=92=E9=99=A4=E5=8A=A8?= =?UTF-8?q?=E6=80=81JNI=E4=B8=8E=E5=90=84=E5=BA=93RN,=20=E9=9D=99=E6=80=81?= =?UTF-8?q?=E7=89=A9=E8=AF=81=3D=E5=86=85=E8=81=94=E6=8F=8F=E8=BF=B0?= =?UTF-8?q?=E7=AC=A6=E5=9D=97;=20=E5=8F=8C=E5=BA=93unidbg=E6=89=93?= =?UTF-8?q?=E9=80=9A;=20=E8=AE=BE=E5=A4=87=E7=A8=B3=E5=AE=9A=E6=B3=A8?= =?UTF-8?q?=E6=84=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 排除: Java_导出扫描/各库dlopen JNI_OnLoad RN拦截/健康进程重触发RN = 全0命中 - unidbg verbose: hydeviceid仅FindClass+NewGlobalRef; udb仅注册HuyaAuthCore - 物证: hydeviceid .data@0x3e21e4 内联方法描述符块(klog/b/c/g/h+getDataDir/getPath) + 类名字符串 - 双库: udb(JNI_OnLoad@0x262620)解密合并 unidbg 加载成功; merge 升级段驱动+动态保留+init_array置零+仅重定位槽清理 - 新输入: udb_deviceID/udb_deviceID_encode/oaid prefs + b(104) + Build.*字段 - 设备稳定: pm clear+frida attach 探测致闪退, 后续禁用; 无frida冷启动健康 - 静态还原重启点: isNative定讫 -> 注册函数(运行期建表模式) 或 Java逻辑; unidbg差分oracle + fnPtr兜底 --- docs/HUYA_HDID_ALGORITHM_GEN.md | 37 +++++ scripts/phone_probe_rn_lazy.py | 134 +++++++++++++++++++ tools/unidbg/hydev/merge_decrypted.py | 121 +++++++++++++---- tools/unidbg/hydev/src/hydev/HyDeviceId.java | 30 ++++- 4 files changed, 294 insertions(+), 28 deletions(-) create mode 100644 scripts/phone_probe_rn_lazy.py diff --git a/docs/HUYA_HDID_ALGORITHM_GEN.md b/docs/HUYA_HDID_ALGORITHM_GEN.md index 0eb75d8..6893f9d 100644 --- a/docs/HUYA_HDID_ALGORITHM_GEN.md +++ b/docs/HUYA_HDID_ALGORITHM_GEN.md @@ -200,3 +200,40 @@ lib 内置大量模拟器检测串(qemu_pipe / mumuvmm / genymotion / windroye 需在真机 dlopen 全拦截清单里补齐),让 b(100) 真实计算; 2. **静态还原 b(100) 公式**:基于 (ANDROID_ID→GUID) 差分对拍(unidbg 内改 ANDROID_ID 即可批量 产对),用 .data 密钥(`7jbF5I...`/`HuyaUdb...`/config 解密链)猜 MD5/SHA1/AES 结构。 + +## 十、Hook 阶段结果(2026-08-28 深夜续) + +### 10.1 NativeBridge 归属排查(穷尽运行时手段) +- **动态 JNI 排除**:全模块 `Java_*` 导出扫描,自研库仅 libhypopup/libjsiwup/AI 库有——无 + `Java_com_huya_security_hydeviceid_NativeBridge_*`。 +- **RegisterNatives 排除**:①v7 注册表(23 条)= NativeEntry(19@hydeviceid)+HuyaAuthCore(4@udb), + 无 NativeBridge;②dlopen→JNI_OnLoad 拦截各库(hydeviceid/udb)0 命中;③健康进程 attach+ + 重触发 init/getGUID,RN 全程 0 命中。 +- **unidbg verbose 实锤**:hydeviceid JNI_OnLoad 只 `FindClass(NativeBridge)+NewGlobalRef`(不注册), + udb JNI_OnLoad 只注册 HuyaAuthCore;NativeBridge.b/klog 在 unidbg 内经 JNI 以"未注册 native" + 路由到 AbstractJni(桩)。 +- **定位到的关键静态物证**:hydeviceid .data@0x3e21e4 起为内联方法描述符块 + (`klog()(L...Z)V`、`b (I)L..`、`c (I)J`、`h ()L..`、`g (L)L..`、getDataDir/getPath…), + 类名字符串 `com/huya/security/hydeviceid/NativeBridge`@0x3e2f20 紧随其后;但代码无任何 + ADRP/GOT 引用该页(间接/运行期构建)。完整 {name,sig,fnPtr} 指针表未以静态形式存在。 +- **新输入候选**:采集器还读写 `udb_deviceID`/`udb_deviceID_encode`/`oaid`(prefs)+ + `NativeBridge.b(104)`(oaid 相关);`Build.*` 静态字段读。 + +### 10.2 双库 unidbg 已打通但 b 仍未真注册 +- libudbauthunify(JNI_OnLoad@0x262620,RN 调用点 0x2629bc)解密合并后 unidbg 加载成功; +- merge_decrypted.py 升级为**段驱动**(vaddr→文件偏移,支持 .rodata/delta 各段)+ .dynamic 保留 + + .init_array 置零 + 仅重定位槽指针清理(避免误伤字符串); +- 卡点:NativeBridge natives 从未在任何可见路径注册 → b(100) 仍走桩。 + +### 10.3 设备稳定性注意(重要) +- 反复 `pm clear` + frida attach 探测会清空应用数据(弹协议)并导致 App 闪退; + 已确认无 frida 冷启动 App 健康(首页焦点正常)。**后续禁 pm clear、attach 探测收敛**。 + +### 10.4 静态还原重启点(step-2 方案,全部 PC 侧) +1. **歧义清除**:确认 NativeBridge.b/klog 到底是否 native(dex 反射 isNative,用 SPAWN+bypass + 轻探,一次即可);若 native → 在 hydeviceid 代码中用"运行期构建表"模式(构造 + {name,sig,fnPtr} 时 ldr 常量页)找注册函数;若 Java → 直接读运行期 dex 里 getGuid/b 逻辑。 +2. **oracle 差分**:unidbg 侧可控 ANDROID_ID/Settings.Secure 已证明 androidId 密文会变; + b 一旦真注册,`(ANDROID_ID → GUID)` 差分对即可在 unidbg 内批量生成 → MD5/SHA1/AES 对拍。 +3. b 的 fnPtr 兜底获取:运行期 dump hydeviceid .data 时同时读 JNINativeMethod 运行期构造表 + (spawn+bypass 一次抓全,附 base 校验)。 diff --git a/scripts/phone_probe_rn_lazy.py b/scripts/phone_probe_rn_lazy.py new file mode 100644 index 0000000..40266ed --- /dev/null +++ b/scripts/phone_probe_rn_lazy.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +"""真机: 全 dlopen 拦截 -> 每个库 JNI_OnLoad 前置 RegisterNatives 钩子, +主动触发 getGUID/NativeBridge 类初始化, 抓 NativeBridge 懒注册归属. + +用法: python3 scripts/phone_probe_rn_lazy.py [serial] [remote] +""" +from __future__ import annotations +import json, subprocess, sys, time +from pathlib import Path +import frida + +SERIAL = sys.argv[1] if len(sys.argv) > 1 else "5dd8c93f" +REMOTE = sys.argv[2] if len(sys.argv) > 2 else "127.0.0.1:31878" +PACKAGE = "com.duowan.kiwi" +RE = Path("/Users/yml/codes/Reverse-Engineering-Agent-Universal-v3.0") +ART_CALLSITE = RE / "evidence" / "scripts" / "bypass_msaoaid_maps_art_callsite.js" + +JS = r""" +'use strict'; +function jmod(a){ try{var m=Process.findModuleByAddress(a); return m?{n:m.name,off:'0x'+a.sub(m.base).toString(16)}:{n:'?',off:'?'};}catch(e){return {n:'?',off:'?'};} } +var rnHooked=false; +function hookRN(){ + if(rnHooked) return; + try{ + var env=Java.vm.tryGetEnv(); if(!env) return; + var rn=env.handle.readPointer().add(215*Process.pointerSize).readPointer(); + if(rn.isNull()) return; + Interceptor.attach(rn,{ + onEnter:function(args){ + var methods=args[3], count=args[4].toInt32(), mm=[]; + for(var i=0;i=0||md.name.indexOf('libudb')>=0||md.name.indexOf('msaoaid')>=0|| + md.name.indexOf('device')>=0||md.name.indexOf('sec')>=0||md.name.indexOf('auth')>=0|| + md.name.indexOf('wup')>=0||md.name.indexOf('hycrypto')>=0||md.name.indexOf('jsiwup')>=0){ + hookedMods[md.name]=true; + hookJniOnLoad(md); + } + }); +}, 50); + +setTimeout(function(){ + Java.perform(function(){ + try{ + // 先强制 NativeBridge 类初始化(触发可能的懒注册) + var NB=Java.use('com.huya.security.hydeviceid.NativeBridge'); + send({type:'cls-loaded'}); + var NE=Java.use('com.huya.security.hydeviceid.NativeEntry'); + NE.init(); + send({type:'tid', tid:Process.getCurrentThreadId()}); + send({type:'trigger', guid: (function(){ try{ return NE.getGUID(); }catch(e){ return 'ERR:'+String(e).slice(0,60);} })(), + mid: (function(){ try{ return NE.getMID(); }catch(e){ return 'ERR';} })() }); + try{ var b100=NB.b(100); send({type:'b100', v:String(b100)}); }catch(e){ send({type:'b100err', e:String(e).slice(0,80)}); } + }catch(e){ send({type:'err', e:String(e).slice(0,150)}); } + }); +}, 9000); +""" + + +def adb(*a): + return subprocess.run(["adb", "-s", SERIAL, *a], capture_output=True, text=True) + + +def main(): + adb("shell", "am", "force-stop", PACKAGE) + time.sleep(1.5) + d = frida.get_device_manager().add_remote_device(REMOTE) + pid = d.spawn([PACKAGE]) + print(f"[*] spawn {pid}", flush=True) + s = d.attach(pid) + s.create_script(ART_CALLSITE.read_text()).load() + seen = set() + regs = [] + + def on_m(m, data): + p = m.get("payload") or {} + t = p.get("type") + if t == "rn": + for mm in p.get("methods", []): + k = (mm["name"], mm["sig"], mm["mod"], mm["off"]) + if k not in seen: + seen.add(k) + regs.append(mm) + print(f"[RN] {mm['name']} {mm['sig']} @ {mm['mod']} {mm['off']}", flush=True) + elif t in ("rn-hooked", "cls-loaded", "trigger", "b100", "b100err", "err", "tid"): + print(f"[*] {t}: {json.dumps(p, ensure_ascii=False)[:220]}", flush=True) + elif t in ("ol", "ol-out", "jni-hooked"): + print(f"[*] {t}: {json.dumps(p, ensure_ascii=False)[:160]}", flush=True) + elif m.get("type") == "error": + print("[JS-ERR]", m.get("description")[:120], flush=True) + + sc = s.create_script(JS) + sc.on("message", on_m) + sc.load() + d.resume(pid) + time.sleep(17) + print(f"[*] total unique regs: {len(regs)}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tools/unidbg/hydev/merge_decrypted.py b/tools/unidbg/hydev/merge_decrypted.py index 97f3db5..c30371a 100644 --- a/tools/unidbg/hydev/merge_decrypted.py +++ b/tools/unidbg/hydev/merge_decrypted.py @@ -1,45 +1,112 @@ #!/usr/bin/env python3 -"""把 FULL dump 的解密数据合并进 ORIGINAL .so (元数据完好, unidbg 直接可加载). +"""通用解密合并 (v3): 按 PT_LOAD 映射 vaddr<->文件偏移, 把 FULL dump 的解密内存 +合入 ORIGINAL .so 的正确文件位置; 数据段内陈旧运行期指针清掉. -关键洞察: unidbg 加载原文件时会自己按 .rela.dyn 重定位(RELATIVE=base+addend, -GLOB_DAT/JUMP_SLOT=按符号解析) —— 重定位槽内容会被覆盖, 所以只需要把 -"非重定位的加密数据"(字符串/密钥/常量)换成解密字节即可. -映射: dump 偏移(vaddr 轴) V -> 原文件偏移 = V - 0x1000 (V>=0x3b21c0) 否则 V. -随后把数据区里残留的运行期指针(>=0x4000000000)统一回退/清零. 用法: python3 merge_decrypted.py """ from __future__ import annotations import struct, sys +def phdrs(data): + e_phoff = struct.unpack_from(' 原文件偏移""" - return v - 0x1000 if v >= 0x3b21c0 else v + ph = phdrs(orig_raw) + secs = sections(orig_raw) + dyn_ranges, zero_ranges = [], [] + for s in secs: + if s['name'] == '.dynamic': + dyn_ranges.append((s['offset'], s['offset'] + s['size'])) + if s['name'] in ('.init_array', '.fini_array'): + zero_ranges.append((s['offset'], s['offset'] + s['size'])) - # 1) 复制解密数据: 覆盖 [0x30000, 0x3eb000) (rodata+data), 含 text 尾部(相同, 无害) - COPY = [(0x30000, 0x3eb000)] - for a, b in COPY: - for off in range(a, b): - pass # 慢, 用切片 - # 切片方式: 逐块映射(避免逐字节) - dst_start, dst_end = 0x30000, min(0x3eb000, len(dd)) - # 一次性: dump[0x30000:0x3b21c0) -> orig[0x30000:0x3b21c0) (v<0x3b21c0 1:1) - s1, e1 = 0x30000, 0x3b21c0 - od[s1:e1] = dd[s1:e1] - # dump[0x3b21c0:0x3eb000) -> orig[0x3b11c0:0x3ea000) - s2, e2 = 0x3b21c0, min(0x3eb000, len(dd)) - d2 = e2 - 0x1000 - if d2 <= len(od): - od[s2 - 0x1000:d2] = dd[s2:e2] - print(f"[*] data merged: dump[{s1:#x}:{e1:#x}]->orig[{s1:#x}] ; dump[{s2:#x}:{e2:#x}]->orig[{s2-0x1000:#x}:{d2:#x}]") + # 按段复制: 数据段文件偏移 <- dump(运行时 vaddr=文件段 vaddr) 内容 + DATA_SECS = ('.rodata', '.data.rel.ro', '.got', '.got.plt', '.data') + n_copied = 0 + for s in secs: + if s['type'] == 8 or s['name'] not in DATA_SECS: + continue + vaddr, foff, sz = s['addr'], s['offset'], s['size'] + if vaddr + sz > len(dd) or foff + sz > len(od): + print(f"[!] skip {s['name']}: out of bounds vaddr={vaddr:#x} off={foff:#x} sz={sz:#x}") + continue + if dd[vaddr:vaddr+sz] != od[foff:foff+sz]: + od[foff:foff+sz] = dd[vaddr:vaddr+sz] + n_copied += sz + print(f"[*] merged {n_copied:#x} bytes via section mapping") + + for a, b in dyn_ranges: + od[a:b] = orig_raw[a:b] + print(f"[*] kept original .dynamic [{a:#x}:{b:#x})") + for a, b in zero_ranges: + od[a:b] = b'\x00' * (b - a) + print(f"[*] zeroed [{a:#x}:{b:#x})") + + # 清理只作用于重定位目标槽: vaddr->文件偏移, 越界运行期指针清零/模块内回退 + R_AARCH64_RELATIVE, R_AARCH64_ABS64 = 1027, 257 + R_AARCH64_GLOB_DAT, R_AARCH64_JUMP_SLOT = 1025, 1026 + def v2foff(vaddr): + for s_ in secs: + if s_['type'] == 8 or s_['size'] == 0: + continue + if s_['addr'] <= vaddr < s_['addr'] + s_['size']: + return s_['offset'] + (vaddr - s_['addr']) + return None + n_zero = n_rebase = 0 + for s_ in secs: + if s_['name'] not in ('.rela.dyn', '.rela.plt') or s_['type'] == 8: + continue + for i in range(0, s_['size'], 24): + r_off, r_info, _ = struct.unpack_from(' len(od): + continue + v = struct.unpack_from(' {out}") diff --git a/tools/unidbg/hydev/src/hydev/HyDeviceId.java b/tools/unidbg/hydev/src/hydev/HyDeviceId.java index 536c13a..d61b386 100644 --- a/tools/unidbg/hydev/src/hydev/HyDeviceId.java +++ b/tools/unidbg/hydev/src/hydev/HyDeviceId.java @@ -98,10 +98,18 @@ public class HyDeviceId extends AbstractJni { vm = emulator.createDalvikVM(); vm.setJni(this); - vm.setVerbose(false); + vm.setVerbose(true); DalvikModule dm = vm.loadLibrary(new File(soPath), false); dm.callJNI_OnLoad(emulator); module = dm.getModule(); + // 第二库: libudbauthunify (NativeBridge.klog/b 等 native 注册处, JNI_OnLoad@0x262620) + try { + DalvikModule udb = vm.loadLibrary(new File("/Users/yml/codes/douyu_login_py/tools/unidbg/hydev/so/libudbauthunify_merged.so"), false); + udb.callJNI_OnLoad(emulator); + System.out.println("[*] libudbauthunify loaded base=0x" + Long.toHexString(udb.getModule().base)); + } catch (Throwable t) { + System.out.println("[!] udb load failed: " + t); + } NE = vm.resolveClass("com/huya/security/hydeviceid/NativeEntry"); System.out.println("[*] lib loaded base=0x" + Long.toHexString(module.base)); } @@ -129,6 +137,7 @@ public class HyDeviceId extends AbstractJni { vm.resolveClass("android/content/ContextWrapper", vm.resolveClass("android/content/Context"))).newObject(signature); } if (signature.equals("com/huya/security/hydeviceid/NativeBridge->b(I)Ljava/lang/String;")) { + System.out.println("[JNI] NativeBridge.b FALLBACK-STUB (native not registered?)"); int i = vaList.getIntArg(0); // 真机实测值 (2026-08-28): 100=GUID 101= MID 102= MID 106=64hex, 99/103=null, 104/105="" String v = null; @@ -200,6 +209,12 @@ public class HyDeviceId extends AbstractJni { System.out.println("[JNI] SharedPreferences.edit()"); return new PrefsEditor(vm); } + case "android/content/SharedPreferences$Editor->remove(Ljava/lang/String;)Landroid/content/SharedPreferences$Editor;": { + String rk = strArg(vaList, 0); + System.out.println("[prefs-remove] " + rk); + PREFS.remove(rk); + return dvmObject; + } case "android/content/SharedPreferences$Editor->putString(Ljava/lang/String;Ljava/lang/String;)Landroid/content/SharedPreferences$Editor;": { String k = strArg(vaList, 0); String v = strArg(vaList, 1); @@ -236,6 +251,19 @@ public class HyDeviceId extends AbstractJni { } } + @Override + public DvmObject getStaticObjectField(BaseVM vm, DvmClass dvmClass, String signature) { + // android/os/Build 静态字段读 + if (signature.equals("android/os/Build->BRAND:Ljava/lang/String;")) return new StringObject(vm, "Xiaomi"); + if (signature.equals("android/os/Build->MODEL:Ljava/lang/String;")) return new StringObject(vm, "M2102J2SC"); + if (signature.equals("android/os/Build->MANUFACTURER:Ljava/lang/String;")) return new StringObject(vm, "Xiaomi"); + if (signature.equals("android/os/Build->DEVICE:Ljava/lang/String;")) return new StringObject(vm, "thyme"); + if (signature.equals("android/os/Build->BOARD:Ljava/lang/String;")) return new StringObject(vm, "thyme"); + if (signature.equals("android/os/Build->FINGERPRINT:Ljava/lang/String;")) return new StringObject(vm, "Xiaomi/thyme/thyme:11/RKQ1.200826.002/V12.5.9.0.RKACNXM:user/release-keys"); + noteUnhandled(signature); + return null; + } + @Override public int callIntMethodV(BaseVM vm, DvmObject dvmObject, String signature, VaList vaList) { try {