-
-
[原创]关于so 的分析(只从补环境的角度来分析)
-
发表于: 2天前 216
-
关于**的 libtiny.so 的分析(只从补环境的角度来分析)
前面的分析里,请求头里那一串参数已经各归各位:
xy-common-params、shield等由各自的 SDK/模块生成,而x-mini-mua、x-mini-sig、x-mini-s1这一组由 native 库 libtiny.so 负责。本篇只关心这一组,目标是从头把"补环境"这条路完整走通:从定位入口、放弃静态、动态摸清初始化序列,到在 unidbg 里逐层补齐,最后拿真机同构的签名输出。样本与环境
- App:** v9.31.2(versionCode 9312801),真机 Android 10(arm64-v8a)
libtiny.so:arm64-v8a,8,171,920 字节,SHA-2560631AAE0B15B37787FCAE2FEEAFDA660D42DF16C53935765E2D749A5CF8DC544- 动态分析:Frida 16.2.1;复现载体:unidbg 0.9.10-SNAPSHOT(Java + Unicorn2)
一、定位入口:hook 出 RegisterNatives
通过 hook 请求头设置的调用堆栈,以及 hook RegisterNatives 的日志,可以知道:x-mini-mua、x-mini-sig、x-mini-s1 的生成关键 native 函数是 com.xingin.tiny.internal.t.a。
关键的 RegisterNatives 日志:
[RegisterNatives] java_class: com.xingin.tiny.internal.t
name: a
sig: (I[Ljava/lang/Object;)Ljava/lang/Object;
fnPtr: 0x792b40fa68
fnOffset: libtiny.so!0x17ba68
callee: libtiny.so!JNI_OnLoad+0xc28
这一行日志里其实藏着两个后面要反复用到的信息:
t.a是 static native。JNI 签名(I[Ljava/lang/Object;)Ljava/lang/Object;只有两个显式参数(非 static 会多一个对象引用)。对应 native ABI:X0=JNIEnv、X1=clazz、W2=i(int opcode)、X3=objArr。后面在 IDA 反汇编0x17ba68时会看到指令恰好保存W2和X3/X0—— 就是这个对上了。- 注册三元组不是静态表。
callee指到JNI_OnLoad+0xc28附近:它在栈上手工拼好{name, sig, fnPtr}三元组再调RegisterNatives。所以拿 IDA 静态扫.rodata永远搜不到{"a", "(I[Ljava/lang/Object;)…", fnPtr}的配对 —— 这也是为什么入口必须靠 hook 才能拿到函数指针。
二、为什么静态分析这条路口直接放弃:CFF 的量化证据
把运行时 fnOffset 0x17ba68 换算到 IDA 的 IDB(imagebase 0x79e9ccc000)即 0x79E9E47A68 = sub_79E9E47A68(0xf4 字节)。静态看下来:
- CFG 只有 1 个 basic block、0 条边 —— 整段 58 条指令全在一条直线上,没有一条静态可跳目标;
- 唯一"出口"是
BR X6(寄存器间接跳转),跳哪由运行时寄存器决定。
79e9e47a84 SUB SP, SP, #3, LSL#12 ; 一次申请 0x37B0 栈帧
79e9e47aa4 ADRP X10, #off_79EA4251F0@PAGE
79e9e47ab4 LDR X10, [X10,#off] ; 基址来自 .data 槽(初值 0x33cc898 静态可解)
79e9e47ac0 ADD X8, X8, X9 ; X8 = sub_79E9E56F20 + 0x3A00AD8(滚动指针)
79e9e47acc ADD X9, X10, 0xFFFFFFFFFCDBE4C4
79e9e47b58 BR X6 ; 唯一出口 —— 运行时才算得出
BR X6 的静态初值其实可以算出来:X6 = 0x33cc898 + 0xFFFFFFFFFCDBE4C4 ≡ 0x18ad5c(相对偏移),把这条链手工推两跳确实能跳进 sub_79E9E56D5C(0x10 B) / sub_79E9E5960C(0x34 B) 这样的微型块 —— 但解开一跳不过是点开下一块,整个 so 是"链式滚动"的控制流平坦化(CFF)状态机。
用 IDA Python 扫一遍全 so 函数,就有量化证据:
| 指标 | 数值 | 占比 |
|---|---|---|
| 全 so 函数总数 | 61,425 | — |
| ≤0x40 字节的微型"函数" | 26,096 | 42.5% |
其中块尾是 BR XN |
24,819 | 95.2% |
其中块尾是正常 RET |
67 | 0.26% |
正常 so 的分布大体反过来。95.2% 的微型块以"算术+BR XN"接力,业务逻辑整体被切成 3577+ 个 CFF 状态块(群集在 sub_79E9E56F20 附近一大片区域)。判断 CFF 的口诀:块尾不是 RET 而是 ADD Xn, Xn, imm; BR Xn;启动时 MOV Xn, X8 把 20+ 个寄存器灌同一个值;0x37B0 大栈帧;魔数跨块互为相反数咬合 —— 全中。
结论:不硬啃汇编,改用动态 hook。
三、动态 hook 的方法
用 Frida spawn 模式挂 t.a(int i, Object[] objArr),打印 opcode、objArr 每个元素、返回值以及 Java 调用堆栈。建议用可过滤的增强版 hook:对目标 opcode 才打全 Java 堆栈,其余调用只打参数(省日志量)。
跑起来后会看到几次会话开头的固定调用序列逐行相同(只变种子值与句柄),且 i 只集中在几个固定值上:
i = -926366385
i = -126071690
i = 1136634082 (后续出现 816 次,最高频)
这三个就是签名前的初始化。经验事实:跳过初始化、直接调签名 opcode,native 会直接返回 null —— 它并没有走进签名生成路径,而是被分支挡了回去。所以补环境必须按这三个 i 先重放一遍初始化。
四、三个初始化 opcode 的完整函数卡
统一的 Java / native 签名(三卡共用)
// com.xingin.tiny.internal.t
public static native Object t.a(int i, Object[] objArr);
// JNI: (I[Ljava/lang/Object;)Ljava/lang/Object;(RegisterNatives 动态注册,无静态符号)
// native 里不同 opcode 分发到不同实现分支,返回值类型随 opcode 变化
native 入口统一为 sub_79E9E47A68 @ libtiny.so!0x17ba68(0xf4 字节 CFF 启动器),内部按 i 分发。
卡 ① t.a(-926366385, [Long]) —— 种子注入
| 项 | 内容 |
|---|---|
| opcode | -926366385 = 0xC8C8C54F |
| 参数 | Object[1],[0] = java.lang.Long(SystemClock.uptimeMillis() 毫秒值) |
| 实测样本 | [0] Long = 43651830(开机 ≈12h07m31.8s);另几个会话 53311 / 96672 / 42348637 |
| 返回值 | null |
| 次数 | 冷启动严格 1 次 |
| 触发链 | XhsApplication.onCreate → startHomeFeedPreload → preloadInitializationByPrivacyCheck → ze2.b.run(后台线程) → axa.f.f(读 SystemClock.uptimeMillis()) → g3.a → t.a |
语义:种子喂 native 内部随机/加扰状态机。种子来源确证是 SystemClock.uptimeMillis()(证据链:调用堆栈 + 对 axa.f.f 的静态反编译里可以看到 android.os.SystemClock;->uptimeMillis() 的调用点,且 g3.a 直接转发到 t.a)。
卡 ② t.a(-126071690, []) —— 上下文句柄派生
| 项 | 内容 |
|---|---|
| opcode | -126071690 = 0xF87C4C76 |
| 参数 | Object[0](空数组,零参调用) |
| 返回值 | java.lang.Long(句柄值) —— 某次会话 482721175346;其他会话 482730452475 / 482711952484 / 482883704217 … |
| 次数 | 冷启动严格 1 次 |
| 触发链 | z1c.q1.n → l4.<clinit> → m7.<clinit> → n7.<init> → g3.b → t.a |
| Java 侧落位 | n7.<init> 唯一语句:this.a = g3.b().longValue() —— 句柄直接进 n7 单例的 public final long a 字段(Java 零运算,完全 native 生成) |
语义:"解密上下文锚" —— 同会话恒定、跨会话每次不同。句柄数值来源没有定论:观察其高 32 位都在 0x706x 区间,但那是用户态地址的通用高位区间;与 libtiny 加载基址并无算术绑定(多次实测会话里,句柄与基址的相对差方向相反、量级也悬殊)。一句话定论:句柄值就是一个 native 内联派生的进程唯一 long 常量。
卡 ③ t.a(1136634082, [Long, byte[], byte[2]]) —— 常量解密
| 项 | 内容 |
|---|---|
| opcode | 1136634082 = 0x43BFA8E2(最高频,全 so 主要流量) |
| 参数 | Object[3]:[0] = Long 句柄 ⇒ 恒等于卡②的返回值[1] = byte[] 密文(编译期 const-string 经 ISO_8859_1 取的字节)[2] = byte[2] tag(两字节 XOR key,每次调用不同,一次会话 832 次 tag 出现去重 743 种) |
| 本日志实测 | 816 次调用;首块 byte[784] tag=c880;其余多为 byte[1]~[16] 小块 |
| 返回值 | t.a 自身返回 null;但 native 把 [1] 数组原地改写 → Java 侧 n7.a([B[B)[B 返回同一数组引用(已是明文) |
| 算法 | data[i] ^= tag[i % tag.length]· 对合性: F(F(x,T)) = x(XOR 自反)· 长度不变 · 全库扫出的 730 个解密站点,按该公式重算与实测输出逐字节吻合 |
| 输出内容 | 首块 784B → Base64 DEX;其余 679+51 站点 → Android 类名/方法名(入参始终是编译期加密的 const-string 字节,解密结果就地写回同一数组,Java 侧直接拿到明文) |
五、三个 opcode 的数据流闭环
SystemClock.uptimeMillis() = 43651830
│ axa.f.f → g3.a
▼
t.a(-926366385, [43651830]) → null ① 种子喂状态机
│ l4.<clinit> → m7.<clinit> → n7.<init> → g3.b
▼
t.a(-126071690, []) → Long 482721175346
│ 关键:句柄存入 n7 单例字段 a:J
▼
t.a(1136634082, [482721175346, // ← 恰为 ② 的返回值
byte[] cipher, // ← const-string 密文
byte[2] tag]) → null ③ data[i]^=tag[i%2] 原址解密
// 返回的 byte[](同一对象)被 n7.a([B[B) 带回 Java,成为明文类名字符串字面量
六、第 4 个 opcode -1746252063:签名入口(这才是主角)
初始化跑完之后,真正出签名头的那次调用是:
| 项 | 内容 |
|---|---|
| opcode | -1746252063 = 0x97EA4EE1 |
| 参数 | Object[5]:[0] = "GET" / "POST" …(method)[1] = "edith.xiaohongshu.com"(host)[2] = /api/sns/v1/...(path)[3] = note_id=...&start=...(query,无则为空串)[4] = byte[](body,无 body 传 byte[0]) |
| 位置来源 | method/host/path/query → OkHttp HttpUrl/Request 提取;body → RequestBody 的 bytes(可为 byte[0]) |
| 返回值 | 含 x-mini 四元组的 Map(Java 侧收到 Map<String,String>),或直接被 native 挂进 header 结构 |
抓到的成功签名调用现场(参数 + 返回值完整样例):
i = -1746252063
[0] java.lang.String => GET
[1] java.lang.String => edith.xiaohongshu.com
[2] java.lang.String => /api/sns/v1/interaction/backup/comment/page/l1
[3] java.lang.String => note_id=6a********************43&start=&num=15&...
[4] [B (byte[0]) =>
================================
t.a result = { x-mini-gid=7f78dd3ebbfd54c7902ef15ec283b697360a26c947359add7725ac0c,
x-mini-s1=AEUAAAABXeQa81u8zku8WkC2JBL92p6Mo2dokOo37rENby9E06FCS2vdKJkORGvD4rc3uV3zlPXTSlrCCw8=,
x-mini-sig=427782d37382752a833c442b42013de99f7a79f96e9badbd14f21399a12507b7,
x-mini-mua=eyJhIjoiRUNGQUFGMDEi... }
native 内部的 HashMap.put 现场(也是之后在 unidbg 里好定位检查点的位置):
0x19bbcc → 0x179a58(XXHash64) → 0x19ca28 → NewStringUTF → HashMap.put
长会话还会出现的其他 opcode:-1776528127(对 "unknown" 之类 String 做处理的分支)、-1348803862(随业务增长的分支)等 —— 它们不是签名必经路径,补环境可以后补。
顺带说明
t.a返回值的类型:声明是Object,实际类型由 opcode 分支决定 —— 签名 opcode 返回 Map、句柄 opcode 返回 Long、其余多数返回 null。
七、"为什么必须先初始化"——native 里的 3 个状态门
补环境时最诡异的形态:unidbg 零报错,但 sign 返回 null。根因在我们对成功会话的 trace 对比里很清楚:真机首个成功 sign 之前,必然且确定性地经历三个额外 opcode —— 我们把它们叫状态门:
| 门 | opcode | hex | 真机参数形态 |
|---|---|---|---|
| 门① | 1030797452 |
0x3D70B88C | null(不是 Object[0]) |
| 门② | -1748167116 |
0x97CD1634 | Object[0] |
| 门③ | -1348803862 |
0xAF9AE2EA | 多形态(null / 句柄+byte[23]+tag / 证书链) |
这三门加上卡①②③和 -1746252063 都是"两次会话必现、次数固定"的稳定 opcode。指令级验证:三门完成后,一个只用来读取状态字节的检查块(0x28288c-0x284c44 区间内)在一个完整的 sign 流程中先出现、后从执行轨迹里消失 —— 门在 native 里做的是"状态位落位"这件事:门没跑全,sign 走"返回空 variant"分支;三门完成,状态字节到位,sign 带出真实输出。这就是"初始化"真正在 native 里做的事。
八、unidbg 补环境落地清单
载体:unidbg(Java + Unicorn2)+ 我们自己写的一个轻量脚手架(JDK < 17),按下面顺序驱动即可。
调用顺序
JNI_OnLoad(也可以直接用 fnPtr0x17ba68调t.a—— 它就是 native 侧com.xingin.tiny.internal.t.a的入口函数)- 注册风暴重放:784B blob 解密 + 356 对
(data, tag)重放(=l4.<clinit>的常量解密风暴;这一步可以关掉,非签名必需) - 三门重放
- 按业务正确顺序
t.a(-1746252063, [5 参])拿签名
JNI 桩需求(libtiny 用到的)
NewStringUTF / GetStringUTFChars / GetArrayLength / GetByteArrayRegion / NewByteArray / RegisterNatives / GetObjectClass / CallStaticObjectMethodV / NewObject / HashMap.put …
系统桩需求
clock_gettime(uptimeMillis 的地基)等。sign 窗口内 native 还会 lazy 采集环境(音量流、phoneType、类名 TencentCommon 等)—— AbstractJni 默认返回值即可全过,不需要逐项精确值。
三个最容易踩的坑
- unidbg 的
GetArrayLength不支持 NULL:真机门①参数是 null,重放时统一退化成Object[0](unidbg 侧对 null array 会requireNonNullNPE); - 有一个 native 会回调的静态方法(如
t.b)必须返回数组形态:返回假对象会让 unidbg 侧抛ClassCastException: DvmObject cannot be cast to Array(它会直接对回调返回值调GetArrayLength),返回空Object[]即可; - JNI handler 内部异常不是结束进程:unidbg 打
debugger break后吞掉继续跑,最终表现为零报错却 sign=null —— 排查先搜日志debugger break。
九、验证:修复后的逐字段比对
| 字段 | unidbg | 真机 | 判读 |
|---|---|---|---|
| Map key 数 | 3 个(mua/s1/sig) | 4 个(含 gid) | ❌ gid 通道未闭合(解法见下) |
mua v = 2.9.75 |
✅ | ✅ | 一致 |
mua p = "a" |
✅ | ✅ | 一致 |
mua c |
1(首笔) |
1,2,3… 递增 |
✅ 机制一致(与 s1 counter 同步) |
s1 counter 形态 AAE…=1 / AAI…=2 |
✅ | ✅ | 机制一致 |
mua k/s |
本会话自生成 | 会话级恒定 | ✅ 机制一致(种子为时间源,跨会话不可对齐) |
mua a/u |
空/缺 | "ECFAAF01" / 设备id |
❌ 身份注入缺口 |
| HashMap.put 现场 | NewStringUTF @0x19ca28 → HashMap.put @0x1a8e54 |
同一路径 | ✅ 同一路径 |
gid 通道的补法:不走 native 内嵌路径,走网络解——调 /api/v1/register/android(POST)拿返回体里的 data.g,再把它作为 t.b 的 gid 字段写回(native 提供 setNativeGid 类的写回入口)。补齐后 X-mini 四元组完整。
业务链验证:gid 补齐后真实业务请求(imagefeed/homefeed)返回 HTTP 200,body 是 -100 登录已过期 —— 签名层已被后端接受,剩下缺口是会话凭证(sid / id_token),不是签名了。
下面是补环境后unidbug运行的日志
[xhs-client] register ok: code=200 cost=996 ms, new gid=7f77fd52xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
[register-smoke] STAGE-4 init+auto-register cost=1538 ms
[register-smoke] STAGE-4 effective gid=7f77fd52xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx (len=56, nativeGid=7f77fd52xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx, fresh=true)
[register-smoke] STAGE-4 homefeed: 200 cost=1487 ms
[register-smoke] STAGE-4 body head: {"code":-100,"success":false,"msg":"登录已过期","data":{}}
声明:本文仅用于学习交流与技术探讨,分析对象均为在本人设备上合法获取的样本;请勿将文中内容用于任何商业或非法用途,由此产生的后果与作者无关。
[招生]科锐逆向工程师培训(2026年11月13日实地,远程教学同时开班, 第57期)