前言
某头部招聘 App 的所有接口请求都经过 native 层加密,请求体加密、URL 签名、响应加密三层保护。我从抓包分析到纯 Python 算法还原,最终实现完全脱离设备的接口请求。核心算法:RC4 + LZ4 + MD5,整个过程踩了不少坑,最大的坑是一个 secretKey 白名单机制。
一、目标与工具
1.1 目标
某招聘 App(Android 端),实现两个核心接口的纯算法本地复现:
推荐职位列表接口 搜索职位接口
1.2 技术栈
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 反编译后,全局搜索 sp、sig 参数,定位到请求构建类:
// 请求签名构建(简化)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));}结果:
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' ✓四、完整算法总结
~ 代替 = 填充 | ||
五、纯 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)六、实测结果
翻页测试(推荐职位):
七、踩坑记录
7.1 secretKey 白名单(耗时最长)
现象:请求返回 {"code":-1001,"message":"请求参数非法."}
排查过程:
以为是 TLS 指纹 → 换 curl_cffi 模拟 → 无效 以为是 HTTP/2 → 换 httpx → 无效 以为是 Cookie → 检查 OkHttp 拦截器 → 无 Cookie 从 App 内部用 HttpURLConnection 发 → 也失败 从 App 内部用 OkHttpClient 发 → 也失败 最终 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 纯算实现 + 验证关键原则:
先验证再深入 — 用 Frida RPC 确认函数能调通,再去分析内部逻辑 最短路径 — 能通过输入输出对比确定算法就不死磕 IDA 分层验证 — 先跑通简单接口(GET),再搞复杂的(batch POST) 白名单意识 — 不同接口可能用不同的密钥策略
总结
这次逆向的核心突破:
RC4 + LZ4 + MD5 — 三个标准算法组合,但被 OLLVM 混淆包裹,直接看 IDA 很难识别 BZPBlock 自定义格式 — 8 字节 magic + 16 字节 header + LZ4 压缩数据,checksum 是两个长度字段的 XOR secretKey 白名单 — 最大的坑,batch 接口用 null key,其他接口用真实 key 输入输出对比法 — 不需要完全读懂混淆代码,多组测试数据就能反推算法
整个过程从抓包到纯算跑通大约 1 小时。
夜雨聆风