最近在看某招聘 App 的职位搜索接口,把 native 层简单逆了下,实现纯算复现协议签名。
抓包:搜索请求的 query 里挂着一个 sp(几百字符的密文串)和一个 sig(V3.0 开头的 32 位 hex),请求体是二进制,响应应该也是密文。这三样都出自一个 native 库 libyzwg.so(JNI 包装类 com.twl.signer.YZWG,Java 侧封装 com.twl.signer.a)。
版本是 v14.050(Y29tLmhwYnIuYm9zc3poaXBpbg==)。
环境:
搜索结果列表顺着 GeekSearchCardRequest 找到端点和关键的通用加签层 net.bosszhipin.base.m ,它的 c() 给每个请求补公共参数(curidentity/v/req_time/uniqid/client_info),然后按请求是不是批量,分流到 e()(普通)或 f()(批量)。搜索走批量,f() 反编译结果如下(去噪):
com.twl.signer.a 就是这层 native 的 Java 薄封装,每个方法都会转发到对应的 nativeXxx:

关键逻辑全在libyzwg.so 里:nativeSignature / nativeEncodeRequest / nativeEncodeRequestBody / nativeCalculateCRC32 / nativeDecodeContent。
libyzwg.so 4.47MB、OLLVM 平坦化、字符串加密、JNI_OnLoad 里带反重打包自检,反汇编难度较大。先让这个 so 在 unidbg 里跑起来,当一个可以任意喂输入、看输出的黑盒预言机,用差分探针把算法的形状摸出来。
跑 unidbg 的门槛是 JNI_OnLoad 的反篡改自检,用 -Dvmverbose=1 看 JNI upcall 序列:getPackagesForUid 要真实包名、getPackageInfo(pkg, GET_SIGNATURES).signatures[0].toCharsString().hashCode() 要等于内嵌常量。满足它(喂真实证书 DER + 补一个真的 String.hashCode),JNI_OnLoad 就正常返回、native 方法注册上了。
签名侧:
全部是 V3.0 + 32 hex(=16 字节,MD5 家族),前导 0 恒定,key 参与。但拿真机抓的一条 (input, key, sig) 三元组去对撞 md5(input+key)、md5(key+input)、hmac 等等,一个都不中。哈希里还包含一个内嵌盐。黑盒推不出盐,这步必须分析汇编。
sp 探针的线索:
把 base64 解回原始字节观察:同一个 key 下,前 12 字节恒定(70e22488554382feca2200f2,换 key 就变);再把 16*'A' 和 32*'A' 两条 payload 逐字节 XOR,结果几乎全 00,keystream 被抵消了。sp = 头(仅由 key 决定) + [ compress(input) XOR keystream(key) ],是流密码、不是分组,且密钥流只由 key 决定(sp 确定、无随机 IV)。这跟之前分析过的 GCash APSE 的 zipAndEncryptData 很类似。
JNI_OnLoad(OLLVM 平坦化,状态机在一个 v5 上转)里能看到 (*env + 1720) 就是 RegisterNatives,注册表 off_443030、10 个方法,类名走 FindClass(0x443130)。类名那串 5f 53 51 13 48 4b 50 13... 用 XOR 0x3c 一解就是 com/twl/signer/YZWG。so 里的字符串都是 XOR 混淆,而且每个字符串用不同的单字节 key,末尾那个字节就是 key(存的是 0x00^key)。照这个规律把 name/sig 表整个解出来:
反编译 nativeSignature(0x23b6c),核心的 buffer 拼接如下:

sig = 前缀 + HASH(input ‖ qword_444470 ‖ key)。前缀 byte_443804(cb ae b3 ad,XOR key 0x9d)解出来是 "V3.0"。qword_444470 是 JNI_OnLoad 里算好缓存的一个 32 字符串,由证书签名的 hashCode 派生(就是反篡改校验那个常量),对官方签名是定值。
salt 的值直接从 unidbg 内存里 dump:
真机 oracle:
sig = "V3.0" + md5(input ‖ salt ‖ key),完全匹配。
反编译 nativeEncodeRequest(0x1f6ac):

头前 12 字节 "BZPBlock"+u32(0) 是常量明文,RC4 keystream 固定,异或出来就是固定的 12 字节密文;后面 压缩长/原长/校验 才随输入变。
sub_340CC 是标准 RC4 KSA(S[i]=i 初始化,j = j + S[i] + key[i%len] 交换);sub_34740 是 RC4 PRGA,但每个字节套了一层 TWIST(x) = (~x & 0xCF) | (x & 0x30):
0xCF | 0x30 == 0xFF 且不重叠,所以 TWIST(x) == x ^ 0xCF,两次 TWIST 在 XOR 时相互抵消,out = keystream ^ in 就是标准 RC4,TWIST 纯属混淆。

纯 Python 复现,对照 unidbg 的全部合成样本 + 真机样本:
真机 2086 字节样本只有前 16 个 b64 字符(=那 12 字节恒定头)逐字节相同,往后就对不上了:设备的 liblz4 和 python-lz4 对同一输入做了不同但都合法的压缩选择,头里的压缩长不一样。但我的 sp round-trip 解回原文成功、校验位对,服务器只解压不比字节,所以照样接受(后面实测证明了)。
剩下三个原语:nativeCalculateCRC32 = snprintf(..., "%u", crc32(x))(IEEE CRC32 的无符号十进制串,空输入返 "");nativeEncodeRequestBody 和 sp 同管线、只是最后返回原始字节不 base64;nativeDecodeContent 是逆(RC4 解密,视情况再 BZPBlock/LZ4 解压)。
搜索走 GET /api/batch/requests,body 里带上请求:
外层 strD 的参数(client_info/curidentity/req_time/uniqid/v)要用 Java URLEncoder 风格编码:~→%7E、空格→+、保留 .-*_。这点很容易踩:Python 默认 quote 会保留 ~,编出来的 strD 和服务器重算的对不上、sig 就废。按 Java 规则实现一个 java_url_encode,逐字节对拍捕获的 strD 和子 query,两个都 match=True,才敢用它重建查询。
踩着服务器的报错往前走:
传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!
最后于 1小时前
被星野安全编辑
,原因: