免责声明
本文仅用于安全技术研究与学习交流。文中目标应用、厂商、库名、域名与接口均已匿名化、脱敏。
本文不提供任何可直接运行的签名/加密/协议核心实现,也不提供数据采集或绕过风控的代码。
内容只涉及分析方法与排障思路。请勿用于任何未授权用途,后果自负。
先说结论。某 App 请求头里有一个 75 字符的签名(下称 x-payload-source),由 native 库生成。我一开始按常规思路去啃它的控制流平坦化,做到一半发现方向不对;后来改用 Unicorn 直接在本地把库"跑"起来,在完全脱离真机和 Frida 的环境里得到了可用的签名结果。
一、问题的由来
这个签名头是 43 + 32 的结构。前 43 个字符跟设备、时间有关,后面 32 个字符随请求体变化,雪崩很明显。
顺着调用链往上摸,Java 层很快就能看清:
x-payload-source = f( md5hex_lower(body) )
│
├─ 插件层 provider
├─ SecurityUtil.bnSimpleSign(byte[], String) // Java 层
└─ native simpleSign(byte[], String) // 进入 libsign.so
到了 native 就不好办了。
这个库大约 2.8 MB,而且是运行期动态下发的,从 APK 里直接抠不到,得让它自己落盘。导出的符号倒很直白:simpleSign、strongSign、getToken、getToken2、getAuar……显然是专门做签名的库。
算法是自研的。我在 .rodata 里找过一遍,MD5、SHA、HMAC 的标准魔数一个都没命中。.rodata 里能看到明文 JNI 类名、方法名、android/app/ActivityThread 之类,说明作者没有刻意藏符号,但算法确实没走标准库。
那就上工具吧。
二、静态分析
最初的思路和大多数人一样:用 IDA 慢慢读。结果这个库先给我上了三课。
第一,混淆 JNI 派发。
原以为点进 simpleSign 就能看到函数体,实际入口是这样:
STP X0, X30, [SP, #-0x10]!
LDR W0, =<一个索引字面量>
BL <dispatcher>
所有 native 方法都是这个套路:先加载一个数字,跳到统一的 dispatcher,dispatcher 再用 LR 相对偏移表算出真正的目标:
w0 = (w0 - 8) ^ 0xE0
w0 >>= 8
w0 += 1
target = X30 + u32(表基址 + w0*4)
反编译器遇到 BR 这种计算跳转,函数边界直接就断了。处理办法是写脚本,枚举所有 LDR W0,=imm; BL dispatcher 的桩,解析偏移表,重建一张 桩 → 真实目标 的映射表。我一共重建出 490 条左右,函数链才算连起来。
第二,控制流平坦化。
追到真正的函数体,它已经被改成"调度器 + 基本块"的结构:
入口:
STP X0, X30, [SP,#-0x10]!
LDR W0, =<lit>
BL <子调度器>
子调度器:
; 由 w0 算出块索引
; target = X30 + 偏移表[idx]
BR target
原来的 if / else、for、while 都被抹平了,变成"改寄存器 → 回调度器 → 再算下一块"。IDA 的 F5 出来读不出结构。还是写脚本,用 capstone 解析指令,把计算跳转、条件跳转、ret 都处理掉,沿着调度器把基本块图重建出来。我关心的 simpleSign,重建完是 166 个基本块。到这里逻辑总算能看:cmp、b.cond、栈槽读写、一个全局指针表引用,都认得出来。
第三,字符串是压缩的。
想去看那些疑似密钥和 token 的常量,结果全是乱码。后来发现字符串在 .rodata 里是压缩的,运行期由段自带的解压器把字符串解出来再用。
三件事叠在一起,"纯静态把算法读出来"就不只是"能不能"的问题了,还有"值不值"的问题。
三、转变思路
在这里我停了一下,重新问自己一个问题:我到底要的是"算法",还是"结果"?
要算法,意味着能脱离原库、自己独立算出结果;要结果,意味着只要签名正确就行,库内部怎么算的并不重要。
而我做这个本身就是需要他加密后得到的结果,其实这里和js逆向中的“全扣”和“半扣”的思路差距很相似。
所以我做了一个一开始有点反直觉的决定:不啃平坦化了,直接把这个库在本地跑起来。
这就是后来用 Unicorn 做的事。当时笔记里记的是——"先做 oracle,别碰平坦化"。
四、把库在本地跑起来
思路不复杂:把 so 当成一个普通 ELF,在 Unicorn 里模拟它的运行环境,然后调用 simpleSign。整体流程大致是:
LIEF 读 ELF
├─ 把全部 LOAD 段映射进 Unicorn
├─ 应用全部重定位(RELATIVE / ABS64 / GLOB_DAT / JUMP_SLOT)
└─ 未解析的符号统一给桩,避免跳到 0
伪造 JavaVM / GetEnv,调用 JNI_OnLoad
└─ 触发 RegisterNatives,拿到这 8 个 native 函数的地址
(simpleSign 在基址 + 0xcbed0,并非明文符号导出)
先执行 .init_array,再调用 simpleSign(input, tag)
└─ 参数通过 JNI 索引传进去
取返回的 byte[],转成 75 个字符的大写 hex
大部分工作属于"照做就行":映射段、贴重定位、跑 .init_array、补上 read/write/gettimeofday 这些系统调用的桩、把跳向 0 的调用兜住——这些都在预期之内。真正费时间的,是下面三个不那么显眼的点。
五、三个比较隐蔽的坑
一个是 dlsym 拿分配器的桩。
库内部会通过 dlsym 去拿分配器。我一开始想偷个懒,把所有 dlsym 都当无关符号桩掉、返回 NOOP,结果签名输出突然变短。
起初以为是算法没跑对,又查了半天,后来才意识到是分配失败,算法走到一半没有内存可用就退化了。所以 dlsym("malloc")、dlsym("calloc")、dlsym("realloc")、dlsym("free") 这些,必须返回真正能用的桩,不能糊弄。这也给我留了个经验:输出无缘无故变短,先查分配器和依赖桩,不要先怀疑算法。
一个是 JNI 索引,184 和 192。
因为要在 Unicorn 里自己实现 JNIEnv 函数表,每个 JNI 函数的索引都得手动填。我把 GetByteArrayElements 的索引填错了一位,后果很隐蔽:不管喂什么输入,输出都完全一样。
盯着屏幕一度怀疑是算法本身的问题,折腾了很久才想起来翻索引表。Android 的 JNIEnv 里:
184 = GetByteArrayElements
192 = ReleaseByteArrayElements
改回来之后,输入才真正传了进去,输出立刻随输入变化。所以看到"输入变、输出不变",第一件事就是查 JNI 索引。
还有一个是全局态残留。
原以为同一个 emulator 反复调 simpleSign 应该很稳定,实际发现同一输入两次输出可能不一样。查下来是 PRNG 状态、缓存、时间基准这些全局量在两次调用间残留,污染了当前这轮。要保证确定性,只能每次开一个全新实例,或者把状态重置干净。
把这几处填平之后,simpleSign 就正常输出了 75 个字符的大写 hex,同一输入结果一致,和真机上 Frida oracle 的结果结构完全吻合。也就是说,在完全没有原 App、没有真机、没有 Frida 的本地环境里,我拿到了一个可用的签名 oracle。
六、回头再看去平坦化
既然前面讲了这么多"跑通",这里也把静态那条路交代清楚。
我并没有因为跑通了就否定去平坦化。实际上我也把 simpleSign 的 166 个基本块全部重建了出来,逻辑是能读的。里面有几个值得注意的原语:eor、sub w,#1 接 mul(像是 n·(n−1) 类型的混合)、csel 做常量选择、以及按输入字节索引解码表,典型的自定义哈希 / 编码,没走标准库。还有一个关键点:simpleSign 是完全自包含的——整个函数里唯一的内部调用是 bl 到 __stack_chk_fail(栈保护),哈希逻辑整个内联在这 166 个块里,没有对外调用别的加密函数。
但恰恰是这个案子,去平坦化的意义其实不大。原因前面已经说了:产物是设备 / 时间绑定的,就算把 166 个块逐块读完、把压缩的字符串解码表还原、把密钥派生追出来,得到的算法跨设备照样用不了。所以我做到"块图可读、大致看清用了什么原语"就停了,没有再往下抠。
(后面我又追深了一点,算是个补充:库里其实藏着一套标准 AES-128——能翻到 Te0Te3、Td0Td3 那些表,Rcon 也在;运行时还抓到过轮密钥和一段 ASCII 明文。所以最终输出很可能等于"43 字符前缀 + 一段 AES 产出的 32 字符"。但密钥派生、明文构造、padding 这些我还没追完,就先不下结论了。)
说到底,去平坦化本身是有价值的技术,只是它并不总是划算。要不要继续往下抠,落点还是那句话——先想清楚要的是算法还是结果。要跨设备离线纯算,静态还原和相关追踪就绕不开;如果只要一个能用的结果,本地执行往往更快,这个案子里就是如此。
顺手记几个容易踩的点,给以后遇到类似情况的自己提个醒:
- 输出变短 → 先看
dlsym、分配器的桩;
- 输入变、输出不变 → 查 JNI 索引(184 / 192);
- 结果忽对忽错 → 看看全局态有没有残留。
免责声明
本文仅为技术研究与交流,目标已匿名化。不提供签名/加密/协议的可运行实现,也不提供任何采集或绕过代码。请勿用于未授权用途。