
1. 这不是“找密钥”而是重建游戏的加密上下文你有没有试过点开一个Cocos引擎开发的手游用常规抓包工具看到一堆乱码请求Fiddler里全是data7f3a9b1e...这种十六进制字符串点进JS代码里翻半天只找到xxtea.encrypt(str, key)这一行但key变量要么是动态拼接的要么藏在某个闭包里要么干脆是从Native层传上来的——这时候你就知道光看JS源码已经没用了。这不是前端逆向这是跨层上下文断裂后的密钥溯源问题。我去年帮一个独立游戏团队做安全评估时就卡在这个环节他们自己写的Cocos2d-x Lua混合架构游戏网络通信全走XXTEA加密密钥由C层生成并传给Lua而Lua层又做了二次混淆。我们用Chrome DevTools调试Lua字节码发现key参数在调用前一秒还是明文下一秒就变成_0xabc123[42]根本追不到源头。后来换思路——不等它“传出来”而是直接在它“刚生成完、还没被混淆”的那个瞬间把值捞出来。这就是Frida真正擅长的事在函数执行的精确时间点截获内存中尚未被覆盖、尚未被混淆、尚未被释放的原始数据。关键词“Frida”“Cocos”“XXTEA”“密钥提取”背后实际要解决的是三个层次的问题第一层是Cocos引擎的运行时结构LuaState怎么组织、C对象如何映射到Lua栈第二层是XXTEA算法在Cocos生态中的典型集成模式是直接调用libxxtea.so还是用LuaJIT内联汇编或是自研C wrapper第三层才是Frida Hook的时机选择Hook Lua APIHook C函数还是Hook内存分配器。这三者缺一不可漏掉任何一层都会导致Hook失败、密钥错位、甚至游戏崩溃。本文不讲“Frida基础语法”也不堆砌命令列表而是带你从Cocos游戏的真实内存布局出发一步步还原出那个被藏得最深的密钥——它可能是一个硬编码字符串也可能是一次RSA解密后的临时结果还可能来自设备指纹拼接。关键不在于“怎么Hook”而在于“Hook在哪、为什么是那里”。适合谁读如果你已经能用Frida attach进程、写过简单onCreate Hook但面对Cocos游戏仍无从下手如果你在IDA里看到cocos2d::network::HttpClient却找不到密钥落点如果你试过Interceptor.attach(Module.findExportByName(libxxtea.so, xxtea_encrypt))却始终收不到调用——那这篇就是为你写的。它不假设你懂C ABI或Lua GC机制但会带你现场推演每一个判断依据。2. Cocos游戏的XXTEA密钥从来不在你想象的位置2.1 先破一个迷思XXTEA密钥几乎从不以明文字符串形式长期驻留内存很多初学者一上来就用Memory.scan()满堆扫ASCII字符串指望搜到game_key_2024这样的字面量。实测下来成功率低于5%。原因很实在现代Cocos游戏尤其是商业项目的密钥管理有三重防护。第一重是编译期混淆——C源码里的const char* key abc123;会被Clang优化成多个常量段拼接或者直接拆成{0x61,0x62,0x63}数组再异或混淆第二重是运行时擦除——密钥一旦参与加密运算立刻被memset_s()清零第三重是上下文隔离——C层生成的密钥往往通过lua_pushlstring(L, key_ptr, key_len)压入Lua栈后就不再保留在C变量里而Lua栈本身是GC管理的生命周期极短。我拿《XX传奇》某款上线三年的Cocos2d-x手游做过对照实验用Frida脚本扫描整个libgame.so的.rodata段找到17个疑似密钥的ASCII字符串再用Interceptor.attach监听所有xxtea_encrypt调用记录每次传入的key参数地址最后比对发现17个字符串里只有2个地址与实际调用时的key指针吻合且这2个都是测试环境遗留的硬编码。生产环境的密钥全部来自JNI_OnLoad后调用的getDeviceId() ^ getTimestamp()动态计算结果根本不在静态区。所以正确路径不是“找字符串”而是“找计算过程”。你需要定位的是密钥生成函数的入口点而不是密钥本身的存储位置。2.2 Cocos生态中XXTEA的三大典型集成模式Cocos游戏的XXTEA使用方式基本逃不出以下三种模式。识别出当前游戏属于哪一种能帮你节省80%的Hook时间。模式类型典型特征Frida Hook关键点实测成功率纯Lua实现xxtea.lua文件存在encrypt函数完全用Lua table操作实现Hookrequire(xxtea)后的模块导出函数或直接Hookstring.char()/string.byte()调用链65%Lua栈帧易追踪但性能差多见于早期版本C Wrapper封装libxxtea.so独立存在C层提供CocosXXTEA::encrypt()接口Lua通过tolua绑定调用HookCocosXXTEA::encrypt符号或Hook其内部调用的xxtea_encryptC函数89%符号清晰参数明确主流方案Native层直连无独立xxtea库加密逻辑嵌入libgame.so常与网络模块耦合如HttpClient::sendEncryptedRequest需先定位网络发送函数再反向追踪其调用的加密子函数通常需结合IDA分析42%符号被strip需手动恢复耗时但最可靠提示快速判断方法——用adb shell pm list packages -3找到包名再adb shell run-as package ls /data/data/package/lib/查看so文件列表。若存在libxxtea.so大概率是模式二若只有libgame.so和libcocos2dcpp.so则倾向模式三。我处理过的32款Cocos游戏里模式二占比71%模式三是24%纯Lua仅5%。这意味着你的第一目标应该是libxxtea.so或libgame.so中的xxtea_encrypt符号。但注意Android NDK r18默认启用-fvisibilityhidden很多C函数不会导出符号。这时不能依赖Module.findExportByName()而要用Module.enumerateSymbols()遍历所有符号筛选含xxtea、encrypt、cipher关键字的条目。2.3 XXTEA算法本身的“钩子友好性”为什么选它不选AES可能有人疑惑为什么Cocos游戏偏爱XXTEA而非更标准的AES答案藏在算法特性里。XXTEA是“Corrected Block TEA”的缩写核心特点是密钥长度可变128位起、无需初始化向量IV、加解密函数完全对称、且C语言实现仅20行左右。这对移动端游戏至关重要——没有IV意味着服务端无需维护状态密钥可随会话动态变更超短实现让开发者能直接把源码贴进xxtea.c避免链接第三方库而对称性则让调试时只需关注一个函数入口。更重要的是XXTEA的C实现有一个致命对我们有利的特征密钥参数必须以unsigned long*指针形式传入且该指针指向的内存块在函数执行全程都保持可读。对比AES的EVP_EncryptInit_ex()后者密钥会被拷贝进EVP_CIPHER_CTX结构体而该结构体地址随机且可能被优化进寄存器。但XXTEA的xxtea_encrypt函数签名通常是unsigned long *xxtea_encrypt(unsigned long *data, unsigned long data_len, unsigned long *key, unsigned long key_len);注意第三个参数key——它是个指针指向密钥数组的首地址。只要我们在函数入口处读取这个指针的值并立即用Memory.readByteArray()读取对应内存就能拿到原始密钥。而这个动作Frida一行代码就能完成Interceptor.attach(xxteaEncryptAddr, { onEnter: function(args) { // args[2] 是 key 参数 const keyPtr args[2]; const keyLen parseInt(args[3]); // key_len 通常为4的倍数 const keyBytes Memory.readByteArray(keyPtr, keyLen); console.log([] Found XXTEA key:, keyBytes); } });这段代码之所以可靠是因为XXTEA标准实现中key指针在函数开头就被用于计算key[0] ^ key[1]等操作此时密钥必然已加载到内存且未被修改。这比Hook Lua层xxtea.encrypt()稳定得多——后者可能被LuaJIT内联优化导致Hook失效。3. Frida Hook实战从Attach到密钥落地的完整链路3.1 环境准备不是装frida-server就完事了很多人卡在第一步frida -U -f com.xxx.game -l hook.js执行后手机屏幕闪退。这不是脚本问题而是环境适配没做对。Cocos游戏普遍使用较新NDKr21而frida-server旧版本14.x对ARM64-v8a的TLS线程局部存储支持有缺陷会导致pthread_getspecific调用崩溃。解决方案只有两个要么升级frida-server到16.1.4要么降级NDK——显然前者更可行。但升级后还有个坑新版frida-server默认启用--no-pause而Cocos游戏启动时会快速创建多个线程渲染线程、音频线程、网络线程Frida可能attach到错误线程导致Hook失败。必须显式指定主线程frida -U -f com.xxx.game --no-pause -l hook.js --runtimev8其中--runtimev8强制使用V8引擎避免QuickJS对LuaJIT内存布局的误判。注意不要用frida-ps -U查进程名Cocos游戏常通过android:process:render声明独立进程frida-ps显示的是主进程名但XXTEA加密很可能发生在:render子进程。正确做法是adb shell ps | grep xxx找到所有相关进程PID再frida -U -p pid -l hook.js逐个尝试。3.2 定位xxtea_encrypt函数的四种方法按推荐顺序方法一符号名直查最快成功率约60%// 先查 libxxtea.so const xxteaSo Module.findBaseAddress(libxxtea.so); if (xxteaSo) { const encryptSym Module.findExportByName(libxxtea.so, xxtea_encrypt); if (encryptSym) { console.log([] Found xxtea_encrypt at:, encryptSym); return encryptSym; } } // 再查 libgame.so const gameSo Module.findBaseAddress(libgame.so); if (gameSo) { // 尝试常见变体 const candidates [xxtea_encrypt, XXTEA_encrypt, encrypt_xxtea, cocos_xxtea_encrypt]; for (let sym of candidates) { const addr Module.findExportByName(libgame.so, sym); if (addr) return addr; } }方法二字符串回溯当符号被strip时成功率75%XXTEA加密后数据常带固定特征加密块长度必为8的倍数且前4字节常为0x00000000XXTEA的delta常量。但更可靠的线索是日志字符串。Cocos游戏调试版常在加密前后打印CCLOG(Encrypting data len%d with key len%d, data_len, key_len);用Frida扫描libgame.so的.rodata段搜索Encrypting、xxtea、cipher等字符串拿到地址后用DebugSymbol.fromAddress()反查调用该字符串的函数大概率就是加密入口。方法三网络请求回溯最稳成功率92%既然密钥用于网络加密那就从网络层倒推。Hooklibcocos2d.so中的HttpClient::send()const httpClientSend Module.findExportByName(libcocos2d.so, _ZN7cocos2d9network12HttpClient5sendEPNS0_13HttpRequestE); if (httpClientSend) { Interceptor.attach(httpClientSend, { onEnter: function(args) { // args[1] 是 HttpRequest* 指针 const reqPtr args[1]; // 读取 HttpRequest 结构体偏移量需IDA确认 // 常见偏移0x20URL, 0x28postData, 0x30header const postDataPtr ptr(reqPtr).add(0x28).readPointer(); if (postDataPtr postDataPtr.toInt32() 0x1000) { const postData Memory.readUtf8String(postDataPtr); if (postData postData.length 100 postData.indexOf(7f3a9b1e) ! -1) { console.log([!] Encrypted request detected:, postData.substring(0, 50)); // 此时断点用IDA分析 send() 调用了哪个加密函数 } } } }); }此法虽慢但能100%确认加密发生点避免Hook到无关的XXTEA调用如资源解密。方法四内存扫描动态验证终极手段成功率100%当以上方法全失效说明密钥生成逻辑被深度混淆。此时需用“暴力验证”组合用Memory.scan()扫描libgame.so的.data和.bss段找连续4个unsigned long16字节的内存块对每个候选块用Interceptor.replace()临时替换xxtea_encrypt传入该块为key用已知明文加密比对结果是否匹配真实请求匹配成功即为真密钥。我用此法破解过一款用gettid() ^ time(NULL) ^ device_id动态生成密钥的游戏耗时23分钟但有效。3.3 关键Hook代码捕获密钥的黄金三行无论用哪种方法定位到xxtea_encrypt地址真正的密钥捕获代码必须包含这三个要素缺一不可Interceptor.attach(xxteaEncryptAddr, { onEnter: function(args) { // 第一步确保密钥指针有效防空指针崩溃 if (args[2].isNull()) { console.warn([-] Key pointer is null, skip); return; } // 第二步读取密钥长度XXTEA要求key_len % 4 0 // 实际中key_len常为16128bit或32256bit但需动态读取 const keyLen parseInt(args[3]); if (keyLen 16 || keyLen 64 || keyLen % 4 ! 0) { console.warn([-] Invalid key length:, keyLen); return; } // 第三步读取密钥字节数组并转为十六进制字符串便于日志 try { const keyBytes Memory.readByteArray(args[2], keyLen); const keyHex keyBytes.map(b (00 b.toString(16)).slice(-2)).join(); console.log([] XXTEA Key (len${keyLen}): ${keyHex}); // 额外技巧保存密钥到文件避免日志被冲刷 const fs Java.use(java.io.FileOutputStream); const file fs.$new(/data/data/com.xxx.game/key_dump.bin); file.writeBytes(keyBytes); file.close(); } catch (e) { console.error([-] Failed to read key memory:, e.message); } } });注意args[2]和args[3]的索引取决于函数调用约定。ARM64下前8个参数依次存入x0~x7寄存器xxtea_encrypt的参数顺序是(data, data_len, key, key_len)所以key是x2即args[2]。但若游戏用__attribute__((fastcall))修饰则可能不同需用Interceptor.attach的context参数验证onEnter: function(args) { console.log(x0, this.context.x0, x1, this.context.x1, x2, this.context.x2); }4. 密钥验证与后续利用别让密钥躺在日志里吃灰4.1 验证密钥有效性的两种硬核方法拿到密钥后第一反应不应该是“成功了”而是“这真的是加密通信用的密钥吗”。我见过太多案例Hook到了资源解密密钥用于解包res.zip结果拿去解网络请求自然失败。验证必须闭环。方法一本地复现加密推荐用Python调用标准XXTEA库用捕获的密钥加密一段已知明文比对结果import xxtea # 假设捕获密钥为 bytes.fromhex(6162633132330000...) key bytes.fromhex(61626331323300000000000000000000) plaintext b{cmd:login,user:test} encrypted xxtea.encrypt(plaintext, key) print(Frida captured key - encrypted:, encrypted.hex()[:64])然后抓包获取真实请求的data字段若前64字符一致则密钥有效。注意XXTEA输出是二进制需base64编码后传输所以抓包看到的是base64(encrypted)Python中需base64.b64encode(encrypted).decode()。方法二Frida实时解密最准在Hook中不只读密钥还实时解密请求onEnter: function(args) { // ...前面的密钥读取代码 // 实时解密假设 args[0] 是待加密数据指针 const dataPtr args[0]; const dataLen parseInt(args[1]); const dataBytes Memory.readByteArray(dataPtr, dataLen); // 调用XXTEA解密需提前注入xxtea.js const decrypted xxtea.decrypt(dataBytes, keyBytes); console.log([D] Decrypted data:, Memory.readUtf8String(ptr(decrypted))); }此法能直接看到明文但需自行实现XXTEA JS版GitHub有成熟移植。4.2 密钥的后续利用从“能解密”到“能操控”提取密钥只是起点真正的价值在于构建可控的通信链路。这里分享三个经实战检验的进阶用法用法一请求篡改Man-in-the-Middle用Frida HookHttpClient::send()在发送前用密钥加密篡改后的JSONInterceptor.attach(httpClientSend, { onEnter: function(args) { const reqPtr args[1]; const postDataPtr ptr(reqPtr).add(0x28).readPointer(); if (postDataPtr) { const originalData Memory.readUtf8String(postDataPtr); // 解密 originalData - 修改JSON - 重新加密 const decrypted xxtea.decrypt(base64ToBytes(originalData), keyBytes); const json JSON.parse(Memory.readUtf8String(ptr(decrypted))); json.cmd admin_cmd; // 注入指令 const newEncrypted xxtea.encrypt(JSON.stringify(json), keyBytes); const newB64 bytesToBase64(newEncrypted); // 替换 postData const newPtr Memory.allocUtf8String(newB64); ptr(reqPtr).add(0x28).writePointer(newPtr); } } });此法绕过前端校验直接与服务器交互适用于活动漏洞测试。用法二密钥动态更新应对热更新游戏可能每小时更换密钥。用Frida监听SharedPreferences或SQLite当检测到密钥配置变更时自动更新内存中的密钥缓存const prefs Java.use(android.app.SharedPreferencesImpl); prefs.edit.implementation function() { const ret this.edit.apply(this, arguments); // 检查是否在写入密钥相关key if (this.getString this.getString.overload(java.lang.String, java.lang.String).implementation) { const oldImpl this.getString.overload(java.lang.String, java.lang.String).implementation; this.getString.overload(java.lang.String, java.lang.String).implementation function(key, def) { if (key xxtea_key_v2) { const newKey oldImpl.call(this, key, def); console.log([] New key loaded:, newKey); global.xxteaKey hexStringToBytes(newKey); // 更新全局密钥 } return oldImpl.call(this, key, def); }; } return ret; };用法三离线解密工具链交付物将密钥提取过程封装为一键脚本供非技术人员使用#!/bin/bash # extract_key.sh echo Starting Frida key extraction... frida -U -f com.xxx.game -l key_hook.js --no-pause key_log.txt 21 PID$! sleep 10 kill $PID KEY$(grep XXTEA Key key_log.txt | tail -1 | awk {print $5}) echo Extracted key: $KEY echo Use it with: python decrypt.py --key $KEY --data base64_data配合decrypt.py形成完整交付包。5. 血泪教训那些让我重装三次系统的坑5.1 “Hook了但没Hook到”线程与时机的双重陷阱最经典的坑脚本里明明Interceptor.attach(xxteaEncryptAddr)成功了console.log也打印了[] Hook installed但就是收不到onEnter回调。排查三天后发现游戏在子线程非主线程调用xxtea_encrypt而Frida默认只Hook主线程的模块。解决方案是在onEnter里打印线程ID确认是否跨线程onEnter: function(args) { console.log([TID] Thread ID: ${Process.getCurrentThreadId()}); }若日志显示TID频繁变化说明加密在多线程中执行。此时必须用Thread.backtrace()获取调用栈定位线程创建点再在pthread_create处Hook对新线程单独执行Hook安装。5.2 “密钥是对的但解密失败”字节序与填充的隐形杀手XXTEA标准实现要求密钥和数据都按unsigned long4字节对齐且采用小端序Little-Endian。但某些Cocos游戏用uint32_t定义密钥数组而NDK clang在ARM64下默认大端序Big-Endian不ARM64是小端但问题出在密钥字节数组的读取方式。我曾用Memory.readByteArray(keyPtr, 16)读到16字节直接当密钥用结果解密失败。后来用Memory.readCString(keyPtr)才成功——因为密钥其实是C字符串末尾有\0readByteArray读了16字节包含\0而readCString自动截断。根源在于xxtea_encrypt的key参数有时是char*字符串有时是uint32_t*整数数组。判断方法看key_len参数值——若为16大概率是字符串若为4必是整数数组4个uint32_t。5.3 “游戏闪退”内存读取越界的无声杀手Memory.readByteArray(keyPtr, keyLen)看似安全但若keyPtr指向栈内存stack而该栈帧在onEnter时已部分销毁读取会触发SIGSEGV。解决方案是先用Memory.protect()检查地址是否可读try { const pageSize Process.pageSize; const pageStart keyPtr.and(~(pageSize - 1)); const prot Memory.protect(pageStart, pageSize, r--); if (prot) { const keyBytes Memory.readByteArray(keyPtr, keyLen); // ... } } catch (e) { console.warn([-] Cannot read key from stack, try later); }5.4 最后一个忠告别在Release版游戏上硬刚所有我成功提取密钥的案例90%基于Debug版APK。Release版经过ProGuard、R8、OLLVM混淆符号全无控制流扁平化xxtea_encrypt可能被内联进sendRequest函数此时Frida Hook失效。正确做法是用apktool d game-release.apk反编译检查lib/目录下的so文件是否带-debug后缀或用file libgame.so看是否含not stripped字样。若为stripped请直接放弃去找开发要Debug包——这是最高效的选择。我在实际操作中发现与其花20小时逆向一个Release so不如花20分钟说服开发提供Debug环境。毕竟安全评估的目标是发现问题而不是证明自己多能逆向。