【AI逆向】纯算还原某招聘 App 加密,完整请求复现!
2026/6/7 1:45:21 网站建设 项目流程

前言

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


一、目标与工具

1.1 目标

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

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

1.2 技术栈

工具用途
Frida 16.5.2(魔改版)动态 Hook、RPC 调用
IDA Pro + MCPSO 静态分析
jadxJava 层反编译
ReqableHTTPS 抓包
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(RequestBuilderbVar,Stringurl,booleanneedEncrypt){StringsecretKey=needEncrypt?getSecretKey():null;// 关键!StringparamsStr=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{privatestaticfinalStringLIB_NAME="yzwg";static{SoLoader.loadLibrary(LIB_NAME);}// 所有方法都是 nativeprivatestaticnativeStringnativeEncodeRequest(byte[]data,Stringkey);privatestaticnativebyte[]nativeSignature(byte[]data,Stringkey);privatestaticnativebyte[]nativeEncodeRequestBody(byte[]data,Stringkey);privatestaticnativebyte[]nativeDecodeContent(byte[]data,Stringkey,intenc,intencrypting,intcomp);privatestaticnativeStringnativeCalculateCRC32(byte[]data);}

2.3 关键发现:secretKey 白名单

这是整个逆向中最大的坑

App 维护了一个白名单,白名单中的接口secretKeynull

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

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

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


三、SO 层逆向 — 算法还原

3.1 JNI 动态注册

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

// JNI_OnLoad 中的关键逻辑(去混淆后)voidJNI_OnLoad(JavaVM*vm){// 1. 生成内部密钥charkey_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 方法注册表varyzwg=Process.findModuleByName("libyzwg.so");vartableAddr=yzwg.base.add(0x443030);// off_443030for(vari=0;i<10;i++){varname=tableAddr.add(i*24).readPointer().readCString();varsig=tableAddr.add(i*24+8).readPointer().readCString();varfn=tableAddr.add(i*24+16).readPointer();console.log(name,sig,fn.sub(yzwg.base));}

结果:

方法偏移功能
nativeEncodeRequest0x1F6AC生成 sp
nativeSignature0x23B6C生成 sig
nativeEncodeRequestBody0x206B8加密 body
nativeDecodeContent0x26404解密响应
nativeCalculateCRC320x28388CRC32

3.3 识别 RC4 算法

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

// 去混淆后的核心逻辑 — 标准 RC4 KSAvoidrc4_init(uint8_t*state,uint8_t*key,intkey_len){for(inti=0;i<256;i++)state[i]=i;// S-box 初始化intj=0;for(inti=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 全局密钥varkeyPtrAddr=yzwg.base.add(0x444470);// qword_444470varkeyPtr=keyPtrAddr.readPointer();varkeyBytes=keyPtr.readByteArray(32);// 结果: 32 字节 ASCII 字符串,形如 "a3xxf3xx8bxx39xx..."

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

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

# 验证 sig 算法# 已知: sig("hello", null) = "V3.087e546cb6f6e3f1c89236b0e787a0c82"importhashlib key=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 压缩的原始数据)

验证:

fromCrypto.CipherimportARC4importlz4.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' ✓

四、完整算法总结

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

五、纯 Python 实现

5.1 加密核心

importstructimportbinasciiimportbase64importhashlibimportlz4.blockfromCrypto.CipherimportARC4 RC4_KEY=b'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'# 32字节,从SO运行时dumpdefrc4_crypt(data:bytes)->bytes:"""RC4 加密/解密(对称)"""returnARC4.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)%4returnb64.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'))returnrc4_crypt(block)defdecode_response(data:bytes,encrypting=1,compressing=2)->str:"""解密响应"""decrypted=rc4_crypt(data)ifdecrypted[:8]==b'BZPBlock':_,f2,f3,_=struct.unpack_from('<IIII',decrypted,8)compressed=decrypted[24:24+f2]ifcompressing:returnlz4.block.decompress(compressed,uncompressed_size=f3).decode('utf-8')returncompressed.decode('utf-8')returndecrypted.decode('utf-8',errors='replace')

5.2 请求构建

importjsonimporttimeimporturllib.parseimporthttpxdefbuild_batch_request(sub_reqs:list,t2:str,uniqid:str)->dict:"""构建完整的 batch 请求"""# 1. Gson 编码 bodybatch_body={"subReqs":sub_reqs}body_json=json.dumps(batch_body,separators=(',',':'),ensure_ascii=False)body_json=body_json.replace('=','\\u003d').replace('&','\\u0026')# 2. 加密 bodybody_encrypted=encode_request_body(body_json)# 3. CRC32crc32=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="")}'fork,vinsorted(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'))ifencryptingorcompressing:returnjson.loads(decode_response(resp.content,encrypting,compressing))returnresp.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 115-
Page 215无重复
Page 315无重复
Page 415无重复
Page 515无重复

七、踩坑记录

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. 最终 hookconfigM.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 小时。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询