pc模拟执行指令,jni libc svc转发到真实设备实现急速trace,以前在手机上跑要几个小时,现在只需要几分钟,使用llvm做指令解析,收集足够的信息,存入自定义的数据库格式,即使几十G的文件,也只需要几秒就可以查完,中间不断优化skill 跟 mcp 工具,主要突破是给了更高维度的信息,让ai不再是追踪字节计算,想办法绕过复杂计算过程,快速的进行算法识别,我本身自己有trace数据adj8的还原只用了一点时间,因为我是一直跟过来的,但是ai是没有任何提示的情况下独立完成的,强行从vm里面还原了所有计算
这篇文章不是一篇“我猜中了某个算法”的爽文。真正值得讲的是另一件事:当目标函数被 VMP、扁平化控制流、白化常量、反调试和海量 trace 淹没时,我没有让 AI 坐在反编译伪代码前面瞎猜,而是把执行轨迹做成一套可查询的数据库,再用自定义 MCP 把这些查询能力交给 AI。
结果是,AI 不再只是聊天窗口里的“逆向经验包”,而变成一个能反复查证据、写查询计划、维护错误路线、生成验证脚本的协作者。它做的不是玄学命名,而是把每个猜测压成一组问题:
最终,Adjust SDK nSign 的 176 字节输出被还原成:
这里最有意思的地方,不是最终出现了 AES 和 HMAC,而是中间我们曾经认真走过很多错路:KEY16、repeating-key XOR、"Saw" 明文、HKDF、五段 SHA 输出、自定义 byte-mixing VM、非标准 AES。它们都不是胡说八道,它们都来自当时 trace 里的真实局部现象。问题在于,局部现象不是算法结论。
我的框架解决的正是这个问题:把“看起来像”变成“证据链闭合”。
目标是 Java_com_adjust_sdk_sig_NativeLibHelper_nSign,返回 176 字节。静态上看,核心逻辑被压进巨大的扁平化函数和大量内联 step gadget 里。里面有 dispatcher、slot、linked-list 节点、字节表、白化常量和重复的位运算模板。反编译器能给出一些结构,但无法告诉你哪条数据流才是签名算法本体。
更麻烦的是 trace 本身也不好拿。早期真机 trace 跑了 9 小时,产生 1.13B 条指令和约 196GB 文本,结果卡在反调试逻辑里。根因后来被确认:目标会枚举 /proc/self/fd,而 trace 工具自身在采集期间不断制造 fd,导致真实 Linux procfs 下 readdir 永远读不完。unidbg 对照 trace 则显示同一段逻辑只需要很少的 readdir 就能返回。
这件事给了一个很重要的教训:逆向不是只有“算法难”,采集环境也会改变程序行为。如果没有 PC 统计、调用统计和 trace 对照,我们很容易把反调试死循环当成算法复杂度。
于是工具链变成了这样:
数据库层的意义很直接:几十 GB 到几百 GB 的 trace 不能靠打开文本、grep 和肉眼滚动来分析。它必须支持按 trace_id 顺序取窗口、按地址找读写、按值找出现、按寄存器找 producer、按 PC 统计执行次数、按调用层解析 libc/JNI/syscall。MCP 的意义是把这些动作变成 AI 可以调用的工具,而不是让我在聊天窗口里复制粘贴命令和结果。
这套流程的核心原则只有一句话:从输出开始,不从算法名开始。
我不会因为看到 32 字节就叫它 SHA-256,也不会因为看到 0x55、0x36、S-box 或 GF 运算就直接宣布 AES。所有结论都要从具体执行证据落地:
在这个过程中,MCP 工具承担的是证据放大器:
AI 的价值在这里开始显现。它适合维护上下文,适合把一堆 trace 片段整理成表,适合看到矛盾后提醒“这可能是跨 run 样本混用了”,也适合把手动推导翻译成可执行自检。它不适合做最终裁判。最终裁判只能是 trace 证据和前向复现。
这次分析里最值得保留的不是“我一开始就对了”,而是每条错路怎么出现、怎么被淘汰。因为这正好说明自定义 MCP 框架为什么有必要。
早期出现过一个非常诱人的 16 字节值:
当时很多局部公式可以被写成:
这条路线为什么合理?因为 trace 中确实大量出现 0x55、0x36、0xff 这样的白化/掩码常量,也确实有很多字节值能和某个 16 字节周期发生匹配。早期 PRE/OP/DSP/HEAP 的来源统计里,也能看到“唯一 KEY16 命中”的槽位。
但后来它被推翻了。关键原因有三个:
这就是值碰撞的典型陷阱。一个值出现在 trace 里,不等于它的语义就是“密钥”。它可能是白化后的 round key,可能是中间 state,也可能只是某个动态值碰巧等于你正在找的常量。
另一条很有戏剧性的错路是 "Saw"。旧分析里曾经看到:
于是很容易得出结论:明文直接进入了 B 段,甚至能在某些输出字节里被 XOR 还原出来。
这同样不是完全凭空来的。早期 trace 里确实有这些字节,局部 XOR 公式也能复现某些输出。但后来更完整的 producer 追踪和明文层恢复表明,当前 native 可见的输入主窗口是:
"Saw" 路线的问题是把单 trace 中间 operand 当成了语义明文。对 VMP 来说,一个中间槽的值可能来自 nibble 重组、白化、S-box、旧状态覆盖,不能因为它等于 ASCII 就给它命名。
这也是 AI 容易犯错的地方:它特别擅长给人类可读的字节赋予故事。但 MCP 查询让这个故事必须回答 producer 问题:这个 0x53 是从输入 copy 来的,还是从 0xb8 >> 4、ORR、EOR、mask 一路算出来的?
176 字节输出天然会让人想到扩展输出:HKDF、HMAC-PRF、若干段 SHA/HMAC 拼接。早期也确实找到过 SHA-256 IV、K 常量、HMAC ipad/opad,以及多个 SHA final-add 形态。
这条路线的合理性在于:C 段最终确实是 HMAC-SHA256,rnd32 的一部分也确实落到了 SHA-256 compression 的 final-add 层。问题是,这些证据只能说明“局部存在 SHA/HMAC”,不能说明“整个 176B 是 HKDF 输出”。
最终边界是:
而不是:
这条错路被淘汰的方式很典型:先确认标准算法的 I/O 边界,再用标准库验证。标准库跑不通,就不要继续给 HKDF 猜变体;回到 trace 找真实 producer。
早期 case-study 里,一度把 output[16:176] 解释成多段 SHA-256 digest 拼接:
这条路线来自真实的 SHA 常量命中和 add w2,w0,w21 finalization 证据。它的价值不是最终结论,而是证明了工具链能快速识别标准 SHA-256 结构:IV、K、H[i] += working[i]、message schedule。
后续更完整的 B 段端到端验证推翻了“B 是 SHA digest 拼接”。现在的结论是:
rnd32 分支里仍然有 SHA-256 compression 证据,但它属于明文 P 内的随机派生字段,不是 B 段整体结构。
最难处理的一条路线是“B 段是自定义 byte-mixing VM”。它不是简单错误,因为早期看到的现象都是真的:
如果只看局部,这就是一个复杂的自定义 VM。甚至用 trace replay 可以复现很多中间状态。但“能 replay”不等于“还原了算法”。trace replay 只是录音机,算法还原要求我们把每个 term 追到 primitive input、常量或外部来源。
最终 B 段被反转为标准 AES-256-CBC,靠的是更强的证据:
这时原先的 VM 现象才被重新解释:VM 是承载层,AES 是它承载的计算。white_sbox 是带白化的 SubBytes,GF 链是 S-box 生成和 MixColumns 的 GF 运算,KEY16 是末轮密钥白化快照,不是外部 repeating key。
这次反转很适合拿出去讲。它说明 AI + MCP 的目标不是坚持某个漂亮假设,而是让假设随证据升级甚至死亡。
输出前 16 字节是 nonce。trace 的 call layer 证明它来自 srand(time()) 和 8 次 rand(),按 4 个 word 输出:
这里有一个方法论要点:随机数返回值往往不是模块内 ALU 算出来的。纯 value-walk 或 taint 可能会在某个寄存器上断掉,甚至错误落到一个常量 mov。这时要切换到 call 层查询,用 get_call_at 或 find_callers 确认 rand@libc.so 的返回值。
这就是 MCP function/call layer 的价值。它让“追不到”变成一个明确结论:这是外部来源,不是内部算法。
B 段是 128 字节,也是整个分析中反转最多的地方。最终公式是:
证明过程分几层。
第一层是计数。pc=0x10ec6c 的 ORR 序列化出现 32 次,对应 32 个 32-bit word,也就是 128 字节 B。pc=0x104eec 出现 1920 次,刚好是:
这给出了 AES-256 的轮数轮廓。
第二层是末轮。末轮没有 MixColumns,trace 中能看到:
旧的 KEY16 = 4a7f... 在这里被重新定位:
也就是说,早期所谓 KEY16 是末轮密钥被 0x55 白化后的形态。
第三层是中间轮。把 block0 的 round1 到 round2 拿出来,用标准 AES 的:
逐字节计算,结果与下一轮输入匹配。再用 RK0||RK1 展开 AES-256 key schedule,得到的 RK2 和 RK14 都与 trace 实测一致。
第四层是端到端。只要 K 和 CBC 输入块成立,标准库风格实现就必须能从明文块得到密文块:
实际脚本已经把全 8 块 B 前向加密到逐字节相等。到这一步,B 段不再是“像 AES”,而是标准 AES-256-CBC。
知道 B 是 AES-256-CBC 之后,下一步就不是继续追 VM slot,而是直接解 CBC。recover_b_plaintext.py 用固定主钥 K 和 IV=A 解出 128 字节 P,再做双 run 对拍。
得到的结构是一个 113 字节数据加 15 字节 PKCS#7 padding 的 TLV-like 信封:
三个断言非常关键:
同输入、不同 nonce 的两条 run 对拍后,可以看到 SHA1(update_arg)、SHA256("")、固定 tag、padding 保持不变,而 salt16、rnd4、rnd32 随 nonce 变化。
这一步把“输入相关字段”和“随机字段”分开了。它也解释了为什么很多早期路线会混乱:如果你只在密文 B 上看差异,nonce 会把全部 128 字节都搅动;只有先识别 CBC,才能看到明文 P 的真实结构。
虽然最终签名生成可以把 salt16 当 nonce 随机生成,但分析上我们还是把它下钻闭合了。salt16 来自 /dev/urandom 首 4 字节,也就是 rnd4,经过一条确定性 VM 扩展链:
这条链的价值在于,它展示了“自定义算法不是死路”。标准 AES/HMAC 可以用标准库证明;自定义 VM 分支则用 producer、PC、operand 和跨 run 模板证明。
例如 T 表写入不是抽象拟合,而是 trace 中真实的:
HALF_COMBOS 也不是从输出凑出来的常量表,而是同一条 eor @0x135428 在两条 run 中稳定执行出的固定 XOR 子集。run1/run2 都能从各自 rnd4 生成对应 salt16:
这就是“不是 replay”的标准:per-run 值只作为 test vector,程序常量和操作模板必须来自 producer 证据。
rnd32 是 P 中的 32 字节可变字段。它的输出层被推进到了 SHA-256 compression final-add:
第一组 8 次 final-add 从标准 SHA-256 IV 得到 SHA256("")。最后一组 8 次输出正好等于 P[46:78] 的 rnd32。这说明它不是随手写的 VM add/bit-mix,而是在 SHA-256 state 上做标准压缩。
run1 首 word 的证据是:
更进一步,最后一个 compression block 的 W[0..15] 也从 K[t]+W[t] 注入点恢复,并用标准 SHA-256 compression 复算通过。这里我们仍然保持边界清晰:rnd32 的 SHA 层已证,前序消息块来源仍可继续下钻,但对最终可生成签名来说,它属于 nonce 派生字段,不承载输入信息。
C 段最终最干净:
[招生]科锐逆向工程师培训(2026年7月3日实地,远程教学同时开班, 第56期)!