硕士生
小佬
 
- 金币
- 7002
- 好评
- 162
- 信誉
- 270
  

|
本帖最后由 JiGuro 于 2026-8-10 18:02 编辑
【逆向实战】
Cocos2d-Lua 手游从资源解密到打包破解 (上)
—— XXTEA 加解密与 LZ4 解压缩流程详析
一、分析目标与样本初探
二、确定 Cocos2d-Lua 逆向分析路线
三、定位加密资源与文件格式
四、进入 Native 层:定位资源解密入口
4.1 还原资源解密流程
4.2 拆解资源文件格式与密钥派生机制
五、追踪 XXTEA Key 的来源
5.1 从 AppDelegate 还原密钥生成逻辑
5.2 动态验证:用 Frida 确认 Key
六、验证 XXTEA 实现
七、编写 XXTEA 解密器
八、攻克 tj!:XXTEA 与 LZ4 的组合格式
九、批量解密与性能优化
十、最终验证与阶段性成果
开篇严正声明:本文仅用于学习逆向工程与网络安全相关技术,未掺杂任何不良目的。文中的软件已对其名字、图标及相关敏感信息做了模糊处理,本人也不会提供软件原包样品,内容仅作学习交流使用!同时,本人并未对该软件样本进行任何分享,下载仅做技术研究,均在个人设备上和虚拟设备中进行分析,并已在分析完后删除!
几周未见,甚是想念!
没错,我又找到素材了
继上次连载教程之后,我又准备出一期连载,分为上、下两篇,上篇主要讲的是 SO 层分析,逆向算法;下篇主要教的就是 Lua 逆向和破解越狱了。
看前提醒:这系列文章较以前文章技术难度有一定提升,全是干货,希望各位能够耐心看完,感谢大家的支持与理解!
一、分析目标与样本初探
这次我们的分析对象是一个宝可梦二次元手游,玩家完成主线任务、成功闯关后,就有机会解锁人物进行互动,游玩方式非常简单。当我们未完成主线任务时,游戏就不让我们与角色互动 :

那么我们这期的任务就是绕过这个机制,实现获取互动资源或者做到游戏直装破解。
我们先看 APK 详细信息,发现包名为 com.bkm2.js.android,安装包大小非常大,足有 961.19M ,大概率本地塞了大量图片/音频/视频。

然后我们看 AndroidManifest.xml 里的 application 入口:

打开 lib 目录,我们看到只有一个 SO 文件,体积还不小:

看到 org.cocos2dx.lua.App 和 libcocos2dlua.so 了吗?这是 Cocos2d-Lua 引擎的标志。这是 Lua 游戏,核心逻辑在 Lua 脚本和 SO 里,不在 Java 里。 Java 层大概率只是个壳。
以防万一,我们还是瞄一眼
用 MT 管理器 打开 Dex ,发现基本上都是一些现成 API 或第三方库,干净利落。从当前 APK 的 DEX 分析来看,并无与核心游戏业务直接相关的 Java 实现,因此我们后续分析重点转向 Native 层与 Lua 层。
对于这两层,我们猜测,我们要破解的主要逻辑藏在 Lua 层,由开发者自定义;native 层更多的应该是 Cocos2d 的生态。
二、确定 Cocos2d-Lua 逆向分析路线
此时,很多同学应该就迫不及待准备进行下一步了,但这种冲动恰恰是最大的禁忌。这时你应该:
上网查!
网络是获取信息的最快方式,特别是当你遇到一个不太熟悉的游戏框架时。这时就到了我最爱的引文环节:
【小白反编译某手游(二):动手】https://www.52pojie.cn/thread-1838722-1-1.html
【逆向学习笔记 - Cocos2d Lua逆向提取资源】https://xupin.im/2024/04/08/reverse-cocos2dlua/
这两篇文章讲得比较详细,都是关于 Cocos2d-Lua 引擎逆向的文章。
哈哈,都有这么多前人铺路了,那解个这玩意不是简简单单吗?
实际上不然。虽然网上相关教程非常多,但要不然就是讲不清楚,要不然就是太高深,小白听不懂。同时,不同版本的引擎和游戏都会造成不一样的输出结果和代码架构,这也成为我写这篇文章的原因。
根据网络信息我们可以得知,这个引擎整体分为四层架构:

这与我们之前猜测的职责分布大差不差。
同时,从两位大佬的逆向文章中,我们还得知,游戏资源(包括 Lua 脚本和图片、音频、视频、骨骼等等资源)一般都位于安装包 /assets/ 目录下,同时使用 XXTEA、LZ4 进行打包加密。
这里涉及到两个陌生的名词,简单科普一下。
XXTEA:一种对称分组加密算法,Cocos2d 使用其加密文件,通常包含以下两个关键元素:
Sign:加密标记,用于判断文件是否被加密(特定文件头)
Key:解密时使用的密钥
LZ4:一种极速压缩算法。
三、定位加密资源与文件格式
现在我们回到我们的游戏安装包,发现确实有约 900 多 M 的文件存储在 /assets/repository/ 目录下,包含各种扩展名。显然,这就是游戏运行时所需要用到的资源文件。

而且,和网上说的一致,这些文件大部分都被加密了。

加密文件统计后大致分为以下两种:
1、文件头魔数为 74 6A 65 (tje)

2、文件头魔数为 74 6A 21 (tj!)

这与网络文章中所说的情况相似 (Lua脚本都是MQKK开头,png都是DDD3开头),我们初步可以得出结论,这些资源应该就是使用 XXTEA 加密的。
由于我们所需要的关键代码和一些资源文件都存储在该文件夹中被加密,所以我们需要通过逆向 SO 的方式找出 XXTEA 加密的 Key ,从而解密文件。
现在思路就清晰了,直接把 SO 文件解压出来,然后请出我们的逆向神器 —— IDA Pro 
四、进入 Native 层:定位资源解密入口
先把 libcocos2dlua.so 拖进 IDA。 这个 SO 是 arm64 架构,17 MB,让 IDA 自动分析,几分钟的事。打开后先做两件事:
1、看段(Segments):按 Shift+F7。注意第一个 LOAD 段的起点是 0x00000000—— 说明 IDA 把 so 加载到基址 0,后面我给的地址(0x93fb20 之类)各位就可以直接跳转了,方便复现,不用加偏移。

2、搜函数(Functions):按 Shift+F3 打开函数窗口,搜索框输入算法关键词回车。
先搜索 xxtea ,逐步跟进,发现:
tj_xxtea_decrypt(uchar*,uint,uchar*,uint,uint*) 0x5AAB68 // 函数体
tj_xxtea_decrypt(uchar*,uint,uchar*,uint,uint*) 0x482DC0 // PLT 桩
tj_xxtea_encrypt(uchar*,uint,uchar*,uint,uint*) 0x5AA870
xxtea_decrypt 0xA20908
xxtea_encrypt 0xA20600
符号名直接就把算法名字告诉你了:XXTEA 。 这个 so 的导出符号 (.dynsym)没有被 strip,算法名全是明文,这极大方便我们的分析。
顺带解释为什么 tj_xxtea_decrypt 出现了两次:PLT 桩 vs 函数体。 ELF 里对导出函数的调用一律走 PLT(间接跳转,允许运行时符号替换), 0x482DC0 是 PLT 桩,真正干活的函数体在 0x5AAB68。记住这个规律 —— 后面 LZ4_decompress_safe 也是同样情况,调用点打的是 PLT 桩,函数体在别处。
这个 tj_ 前缀很有意思 —— tj 可能是 TianJi(天机)的缩写 (挺中二的 ), 等一下我们会看到 TianJi 这个词出现在密钥构造链里。
再搜 lz4 :
LZ4_decompress_safe 0x471820 // PLT 桩(函数体在 0x64778C)
LZ4_decompress_fast 0x484AC0 // PLT 桩
luaopen_lz4 0x6461A8 // Lua 绑定入口
好,XXTEA(加密)+ LZ4(压缩),两个关键词已经浮出水面。 我们的工作假说是:资源 = XXTEA(原文)、资源 = LZ4(XXTEA(原文)) 或者 XXTEA(LZ4(原文)),具体顺序和其与文件魔数的对应关系待验证。
接着,我们应该如何下手呢?
我的思路是,既然有一种文件格式一定要先后使用 XXTEA 和 LZ4 的话,那么我们就可以通过这些底层函数找到上游的总解密入口。
我们这样定位:
1、在函数表双击 LZ4_decompress_safe(0x471820 那个 PLT 桩),光标放函数名上按 X(交叉引用),列出所有调用者;
2、对 tj_xxtea_decrypt(0x482DC0)同样按 X;
3、两份列表的交集,就是"总解密入口"。


两份 xref 结果指向同一个函数:
cocos2d::FileUtils::lua_cocos2dx_ui_RichText_createWithXML__Overloading
很多同学有疑问,这个函数名看起来不像总解密函数啊
实际上,这个 SO 对函数名还是做了一定程度的混淆,只是一些底层函数没有混淆而已。函数混淆骗得过我们,但骗不过交叉引用。只要它同时调用 XXTEA 和 LZ4, 说明它就是 FileUtils::getData 的资源解密函数。
4.1 还原资源解密流程

跳到 0x93FB20(这就是函数起点), 按 F5 让 Hex-Rays 出伪代码,一切就明朗了。我们来看 IDA 的 F5 伪代码(变量名按语义重命名,原代码里的 goto LABEL_13 是三个分支共用的返回/解压段,我展开成直线流):
- // 0x93FB20 FileUtils::lua_cocos2dx_ui_RichText_createWithXML__Overloading
- // 参数: data=文件数据, size=长度, &out=输出缓冲区, a4=预留
- __int64 decrypt(const char *data, int size, _QWORD *out, char **a4)
- {
- int magic2 = (unsigned char)data[2]; // 第 3 个字节
- void *buf = NULL;
- u32 len1, rlen;
- u8 *p3;
- if (magic2 != 101 /*'e'*/ && magic2 != 33 /*'!'*/) {
- // 非 tje/tj!
- len1 = LE32(data + 3); // tjz: 解压后长度
- p3 = data + 7; // 数据起点
- rlen = (u32)size - 7; // 返回长度 = size-7
- } else {
- // tje/tj! : XXTEA
- if (!s_xxteaEnabled) return -1; // 0x10525F0
- u8 derived[16] = {0};
- for (int i = 0; i < 16; i++)
- derived[i] = s_xxteaKey[i % s_xxteaKeyLen] // key @0x10525F8
- ^ data[3 + i]; // ^ 文件头 keymat
- len1 = LE32(data + 19); // 明文长度 plen(offset 19)
- buf = tj_xxtea_decrypt(data + 23, // 密文 @23
- size - 23, // 长度 = size-23
- derived, 16, &outlen);
- if (!outlen) return 0;
- p3 = buf; rlen = outlen;
- }
- // 共用段
- if (len1 >= 0xC800001) { free(buf); return -1; } // ~200MB 上限 guard
- if (magic2 != 33 && magic2 != 122 /*'z'*/) {
- *out = p3;
- return rlen; // tje: 返回 XXTEA 明文;非魔数: 跳过 7 字节头返回
- }
- // LZ4 (tj! / tjz)
- buf = malloc(len1 + 1); *out = buf;
- if (*(u32*)p3 == len1) { // 一致性校验
- n = LZ4_decompress_safe(p3 + 4, buf, ...);
- if (n == len1) { buf[len1] = 0; return len1; }
- }
- return -1;
- }
复制代码
就这?读第三个字节,比较 ‘e’ 和 ‘!’?
没错,真正的判断就是这样朴实无华 这也与网上大佬的文章中的判断逻辑相似。
我们可以看到,这游戏用文件头 3 个字节区分格式:
1、tje → 纯 XXTEA 加密(解完直接返回);
2、tj! → XXTEA 解密后再 LZ4 解压;
3、tjz → 纯 LZ4 (在这次逆向过程中并未实际发现这种格式,只在代码中理论存在,我们这期不讲);
4、其它 → 跳过 7 字节头直接返回。
注意 tje 和 tj! 第 3 个字节分别是 e 和 !,而"第 2 个字节"都是 j, "第 1 个字节"都是 t。这是典型的变长魔数 + 同前缀设计:前缀相同, 后面的字母区分是否额外套了一层压缩。
此时,我们之前对于文件魔数的疑问就已经完全解决了。
4.2 拆解资源文件格式与密钥派生机制
我们再逐段拆解代码里的关键逻辑:
① XXTEA 开关:
else 分支开头先查全局 s_xxteaEnabled,没开直接返回 -1。
② 派生密钥:
内置密钥 XOR 文件头 keymat:循环里这一句是重点 ——
- derived[i] = s_xxteaKey[i] ^ 文件头[3+i]; // i = 0..15
复制代码
真正的解密密钥不是写死的 16 字节,而是 "程序内置密钥" 和 "每个文件自带的 16 字节密钥材料(keymat)“ 逐位异或得到的。
注意:keymat 就明文存放在 文件头里(data[3..18]),是公开可见的"盐”。它并没有给加密增加机密性 —— 只要内置的 s_xxteaKey 被提取出来,任何文件的派生密钥都能立刻算出来。它的作用只是多一层混淆,让密钥不能一眼看到,得先定位到这段异或逻辑。 真正值得保护的,始终只有内置的 s_xxteaKey 本身。
③ 文件头结构:
接着读偏移 19 处 4 字节 = 明文长度 plen,密文从偏移 23 开始、长度 size-23,用派生密钥(16 字节)解密:
tje/tj! 文件头 = "tje|tj!"(3) + 密钥材料 keymat(16) + 明文长度 plen(4) + 密文(...)
④ 这里还有两个细节:
透传分支:非 tje/tj!/tjz 的文件返回 data+7 / size-7,即假定有 [3字节魔数][4字节长度] 头。MP3/字体等真正无魔数的文件不会进这个函数 (调用方先判魔数,无魔数直接原样返回)。
解压上限 guard:len1 >= 0xC800001(约 200MB)直接报错,防解压炸弹。
⑤ 关于 tjz:
分支确实存在,读 data+3 与 data+7 两个 4 字节字段, LZ4 数据从 data+11 开始。但整个安装包里没有发现任何 tjz 文件, 它只存在于代码逻辑里,属于理论路径,实际处理时用不到,我们这期不讲。
顺带说一句:这三个全局变量的符号名没有被混淆,IDA 直接解析出了 cocos2d::FileUtils::s_xxteaEnabled、s_xxteaKey、s_xxteaKeyLen, 地址分别是 0x10525F0 / 0x10525F8 / 0x1052600,记下来我们后面用。
很好,现在整条链都较为清晰了。现在只剩最后一个谜:s_xxteaKey(程序内置的 16 字节)是谁写进去的?我们的目的就是拿到这串 Key 。
五、追踪 XXTEA Key 的来源
这里有两种情况:
1、由 Lua 传递
2、内置在 SO 中
答案是,Lua 确实有 cc.FileUtils:setXXTEKey(...) 这种 API,但这个游戏没用它。我们在 IDA 里跳到全局变量 s_xxteaKey(0x10525F8),按 X 看谁写它 —— 只找到一处写入点:
0x49E5CC: bl setXXTEKey ; 调用点,位于 AppDelegate 构造函数
这个调用点在 AppDelegate::AppDelegate() 构造函数里。也就是说:密钥在 Native 侧,属于情况2。那我们只能硬啃构造函数。
跳到 0x93F9C8(setXXTEKey 函数体),按 F5:
- // 0x93F9C8 setXXTEKey(char *key, int len)
- void setXXTEKey(char *key, int len) {
- if (s_xxteaKey) { free(s_xxteaKey); s_xxteaKey = NULL; }
- s_xxteaKeyLen = 0;
- if (!key || !len) { s_xxteaEnabled = false; return; }
- if (len == 0x20) { // 32 字符,十六进制字符串解码成 16 字节
- buf = malloc(0x11);
- // Hex-Rays 里这段是一串 SIMD 查表指令,等价于 bytes.fromhex("...")
- buf[16] = 0;
- s_xxteaKey = buf;
- } else if (len == 0x10) { // 16 字节,直接 memcpy
- buf = malloc(0x10);
- memcpy(buf, key, 16);
- s_xxteaKey = buf;
- } else {
- s_xxteaEnabled = false; return;
- }
- s_xxteaKeyLen = 0x10;
- s_xxteaEnabled = true;
- }
复制代码
在 IDA 里,这个函数的符号名是 cocos2d::FileUtils::lua_cocos2dx_ui_RichElement_equalType__Terminology —— 又一个混淆名 (setXXTEKey 的真身,和下文 AppDelegate 末尾的调用对得上)。len==0x20 分支里那串 SIMD 指令(vld2q_s8 载入 32 字符 + 一堆 vaddq/vandq/vshlq) 就是"十六进制字符 → nibble → 字节"的查表实现,等价于 bytes.fromhex(...), 与上面的注释一致。
有个重点:调用处传的是 32 字符的十六进制串(len=0x20 分支), 说明 AppDelegate 里构造的是 32 个十六进制字符,再被解码成 16 字节。 我们顺着 AppDelegate(0x49DFC0,2184 字节,是个大函数)继续挖。
5.1 从 AppDelegate 还原密钥生成逻辑
AppDelegate 构造函数里面全是 std::string 的手工构造。
F5 伪代码会很长,我们换个更快的办法:按 Shift+F12 打开字符串窗口, 搜 cocos、pkm、20240516,双击跳转到 .rodata 段对应地址:
0xcbd12f: "cocos" (5 字节)
0xcbd1a2: ",pkm" (4 字节)
0xcbd1a7: "20240516" (8 字节,像是个日期/版本号)
0xcbd1e2: 表A (34 字节, 有符号索引)
0xcbd204: 表B (替换表)
20240516 这种 8 位数字串,通常是"构建日期"。
表A:
表B:
表A / 表B 眼尖的同学其实一眼就能看出,这很像经典的字符串混淆:真实字符串被拆成"索引表 + 字符表",运行时重建。这两张表,理论上就是我们还原 Key 的关键。
F5 伪代码里能看到这样的操作(Hex-Rays 把 std::string 的手工构造 还原成了逐字节赋值):
- // 逐字节写入 "TianJi" (6 字节)
- s[0]='T'; s[1]='i'; s[2]='a'; s[3]='n'; s[4]='J'; s[5]='i';
- s.insert(0, "cocos", 5); // 头部插入 "cocosTianJi"
- s.append(",pkm", 4); // 尾部追加 "cocosTianJi,pkm" (15 字符)
复制代码
pkm 我估计就是 Pokemon 了,哈哈。管他呢,先记着。
表 A 有 34 个字节,每个字节当作有符号索引去表 B 里取字符。 IDA 的 F5 里就是一行 v15[n34] = byte_CBD204[byte_CBD1E2[n34]] (byte_CBD1E2 / byte_CBD204 正是表 A / 表 B,与上面对地址的定位一致):
- char t[34];
- for (i = 0; i < 34; i++)
- t[i] = tableB[ tableA[i] ]; // tableA[i] 是有符号 char
复制代码
我们把表 A 抄出来:
tableA = 50 02 5d 21 57 02 62 7e 21 0a 09 29 53 5d 25 02 09 21 62 50
1f 39 4f 21 09 33 09 63 03 62 7e 21 39 75
配合表 B 还原出来的是:
"TianJi Information Technology Inc."
天机信息技术?—— 这家公司(或其品牌)我估计就是引擎作者/发行方。所以 tj_ 前缀 = TianJi。
接下来,代码会对字符串进行拼接和字符变换。
- base = "cocosTianJi,pkm" + "TianJi Information Technology Inc.";
- // 对 base 做 in-place 变换:
- // 'a'-'z' → 'A'-'Z' (字母大写)
- // '.' → ';'
- // '_' → '+'
- // '6' → ';'
复制代码
变换后的字符串长这样:
"COCOSTIANJI,PKMTIANJI INFORMATION TECHNOLOGY INC;"
进行第一轮 MD5:
- md5_hex1 = CCCrypto::MD5String(transform(base), len);
- // 返回 32 字符小写十六进制
复制代码
然后拼版本串:
- ver = "2.1.0.0"; // 跟 versionName 一致
- sub = ver.substr(0, 5); // 找最后一个 '.', 截 5 字符 = "2.1.0"
- extra = "20240516" + sub + "TianJi";
- // = "202405162.1.0TianJi"
- md5_hex1 += extra; // 拼接
复制代码
F5 伪代码里这段也很清晰:循环从版本串尾部找最后一个 . ,得到索引 5, 于是截取长度 = 5,得到 "2.1.0",再 insert(0, "20240516", 8)、 append("TianJi", 6),拼出 "202405162.1.0TianJi"。
第二次 transform + 反转 + 第二轮 MD5:
- s = transform(md5_hex1 + extra); // 再次大写 + 特殊字符映射(含 6→;)
- reverse(s); // 整串反转
- md5_hex2 = CCCrypto::MD5String(s, len); // 32 字符 hex
复制代码
注意这里的 transform 同时作用于 md5_hex1: md5_hex1 里若有 a-f 会变 A-F,若有 6 会变 ;, 这会直接影响第二轮的输入。
得到最终密钥:
- setXXTEKey(md5_hex2, 32);
- // s_xxteaKey = bytes.fromhex(md5_hex2) 16 字节
复制代码
在 AppDelegate 的 F5 里,这个调用显示成 cocos2d::FileUtils::lua_cocos2dx_ui_RichElement_equalType__Terminology —— 又是一个混淆名,但传参(md5_hex2 的指针、32)骗不了人,它就是 setXXTEKey。
好,一条看似严密的密钥推导链:
key = hex_decode( MD5( reverse( transform( MD5( transform(base) ) + extra ) ) ) )
这样我们可以使用 Python 脚本完美还原密钥:
- import hashlib
- def transform(s):
- out = bytearray()
- for c in s:
- if 0x61 <= c <= 0x7a:
- c = (c + 0xe0) & 0xff # 小写→大写(溢出回绕)
- if c == 0x2e: c = 0x3b # '.'→';'
- elif c == 0x5f: c = 0x2b # '_'→'+'
- elif c == 0x36: c = 0x3b # '6'→';'
- out.append(c)
- return bytes(out)
- base = b"cocosTianJi,pkmTianJi Information Technology Inc."
- md5_1 = hashlib.md5(transform(base)).hexdigest().encode()
- extra = b"202405162.1.0TianJi"
- s = transform(md5_1 + extra)[::-1]
- key = bytes.fromhex(hashlib.md5(s).hexdigest())
- print(key.hex()) # 94e1e4efb4ddea414f6d65484680a829
复制代码
输出结果为:94e1e4efb4ddea414f6d65484680a829。密钥推导成功。
这里扔张图:

有同学会问,那是不是可以拿它去解密了?
别急。逆向圈有句老话 —— “静态猜,动态验”。静态推导再漂亮, 也只是"我们认为代码会这么做"。动手解密之前,先用 Frida 到运行中的游戏里 把真实密钥读出来,两边对上了,才算板上钉钉。
5.2 动态验证:用 Frida 确认 Key
先准备环境:
在 root 的真机或模拟器上:
- pip3 install frida frida-tools
- # 下载 frida-server-<版本>-android-<arch>,push 到设备
- adb push frida-server /data/local/tmp/
- adb shell "su chmod +x /data/local/tmp/"
- adb shell "su nohup /data/adb/frida-server &"
- frida-ps -U # 看到 frida-server 进程 = 就绪
复制代码
我使用的是 Frida 17.2.15 ,实测这个版本稳定一些。
接下来我们需要选定 Hook 目标。最省事的 Hook 点是 setXXTEKey(0x93f9c8) ,因为它在 App 启动时被调用,直接传进来 32 字符的十六进制串。我们 hook 它的参数:
- // frida_hook.js (节选)
- // setXXTEKey
- const setter = base.add(OFF.SETTER);
- Interceptor.attach(setter, {
- onEnter(args) {
- this.key = args[0];
- this.len = args[1].toInt32();
- console.log("\n==== setXXTEKey ====");
- console.log(" len = " + this.len);
- if (this.key.isNull()) {
- console.log(" key = NULL");
- } else if (this.len > 0 && this.len <= 256) {
- const buf = this.key.readByteArray(this.len);
- const bytes = Array.from(new Uint8Array(buf));
- console.log(" key = " + bytes.map(b => ("0" + b.toString(16)).slice(-2)).join(""));
- console.log(" ascii= " + bytes.map(b => b >= 32 && b < 127 ? String.fromCharCode(b) : ".").join(""));
- }
- },
- });
复制代码

跑起来:
- frida -U -f com.bkm2.js.android -l Dump_Key.js
复制代码
终端输出:

- setXXTEKey = 94e1e4efb4ddea414f6d65484680a829
复制代码
这跟咱们静态推导的 94e1e4efb4ddea414f6d65484680a829 一模一样!
那么证明,这玩意它就是密钥。
六、验证 XXTEA 实现
接下来,我们就可以尝试解密文件了……吗?
我们还是有必要看一眼 XXTEA 算法,如果算法和标准库不一致,那么盲目尝试解密只能是浪费时间。

双击函数表里的 tj_xxtea_decrypt (0x5AAB68 那个函数体),按 F5 —— 咦,函数不大,而且看起来像个壳:
- // 0x5AAB68 tj_xxtea_decrypt(密文, 长度, 密钥, 密钥长度, &outlen)
- __int64 tj_xxtea_decrypt(unsigned __int8 *密文, unsigned int 长度,
- unsigned __int8 *密钥, unsigned int keyLen,
- unsigned int *outlen)
- {
- *outlen = 0;
- if (keyLen > 0xF) // 密钥 >= 16 字节,直接用
- return sub_5AAC34(密文, 长度, 密钥, outlen);
- // 密钥不足 16 字节:补零到 16 字节再解
- buf = malloc(0x10);
- memcpy(buf, 密钥, keyLen);
- memset(&buf[keyLen], 0, 16 - keyLen);
- v = sub_5AAC34(密文, 长度, buf, outlen); // 真正的算法在 sub_5AAC34
- free(buf);
- return v;
- }
复制代码
tj_xxtea_decrypt 只是层壳,密钥够 16 字节就直接用, 不够就补零到 16 字节。我们传给它的派生密钥正好 16 字节,所以每次都走 sub_5AAC34(0x5AAC34)。
双击它,按 F5,伪代码长这样 (我把开头/结尾的"字节打包、解包"循环折叠成注释,其余保持原样):
- // 0x5AAC34 XXTEA 解密核心(F5 原样,仅折叠字节打包/解包循环)
- __int64 sub_5AAC34(__int64 data, unsigned int len, __int64 key, _DWORD *outlen)
- {
- n = (len + 3) >> 2; // 向上取整,32 位字数
- v = calloc(n, 4); // [循环A: 密文按小端塞进 v[]]
- k = malloc(16); // [循环B: 16 字节密钥按小端塞进 k[]]
- if (n != 1) {
- sum = DELTA * (52 / n) + 6 * DELTA; // DELTA = 0x9E3779B9
- do {
- e = (sum >> 2) & 3;
- y = v[0];
- for (p = n - 1; p > 0; p--) {
- z = v[p - 1];
- v[p] -= ((z >> 5 ^ y << 2) + (y >> 3 ^ z << 4))
- ^ ((y ^ sum) + (k[(p & 3) ^ e] ^ z));
- y = v[p]; // 链式:用刚更新过的 y
- }
- z = v[n - 1];
- v[0] -= ((z >> 5 ^ y << 2) + (y >> 3 ^ z << 4))
- ^ ((y ^ sum) + (k[e] ^ z));
- sum -= DELTA;
- } while (sum);
- }
- plen = v[n - 1]; // 明文长度藏在最后一个字
- if (plen >= n*4 - 7 && plen <= n*4 - 4) {
- out = malloc(plen + 1);
- // [循环C: v[] 按小端解包成 plen 字节]
- out[plen] = 0; // 末尾补 0
- *outlen = plen;
- return out;
- }
- return NULL;
- }
复制代码
这段和标准 XXTEA 的 MX 公式基本一致:
- MX = (((z>>5 ^ y<<2) + (y>>3 ^ z<<4)) ^ ((sum ^ y) + (key[(p&3)^e] ^ z)))
复制代码

从算法结构、常量、轮函数以及实际解密结果来看,可以确认该实现属于标准 XXTEA 解密流程。
七、编写 XXTEA 解密器
密钥、格式、算法都有了,动手写解密器。XXTEA 是公开的经典算法,网上标准伪代码遍地都是, 直接拿来用。把标准 XXTEA 的 Python 实现抄一份:
- def tj_xxtea_decrypt(data, key):
- n = (len(data) + 3) // 4
- v = [0]*n
- for i, b in enumerate(data):
- v[i >> 2] |= b << ((i & 3) * 8)
- k = list(struct.unpack("<4I", key))
- DELTA, MASK32 = 0x9E3779B9, 0xFFFFFFFF
- if n > 1:
- s = ((52 // n) * DELTA + 6 * DELTA) & MASK32
- while s != 0:
- e = (s >> 2) & 3
- y = v[0]
- for p in range(n - 1, 0, -1):
- z = v[p - 1]
- mx = ((((z >> 5) ^ ((y << 2) & MASK32)) +
- (((y >> 3) & MASK32) ^ ((z << 4) & MASK32))) & MASK32)
- mx ^= ((y ^ s) + (k[(p & 3) ^ e] ^ z)) & MASK32
- v[p] = (v[p] - mx) & MASK32
- y = v[p] # ← 链式:用更新后的 y
- z = v[n - 1]; ki = k[e]
- mx = ((((z >> 5) ^ ((y << 2) & MASK32)) +
- (((y >> 3) & MASK32) ^ ((z << 4) & MASK32))) & MASK32)
- mx ^= ((y ^ s) + (ki ^ z)) & MASK32
- v[0] = (v[0] - mx) & MASK32
- s = (s - DELTA) & MASK32
- outlen = v[n - 1]
- # 长度校验
- if not (len(data) - 7 <= outlen <= len(data) + 3 - 4):
- return b""
- return bytes((v[i >> 2] >> ((i & 3) * 8)) & 0xFF for i in range(outlen))
复制代码
我们这里用一张 tje 格式加密过后的图片做示例:
- ~/逆向素材/神奇宝贝 $ python3 decrypt.py box_zzfz_24408@.png
- 处理: box_zzfz_24408@.png -> decrypted/box_zzfz_24408@.png
复制代码
一次通过,没有报错。回到 MT 管理器,我们发现先前损坏的图片可以正常解析了!

第一波胜利。tje 格式:XXTEA 解完就是明文,没有任何额外压缩。
八、攻克 tj! 天坑:XXTEA 与 LZ4 的组合格式
那么接下来,我们就要攻 tj! 格式了,理论上它是 XXTEA + LZ4 。
根据上面大佬的文章(【小白反编译某手游(二):动手】https://www.52pojie.cn/thread-1838722-1-1.html),大佬是通过 Frida hook 的方式从内存 DUMP 文件的。
这里要注意,大佬文章中图片也是经过 LZ4 压缩的。但是我们这里图片只进行了 XXTEA 加密,而其他资源 (极有可能包含 Lua)很多都是 tj! 格式。
文章中写 LZ4 可能做了适当改动,大佬说尝试了直接用公开的LZ4依赖库去尝试解压,但失败了。
那我也来尝试尝试:
先用写好的解密脚本先把 XXTEA 解密了,我们这里用一份 tj! 格式加密过后的 json 配置文件做示例:
- ~/逆向素材/神奇宝贝 $ python3 decrypt.py txt_tebedc.json
- 处理: txt_tebedc.json -> decrypted/txt_tebedc.json
复制代码
成功!

看一看文件,发现里面虽然还有很多乱码,不过已经出现了一些可读的单词了。文件里夹杂的可读文本不是巧合,它是 LZ4 的 literals。这就说明,至少我们方向是对的。
接下来我们进行 LZ4 解压缩:
- lz4.block.decompress(plain, uncompressed_size=plen) # LZ4 解压
复制代码
结果:
Decompression failed: corrupt input or insufficient space...
Error code: 11
果然像大佬说的一样,用标准库解密不行。这是怎么回事?我排除了设备上的各种因素,包括环境,发现都不是。
难道,LZ4 真是魔改的吗?相信很多同学可能会卡死在这一步
这里告诉大家一条铁律:网上信息要参考,但不能一味相信网上说法。
我决定不猜了,回到解密入口 0x93FB20 的 F5 伪代码,把 tj! 的 LZ4 分支看仔细。就是这一段:
- // LZ4 (tj! / tjz)
- buf = malloc(len1 + 1); *out = buf;
- if (*(u32*)p3 == len1) { // 一致性校验: 头4字节==len1
- n = LZ4_decompress_safe(p3 + 4, buf, ...); // 关键!
- if (n == len1) { buf[len1] = 0; return len1; }
- }
复制代码
我们之前忽略了一致性校验的代码,其实答案在这里! LZ4_decompress_safe(p3 + 4, buf, ...) —— LZ4 的输入 从 p3 开始偏移 +4!"跳过 4 字节头" 就是这一行的全部含义。
也就是说,XXTEA 解出来的明文,本身是一个标准的 LZ4 block, 前面还带了个 4 字节的"解压后长度"头:
XXTEA 明文 = [4字节 LE 解压长度][LZ4 压缩数据]
而我们第一次写代码时,很有可能犯了和大佬一样的错误 (当然我这里只是猜测)。把整个 XXTEA 明文 (含前 4 字节头)直接喂给了 LZ4 ,于是那 4 字节 79 18 00 00 被当成了 LZ4 流的第一个 token,当然解不开。
修正后:
- ulen = struct.unpack("<I", plain[:4])[0] # 4B LE 解压长度 = 6265
- out = lz4.block.decompress(plain[4:], uncompressed_size=ulen)
复制代码
结果:
- {"textures":[],"designHeight":1440,"name":null,"designWidth":2560,...
复制代码

一个完整的 CocoStudio UI JSON,完美解出。
对,这就是逆向里最常见的坑之一,即算法本身没错,是调用约定没搞对。其实网上有很多说 "特殊变体" 的人,多半也是卡在这一步没深入。
这种坑简单也简单,但正是这种微小的差别,蝴蝶效应成我们与成功之间的鸿沟。所以,称它为“天坑”并没什么不妥。
其实,这次踩坑并不无用,教会了我们很多东西
九、批量解密与性能优化
现在,基本上所有格式都被我们攻克了,接下来就是爽爽解密
我们直接将整个 repository 文件夹内文件解密。
不过,这里还有最后一个坑,就是 Python 解释器。之前在网上刷到很多嘲讽 Python 速度慢的视频,当时也不怎么当回事,觉得方便简单就行。今天才算真正见识到,两万个仓库文件,Python 处理一个小小的 json 文件都要解密十多秒,更别说要循环解密和解压两万个文件。
所以我把 Python 逻辑全部迁移到了 C ,现在连两三百兆的文件都是秒解,你们自己算算快了多少倍 (学 C 还是太重要了 )
- void xxtea_decrypt_core(uint32_t *v, int n, const uint32_t *k) {
- if (n <= 1) return;
- uint32_t s = ((52 / n) * DELTA + 6 * DELTA) & MASK32;
- uint32_t y, z, e, ki;
- uint64_t mx64, tmp64;
- while (s != 0) {
- e = (s >> 2) & 3;
- y = v[0];
- for (int p = n - 1; p > 0; p--) {
- z = v[p - 1];
- tmp64 = (uint64_t)((z >> 5) ^ ((y << 2) & MASK32)) +
- (uint64_t)(((y >> 3) & MASK32) ^ ((z << 4) & MASK32));
- mx64 = tmp64 & MASK32;
- ki = k[(p & 3) ^ e];
- tmp64 = (uint64_t)(y ^ s) + (uint64_t)(ki ^ z);
- mx64 ^= (tmp64 & MASK32);
- v[p] = (v[p] - (uint32_t)mx64) & MASK32;
- y = v[p]; // 链式更新
- }
- z = v[n - 1];
- tmp64 = (uint64_t)((z >> 5) ^ ((y << 2) & MASK32)) +
- (uint64_t)(((y >> 3) & MASK32) ^ ((z << 4) & MASK32));
- mx64 = tmp64 & MASK32;
- ki = k[e];
- tmp64 = (uint64_t)(y ^ s) + (uint64_t)(ki ^ z);
- mx64 ^= (tmp64 & MASK32);
- v[0] = (v[0] - (uint32_t)mx64) & MASK32;
- s = (s - DELTA) & MASK32;
- }
- }
复制代码
直接 gcc 编译成可执行文件:
- gcc -O3 -march=native -flto -o decrypt decrypt.c -llz4
复制代码
然后执行:
- ~/逆向素材/神奇宝贝 $ ./decrypt -o repository/
复制代码
实测在我的设备上花费10多秒就全解密了。
用法:
- ~/逆向素材/神奇宝贝 $ ./decrypt --help
- ══════════════════════════════════════════════════════════════════
- Cocos2d-Lua 手游解密器 By JiGuro
- ══════════════════════════════════════════════════════════════════
- 支持格式: tje / tj! / tjz / 仓库LZ4块 / 原样透传
- 用法:
- ./decrypt [选项] <输入文件/目录> ...
- ./decrypt (无参数) 进入交互模式
- 选项:
- -o, --overwrite 直接覆盖原文件
- -d, --dir <目录> 指定输出根目录 (默认: decrypted)
- -h, --help 显示此帮助信息
- 示例:
- ./decrypt file.tje
- ./decrypt -d output/ file1.tje dir/
- ./decrypt -o file.tje
- 说明:
- 解密结果默认输出到 ./decrypted/ 目录。
- 使用 -o 覆盖原文件时请谨慎,操作不可逆。
- 支持递归处理目录。
- ══════════════════════════════════════════════════════════════════
复制代码
十、最终验证与阶段性成果

最终,经过验证,整个仓库中的文件均被完美解密,并没有失败情况。
至于接下来的破解,由于篇幅限制,我们留到下篇再说。
能仔细看到这里的都是大佬!感谢各位的阅读与支持!
码字不易,点赞可有?
帖子内所有工具都已放在下面,可供大家练手,评论自取。
https://jiguro.lanzouw.com/ijw7841m84gb
|
本帖子中包含更多资源
您需要 登录 才可以下载或查看,没有账号?立即注册
x
-
查看全部评分
总评分:好评 +4
金币 +3
|