吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 205|回复: 0
上一主题 下一主题
收起左侧

[Android 原创] qi5uxeel 原理完整剖析:一份风控请求头的还原实录

[复制链接]
跳转到指定楼层
楼主
zepp7289 发表于 2026-8-23 21:09 回帖奖励

qi5uxeel 原理完整剖析:一份风控请求头的还原实录

宿主:某邮政系统的 PDA 拣货 App
载体:libmsec.so(商业风控 SDK)
全文按还原推进顺序组织,关键点附汇编指纹(认指令,不记地址)


引言:它是什么,为什么存在

qi5uxeel 是一个 HTTP 请求头的名字,App 每次发请求时,内置的风控 SDK(libmsec.so)往这个头里塞一个值。这个值是几百到上千字符的 base64url 文本,用 .. 分几段,尾部带校验字符,高熵乱码,肉眼看不出结构。

服务器需要一份"可信的设备身份 + 环境报告":识别设备(防模拟器、防多开)、证明请求来自真实客户端而不是脚本(防刷接口)、防止重放。设计目标一句话:让客户端能产生、不能篡改——攻击者就算抓到完整报文,缺服务器私钥,也拼不出第二个合法会话。


第 0 步:这些关键点是怎么定位的

先把话说在前面:后面每一步里出现的汇编,没有一个是"一眼看到"的。libmsec 是个几十万行代码量级的库,又套着混淆,在里头找一个函数不靠运气,靠的是几条固定的定位路径。归纳下来四类手段,按使用频率排:

1. 字符串反查(最常用)

解开 SDK 内部字符串的加密之后,类名、方法名、密钥、头名全是明文——这些字符串在代码里都是有引用的,拿它们做交叉引用:谁引用了 header 名?谁引用了那个 JNI 类名?一跳就跳到使用代码。打包器(把各段拼成最终值的那个函数)就是这么找到的:它是最后一个写输出缓冲的地方,而它写出来的内容和抓包值逐字节一致。

2. 数据反查(次常用)

内存快照里先认数据结构(池头 begin/end、条目布局、单例对象),再反查"谁在读、谁在写":写池的代码就是响应处理链,读池的代码就是请求构建链——两条链就这么被切开了。密钥池、会话轮换这些机制,全是先认数据、再反查代码认出来的。

3. 常量指纹搜索

特征常量在代码里是唯一的,搜到即命中算法本体——比如汇编里用 movz/movk 两条指令拼出来的 32 位种子常量、AES 查表的首项。后面那个 30B 完整性校验的状态机就是靠种子常量搜到的;AES 靠查表首项定位。

4. 输出回溯(抓包反推)

拿着抓包值往回找"最后一个写它的人":对比候选函数写出的缓冲内容,看哪个中间输出和最终值前缀一致;一致就再往前推一层,直到源头。字符移位、key 编码这些层,就是一层层这样剥出来的——这也是第 3 步那套结构能被拆开的真正过程。

另有 Unicorn 模拟——它定位不了代码(定位是上面三招干的),但它是验证和理解算法的主力,三个用途:① 黑盒执行真实函数,给输入拿输出,跑不通就说明理解错了;② 寄存器级执行日志(trace),逐条记录指令和内存读写,算法公式就是从日志的数据流里推出来的;③ 按 BLR 调用点逐个展开调用树,确认"谁调了谁"。后面几步里凡是"怎么验证的",基本都是它干的。

后面出现的汇编,都能对上号属于这四类里的哪一类。


第 1 步:抓包看到一个奇怪的 header

抓包的时候,每个请求头上都挂着一个叫 qi5uxeel 的 header,值几百到上千字符,.. 分成几段,尾部带 8 位校验字符。每请求都变,高熵乱码,肉眼完全看不出结构。

我们在反编译出来的 Java 代码里全文搜这个字符串——零命中。再梳理请求链,确认 header 是 HTTP 拦截器注入的。也就是说,这个名字和值都不是业务代码写的,肯定是某个 native 安全 SDK 在背后搞的。顺着查下去,锁定到了 libmsec.so(商业风控 SDK)。

这一步还算顺利,没什么坑——真正的坑从下一步开始。

第 2 步:原始 so 几乎不可分析

拿到 libmsec.so 直接静态分析,很快发现不对劲:主体代码像乱码,标准 AES 的特征表(S-box)、密钥常量、内部字符串,全都搜不到。把原始 so 和运行时内存快照逐字节对比,主体区域大约 99.6% 不一致——文件里装的根本不是真代码,进程启动后才把真东西解出来。

顺着明文区的引导代码追,果然如此:一小段 loader 把密文主体解密到新映射的可读可写可执行内存里,清 cache 后跳进去执行。解密循环的形态一眼可认——链式 XOR,不是单密钥异或(前一段输出当后一段的输入):

; 自解密引导(loader): 链式 XOR 解密循环(64 位粒度)
ldr   x1, [x2]          ; 取当前 8 字节
ldr   x0, [x2, #-8]     ; 上一段(链式,种子 = 1)
eor   x3, x1, x0
str   x3, [x2], #8      ; 写回并前进
mov   x0, x1            ; 更新链寄存器
...
; 结尾 cache flush + 跳转:
dc    cvau, x4          ; 数据 cache 清理
ic    ivau, x4          ; 指令 cache 失效
dsb   ish
isb
br    x5                ; 跳入解密后的代码

所以铁律就是:拿到 so 先 dump,别浪费时间静态分析,分析对象必须是运行时快照。

解密出来的主体是一个扁平化 VM 状态机——正常函数调用语义(memcpy/memset/解压)全被拍平成 case 分支,这就是后面"反编译出来全是死代码"的根源:

; 扁平化 VM 状态机: 分派器(二叉比较树 → case 匹配链)
mov   w9, #imm          ; 32 位 case 常量
movk  w9, #imm, lsl #16
cmp   w8, w9
b.eq  <handler>         ; 命中执行
b     <dispatch>        ; 未命中回分派器
...
; 跳转表分派:
ldrsw x10, [x11, x9, lsl #2]   ; 表项 + 基址
br    x12                       ; 间接跳转

注意它内部的 bl 目标都是位置无关的(相对自身基址),别被反汇编器显示的绝对 0 误导。

接下来破字符串表。SDK 内部字符串全部双层加密(索引表 + 逐字节异或),形态是典型的 XOR 循环:

; 字符串解密(双层 XOR):
ldrb  w8, [x9]              ; 取密文字节
ldrb  w10, [x11]            ; 取索引表项
eor   w8, w8, w10           ; 逐字节异或
eor   w8, w8, #imm          ; 位置相关常数

全量解码后,类名、密钥、头名全变明文——协议骨架就从字符串引用关系里浮出来了。

这步踩了两个坑。第一个:loader 的源地址算错了一个固定偏移,解出来的东西整体错位——说它错吧,又像模像样,入口点位置、起始字节都不对,但"差一点就通"。后来凡是遇到这种"差一点就通"的解密,先复查所有基址计算,再用熵值 + 指令解码率 + 函数序言三连验证。第二个:SDK 内部字符串是 24 字节对齐的三段结构(标志/长度/指针),用常规的紧凑布局去拼会少对齐字节,所有模拟直接走错误路径。这个结构在汇编里到处可见:

; SSO 字符串结构读写:
ldr   x8, [x0, #0x8]        ; 长度
ldr   x8, [x0, #0x10]       ; 数据指针

第 3 步:拆外层壳,看见报文骨架

值的样本摆在一起也看不出结构——横向比对只能发现"固定用 .. 分段、尾部 8 位像校验"这种表层规律,谁也说不出第一字节是什么、第二字节是什么。真正确认结构,是读代码读出来的

破局点在生成链路的那个打包器:它是逐字节构造输出的,每个字节写什么、从哪里取、经过什么变换,指令里写得清清楚楚。一上来就看到:输出第 1 字节来自某个对象的固定槽(外层类型标记);第 2 字节是 key 的编码结果——key 来自 rand()%25+1;然后一个循环把整段数据做字符循环移位(字母、数字各自按区回绕);接着拼长度字段、拼两段 base64、最后拼 CRC32 的 8 位 hex:

; 打包器: 逐字节构造输出
ldrb  w8, [x19, #0x50]      ; 从对象取外层类型标记('B')
strb  w8, [x0]              ; 输出[0] = 外层类型标记
...
; key = rand()%25 + 1(随机数生成器输出之后取模):
udiv  w9, w8, w25
msub  w9, w9, w25, w8       ; w9 = w8 % 25
add   w9, w9, #1
; key 编码字符(编码 = ((2k)&0xC2) + (k^0x61)):
eor   w10, w9, #0x61
lsl   w11, w9, #1
and   w11, w11, #0xC2
add   w10, w10, w11
strb  w10, [x0, #1]         ; 输出[1] = key 编码
; 字符循环移位(每字符按区回绕: 大写/小写/数字):
ldrb  w12, [x13], #1        ; 取输入字符
cmp   w12, #0x41            ; 大写区 0x41-0x5A?
b.lt  <next>
cmp   w12, #0x5A
b.gt  <next>
add   w12, w12, w9          ; 加 key
sub   w12, w12, #26         ; 超界回绕
strb  w12, [x0, #2], #1     ; 写输出

看到这段,报文头两层就定了:首字符 = 外层类型标记,第二字符 = key 编码,后面整段 = 移位后的数据。再回头对照抓包值,全都能对上——这就是"读代码找结构,抓包做验证"的闭环。

长度字段的查表编码也在附近,同样一眼可认:

; 查表编码(TBL / 长度字段):
lsr   w14, w17, #6          ; 长度高 6 位
ldrb  w15, [x16, w14]       ; 查 64 字符表
and   w15, w15, #0x3F       ; 索引掩码

表里混了 ._,和标准 base64 的字符集对不上。剥掉移位、解出第二字符后,剩下的骨架是:两个长度字段 + 段类型标记 + 两段 base64url 数据 + 尾部 8 位校验。

段1 解码出来 = 4B 协议头 + 256B(这个协议头和外层的类型标记不是一回事)。256B 是 RSA-2048 的固定长度,基本可以断定是 RSA 密文;段2 = 16B IV + 密文,像 AES。后来验证:段1 的 RSA 公钥就是 SDK 内嵌的 X.509 证书,段2 确实是 AES。

分析生成链路的时候,反编译结果里还混着一堆死代码干扰项——纯 MOV/CMP/CSEL 状态机、零函数调用,唯一出口返回固定错误码或死循环。这是壳塞的混淆 stub,不含真实逻辑,识别后直接放弃:

; 混淆 stub(识别后直接放弃的形态):
mov   w8, w9
cmp   w8, w10
csel  w8, w11, w12, eq
...  ; 全是 MOV/CMP/CSEL,零 BL 调用

这步最大的坑是"VMP 误判"。VMP 是一种把代码虚拟化成字节码的保护技术,反编译出来像解释器。当时看到一串间接调用,非常像字节码解释器,一度怀疑整个生成链路跑在一个 VMP 虚拟机里,为此白费了一轮黑盒模拟。后来逐个核对才发现那就是 JNI 调用——LDR vtable + LDR 槽 + BLR 是 JNI 的标准形态,那些小整数指针值是 jclass/jmethodID 全局引用,根本不是静态结构:

; JNI 调用(曾被误判为"VMP"):
ldr   x8, [x0]              ; 取 vtable
ldr   x9, [x8, #off]        ; 取函数指针
blr   x9                    ; 间接调用

识别要点:大量 BLR 间接调用 + 小整数句柄 = JNI,不是 VM

还有个坑:响应格式多变,两段式、三段式、.. 做分隔符的变体都有,固定格式解析必然失败。解析器只能写成容错式,先解混淆,再按长度字段动态切。

第 4 步:认出 AES——标准 FIPS-197

加密核心看形态就像 AES:密钥扩展写出一张 240B 的扩展表,加密循环四张表轮换查表 + 异或累加:

; AES-256(标准 FIPS-197 查表版): 轮变换(Te0-Te3 四张 T 表,ARM 小端 LDR dword)
ldr   w8, [x9, #off]          ; 查表
lsr   w10, w8, #24
add   x11, x7, w10, uxtb      ; 表基址 + 索引
ldr   w12, [x11]
eor   w13, w12, w14           ; 异或累加轮密钥
...
; 密钥扩展写 240B 扩展表:
stp   x8, x9, [x10, #off]

关键指纹是表首项 c66363a5、S-box 开头 637c777b——这两个是标准 AES 的固定值。

但早期我们犯过一个错:中间某张表看起来不像标准实现,一度怀疑是"自定义 AES",还花精力去还原"自研算法"。最后把 AES 函数跑进 Unicorn——寄存器级执行日志,每轮变换的输入输出、每次查表的地址和值全部记录——轮变换公式就是从这些日志的数据流里逐轮反推的,再和标准库三方交叉验证(Unicorn 执行 × 自写纯算法 × 标准密码库),结论是 100% 标准 FIPS-197——那张"不像标准"的表其实是另一个算法(GCM 的认证部分)的 H 表,被误认了。

教训:先判"标准 vs 自定义"再动手,看到表指纹直接对照标准值,以标准库为准绳。

另外还有 ARM 字节序三连坑:LDR 按小端读 dword、T 表内存大端存储小端读取、表的旋转方向还有讲究——三个坑叠一起,手写对照实现时怎么算怎么不对。只能逐轮跟踪数据流、每轮单独验证,不靠静态猜。

第 5 步:B 段的真相——"准 GCM"

段2 按标准 CTR 解不出明文:IV 不是直接当 counter 起点,密文前面还多出 16B 看似随机的内容。试着按 GCM 的方式构造 J0(对 IV 做 GHASH 变换),keystream 从 J0+1 起逐块递增——成功了,明文是一份设备指纹 JSON,31 个字段。

GHASH 就是 GCM 里的 GF(2^128) 乘法,在 ARM 上的形态是"判最高位 + 条件异或 + 右移反馈"循环:

; GHASH(GF(2^128) 乘法): 逐位判 1 → 条件异或 → 右移反馈多项式
lsl   x3, x1, #63           ; 取出最高位
and   x4, x3, x2
...
eor   x2, x2, #0xE1         ; 反馈多项式(8 位截断)

字段按用途分几类:设备标识(设备唯一标识、厂商、型号、系统版本)、应用信息(包名、签名、SDK 版本、构建日期)、运行状态(生成计数、标志位、启动安全标志、当前页面)、会话材料(会话时间戳、路径校验值、设备级令牌)、随机标识、环境数据(传感器串、Build 串)。后来实测下来,服务器重点核对的是路径校验值(CRC32(请求路径))和设备级令牌,其余字段多为风控建模用。

那 16B"随机头"一开始以为是纯随机填充,后来怀疑是确定性派生的东西,最后用 GHASH 公式重算,和 Unicorn 实跑乘法函数的输出逐位对比——一致,定案:它是 GCM 认证 tag(GHASH 部分),服务器可以重算验证。对解密没影响(CTR 对称自动还原),但构造时不能随便填。B 段实际是"只加密、缺最后一步"的准 GCM。

顺带还发现:请求 body(请求体里那段加密数据,和 header 一起发送)和 B 段同款加密、同一把密钥。

这步两个坑。16B 头三阶段误判(随机填充 → 确定性派生 → 认证 tag),靠动态观测实证 + 逐字段比对才定案,不是猜出来的。另外 body 解密曾用几组候选密钥全布局失败,误以为"每请求随机密钥"——实际共用一把池密钥。穷举全失败本身说明密钥集错了,换正确密钥一次成功。

第 6 步:30B 尾部校验与 C8

指纹 JSON 明文尾部多了 30B 不像 JSON 的内容,整个 header 尾部还有 8 位 hex 校验字符。

C8(就是引言里说的尾部 8 位校验)很快:对整值重算 CRC32,逐字节对上——标准 CRC32,没什么好说的。

30B 麻烦多了。它来自一个控制流混淆过的状态机,没有任何库能替。它的形态是 movz/movk 拼 32 位种子常量 + dword 循环变换 + ror 旋转。做法是把状态机整体跑进 Unicorn,逐轮记录 dword 的变换轨迹——哪一步从哪读、和谁异或、加什么、怎么旋转——再对照汇编逐条翻译成纯 Python:

; 30B 校验状态机: 种子常量(movz/movk 组合)+ 轮变换
movz  w9, #imm
movk  w9, #imm, lsl #16
ldr   w10, [x11, #off]      ; 取 dword
eor   w12, w9, w10
add   w13, w12, w8
ror   w8, w13, #23          ; 旋转
str   w13, [x11, #off]

没有查表、没有标准库调用——看到这种形态别找库,只能逐条翻译。全部翻译成纯 Python 后,多组输入输出逐字节对比。它是确定性的——同样输入永远同样输出,输入是一个全局调用计数(和指纹计数字段同源递增),种子与 App 构建信息相关。

这步有个认知坑:一度看到 30B 和指纹字段同步变化,以为它是字段的函数,差点去拟合——实际只是伴随(同源计数)。相关性不等于因果,用控制变量法定位真实输入才翻案。

第 7 步:密钥体系——池与 CSPRNG

内存里有一块"密钥池"结构:池头(begin/end 指针)+ 若干条目,每条目 32B 密钥 + 状态 dword + 时间戳。响应处理成功后状态置"已激活"、写入新材料——下次请求换新密钥,是个轮换链。池操作在汇编里就是一组固定偏移的 STR:

; 密钥池操作:
str   w8, [x9, #0x30]       ; 写池条目状态 = 2(已激活)
stp   x10, x11, [x12]      ; 写池头 begin/end 指针

从内存快照按"池头 → 条目 → 密钥"的路径就能提出 32B 会话密钥。再追随机源:AES-256 counter 模式——16B counter 逐块递增、每块过一遍 AES 输出 16B 随机块:

; CSPRNG(AES counter 模式):
rev   x8, x9                ; counter 大端处理
add   x10, x8, #1           ; 递增
...
bl    <aes_enc_block>       ; 每 16B 一块调一次 AES 单块

会话密钥(32B)、随机标识(16B)、body 里的随机段(122B)全部出自这同一个 CSPRNG,统计形态完全一致(满熵、无结构)。用 Unicorn 把生成核心跑起来(全零密钥就能跑通生成循环),产出的样本和抓包里的随机段做统计对比——熵、字节分布、块分布全部同分布;两次生成结果逐字节全不同(counter 在递增)。

用 SDK 内嵌的 X.509 公钥做 RSA 加密验证:把 32B 会话密钥用公钥加密,结果和抓包的 A 段密文完全一致——A 段明文就是它(PKCS#1 v1.5)。同一会话内并发请求复用同一份 RSA 密文——会话密钥是会话级缓存,不是每请求随机。一把密钥贯穿全链路:请求 B 段、请求 body、响应全用它。

这步两个坑。用 Unicorn 模拟随机源时初始化逻辑被跳过、接口直接返回错误码——后来发现是内存快照里的锁变量残留非零,模拟前清零就好(快照里的锁不代表"进程正要运行的状态")。还有 IDA 函数边界误归:关键生成入口藏在别的函数的地址区间里,调用图完全对不上,以实际执行流为准才理清。

第 8 步:响应侧——标准 AES-256-GCM

响应值走和请求一样的混淆层:剥标记 → 解混淆 → 长度字段切段,切出 chunk1/chunk2 两段。chunk2 解出 base64 密文,布局是 16B nonce + 16B tag + 密文——标准 GCM 的样子。

但当时我们先用了一把错误的会话密钥去测,全失败,于是误判"libmsec 的 GCM 是自定义实现,标准库不兼容",甚至准备上模拟器。后来换正确的密钥,cryptography 标准 AESGCM 一行解出。

这是全项目最典型的"结论污染":错误前提推导出错误算法。教训就一条:先验证密钥归属,再下算法结论

第 9 步:会话轮换——P→C 接力

某个阶段,按已有理解去解某些请求,怎么都解不对:某类请求的 A 段不是 RSA 密文,长度也不对。逐字节比对发现,请求 A 段 = 上一响应 chunk1 的内容。

于是轮换链浮出水面:首次请求(P 型)A 段 = RSA(会话密钥);服务器解密后把下一段材料放响应 chunk1 下发;后续请求(C 型)A 段 = chunk1 解码后原样转发。链路就是:

RSA(密钥) → 服务器下发 chunk1 → 转发 chunk1 → 再下发新 chunk1 → 转发 → ...

chunk1 的明文是服务器密钥加密的,客户端拿到后唯一合法动作就是转发;不按规矩来,服务器直接拒绝(HTTP 203)。

这步的坑是 chunk1 协议变更:早期抓包是 130B 裸二进制,后来变成 base64url 文本 + 尾部 . 标记,旧构造端原样转发被拒。解决:构造端自动识别新旧协议(纯 base64url 字符集 + 去尾部标记 → 解码)。教训:服务器会主动改协议,构造端要容错,不能死磕旧格式。

第 10 步:服务器到底校验什么

构造的请求(按现有理解拼出来的)真实发出去,被 401 拒绝,业务码 276。于是做逐字段替换对照实验:一次只改一个字段,其余保持真实值,观察错误码变化,建立"改了什么 → 得到什么错误码"的矩阵。

结果:路径校验值 s1 = CRC32(请求路径),服务器严格核对,修正后 200;设备级令牌精确匹配(逐字节比对,随机值直接拒绝,三种错误码区分首/尾/格式错);标志位某一位是毒位,置 1 直接拒绝,其余位可自由变化;其余字段(随机标识、传感器串等)实测不校验。错误码全集:0 成功 / 209 标志位 / 276 路径校验 / 310 A 段材料 / 423·424·425 设备令牌。

这步最扎心的坑:静态分析阶段我们写过"设备令牌和标志位不校验",被实测直接推翻——实际是精确校验。教训:"不校验"必须实测确认,不能信静态推理。构造端也从此必须硬编码真实设备令牌,随机化直接拒绝。

第 11 步:全链路闭环

所有环节都有答案之后,把全部翻译成纯算法实现(零 Unicorn):指纹组装 → 30B/C8 校验 → CTR 加密 → 打包混淆 → 请求发送 → 响应 GCM 解密。Unicorn 完成了它的使命——验证每一个环节的理解——然后被卸掉,最终产物不依赖任何模拟器。构造端遵循行为镜像原则:该固定的固定(设备令牌、设备标识、包信息),该随机的随机(随机标识、body 随机段、时间戳、计数逐请求递增),请求节奏模拟真人。

真实环境全离线构造 → HTTP 200 + 业务成功响应 + 响应可正常解密。

回头看,qi5uxeel 就是一份"标准密码学组件拼装、外面裹多层自定义混淆"的设备凭证:算法全是标准件(RSA-2048 / AES-256-CTR / AES-256-GCM / CRC32,零自研密码),自定义的全在装配层(查表长度、字符移位、GHASH-J0、30B 状态机、TBL base64)——不增加密码学强度,只增加分析成本。真正的护城河是密钥管理:密钥客户端生成、RSA 上送、chunk1 接力轮换,材料不可本地伪造

最后一句方法论:记指令形态,不记地址。地址随加固版本会变,指令形态永远在——前面各步里的那组汇编指纹,足够在下一版 so 上快速定位全部关键点。

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-8-25 04:02

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表