首页
课程
问答
CTF
社区
招聘
峰会
发现
排行榜
知识库
工具下载
看雪20年
看雪商城
证书查询
登录
注册
首页
社区
课程
招聘
发现
问答
CTF
排行榜
知识库
工具下载
峰会
看雪商城
证书查询
社区
Android安全
发新帖
6
6
[原创] 去平坦化不是唯一解:用 Unicorn 本地执行还原一个全平坦化的 native 签名
发表于: 1天前
545
[原创] 去平坦化不是唯一解:用 Unicorn 本地执行还原一个全平坦化的 native 签名
吡啶不是苯氮
1天前
545
**免责声明** > 本文仅用于安全技术研究与学习交流。文中目标应用、厂商、库名、域名与接口均已匿名化、脱敏。 > 本文不提供任何可直接运行的签名/加密/协议核心实现,也不提供数据采集或绕过风控的代码。 > 内容只涉及分析方法与排障思路。请勿用于任何未授权用途,后果自负。 --- 先说结论。某 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` 就能看到函数体,实际入口是这样: ```asm 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 条左右,函数链才算连起来。 **第二,控制流平坦化。** 追到真正的函数体,它已经被改成"调度器 + 基本块"的结构: ```asm 入口: 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**——能翻到 Te0~Te3、Td0~Td3 那些表,Rcon 也在;运行时还抓到过轮密钥和一段 ASCII 明文。所以最终输出很可能等于"43 字符前缀 + 一段 AES 产出的 32 字符"。但密钥派生、明文构造、padding 这些我还没追完,就先不下结论了。) 说到底,去平坦化本身是有价值的技术,只是它并不总是划算。要不要继续往下抠,落点还是那句话——先想清楚要的是算法还是结果。要跨设备离线纯算,静态还原和相关追踪就绕不开;如果只要一个能用的结果,本地执行往往更快,这个案子里就是如此。 --- 顺手记几个容易踩的点,给以后遇到类似情况的自己提个醒: - 输出变短 → 先看 `dlsym`、分配器的桩; - 输入变、输出不变 → 查 JNI 索引(184 / 192); - 结果忽对忽错 → 看看全局态有没有残留。 --- > **免责声明** > 本文仅为技术研究与交流,目标已匿名化。不提供签名/加密/协议的可运行实现,也不提供任何采集或绕过代码。请勿用于未授权用途。
冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。
最后于
1天前 被吡啶不是苯氮编辑 ,原因:
#逆向分析
#协议分析
#混淆加固
收藏
・
6
点赞
・
6
打赏
分享
分享到微信
分享到QQ
分享到微博
赞赏记录
参与人
雪币
留言
时间
by_Lin
感谢你的贡献,论坛因你而更加精彩!
20小时前
我的小拇指啊
感谢你的积极参与,期待更多精彩内容!
23小时前
欲拭泪
期待更多优质内容的分享,论坛有你更精彩!
1天前
新气象
非常支持你的观点!
1天前
王叔叔
为你点赞!
1天前
马来
非常支持你的观点!
1天前
查看更多
赞赏
×
1 雪花
5 雪花
10 雪花
20 雪花
50 雪花
80 雪花
100 雪花
150 雪花
200 雪花
支付方式:
微信支付
赞赏留言:
快捷留言
感谢分享~
精品文章~
原创内容~
精彩转帖~
助人为乐~
感谢分享~
最新回复
(
1
)
Imxz
雪 币:
110
活跃值:
(9586)
能力值:
( LV2,RANK:10 )
在线值:
发帖
6
回帖
776
粉丝
9
关注
私信
Imxz
2
楼
666
1天前
0
游客
登录
|
注册
方可回帖
回帖
表情
雪币赚取及消费
高级回复
返回
吡啶不是苯氮
1
发帖
0
回帖
0
RANK
关注
私信
他的文章
[原创] 去平坦化不是唯一解:用 Unicorn 本地执行还原一个全平坦化的 native 签名
504
关于我们
联系我们
企业服务
看雪公众号
专注于PC、移动、智能设备安全研究及逆向工程的开发者社区
看原图
赞赏
×
雪币:
+
留言:
快捷留言
为你点赞!
返回
顶部