平台:Android / ARM64 · 目标:com.jingdong.app.mall(京东 App)· 目标参数:HTTP/2 请求头 jdgs
工具:eCapture、jadx、Frida、IDA Pro、Python
耗时:约一天 · 结果:jdgs 七个字段 b1~b7 全部还原并离线复现
本文是一篇完整的实战复盘,按时间线还原我从「抓到 1386 个错误码」到「七字段签名全部离线复现」的全过程,包括我走过的每一条死路和犯过的每一个错。
关于脱敏:设备根密钥 KEY_B(及其派生 KEY_A)已打码;设备指纹 UUID、eid、证书 MD5 保留真实值(作者自己的测试机)。
真正卡死我的三块硬骨头:OLLVM 控制流平坦化、一个 85 条指令的自定义 VM、一个"IV 和 MD5 共用导致 SHA-1 被误判不存在"的陷阱。
目标是还原京东 App 网络请求里的 jdgs 签名参数。这个参数出现在每一个 HTTPS 请求的自定义头里,是京东风控/防刷的一部分——签名不对,服务端直接拒绝。
我手头的环境:
一点说明:测试机是一台定制系统(带内核级 hook 能力),但本文用到的抓包、Frida、IDA 都是通用能力,你在普通 root 机上也能复现大半。
jdgs 在 HTTPS 请求的 header 里,明文被 TLS 加密。我不打算装 CA 证书去劫持(京东 App 很可能有证书校验),直接用 eCapture 走 eBPF/uProbe 在 libssl.so(conscrypt/BoringSSL)的加密函数入口抓明文,天然绕开证书固定。
命令(设备侧,后台跑,按 UID 过滤):
抓了 13.99MB 日志,筛 jdgs 字段。结果出来了,但全是一个值:
-3107 明显不是签名,是个错误码。真实 jdgs 应该是长字符串(base64/hex)。这说明 Guard SDK 检测到了"风险环境"(root + 定制系统插桩),拒绝生成真实签名。
第一个坑就这么来了:环境太"干净"反而拿不到真实签名。逆向签名算法,第一步先要"过检测拿到真实样本",否则后面全是空转。
先不管检测,把签名生成逻辑在 Java 层定位清楚。jadx 反编译 APK,搜 jdgs 字符串,很快定位到调用链:
几个关键点:
-3107 是 Java 层的硬编码出口,不是 native 检测失败。触发条件是 oc.a.d(一个 AtomicBoolean)为 true。
canonical 规范化字符串(这是签名的核心输入):
参数先各自 URL 编码,然后先按 value 排序、再按 key 排序;如果 body 不是 application/x-www-form-urlencoded,就在末尾追加 body 的 SHA-256 hex。
Bridge.main(int, Object[]) 是通用分发器,第二个参数是 Object[],五个元素:
-3107 的根因也定位了:JDGuardHelper.enable() 读远程配置 enable_jdguard,本机返回非 "1",于是 oc.a.d=true,Java 层直接短路,native 的 main(101) 从未被真正调用过。
到这里我意识到:要拿真实签名,得先让 enable() 返回 true,让流程真正走到 native。
native 库是 libjdg.so(System.loadLibrary("jdg"))。先看 ELF 结构:
结果:
完整的段布局(手解 ELF64 得到,后面分析常量全靠它):
关键记忆点:.rodata 的 VA 等于文件偏移(delta 0),但 .data/.data.rel.ro 要 +0x1000。两个 LOAD 段的边界就在这——seg1 覆盖 0x0 起、seg2 从 off=0x98e48 起对应 vaddr=0x99e48。后面所有常量(IV、K 表、方法表)的地址都要按这张表核对,否则差 0x1000 会指向完全错误的位置。
⚠️ 这里埋了第一个大坑,我后来才踩到:第二个 LOAD 段 file offset 和 vaddr 差 +0x1000。读 .data.rel.ro/.rodata 里的常量时,必须把文件偏移 +0x1000 才是真实 VA。我一开始没注意,导致 SHA-256 IV 地址算错,白白绕了一段路。这个 delta 写进笔记里,后面所有常量地址都要核对一遍。
JNI_OnLoad @0x29a30 是个跳转壳,真实函数体在 @0x29a78。它调 RegisterNatives(JNI 偏移 0x6b8)注册了方法表 @0xa03b0:
main 分发器 @0x29d1c 的逻辑:
我要追的是 op=101(jdgs 签名)→ 0x29d94。
一进 op-101 就发现不对,每个基本块开头都重复出现两行:
这是 OLLVM 的虚假控制流(BCF,Bogus Control Flow):往几个永远没人读的固定栈偏移写 0,再基于这些恒定的值制造大量永远不会走的垃圾分支。效果是 IDA 的 F5 反编译出一屏又一屏 if ( v20 == 0x154 ) 之类的死代码,把真实逻辑淹没掉。识别特征就是这种「str wzr 到固定栈偏移」在基本块里高频重复。
第二个特征是跳转壳。几乎每个函数——JNI_OnLoad、Bridge.main、所有 hash 入口——它的"入口"都不是真实函数体,而是一小段:
它把「函数入口」和「真实函数体」拆成两段,中间用 br x9 间接跳转。IDA 默认无法确定 br x9 的目标,于是函数边界被击穿——一个函数被切成几段、几个函数被粘成一段。JNI_OnLoad @0x29a30 → 0x29a78、Bridge.main fn_ptr 0x29ce4 → 0x29d1c 都是这个套路。
第三个特征是控制流平坦化(CFG flattening):op-101 主体是一个大 switch(状态变量),真实逻辑被打散成一堆 case 块,靠状态变量决定下一个块。crypto 调用又经 blr / br x9 间接跳转,静态调用图上连不起来。
arm64 是定长 4 字节指令,我直接用 capstone 逐 4 字节扫整个 .text,绕开两个坑:① IDA 被跳转壳切碎的函数边界;② capstone 一次性 disasm 遇到「非法指令」就提前停(我第一版只扫出 719 条 bl、337 个 decrypt_str,其实都是漏的)。
全量扫出的真实数字:
719 → 6971,差了近 10 倍。如果信了第一版的 719,后面所有"调用者分析"都建立在一张残缺的图上。 这是 OLLVM 对抗的第一条铁律:先拿到一张完整的、可信的静态图,再谈分析。
sub_46F50 / sub_47240 一开始被我标记成「签名核心」。后来解密它们的字符串才发现全是 /proc/net/tcp、TracerPid:、/memfd……是反调试函数,不是签名。OLLVM 把控制流打散后,光看「谁调了谁」很容易把「签名前被调用的函数」误当成「签名的一部分」——它确实在 op-101 路径上被调用,但只是签名前跑一遍环境检测。
教训:OLLVM 下「谁调了谁」≠「谁参与了算法」。 一个函数在签名路径上被调用,不代表它参与计算,可能是前置的环境检测、日志、参数准备。判定一个函数是不是算法核心,唯一可靠的手段是动态看它的输入输出,而不是静态看它被谁调用。
decrypt_str @0x19ddc 是个滚动 XOR:
key 是初始值,每字节 +3。我写了个脚本 decrypt_strings.py 把 so 里所有加密字符串批量解密,验证用类名命中:
用 capstone 逐 4 字节反汇编(arm64 定长指令,一次性 disasm 遇到非法指令就停,会漏),全 .text 扫出 115290 条指令 / 6971 条 bl / 946 个 decrypt_str 调用点,批量解密后得到一批关键字符串:
一个反向可达分析的重要结论:0102030405060708、WU@EVA、F1D3FF... 这些"看起来像密钥/盐"的字符串,全部属于独立的 eva 上传/加密模块,不在 jdgs(op-101) 签名路径上。jdgs 路径里没有 AES 密钥/盐。这是用调用链反向可达分析排除的——别看到密钥材料就往签名里套,先确认它在不在你要追的那条链上。
op-101 在真正签名前会先跑一遍环境检测,libjdg.so 里藏着一条完整检测链:
这就是为什么 Frida attach 即被杀、root 环境拿不到真实签名。它解释了 -3107 背后那层"环境检测"到底是什么。
逆向写到一半,我发现之前几个"板上钉钉"的结论是错的。这些错单独拎出来,因为它们恰恰是最值得记的。
早期我看 wrapper @0x232cc 里有 NEON 指令:
这是把每个字节拆成两个 nibble,映射到 '0'-'9' + 'A'-'F' 的大写 hex 编码。我当时判断它是"证书指纹的哈希",但归因成了 SHA-1。
真相:它调的是 MD5 core(0x18190),得到 16 字节,再用 NEON 转成 32 字符大写 hex。证书指纹 = MD5(DER) 的大写 hex,不是 SHA-1。
我搜遍了 so 找 SHA-1 的轮常量 0x5a827999、0x6ed9eba1、0x8f1bbcdc、0xca62c1d6 和第五个 IV 字 0xc3d2e1f0,磁盘上搜不到。于是下结论"SHA-1 不存在"。
这个结论后来被推翻,而且推翻的方式很打脸:SHA-1 的前 4 个 IV 和 MD5 完全一样,共用同一个 xmmword_81000;只有第 5 个 IV 字 0xC3D2E1F0 是 SHA-1 独有,而它是用 MOV/MOVK 立即数构造的,不是常量表,所以字节搜索永远搜不到。
教训:搜不到常量 ≠ 算法不存在。 常量可能用立即数构造、可能被混淆、可能在运行时解密。字节搜索只能做"阳性"证据,做不了"阴性"证据。
0123456789abcdefABCDEFxX+-pPiInN @0x82600,我一度以为 jdgs 用它做 hex 编码。全 so 一搜,只有 4 个引用,全在 printf/money 模块(0x5759c、0x59128),跟 jdgs 没关系。
sub_46F50 / sub_47240,我一开始标记为"签名核心"。解密它们的字符串后发现全是 /proc/net/tcp、TracerPid:、/proc/self/maps、/memfd、/proc/%s/cmdline——是反调试函数,不是签名。op-101 只是在签名前调它们做环境检测。
过检测时我想 hook 静态 native Bridge.main 保存原实现,结果 this.main / saved impl 都拿不到(静态 native 方法的 Frida 保存实现不可用)。最后改成 hook 实例方法 qc.i.I() 捕获入参/出参才绕过去。
前面提过 Frida attach 会直接被检测(进程被杀)。实测:
所以动态要非常小心。我的过检测方案(route_a_v4.js):
然后在实例方法 qc.i.I() 上 hook,捕获 Bridge.main(101) 的入参和返回值。
结果出来了,直接推翻了我之前的静态结论。
真实 jdgs 长这样:
jdgs 是一个 JSON 对象 {b1..b7},不是我之前静态推的 "MD5大写hex + SHA256 + 时间戳;计数 + 包信息" 拼接串!
静态分析在 OLLVM 的掩盖下,把 JSON 装配误判成了字符串拼接。动态一跑,真相大白。这再次说明:OLLVM 混淆下的纯静态分析,结论必须用动态验证兜底。
我写了 route_a_controlled.js 直接调 Bridge.main(101, {canonical, v, u, w, s}),一次只改一个变量:
关键观察:同 canonical 连调两次,b5 相同、b4/b6 不同(时间戳影响 b4/b6,不影响 b5)。
这给我一个重要线索:b5 是 canonical 的纯函数,不含时间戳;b6 含时间戳。
b5 是 40 hex(20 字节),明显是某种 160-bit 哈希。但要确定它到底是什么,静态 OLLVM 太硬,我改用动态"黑盒"手段。
hook MD5 core @0x18190,抓 MD5#1 的输入(= 变换后的 canonical)。做受控差分(多个 canonical,s=83):
结论:变换是逐字节 XOR,同长度不同内容 → 密钥流相同(内容无关),但密钥流随长度变化。
我当时卡在这里:序列 62→46→75→76→36→62 像个 5 元环,但 L=5 起点异常,缺 L=4 样本。后来证明这个"长度依赖密钥流"的表象是个假象——真相是 KEY_A 的循环 XOR 折叠(见第九回合)。
受控差分时我把 s()(配置 ano)从 83 改成 0,意外发现签名走了一条完全不同的简化路径:
这个 s=0 分支帮我把两个前提先钉死:
教训:逆向签名时先摸清每个入参各自控制哪个分支。ano 这种"看着像版本号"的字段实际是算法选择器——不试 s=0 就不知道存在一条更简单的路,也不会顺手拿到"key 固定"这个免费结论。
篡改 MD5#1 的输出,观察 b5 变化:
于是 b5 的形态锁定为:
但 H 是什么?我用 SHA-1 / SHA-256 截断 / MD5 对 m1 的各种拼接做了离线穷举,无一匹配。于是陷入了"SHA-1 不存在"的死胡同(这就是第三回合错 2 的由来)。
既然猜哈希猜不出来,我换思路:不猜,直接用证据钉死 s=83 到底调了哪些哈希。
两条线并行:
OLLVM 下每个 hash 都配一张方法表:{函数指针0, 函数指针1} + IV + 常量表。直接扫 .data 段里「相邻两个指向 .text 的指针 + 后面跟着已知常量」的模式,挖出 3 张表:
但方法表里的函数指针本身也是跳转壳,要手动去壳追 br x9 的真实目标:
去壳后的地址才是真实函数体。这一步把「方法表」和「真实算法」连上了。
@0x9e140 那三张表值得单独说:我一度怀疑它是"第三个 160-bit hash",因为它的 72B 数据里 f3(0x1f404)有一处 decrypt_str(dst=0x9e196, src=0x9e210, key=0xb, len=0x14),离线用滚动 XOR(key=0xb)解密出 ro.build.version.sdk——是环境检测字符串,不是 hash 常量。再全 .text 扫 adrp 0x9e000 + add #0x140(引用这张表的指令)= 0 处(对照 SHA256 方法表 add #0x5a0 只有 0x26a68 一处)→ @0x9e140 是死数据/反检测,与签名路径无关。
方法表枚举 + 去壳,是 OLLVM 下绕开"函数边界被击穿"的最有效静态手段:不依赖 IDA 的交叉引用,直接靠「数据段里的函数指针对 + 已知常量」定位入口,再手工追 br x9。
scan_sha1.js 在签名前后扫描 libjdg.so 内存,找各类哈希的 IV/常量:
阳性对照(MD5/SHA256)都命中了,说明扫描方法有效。SHA-1/RIPEMD-160 的常量签名前后都不存在。
静态搜字节:AES S-box/invSbox/Rcon、ChaCha20/Salsa20 的 expand 32-byte k、Blake2s IV,全部 0 命中。
结论:libjdg.so 里只有 MD5 + SHA256 两个哈希原语。
hook_all_crypto.js 同时 hook 所有已知 crypto 入口,看 s=83 签名到底调了什么:
也就是说,b5(20B) + b6(20B) = 40B 输出,只能由 m1(16B) + m2(16B) + key(32B) 通过纯字节操作构造,签名路径上没有任何额外哈希调用。
这跟"b5 是 160-bit 哈希有雪崩"的现象矛盾了——没有哈希,哪来的雪崩?这个矛盾是全文最关键的一个疑点,它逼着我去查 0x63xxx 区到底藏了什么。
0x63dcc 只出现在 MD5#1 链里,正好落在可疑的 0x63xxx 区。canonical → m1 → b5 的关键路径确实经过 0x63xxx。
上一回合的"运行时扫描排除 SHA-1"是错的,或者说是不完整的。我后来在 hook 分发器时,抓到了 SHA-1 的真实输入输出,才揭开真相。
hook 分发器 0x22b50 + SHA-1 入口 0x1554c + MD5 后处理 0x22850,样本 canonical="A" s=83:
离线验证:
SHA-1 确实存在! 我之前"搜不到常量所以不存在"的结论是错的,根因就是错 2 里说的:
所以无论磁盘搜字节还是运行时扫常量,都永远找不到 SHA-1 的完整 5 字 IV 序列——前 4 字虽然与 MD5 共用 0x81000(会被当成 MD5 命中),但第 5 字 0xC3D2E1F0 是立即数构造,导致 5 字完整序列在内存里根本不存在。这就是第六回合运行时扫描"排除 SHA-1"却漏判的真正原因:扫描的是完整 5 字序列,而它在内存里不存在。
最终确认的函数语义:
于是 b5 公式清晰了一半:
只剩 X25 和 keystream 两个未知。
sub_27688 是 16B → 32B 的变换。走到这一步,前面 OLLVM 的账该还了——上一回合我能靠 hook 绕过去,这一回合绕不过去:X25 的算法藏在控制流混淆下面,不把这一层撕开,永远只能黑盒猜。
先在 IDA 里对 sub_27688 按 F5,得到的伪代码只有一行:
JUMPOUT 说明函数入口是 OLLVM 的跳转 thunk——真正的函数体被藏到了 0x276E0,控制流先过一层间接跳转才到。dump 出真身汇编,能看清这层"接力"是怎么做的:
跳过 thunk 看真身,sub_27688 的结构豁然开朗——标准的哈希函数三段式:
init / update / finalize 这个骨架,做哈希的人一眼就认得。但怪就怪在:三个函数里看不到任何 SHA/MD5 的轮运算,只有一堆 memcpy 和函数调用。真正的算法去哪儿了?
继续挖 sub_2777C(init),关键线索就藏在开头这几行:
decrypt_str + sub_241FC(加载)+ sub_24420(执行)这三件套一出现,性质就定了:这是一台带自解密字节码的 VM。sub_24420 是解释器,unk_9F510 是它吃的字节码。
于是我用 IDA 脚本 ida_dump_vm_full.py 把解释器整段(0x24478–0x24F00)导出来,第一眼就是决定性证据:
一个 LDRB 取指 + 一张 85 项的跳转表 + BR 分发——这是教科书级的字节码解释器主循环。到这里,"它是个 VM"不再是猜测,而是摆在面前的汇编事实。
三张字节码表,每张都有 magic 头 0xB3B6A81B:
表头结构(之前理解错了,dump 出真实字节后才修正):
验证一下:update 表总大小 = 0x18 + 48 + 576 + 16 = 648B,finalize 表 = 0x18 + 28 + 1728 + 16 = 1784B,和磁盘上看到的完全一致。表结构对了,后面的反汇编才有意义。
init 表在磁盘上是加密的,decrypt_str 的还原很简单,是位置相关的流式异或:
key=2,所以第 i 字节 XOR (2 + 3i) mod 256。这个"步进 3"的异或不是为了加密强度,纯粹是防静态扫描——让 strings 和 IDA 的常量搜索永远抓不到明文字节码。这也解释了为什么一开始对着磁盘 so 分析会一头雾水:真正的字节码要等运行时才现形。
表结构清楚后,写反汇编器就是体力活:把解释器每个 case 的语义读出来,映射成指令。sub_24420 是栈机,寄存器约定:
完整指令集 85 条,我反汇编器(vm_disasm.py)里的 INSN 映射:
立即数长度:push_i32 / push_addr_* / br* / jmp / add_sp 是 4 字节,push_i64 是 8 字节,其余 0 字节。指令总长 = 1(opcode)+ 立即数长度,反汇编器据此线性扫描。
85 条指令里最特殊的是 0x4f syscall。它跳进 sub_24F00,一个 11-case 的分发器:
11 个 case 的共同套路是同一个:从 VM 栈顶取函数指针,BLR X9 间接调用,区别只在于参数个数/宽度和返回宽度。举两个:
也就是说,VM 自己不算 SHA-256——它通过 syscall 把函数指针和参数从字节码里取出来,BLR X9 调宿主的原生函数。VM 的职责是"编排":喂数据、调度压缩函数、再套一层后处理。
有了反汇编器,init 表的字节码现了原形。开头一段长这样:
push_i32 + push_i32 + xor32 这个模式重复了 8 次。把 xor 一个个算出来:
6A09E667 BB67AE85 3C6EF372 A54FF53A 510E527F 9B05688C 1F83D9AB 5BE0CD19——这是 SHA-256 的 8 个标准初始 IV,一个不差。
关键在第二个立即数:0xfffe69b6, 0xfffe69b5, 0xfffe69b4, ... 是一串递减 1 的密钥序列,第一个立即数才是被它"加密"过的 IV 原值。VM 把 SHA-256 的魔数故意拆成"两数异或",让 strings 和常量扫描永远搜不到裸的 6A09E667。你只有把字节码还原、把 xor 真正执行出来,才能看到它。
这就是"逆向 VM 本身"和"拿 unidbg 跑起来黑盒猜"的分水岭:黑盒永远看不到这一层混淆,只能靠比对标准 SHA-256 输出发现"对不上",却不知道为什么对不上。
反汇编 update / finalize 表时,有几处 push_i64 的立即数长得像"假地址",我在反汇编器里标成了 RELOC:
表头的 nreloc 字段记录的就是这些位置:update 表 4 处(0x8d / 0x156 / 0x186 / 0x1e7,立即数全是占位值 0x240),finalize 表 2 处(0xc4 / 0x1f3,占位值 0x6c0)。这些 8 字节立即数在加载时被替换成宿主函数的真实地址——所以字节码不是自包含的,它和宿主 so 的函数绑定在一起。静态 dump 看到的 0x240 是占位符,必须理解重定位语义,ld_i64 才不是"从一个野地址读",而是"取出真正的函数指针"。
finalize 表末尾,先逐字节 push 两张 32 字节常量表,再一个循环做后处理。push 出来的字节(按偏移排)正好就是之前黑盒死活匹配不上的那两个表:
然后是核心循环:
翻译成伪代码就是一层后处理:
之前测 SHA256(m1) 匹配不上,正是漏了这层 POST。它是 VM 在标准 SHA-256 输出上额外套的一步混淆,两张常量表直接埋在 finalize 表的字节码尾部。
于是 X25 的完整真相:
验证:8/8 黑盒样本命中(scripts/verify_x25.py)。
这里揭示了一个很有代表性的保护手法:OLLVM 混淆 + 自定义 VM 双重保护。OLLVM 让每个函数入口都变成 BR X9 的跳转 thunk,控制流读不懂;VM 再把真正的 SHA-256 压缩函数藏进一段自解密字节码里——初始化向量用"双立即数异或"藏、函数调用用重定位表藏、后处理常量塞进字节码尾、最后还套一层"加 t1 再异或 t2"的混淆。四重手段叠下来,黑盒既匹配不上标准 SHA-256,静态分析也看不到任何裸魔数。唯一的出路就是把 VM 本身反汇编出来,逐条还原它的字节码语义。
X25 破了,b5 就剩 keystream(sub_226E0 的 XOR 密钥)没还原。
我一开始把 sub_226E0 当成"keystream 生成器",看汇编后发现又错了——它是一个通用循环 XOR 工具函数:
真正的密钥是调用方传入的 buf_b,由 sub_1E228 + sub_22410 从全局单例 + 入参生成。
hook sub_226E0 抓到两个静态密钥(跨冷启动稳定):
脱敏说明:本文隐藏 KEY_B(64B 设备根密钥)和 KEY_A(36B,是 KEY_B 的派生,公开可反推 KEY_B),以 <KEY_B> / <KEY_A> 占位。KEY_B 是一段 64 字符的 ASCII 串(形如 iy0yIVKJ…,已打码),KEY_A 是一段 36 字节的二进制(hex 形如 7e11060b…,已打码)。此外 s=0 简化分支里还有一把 32B 固定 key(<32B固定key>),与 KEY_B 的派生关系尚未建立,同样打码。读者需在自己设备抓取这些值,抓取方法见后文(第十回合)。
于是 b5 全链打通:
verify_full_b5.py 10/10 命中:
第五回合那个"长度依赖的 5 元环密钥流",真相就是 KEY_A 的循环 XOR 折叠——因为它对短于自己的 canonical 做"末尾覆盖",才表现出那种诡异的长度相关规律。
具体拆一个例子(canonical="hello",5 字节,KEY_A 长 36 字节)。sub_226E0 走 len_a < len_b 分支:
因为 36 不是 5 的倍数,每个 out[j] 最终保留的是 最后一个 i % 5 == j 的那次 XOR:
所以 L=5 的密钥流 = [KEY_A[35], KEY_A[31], KEY_A[32], KEY_A[33], KEY_A[34]]。KEY_A 末尾字节是 ...46 75 76 36 62,对应算出来正是 62 46 75 76 36——跟第五回合差分测到的一模一样。
这就是为什么 L=1 时"密钥流"恒为 0x62(= KEY_A 最后一个字节),L=2 是 36 62,L=3 是 76 36 62——它们全是 KEY_A 末尾字节的错位切片,根本不是独立生成的 keystream。第五回合那个"5 元环起点异常"的困惑,就是 36 不能被 5 整除导致的相位差。
关键:b6 的 JSON 前缀包含 b5 和 b7(即结果里 b6 之前的全部字段)。
抓到的 KEY_A 和 KEY_B 一开始被我当成两个独立静态密钥。验证后发现又是错的:
即 KEY_A 是 KEY_B 与 UUID 的循环 XOR 派生,不是独立常量。verify_key_derivation.py 逐字节验证命中。
因为 UUID(b1)是安装时固定的随机串,所以 KEY_A 看起来"静态、跨冷启动稳定",实则是对 KEY_B 的确定性派生。
结论:整个签名算法只有一个真正的秘密 = KEY_B(64B)。
这也解释了 hook 里观察到的 4 次 sub_226E0 调用:
前面脱敏里说"抓取方法见后文",就是这里。抓法很直接——hook sub_226E0,把每次调用的 buf_a / buf_b 都打出来:
按上表,第一次调用 buf_a = UUID(36B)、buf_b = KEY_B(64B)——buf_b 就是 KEY_B。UUID 是 b1 的明文,抓包里直接有。拿到 KEY_B + UUID,sub_226E0(UUID, KEY_B) 一算就是 KEY_A,所以只需抓 KEY_B 一把,KEY_A 不必单独抓。
更省事的入口:KEY_B 存在全局 map 的 "a6" 键里(见第十二回合),直接 hook sub_1DE30(map 查找)在 key="a6" 时 dump 返回值也能拿到。两者等价,sub_226E0 那个对不熟悉 map 结构的读者更直观。
七个字段里 b4 是最复杂的——245B 二进制、base64 328 字符、每请求变。它的构造链:
base64 调用链:0x2b8bc(main) → 0x205cc → 0x20f60 → sub_216C4 → 0x22354 → base64(0x2390c→0x14f4c)。
b4 明文结构:
离线复现:zlib 解压 RC4 输入得 392B 明文 JSON;RC4 复现 out 与 hook 逐字节相等。
至此 b1~b7 七字段全部还原。
最后追 KEY_B 的出生地。动态 hook sub_1DE30(map 查找函数),发现每次签名调它 2 次:
KEY_B 和 SHA1(UUID) 都是全局对象 map 里的条目。继续追初始化函数 sub_1DBA4:
a2 内容前 8 字节 89504e470d0a1a0a = PNG magic,IHDR 显示 60×60、位深 8、颜色类型 6(RGBA)。
追下去发现 sub_1C450 是一个 Data Matrix(ECC200)解码器(我之前误判成 QR 码,纠正了),内嵌完整 PNG 解码器:
Data Matrix 区域表:dword_81794=[1,0,3,1,2,0,4] 等 5 张。
KEY_B 不是 so 内置常量、不是 apk 静态资源,而是编码在一张 60×60 的 Data Matrix 二维码里,运行时解码进 map。
这张 PNG 在 so 里和 apk 资源里都搜不到,是运行时 anon 堆数据 → 要么运行时生成,要么从加密数据解密,要么网络下发。来源尚未定位(这是本文最后一个未决项,见诚实边界)。
把整条链拼起来,jdgs 的完整算法如下:
我把整条链固化成了一个脚本 jdgs_sign.py,喂入 KEY_B + 当次输入,直接吐出完整 jdgs:
核心实现(节选自 scripts/jdgs_sign.py,逐行对应 libjdg.so 的原语):
三个容易被忽略、但一旦写错就全错的细节:
自测结果(python jdgs_sign.py --self-test):
回头看,把死路串起来,真正的转折点有这几个:
逆向里最贵的不是"看不懂的代码",而是"自以为看懂了、其实是错的"的结论。每一个"板上钉钉",都值得再用动态证据锤一遍。
eCapture 抓包 → 全部是 -3107(环境检测错误码)
→ jadx 还原 Java 层调用链(canonical 规范化 + Bridge.main(101))
→ IDA 静态还原 libjdg.so(OLLVM 混淆 + RegisterNatives 动态注册)
→ Frida 过反调试拿到真实 jdgs → 推翻静态结论(是 JSON 不是拼接串)
→ 受控差分 + 故障注入 + 雪崩测试钉死 b5 是 160-bit 哈希
→ 方法表 + 全 crypto 入口 hook 排除 SHA-1/AES/RIPEMD(后被推翻一半)
→ 发现 SHA-1 其实存在(IV 与 MD5 共用,是误判根因)
→ 逆向自定义 VM 破解 X25 = POST(SHA256)
→ 破解 XOR 密钥流 = KEY_A(是 KEY_B 与 UUID 的派生)
→ 破解 b4 = base64(RC4(zlib(明文JSON)))
→ 定位 KEY_B 来源 = 一张 60×60 的 Data Matrix 二维码