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

张开发
2026/6/7 1:45:22 15 分钟阅读

分享文章

【AI逆向】纯算还原某招聘 App 加密,完整请求复现!
前言某头部招聘 App 的所有接口请求都经过 native 层加密请求体加密、URL 签名、响应加密三层保护。我从抓包分析到纯 Python 算法还原最终实现完全脱离设备的接口请求。核心算法RC4 LZ4 MD5整个过程踩了不少坑最大的坑是一个 secretKey 白名单机制。一、目标与工具1.1 目标某招聘 AppAndroid 端实现两个核心接口的纯算法本地复现推荐职位列表接口搜索职位接口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 反编译后全局搜索sp、sig参数定位到请求构建类// 请求签名构建简化privatestaticvoidbuildRequest(RequestBuilderbVar,Stringurl,booleanneedEncrypt){StringsecretKeyneedEncrypt?getSecretKey():null;// 关键StringparamsStrsortAndEncode(params);// 参数排序 URL 编码// 生成 sp加密参数bVar.addQuery(sp,SignerA.encodeRequest(paramsStr,secretKey));// 生成 sig签名bVar.addQuery(sig,SignerA.signature(pathparamsStrcrc32,secretKey));// 加密请求体byte[]bodyEncryptedSignerA.encodeRequestBody(bodyJson,secretKey);}2.2 YZWG 类 — 纯 JNI 壳publicclassYZWG{privatestaticfinalStringLIB_NAMEyzwg;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 维护了一个白名单白名单中的接口secretKey传null// 配置类中的判断逻辑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,seed1,seed2,seed3);// 2. RC4 KSA 初始化rc4_init(state,key_material,strlen(key_material));// 3. RC4 加密固定数据生成 32 字节运行时密钥rc4_crypt(state,fixed_data,runtime_key,32);global_key_ptrmalloc(33);memcpy(global_key_ptr,runtime_key,32);// 4. 注册 JNI 方法10 个RegisterNatives(env,clazz,methods_table,10);}3.2 读取 JNI 方法注册表通过 Frida 读取内存中的方法表// 读取 JNI 方法注册表varyzwgProcess.findModuleByName(libyzwg.so);vartableAddryzwg.base.add(0x443030);// off_443030for(vari0;i10;i){varnametableAddr.add(i*24).readPointer().readCString();varsigtableAddr.add(i*248).readPointer().readCString();varfntableAddr.add(i*2416).readPointer();console.log(name,sig,fn.sub(yzwg.base));}结果方法偏移功能nativeEncodeRequest0x1F6AC生成 spnativeSignature0x23B6C生成 signativeEncodeRequestBody0x206B8加密 bodynativeDecodeContent0x26404解密响应nativeCalculateCRC320x28388CRC323.3 识别 RC4 算法IDA 反编译sub_340CC被nativeEncodeRequest调用// 去混淆后的核心逻辑 — 标准 RC4 KSAvoidrc4_init(uint8_t*state,uint8_t*key,intkey_len){for(inti0;i256;i)state[i]i;// S-box 初始化intj0;for(inti0;i256;i){j(jstate[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](~a0xCF|a0x30)^(~b0xCF|b0x30);// 化简后 a ^ b标准 RC4 XOR3.4 Dump 运行时密钥// Frida dump 全局密钥varkeyPtrAddryzwg.base.add(0x444470);// qword_444470varkeyPtrkeyPtrAddr.readPointer();varkeyByteskeyPtr.readByteArray(32);// 结果: 32 字节 ASCII 字符串形如 a3xxf3xx8bxx39xx...3.5 验证算法 — 多组输入输出对比用 Frida RPC 调用 native 方法收集已知输入输出# 验证 sig 算法# 已知: sig(hello, null) V3.087e546cb6f6e3f1c89236b0e787a0c82importhashlib keyba3xxf3xx8bxx39xx...# 32字节密钥# 尝试各种组合resulthashlib.md5(bhellokey).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) 的输出cipherARC4.new(key)decryptedcipher.decrypt(bytes.fromhex(cf0a7f34...))# bBZPBlock\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x03\x00\x00\x00\x10a# LZ4 解压compresseddecrypted[24:]originallz4.block.decompress(compressed,uncompressed_size1)# ba ✓四、完整算法总结组件算法说明spBase64URL(RC4(BZPBlock LZ4(params)))URL-safe Base64~代替填充sig“V3.0” MD5(data key)data path params crc32bodyRC4(BZPBlock LZ4(body_json))原始字节发送CRC32binascii.crc32(encrypted_body)标准 CRC32响应解密LZ4_decompress(RC4(response)[24:])跳过 BZPBlock header五、纯 Python 实现5.1 加密核心importstructimportbinasciiimportbase64importhashlibimportlz4.blockfromCrypto.CipherimportARC4 RC4_KEYbxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx# 32字节从SO运行时dumpdefrc4_crypt(data:bytes)-bytes:RC4 加密/解密对称returnARC4.new(RC4_KEY).encrypt(data)defbuild_bzp_block(plaintext:bytes)-bytes:构建 BZPBlock 结构compressedlz4.block.compress(plaintext,store_sizeFalse)f2len(compressed)f3len(plaintext)f4f2^f3# XOR 校验returnbBZPBlockstruct.pack(IIII,0,f2,f3,f4)compresseddefencode_sp(params_str:str)-str:生成 sp 参数blockbuild_bzp_block(params_str.encode(utf-8))encryptedrc4_crypt(block)b64base64.urlsafe_b64encode(encrypted).decode()padding(4-len(b64)%4)%4returnb64.rstrip()~*paddingdefsignature(data:str)-str:生成 sig 签名digesthashlib.md5(data.encode(utf-8)RC4_KEY).hexdigest()returnfV3.0{digest}defencode_request_body(body_str:str)-bytes:加密请求体blockbuild_bzp_block(body_str.encode(utf-8))returnrc4_crypt(block)defdecode_response(data:bytes,encrypting1,compressing2)-str:解密响应decryptedrc4_crypt(data)ifdecrypted[:8]bBZPBlock:_,f2,f3,_struct.unpack_from(IIII,decrypted,8)compresseddecrypted[24:24f2]ifcompressing:returnlz4.block.decompress(compressed,uncompressed_sizef3).decode(utf-8)returncompressed.decode(utf-8)returndecrypted.decode(utf-8,errorsreplace)5.2 请求构建importjsonimporttimeimporturllib.parseimporthttpxdefbuild_batch_request(sub_reqs:list,t2:str,uniqid:str)-dict:构建完整的 batch 请求# 1. Gson 编码 bodybatch_body{subReqs:sub_reqs}body_jsonjson.dumps(batch_body,separators(,,:),ensure_asciiFalse)body_jsonbody_json.replace(,\\u003d).replace(,\\u0026)# 2. 加密 bodybody_encryptedencode_request_body(body_json)# 3. CRC32crc32str(binascii.crc32(body_encrypted)0xffffffff)# 4. 构建签名参数按 key 字母序排列client_infojson.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 和 sigsecretKey null → 用全局 RC4 密钥spencode_sp(params_str)sigsignature(/api/batch/requestsparams_strcrc32)# 6. 发送urlfhttps://api.xxx.com/api/batch/requests?sp{sp}sig{sig}app_id1003headers{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,}clienthttpx.Client(http2True,verifyFalse)respclient.post(url,contentbody_encrypted,headersheaders)# 7. 解密响应encryptingint(resp.headers.get(zp-encrypting,0))compressingint(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:encryptExpectIdxxxsortType1pageSize15expectId-2page1...}resultbuild_batch_request([sub_req],t2T2,uniqidUNIQID)# 搜索职位sub_req{method:GET,path:/api/zpgeek/app/geek/search/cardlist,query:queryPythonpage1searchType3sort-1...}resultbuild_batch_request([sub_req],t2T2,uniqidUNIQID)六、实测结果接口状态数据推荐职位列表✅每页 15 条翻页正常搜索职位✅返回完整职位卡片职位详情✅完整 JD、公司信息附近职位✅基于定位返回城市列表✅全量城市数据翻页测试推荐职位页码返回数数据重复Page 115-Page 215无重复Page 315无重复Page 415无重复Page 515无重复七、踩坑记录7.1 secretKey 白名单耗时最长现象请求返回{code:-1001,message:请求参数非法.}排查过程以为是 TLS 指纹 → 换 curl_cffi 模拟 → 无效以为是 HTTP/2 → 换 httpx → 无效以为是 Cookie → 检查 OkHttp 拦截器 → 无 Cookie从 App 内部用 HttpURLConnection 发 → 也失败从 App 内部用 OkHttpClient 发 → 也失败最终 hookconfigM.j()→ 发现白名单机制根因batch 接口在白名单中secretKey 必须传 null。我一直传了真实 secretKey。教训不要假设所有接口用相同的密钥。先用最简单的接口GET 请求验证基本机制是否正确。7.2 搜索接口返回空现象code0 但 cardList 为空根因搜索需要特定参数组合searchType3不是 0sort-1不是 sortType0expectId必须是真实数字 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 是两个长度字段的 XORsecretKey 白名单— 最大的坑batch 接口用 null key其他接口用真实 key输入输出对比法— 不需要完全读懂混淆代码多组测试数据就能反推算法整个过程从抓包到纯算跑通大约 1 小时。

更多文章