乐于分享
好东西不私藏

【AI逆向】纯算还原某招聘 App 加密,完整请求复现!

【AI逆向】纯算还原某招聘 App 加密,完整请求复现!

前言

某头部招聘 App 的所有接口请求都经过 native 层加密,请求体加密、URL 签名、响应加密三层保护。我从抓包分析到纯 Python 算法还原,最终实现完全脱离设备的接口请求。核心算法:RC4 + LZ4 + MD5,整个过程踩了不少坑,最大的坑是一个 secretKey 白名单机制。


一、目标与工具

1.1 目标

某招聘 App(Android 端),实现两个核心接口的纯算法本地复现:

  • 推荐职位列表接口
  • 搜索职位接口

1.2 技术栈

工具
用途
Frida 16.5.2(魔改版)
动态 Hook、RPC 调用
IDA Pro + MCP
SO 静态分析
jadx
Java 层反编译
Reqable
HTTPS 抓包
Python + httpx
纯算法请求复现

1.3 加密架构概览

Java 层: com.xxx.signer.SignerA    ↓ JNI 调用Native 层: com.xxx.signer.YZWG (动态注册)    ↓SO 文件: libyzwg.so (4.4MB, OLLVM 混淆)

所有加密逻辑都在 SO 里,Java 层只是个壳。


二、Java 层分析

2.1 定位加密入口

jadx 反编译后,全局搜索 spsig 参数,定位到请求构建类:

// 请求签名构建(简化)privatestaticvoidbuildRequest(RequestBuilder bVar, String url, boolean needEncrypt){    String secretKey = needEncrypt ? getSecretKey() : null;  // 关键!    String paramsStr = sortAndEncode(params);  // 参数排序 + URL 编码// 生成 sp(加密参数)    bVar.addQuery("sp", SignerA.encodeRequest(paramsStr, secretKey));// 生成 sig(签名)    bVar.addQuery("sig", SignerA.signature(path + paramsStr + crc32, secretKey));// 加密请求体byte[] bodyEncrypted = SignerA.encodeRequestBody(bodyJson, secretKey);}

2.2 YZWG 类 — 纯 JNI 壳

publicclassYZWG{privatestaticfinal String LIB_NAME = "yzwg";static { SoLoader.loadLibrary(LIB_NAME); }// 所有方法都是 nativeprivatestaticnative String nativeEncodeRequest(byte[] data, String key);privatestaticnativebyte[] nativeSignature(byte[] data, String key);privatestaticnativebyte[] nativeEncodeRequestBody(byte[] data, String key);privatestaticnativebyte[] nativeDecodeContent(byte[] data, String key, int enc, int encrypting, int comp);privatestaticnative String nativeCalculateCRC32(byte[] data);}

2.3 关键发现:secretKey 白名单

这是整个逆向中最大的坑

App 维护了一个白名单,白名单中的接口 secretKey 传 null

// 配置类中的判断逻辑publicstaticbooleanneedEncrypt(String url){return !whitelist.contains(extractPath(url));}

/api/batch/requests 在白名单中 → secretKey = null

我一开始传了真实 secretKey,请求一直返回"参数非法"。从 App 内部用 OkHttp 发请求也失败。排查了 TLS 指纹、HTTP/2、Cookie 等方向,浪费了大量时间。最终通过 Frida hook configM.j() 发现这个白名单机制。


三、SO 层逆向 — 算法还原

3.1 JNI 动态注册

libyzwg.so 使用 OLLVM 控制流平坦化,没有标准 JNI 导出函数。通过 IDA 分析 JNI_OnLoad

// JNI_OnLoad 中的关键逻辑(去混淆后)voidJNI_OnLoad(JavaVM *vm){// 1. 生成内部密钥char key_material[36];sprintf(key_material, format_str, seed >> 1, seed >> 2, seed >> 3);// 2. RC4 KSA 初始化    rc4_init(state, key_material, strlen(key_material));// 3. RC4 加密固定数据,生成 32 字节运行时密钥    rc4_crypt(state, fixed_data, runtime_key, 32);    global_key_ptr = malloc(33);memcpy(global_key_ptr, runtime_key, 32);// 4. 注册 JNI 方法(10 个)    RegisterNatives(env, clazz, methods_table, 10);}

3.2 读取 JNI 方法注册表

通过 Frida 读取内存中的方法表:

// 读取 JNI 方法注册表var yzwg = Process.findModuleByName("libyzwg.so");var tableAddr = yzwg.base.add(0x443030);  // off_443030for (var i = 0; i < 10; i++) {var name = tableAddr.add(i * 24).readPointer().readCString();var sig = tableAddr.add(i * 24 + 8).readPointer().readCString();var fn = tableAddr.add(i * 24 + 16).readPointer();console.log(name, sig, fn.sub(yzwg.base));}

结果:

方法
偏移
功能
nativeEncodeRequest
0x1F6AC
生成 sp
nativeSignature
0x23B6C
生成 sig
nativeEncodeRequestBody
0x206B8
加密 body
nativeDecodeContent
0x26404
解密响应
nativeCalculateCRC32
0x28388
CRC32

3.3 识别 RC4 算法

IDA 反编译 sub_340CC(被 nativeEncodeRequest 调用):

// 去混淆后的核心逻辑 — 标准 RC4 KSAvoidrc4_init(uint8_t *state, uint8_t *key, int key_len){for (int i = 0; i < 256; i++) state[i] = i;  // S-box 初始化int j = 0;for (int i = 0; i < 256; i++) {        j = (j + state[i] + key[i % key_len]) & 0xFF;        swap(state[i], state[j]);  // 交换    }    state[256] = 0;  // i 计数器    state[257] = 0;  // j 计数器}

特征明显:256 字节 S-box + key 混合交换 = RC4 KSA

PRGA 部分有 0xCF/0x30 掩码混淆:

// 看起来复杂,实际数学等价于 XORoutput[k] = (~a & 0xCF | a & 0x30) ^ (~b & 0xCF | b & 0x30);// 化简后 = a ^ b(标准 RC4 XOR)

3.4 Dump 运行时密钥

// Frida dump 全局密钥var keyPtrAddr = yzwg.base.add(0x444470);  // qword_444470var keyPtr = keyPtrAddr.readPointer();var keyBytes = keyPtr.readByteArray(32);// 结果: 32 字节 ASCII 字符串,形如 "a3xxf3xx8bxx39xx..."

3.5 验证算法 — 多组输入输出对比

用 Frida RPC 调用 native 方法,收集已知输入输出:

# 验证 sig 算法# 已知: sig("hello", null) = "V3.087e546cb6f6e3f1c89236b0e787a0c82"import hashlibkey = b'a3xxf3xx8bxx39xx...'# 32字节密钥# 尝试各种组合result = hashlib.md5(b'hello' + key).hexdigest()# 结果: 87e546cb6f6e3f1c89236b0e787a0c82 ✓ 完全匹配!

**sig = "V3.0" + MD5(input + key)**,一次命中。

3.6 BZPBlock 数据格式

解密 body 后发现固定结构:

+0   "BZPBlock"     (8 bytes, magic)+8   0x00000000     (4 bytes, 固定)+12  compressed_len (4 bytes, LE)+16  original_len   (4 bytes, LE)+20  checksum       (4 bytes, compressed_len XOR original_len)+24  LZ4(data)      (变长, LZ4 压缩的原始数据)

验证:

from Crypto.Cipher import ARC4import lz4.block# 解密 encodeRequestBody("a", null) 的输出cipher = ARC4.new(key)decrypted = cipher.decrypt(bytes.fromhex('cf0a7f34...'))# b'BZPBlock\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x03\x00\x00\x00\x10a'# LZ4 解压compressed = decrypted[24:]original = lz4.block.decompress(compressed, uncompressed_size=1)# b'a' ✓

四、完整算法总结

组件
算法
说明
sp
Base64URL(RC4(BZPBlock + LZ4(params)))
URL-safe Base64,~ 代替 = 填充
sig
"V3.0" + MD5(data + key)
data = path + params + crc32
body
RC4(BZPBlock + LZ4(body_json))
原始字节发送
CRC32
binascii.crc32(encrypted_body)
标准 CRC32
响应解密
LZ4_decompress(RC4(response)[24:])
跳过 BZPBlock header

五、纯 Python 实现

5.1 加密核心

import structimport binasciiimport base64import hashlibimport lz4.blockfrom Crypto.Cipher import ARC4RC4_KEY = b'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'# 32字节,从SO运行时dumpdefrc4_crypt(data: bytes) -> bytes:"""RC4 加密/解密(对称)"""return ARC4.new(RC4_KEY).encrypt(data)defbuild_bzp_block(plaintext: bytes) -> bytes:"""构建 BZPBlock 结构"""    compressed = lz4.block.compress(plaintext, store_size=False)    f2 = len(compressed)    f3 = len(plaintext)    f4 = f2 ^ f3  # XOR 校验returnb'BZPBlock' + struct.pack('<IIII'0, f2, f3, f4) + compresseddefencode_sp(params_str: str) -> str:"""生成 sp 参数"""    block = build_bzp_block(params_str.encode('utf-8'))    encrypted = rc4_crypt(block)    b64 = base64.urlsafe_b64encode(encrypted).decode()    padding = (4 - len(b64) % 4) % 4return b64.rstrip('=') + '~' * paddingdefsignature(data: str) -> str:"""生成 sig 签名"""    digest = hashlib.md5(data.encode('utf-8') + RC4_KEY).hexdigest()returnf'V3.0{digest}'defencode_request_body(body_str: str) -> bytes:"""加密请求体"""    block = build_bzp_block(body_str.encode('utf-8'))return rc4_crypt(block)defdecode_response(data: bytes, encrypting=1, compressing=2) -> str:"""解密响应"""    decrypted = rc4_crypt(data)if decrypted[:8] == b'BZPBlock':        _, f2, f3, _ = struct.unpack_from('<IIII', decrypted, 8)        compressed = decrypted[24:24 + f2]if compressing:return lz4.block.decompress(compressed, uncompressed_size=f3).decode('utf-8')return compressed.decode('utf-8')return decrypted.decode('utf-8', errors='replace')

5.2 请求构建

import jsonimport timeimport urllib.parseimport httpxdefbuild_batch_request(sub_reqs: list, t2: str, uniqid: str) -> dict:"""构建完整的 batch 请求"""# 1. Gson 编码 body    batch_body = {"subReqs": sub_reqs}    body_json = json.dumps(batch_body, separators=(','':'), ensure_ascii=False)    body_json = body_json.replace('=''\\u003d').replace('&''\\u0026')# 2. 加密 body    body_encrypted = encode_request_body(body_json)# 3. CRC32    crc32 = str(binascii.crc32(body_encrypted) & 0xffffffff)# 4. 构建签名参数(按 key 字母序排列)    client_info = json.dumps({"version""14""os""Android", ...}, separators=(','':'))    params = {'client_info': client_info,'curidentity''0','req_time': str(int(time.time() * 1000)),'uniqid': uniqid,'v''14.070',    }    params_str = '&'.join(f'{k}={urllib.parse.quote(str(v), safe="")}'for k, v in sorted(params.items())    )# 5. 生成 sp 和 sig(secretKey = null → 用全局 RC4 密钥)    sp = encode_sp(params_str)    sig = signature('/api/batch/requests' + params_str + crc32)# 6. 发送    url = f'https://api.xxx.com/api/batch/requests?sp={sp}&sig={sig}&app_id=1003'    headers = {'user-agent''BossApp/14.070 Android 34','t2': t2,'zp-accept-encoding''1','zp-accept-encrypting''1','zp-accept-compressing''3','content-type''application/json',    }    client = httpx.Client(http2=True, verify=False)    resp = client.post(url, content=body_encrypted, headers=headers)# 7. 解密响应    encrypting = int(resp.headers.get('zp-encrypting''0'))    compressing = int(resp.headers.get('zp-compressing''0'))if encrypting or compressing:return json.loads(decode_response(resp.content, encrypting, compressing))return resp.json()

5.3 业务调用

# 推荐职位列表sub_req = {"method""GET","path""/api/zpgeek/app/geek/recommend/joblist","query""encryptExpectId=xxx&sortType=1&pageSize=15&expectId=-2&page=1&..."}result = build_batch_request([sub_req], t2=T2, uniqid=UNIQID)# 搜索职位sub_req = {"method""GET","path""/api/zpgeek/app/geek/search/cardlist","query""query=Python&page=1&searchType=3&sort=-1&..."}result = build_batch_request([sub_req], t2=T2, uniqid=UNIQID)

六、实测结果

接口
状态
数据
推荐职位列表
每页 15 条,翻页正常
搜索职位
返回完整职位卡片
职位详情
完整 JD、公司信息
附近职位
基于定位返回
城市列表
全量城市数据

翻页测试(推荐职位):

页码
返回数
数据重复
Page 1
15
-
Page 2
15
无重复
Page 3
15
无重复
Page 4
15
无重复
Page 5
15
无重复

七、踩坑记录

7.1 secretKey 白名单(耗时最长)

现象:请求返回 {"code":-1001,"message":"请求参数非法."}

排查过程

  1. 以为是 TLS 指纹 → 换 curl_cffi 模拟 → 无效
  2. 以为是 HTTP/2 → 换 httpx → 无效
  3. 以为是 Cookie → 检查 OkHttp 拦截器 → 无 Cookie
  4. 从 App 内部用 HttpURLConnection 发 → 也失败
  5. 从 App 内部用 OkHttpClient 发 → 也失败
  6. 最终 hook configM.j() → 发现白名单机制

根因:batch 接口在白名单中,secretKey 必须传 null。我一直传了真实 secretKey。

教训:不要假设所有接口用相同的密钥。先用最简单的接口(GET 请求)验证基本机制是否正确。

7.2 搜索接口返回空

现象:code=0 但 cardList 为空

根因:搜索需要特定参数组合:

  • searchType=3(不是 0)
  • sort=-1(不是 sortType=0)
  • expectId 必须是真实数字 ID(不是 -2)

解决:Hook App 真实搜索请求,对比参数差异。

7.3 OLLVM 混淆

SO 使用控制流平坦化,IDA 反编译出来全是 while(1) { switch(state) {...} } 结构。

解决:不死磕静态分析,通过多组输入输出对比反推算法族(RC4 特征:相同输入相同输出、流密码特性、S-box 256 字节)。


八、逆向方法论

这次逆向的路径:

抓包确认加密存在    ↓jadx 定位 Java 层入口    ↓确认 native 调用(YZWG 类)    ↓Frida RPC 验证输入输出    ↓多组对比确定算法族(RC4)    ↓IDA 确认 KSA/PRGA 结构    ↓Dump 运行时密钥    ↓Python 纯算实现 + 验证

关键原则:

  1. 先验证再深入 — 用 Frida RPC 确认函数能调通,再去分析内部逻辑
  2. 最短路径 — 能通过输入输出对比确定算法就不死磕 IDA
  3. 分层验证 — 先跑通简单接口(GET),再搞复杂的(batch POST)
  4. 白名单意识 — 不同接口可能用不同的密钥策略

总结

这次逆向的核心突破:

  1. RC4 + LZ4 + MD5 — 三个标准算法组合,但被 OLLVM 混淆包裹,直接看 IDA 很难识别
  2. BZPBlock 自定义格式 — 8 字节 magic + 16 字节 header + LZ4 压缩数据,checksum 是两个长度字段的 XOR
  3. secretKey 白名单 — 最大的坑,batch 接口用 null key,其他接口用真实 key
  4. 输入输出对比法 — 不需要完全读懂混淆代码,多组测试数据就能反推算法

整个过程从抓包到纯算跑通大约 1 小时。