首页
社区
课程
招聘
[原创] 去平坦化不是唯一解:用 Unicorn 本地执行还原一个全平坦化的 native 签名
发表于: 2026-10-13 08:31 1316

[原创] 去平坦化不是唯一解:用 Unicorn 本地执行还原一个全平坦化的 native 签名

2026-10-13 08:31
1316

免责声明

本文仅用于安全技术研究与学习交流。文中目标应用、厂商、库名、域名与接口均已匿名化、脱敏。
本文不提供任何可直接运行的签名/加密/协议核心实现,也不提供数据采集或绕过风控的代码。
内容只涉及分析方法与排障思路。请勿用于任何未授权用途,后果自负。


先说结论。某 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);
  • 结果忽对忽错 → 看看全局态有没有残留。

免责声明
本文仅为技术研究与交流,目标已匿名化。不提供签名/加密/协议的可运行实现,也不提供任何采集或绕过代码。请勿用于未授权用途。


[招生]科锐逆向工程师培训(2026年11月13日实地,远程教学同时开班, 第57期)

最后于 2026-10-13 08:36 被吡啶不是苯氮编辑 ,原因:
收藏
点赞 ・8
打赏
分享
最新回复 (1)
雪    币: 110
活跃值: (9666)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
2 楼
666
2026-10-13 15:02
0
游客
登录 | 注册 方可回帖
返回