首页
社区
课程
招聘
[原创] 京东 jdgs 签名参数逆向:OLLVM + 自定义 VM 双重保护下七字段全还原
发表于: 1天前 892

[原创] 京东 jdgs 签名参数逆向:OLLVM + 自定义 VM 双重保护下七字段全还原

1天前
892

平台: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 二维码

传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!

收藏
点赞 ・25
打赏
分享
最新回复 (25)
雪    币: 0
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
2 楼
你这个纯ai的吧,我看到了好多熟悉的词。ai面前所有客户端防御就如同渣渣一样,只有服务端能挡住了
1天前
0
雪    币: 0
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
3 楼
所有的主流app已经全部纯算了
1天前
0
雪    币: 6
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
4 楼
666
1天前
0
雪    币: 586
活跃值: (265)
能力值: ( LV3,RANK:20 )
在线值:
发帖
回帖
粉丝
5 楼
没那么简单
1天前
0
雪    币: 0
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
6 楼
xiao_qi_lin 没那么简单
我刚入门,用ai都随便解这个app了,在ai面前没有啥难度的。不用学,不用会,谨慎点说花一个星期学会个入门就行。剩下的就是花token的事。
1天前
0
雪    币: 1387
活跃值: (1920)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
7 楼
666
1天前
0
雪    币: 275
活跃值: (461)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
8 楼
666
1天前
0
雪    币: 317
活跃值: (700)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
9 楼
666
1天前
0
雪    币: 9504
活跃值: (8152)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
10 楼
感谢分享
1天前
0
雪    币: 7
活跃值: (617)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
11 楼
滴滴滴
1天前
0
雪    币: 1200
活跃值: (765)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
12 楼
55555
1天前
0
雪    币: 0
活跃值: (855)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
13 楼
66666666666666666666
23小时前
0
雪    币: 83
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
14 楼
66666
19小时前
0
雪    币: 389
活跃值: (8310)
能力值: ( LV3,RANK:30 )
在线值:
发帖
回帖
粉丝
15 楼
mb_adiebzjv 我刚入门,用ai都随便解这个app了,在ai面前没有啥难度的。不用学,不用会,谨慎点说花一个星期学会个入门就行。剩下的就是花token的事。
你还花token,我用豆包就能解了
19小时前
1
雪    币: 200
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
16 楼
人人拿下纯算时代
19小时前
0
雪    币: 1901
活跃值: (6043)
能力值: ( LV4,RANK:40 )
在线值:
发帖
回帖
粉丝
17 楼
1111
17小时前
0
雪    币: 0
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
18 楼
拿下纯算时代
17小时前
0
雪    币: 325
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
19 楼
mb_gyvxsxkg 拿下纯算时代
开课吧 我报名 实战就行了
15小时前
0
雪    币: 344
活跃值: (2946)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
20 楼
6666
14小时前
0
雪    币: 3259
活跃值: (3900)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
21 楼
666
10小时前
0
雪    币: 0
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
22 楼
6666
5小时前
0
雪    币: 149
活跃值: (1779)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
23 楼
666
4小时前
0
雪    币: 59
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
24 楼
666
3小时前
0
雪    币: 20
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
25 楼
mb_adiebzjv 我刚入门,用ai都随便解这个app了,在ai面前没有啥难度的。不用学,不用会,谨慎点说花一个星期学会个入门就行。剩下的就是花token的事。
纯算现在烂大街了,而且光还原个算法也没啥卵用,看看外边还多少人跑得动协议脚本的
2小时前
0
游客
登录 | 注册 方可回帖
返回