逆向实战:单机斗地主手游发牌逻辑与XXTEA加密Lua脚本分析
一款玩了很久的单机斗地主游戏,时间一长,系统发的牌就变得特别差。因此,我决定深入分析一下这个应用的内部机制,特别是它的发牌逻辑。
注意:这是一个32位的安装包,需要将IDA的32位调试服务器通过 adb push 到 /data/local/tmp 目录中。
开始分析
初步探查
首先将APK文件载入GDA分析器进行初步检查。


虽然信息显示它疑似经过腾讯Bugly服务打包,但其Java层代码是可以正常阅读的。使用Android Killer调用apktool对APK进行重打包并签名后,安装运行也没有出现盗版应用提示对话框。
大致浏览后,发现与发牌、金币结算相关的核心逻辑并不在Java层。因此,我推测该游戏使用了某种游戏引擎,关键逻辑应位于Native层的SO库文件中。
分析游戏逻辑
文件加载监控
使用Frida脚本监控应用加载了哪些文件,以寻找线索。

使用的Frida JS脚本如下:
function my_hook_open() {
Interceptor.attach(Process.getModuleByName('libc.so').getExportByName('open'),
{
onEnter: function (args) {
var pathptr = args[0];
if (pathptr !== undefined && pathptr != null) {
var path = ptr(pathptr).readCString();
console.log("Load: " + path);
}
},
onLeave: function (retval) {
}
}
);
}
setImmediate(my_hook_open);
// 简化打印的方法名,下同
function LOG(sth){
console.log(sth);
}
执行命令:frida -U -f com.june.game.doudizhu.g.baidu -l file.js
输出显示加载了大量后缀为 .luac 的文件,特别是在进入游戏房间时,加载了一些名称可疑的文件,例如 libsendCard2_2.lua 或 libsendcard2_2.luac,猜想这可能是负责发牌的脚本。同时,检查APK包内文件,发现存在 libcocos2dcpp.so 文件,且 assets 目录下也有大量 .luac 文件。


根据这些信息,基本可以判断这款游戏是基于Cocos2d-x引擎开发的,逻辑脚本由Lua编写,并被编译和加密为 .luac 格式。
在使用Frida Hook open 函数时,虽然能监控到Lua文件的打开操作,但在目标目录下并未找到解密后的明文 .lua 文件。
解密luac文件
因此,下一步需要解密关键的 .luac 文件。原始 .luac 文件的十六进制视图如下:

除了文件头部的字符串 bianfengqipai(与游戏开发商“边锋棋牌”对应),其余内容均呈加密状态。通常程序会先检查头部标识,再进行解密。为了避免开发者对字符串进行加密处理,更可靠的方法是Hook系统的 open 函数,过滤特定文件路径参数,并追踪其调用堆栈来定位解密函数。
为此修改了Hook脚本:
function my_hook_open() {
Interceptor.attach(Process.getModuleByName('libc.so').getExportByName('open'),
{
onEnter: function (args) {
var pathptr = args[0];
if (pathptr !== undefined && pathptr != null) {
var path = ptr(pathptr).readCString();
if(path.indexOf("libsendcard") >= 0)
{
// 此处添加自定义操作
console.log("Load: " + path);
// 打印调用堆栈
console.log('Call stack:\n' + Thread.backtrace(this.context, Backtracer.ACCURATE).map(DebugSymbol.fromAddress).join('\n'));
console.log('\n');
}
}
},
onLeave: function (retval) {
}
}
);
}
setImmediate(my_hook_open);
但输出堆栈并未直接显示解密逻辑。于是尝试Hook lua_pcall 和 fread 函数,并匹配文件头 bianfengqipai。最终,通过Hook fread 并检测特定字节序列,得到了关键的调用堆栈,其中出现了 cocos2dx_lua_loader 函数。
⚠️ 检测到目标字节序列!
读取长度: 44121 字节
堆栈回溯:
0xf1f7d51d libc.so!fread+0x38
0xbea767ae libcocos2dcpp.so!_ZN7cocos2d16FileUtilsAndroid11getContentsERKSsPNS_15ResizableBufferE+0x81
0xbf0b1790 libcocos2dcpp.so!_ZN7cocos2d9FileUtils15getDataFromFileERKSs+0x44
0xbeb68d74 libcocos2dcpp.so!cocos2dx_lua_loader+0x6e0
...
在IDA中分析 cocos2dx_lua_loader 函数,其内部调用了 cocos2d::LuaStack::luaLoadBuffer,这个函数包含了实际的解密逻辑。


幸运的是,SO文件没有去除符号。参考Cocos2d-x开源代码(如GitHub上的CCLuaStack.cpp),可以确认其解密机制。接下来,在IDA中搜索字符串 bianfengqipai,仅有一个结果。通过交叉引用,找到它在 AppDelegate::applicationDidFinishLaunching 函数中被使用。

分析伪代码,找到设置密钥和签名的关键调用。最终定位到 LuaStack::setXXTEAKeyAndSign 函数,其源码逻辑清晰表明了加密方式为XXTEA,且密钥在前,签名在后。

通过动态调试与静态分析相结合,最终提取出了解密所需的密钥和签名:
- 密钥:
03f0fdcbf5215b45fc790aaf2b965237
- 签名:
bianfengqipai
- 算法:XXTEA
在APK解压路径 \assets\src\game\libcard 下,有上百个类似命名的 .luac 文件。

使用工具(如吾爱破解论坛的ToolsFX)进行解密。注意:需要先去掉文件头部的签名字节 bianfengqipai。

在解密工具中配置XXTEA算法、密钥,并选择解密模式。

解密后,得到一个庞大的Lua表(数组),其中定义了大量的牌型组合。

每个元素的结构类似:
[2000]={handCards={[0]={53,41,28,15,2,42,29,16,48,47,46,45,31,43,37,9,7},[1]={4,17,8,6,13,5,21,35,32,22,54,36,10,27,49,50,44},[2]={18,26,23,19,3,40,33,14,38,34,24,1,12,11,51,52,30}},baseCards={25,39,20}}
可以推断,handCards 数组对应三个玩家的手牌,baseCards 对应底牌。这表明游戏中的牌型组合是预先定义好的,App只是从中随机(或按一定规则)选取一组分配给玩家。
牌的点数与数值映射关系
我们已经知道 libcard 下的文件定义了牌型组合。那么,这些数值如何映射到实际的扑克牌点数和花色呢?
一副扑克牌共54张:大王、小王各1张,加上4种花色(黑桃、红心、梅花、方块)的A~K各13张。
游戏逻辑显然在 libcocos2dcpp.so 中。Lua层很可能会申请内存来存储每个玩家的牌,数值即如上文所示的 {53,41,28,...}。由于 malloc 调用频繁,建议在游戏开始前附加Frida脚本进行Hook,当分配大小为 0x11(即17字节,对应17张手牌)时,打印调用堆栈。
// hook malloc,当 size == 0x11 时打印调用堆栈
Interceptor.attach(Module.findExportByName(null, "malloc"), {
onEnter: function (args) {
var size = args[0].toInt32();
if (size === 0x11) {
console.log(" malloc called with size = 0x11");
console.log(Thread.backtrace(this.context, Backtracer.ACCURATE)
.map(DebugSymbol.fromAddress)
.join("\n"));
console.log("--------------------------------------");
}
},
onLeave: function (retval) {
// 可选:你也可以打印返回的指针地址
// console.log("malloc returned:", retval);
}
});
// frida -U 单机斗地主 -l malloc.js
启动游戏后,在大量输出中找到有价值的堆栈,其中包含 lua_RunRule_RunRule_findCards 和 bianfeng::RunRule::findCardsByNums 函数。在IDA中查看 findCardsByNums 函数,发现其中调用了 bianfeng::CardFunc::getCardNum,这很可能就是进行牌点与数值转换的关键函数。


通过动态调试,在游戏中进行一局包含多张相同点数不同花色牌(如炸弹)的对局,并在 getCardNum 函数处下断点。通过选取不同花色的“9”,观察其对应的数值:
- 方块9 -> 数值 9
- 梅花9 -> 数值 22
- 红心9 -> 数值 35
- 黑桃9 -> 数值 48


依此类推,可以总结出完整的映射关系。其中,方块花色数值最小,黑桃花色数值最大。A对应1,J、Q、K对应11、12、13。小王为53,大王为54。
十进制映射表
| 扑克牌点数 |
方块♦️ |
梅花♣️ |
红心♥️ |
黑桃♠️ |
| A |
1 |
14 |
27 |
40 |
| 2 |
2 |
15 |
28 |
41 |
| 3 |
3 |
16 |
29 |
42 |
| 4 |
4 |
17 |
30 |
43 |
| 5 |
5 |
18 |
31 |
44 |
| 6 |
6 |
19 |
32 |
45 |
| 7 |
7 |
20 |
33 |
46 |
| 8 |
8 |
21 |
34 |
47 |
| 9 |
9 |
22 |
35 |
48 |
| 10 |
10 |
23 |
36 |
49 |
| J |
11 |
24 |
37 |
50 |
| Q |
12 |
25 |
38 |
51 |
| K |
13 |
26 |
39 |
52 |
| 小王 |
53 |
| 大王 |
54 |
规律:
- 纵向:同一花色,相邻点数牌相差1。
- 横向:同一点数,相邻花色牌相差13。
十六进制映射表(部分)
| 扑克牌点数 |
方块♦️ |
梅花♣️ |
红心♥️ |
黑桃♠️ |
| A |
0x1 |
0xE |
0x1B |
0x28 |
| 9 |
0x9 |
0x16 |
0x23 |
0x30 |
| ... |
... |
... |
... |
... |
| 小王 |
0x35 |
| 大王 |
0x36 |
技巧:也可以直接在IDA中搜索包含 card 关键字的函数,对类似 getCardNum 的函数下断点进行验证,效率更高。
内存中动态改牌
掌握了牌的数值,就可以在游戏运行时动态修改内存中的牌数据,实现“出千”。
在 getCardNum 函数或其相关调用链上下断点。进入游戏房间,触发断点后,通过寄存器回溯,找到存储手牌数据的容器地址。通常,手牌数据会以数组形式存放在一块连续内存中。

在调试器中找到这块内存,将其中的牌数值全部修改为大王(54)和小王(53)的数值。

取消断点,让游戏继续运行。此时,游戏界面中玩家的手牌将全部变成大小王。

由于牌面绝对优势,必定要“叫地主”。之后可以顺利出牌并获得胜利。

这种方法属于动态修改,每次游戏都需要进行调试操作。
静态改牌与屏蔽热更新
更一劳永逸的方法是静态修改游戏资源文件,并阻止其在线更新。
在 2.1.1 解密luac文件 中,我们了解到可以通过修改 \assets\src\game\libcard 下的文件来改变牌型库。但直接修改并重打包APK后,发现并未生效。通过Frida Hook文件打开操作,发现游戏实际加载的是位于 /data/user/0/com.june.game.doudizhu.g.baidu/files/HotUpdateCacheDir/ 路径下的文件,这是游戏热更新下载的资源。

经过排查,发现在设备无网络连接时,游戏才会回退使用APK内置的原始资源。因此,需要修改游戏的热更新逻辑,强制使其使用本地资源。
在 assets\src\bianfeng\hotupdate 目录下找到 HotUpdateManager.luac 文件,解密后进行两处关键修改:
修改1:禁止版本比较更新
在 compareVersionIsNew 函数中直接返回 false。
function HotUpdateManager:compareVersionIsNew(oldVersion, newVersion)
-- 禁止更新
return false
end

修改2:在更新检查逻辑中提前返回
在 hotUpdateVersion 函数中,添加逻辑,在特定条件下直接调用 hotupdateSuccess 并返回,跳过后续的更新流程。
if self._updateType[tag] == bf.HotUpdateType.NOUPDATE or (self:compareVersionIsNew(oldVersion, newVersion) == true) then
self._responseUpdateType[tag] = bf.HotUpdateType.NOUPDATE
-- 直接不要更新
self:hotupdateSuccess(tag)
return
end

这样就禁用了App的热更新功能,确保游戏始终使用我们修改过的本地资源。
静态改牌
找到文件 assets\src\game\logic\ShuffleLogic.luac,解密后修改其中的 setShuffleCards 函数。该函数内已有注释提示了手动设置牌型的代码位置,直接修改 self.handCards 和 self.baseCards 的赋值即可。

注意:不能像内存修改那样全部设置为大小王,这可能导致游戏出牌逻辑异常。设置为包含两个王和若干炸弹的组合,足以保证胜利。
修改游戏倍数
既然胜券在握,下一步就是修改游戏倍数,从而获得更多虚拟金币。
找到文件 src\game\rule\DDZRunRule.luac,解密后修改其中定义基础倍数和计算总倍数的函数。
- 修改
init 函数中的 self._baseMult(基础倍数)。
- 修改
getAllMult 函数中的计算逻辑,确保返回极高的倍数。

效果验证
完成上述静态修改(改牌、禁更新、改倍数)后,将所有修改后的 .lua 文件重新用XXTEA加密,并在文件头部添加签名 bianfengqipai,最后替换原APK中的 .luac 文件,重新打包签名安装。
修改前后效果对比如下:
修改前:倍数较低,牌型普通。获胜所得金币较少。

修改后:倍数极高(如24576倍),牌型极佳(自定义的炸弹组合)。每局获胜可获得巨额金币。

从结算界面可以看到,由于对手牌型很差,他们根本不会叫地主,胜利和巨额收益轻而易举。

总结
本次分析涵盖了从动态Hook、静态逆向到资源修改的完整Android应用安全分析流程。关键点在于:
- 使用Frida进行动态行为分析,定位文件加载和关键函数。
- 通过逆向Cocos2d-x引擎的SO库,破解其Lua脚本的XXTEA加密机制。
- 动态调试分析游戏内存结构,理解牌值映射关系。
- 静态修改Lua脚本资源,实现定制发牌和高倍数,并关闭热更新以固化修改。
整个过程涉及对Cocos2d-x引擎机制、Lua虚拟机以及ARM汇编调试的深入理解,是一次综合性的移动游戏逆向工程实践。值得注意的是,此类技术学习应仅限于安全研究与学习目的。
本文涉及的技术与方法仅供安全研究与学习之用,请勿用于非法用途。