首页
社区
课程
招聘
[原创]关于so 的分析(只从补环境的角度来分析)
发表于: 2天前 216

[原创]关于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-256 0631AAE0B15B37787FCAE2FEEAFDA660D42DF16C53935765E2D749A5CF8DC544
  • 动态分析: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

这一行日志里其实藏着两个后面要反复用到的信息:

  1. 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 —— 就是这个对上了。
  2. 注册三元组不是静态表。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 字节)。静态看下来:

  1. CFG 只有 1 个 basic block、0 条边 —— 整段 58 条指令全在一条直线上,没有一条静态可跳目标;
  2. 唯一"出口"是 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),按下面顺序驱动即可。

调用顺序

  1. JNI_OnLoad(也可以直接用 fnPtr 0x17ba68 调 t.a —— 它就是 native 侧 com.xingin.tiny.internal.t.a 的入口函数)
  2. 注册风暴重放:784B blob 解密 + 356 对 (data, tag) 重放(= l4.<clinit> 的常量解密风暴;这一步可以关掉,非签名必需)
  3. 三门重放
  4. 按业务正确顺序 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 默认返回值即可全过,不需要逐项精确值。

三个最容易踩的坑

  1. unidbg 的 GetArrayLength 不支持 NULL:真机门①参数是 null,重放时统一退化成 Object[0](unidbg 侧对 null array 会 requireNonNull NPE);
  2. 有一个 native 会回调的静态方法(如 t.b)必须返回数组形态:返回假对象会让 unidbg 侧抛 ClassCastException: DvmObject cannot be cast to Array(它会直接对回调返回值调 GetArrayLength),返回空 Object[] 即可;
  3. 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期)

最后于 2天前 被kanxue编辑 ,原因:
收藏
点赞 ・1
打赏
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回