一款我很久之前玩过的单机斗地主游戏,但是玩的时间一久,系统发的牌就变得特别差,所以我就想分析分析一下这个App.
注意:这是一个32位的安装包,需要把IDA的32位调试服务器adb push到/data/local/tmp 中。
把apk放到GDA4.11中分析,如下图所示。


虽然它有显示疑似有腾讯的什么bugly打包服务,但它Java层代码是可以正常阅读的。
以及使用Android killer调用apktool给apk重新打包签名,也没有提示用户安装盗版应用的对话框。
大致看了下,有关发牌、金币结算的逻辑并不在Java层里,因此我考虑到它应该是用了一些游戏引擎的,关键的逻辑应该在so层中。
使用frida脚本来监视App加载了哪些文件,如下图所示。

frida的JS脚本如下:
执行的命令行为:frida -U -f com.june.game.doudizhu.g.baidu -l file.js
可以看到有许许多多的luac文件,特别是在进入房间的时候有一些疑似关键的luac被加载起来了。
比如,libsendCard2_2.lua 或者libsendcard2_2.luac ,猜想它是负责发牌的一段脚本。再看看apk中包含的文件,so文件又包含libcocos2dcpp.so 文件,assets 下也有许多luac文件。如下图所示。


那么根据网上的资料来看,这个游戏极可能是基于cocos2dx来开发的。
在使用frida hook open函数的时候,虽然它有感知到有lua文件的打开,但在目标目录下并没有找到解密的lua文件。
所以此时的逻辑就是应当解密关键的luac 文件了,luac 的形式如下图所示。

除了最开始的bianfengqipai (边锋棋牌,和游戏的产商名是对的上的),剩余的内容都是加密的。一般来讲,程序是先检查头部的数据再去解密的。不过为了防止开发者做字符串加密,还是hook系统的open函数、过滤参数并跟踪它的调用堆栈就能找它解密的地方了。
此时的hook脚本如下:
输出如下图所示。

都是一样的,以及大致看了一下这些调用堆栈,并没有发现特殊的,负责解密的代码段。于是修改脚本hooklua_pcall ,脚本如下。
此时的打印如下,在IDA中回看这些堆栈,依旧没有看到有用的解密代码。

但是仍没有看出调用堆栈能解密的代码。这时我选择了直接hook fread函数,并对比头部的字节,如果是bianfengqipai 则打印出调用堆栈。但在实际操作过程中,它只打印了一行fread 没有打印出更上一级的调用堆栈了,所以我修改了一下frida的脚本,如下。(另外写的一个脚本)
打印的调用堆栈如下所示:
当然了,这里最好搜搜网上资料了,下图是cocos2dx_lua_loader 的伪代码。

函数cocos2d::LuaStack::luaLoadBuffer 就包含了真正解密的函数,如下图所示。

其源码所在的链接为:4a8K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6U0L8$3y4G2M7K6u0V1i4K6u0r3j5$3!0U0L8%4x3J5k6q4)9J5k6s2S2Q4x3V1k6T1L8r3!0T1i4K6u0r3N6U0c8Q4x3V1k6U0L8$3y4G2M7#2)9J5c8Y4y4U0M7X3W2H3N6r3W2F1k6#2)9J5c8X3I4#2j5g2)9J5k6r3u0A6L8X3c8A6L8X3N6K6i4K6u0r3L8h3q4F1N6h3q4D9i4K6u0r3b7@1y4x3N6h3q4e0N6r3q4U0K9#2)9J5k6h3y4H3M7l9`.`.
如下图所示。好在这个so没有去掉符号。

这里再给两个参考链接:
https://bbs.kanxue.com/thread-268574-1.htm
2dbK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6%4N6%4N6Q4x3X3f1#2x3Y4m8G2K9X3W2W2i4K6u0W2j5$3&6Q4x3V1k6@1K9s2u0W2j5h3c8Q4x3X3b7I4y4K6R3H3y4U0t1$3i4K6u0V1x3g2)9J5k6o6q4Q4x3X3g2Z5N6r3#2D9
那么现在我们就搜索字符串bianfengqipai ,有且仅有一个结果。找到它的交叉引用,找到int __fastcall AppDelegate::applicationDidFinishLaunching(AppDelegate *this)

再看到伪码。

我们寻找这个函数,显然它是一个虚函数,间接调用的形式。所以要找this指针v34 .



以下就是v34+84h 偏移所包含的函数了。

再来看看LuaStack 的源码。是密钥在前,签名在后。


那么我们就能解密luac文件了,密钥是03f0fdcbf5215b45fc790aaf2b965237 ,签名是bianfengqipai 。算法是XXTEA ,我们找到apk解压路径\assets\src\game\libcard ,这个目录下足足有100多个luac文件,而且命名都是差不多的。如下图所示。

随便拿出一个用吾爱破解论坛上的逆向密码学工具来解密即可。一定要先去掉头部的签名,否则会解不出来。

为了不影响原有的文件,我特地将原文件备份了一份,去掉了头部字节。放入解密工具,参数设置如下图所示。

解出来发现是一个超大的数组,如下图所示。

最后返回

例如我拿出最后一个数组。
想必应该能猜出来,这是斗地主三个玩家的牌,baseCards 变量应该指的就是底牌了。如此说来,牌型的组合是相对固定的,只是App随机给你分配一个了。
我们已经知道\assets\src\game\libcard 中的文件都是定义牌型组合的脚本。那么它的数值是怎么和牌的点数对应上的呢?
一副扑克牌有大王、小王,剩下的就是4种花色的A~K了,等于13 * 4 + 2 = 54张牌。
游戏的逻辑很显然在libcocos2dcpp.so 里了,那么有很大概率lua会申请一段内存,存放每个玩家的牌,数值就像上文中提到的{53,41,28,15,2,42,29,16,48,47,46,45,31,43,37,9,7} 。
由于malloc 的调用次数会非常多,所以建议在开始游戏之前附加frida脚本,点击之后再看输出。脚本如下。当参数等于17时打印调用堆栈,因为在叫地主之前,大家都只有17张牌。
先执行命令行frida -U 单机斗地主 -l malloc.js 再开始游戏,如下图所示。

屏幕有一大堆输出,找到有用的调用堆栈如下。
在IDA中跳转至lua_RunRule_RunRule_findCards ,这个函数能一下子看见许多疑似的牌点、数值互转的代码段。如下图所示。

我们再看到堆栈中的_ZN8bianfeng7RunRule15findCardsByNumsERKSt6vectorIhSaIhEES5_S5_RS3_ ,其实是函数名int __fastcall bianfeng::RunRule::findCardsByNums(int, _DWORD *, _DWORD *, _DWORD *, _DWORD *); 如下图所示

CardNum = bianfeng::CardFunc::getCardNum((bianfeng::CardFunc *)*v11++, v9); 就很像牌点和数值之间的转换了。

在这个函数下断,最好是某一局有炸弹的,即存在不同花色的同一点数的牌。我这里选中了方片9,数值是9.函数也断下了。

再选中梅花9,如下图所示。数值是22

选中红心9,如下图所示。数值是35.

最后选中黑桃9,如下图所示。数值是48。

其它牌的点数就能一步一步调出来了。方块花色的数值是最小的,黑桃是最大的。以及A代表1,J、Q、K分别是11,12,13。小王是53,大王是54.所以就有以下牌点和数值的对应关系了。
十进制
纵向:同一花色,相邻点数牌相差1。
横向:同一点数,相邻花色牌相差13。
十六进制
Tips:这里代码的定位也可以直接搜索含有card*** 关键字的函数,看到类似getCardNum 等函数就去下断尝试,速度会更快。***
这一小节会令你十分兴奋,因为不用再忍受差牌了,在内存中改牌相当于你出了老千,可以纵横赌场和赌神媲美了哈哈。
在函数插入断点,如下图所示。

进入房间。断下。

此时还卡在发牌阶段。

R1 = 36h,表示牌数值。往回跟。

退出房间,再进入。R1 = 36h,还仍然是其中的一张牌。再往回跟。

R1来源于R2,R2又来源于R4.

看看R4存放了什么值,如下图所示。


此时没有叫地主,所以只有17张牌。36 35 29 1C 0F 02 21 14 07 1E 11 10 25 12 1F 06 1D,和拿到的牌是一致的。
跟踪到了lua_RunRule_RunRule_sortByWeight(lua_State *)
R1的值来自于R6,如下图所示。

其中的函数luaval_to_cards(lua_State *, int, std::vector<unsigned char> *, char const*)
R2 = R6
R6是局部变量,luaval_to_cards(lua_State *, int, std::vector<unsigned char> *, char const*) 访问了R6,所以我们接下来重点看这个luaval_to_cards 函数。

往下看,发现了一个重要的函数。所在指令是: 

std::vector<uchar>::emplace_back<uchar>(uchar &&) GPT解释如下:
std::vector
在这个例子中:
std::move(value) 将 value 转换为右值引用,以便 emplace_back 使用它来构造一个新的 uchar 元素。
emplace_back 会直接在 vec 的末尾构造 uchar 类型的元素,而不进行额外的拷贝或移动操作。
总结
std::vector
那么也许这个就是动态添加牌数值的函数。
插入断点调试如下:

多按几次F9,直至R4 = 0x11时,再F8 step over 这个函数。
要注意R0的值,LDR R0, [SP,#0x30+var_2C]
如下图所示。

你会惊奇地发现R1是疑似容器指针,值CC14B180 和CC14B191 刚好相差0x11。
CC14B180 的内存空间如下图所示。

尝试把这里的值改动一下(都是大王或小王),也许就能成功出千了哈哈(先忽略剩的3张底牌),改动如下图所示。

取消断点,按下F9让程序继续运行,如下图所示。牌全部变成大王和小王了。

这个时候我们是一定要抢地主的,因为已经是稳赢了。而且还能成功打出去,如下图所示。

最终身为地主的我胜利了,如下图所示。

在章节2.1.1 解密luac文件 我们可以得知,通知修改\assets\src\game\libcard 下的文件来实现静态改牌。要修改luac文件很简单,只需要把解密得到的lua文件使用工具加密,然后用010Editor在头部插入字节bianfengqipai 即可。
但是有一个问题,我修改了以上定义牌点的luac,重新打包、签名最后安装,但并没有生效。所以我又一次hook了文件打开函数。
frida hook的脚本如下。
脚本输出如下,发现App并没有按照我们给定的牌点去发牌,而是疑似用了另外的luac定义的牌组发牌。

路径位于 /data/user/0/com.june.game.doudizhu.g.baidu/files/HotUpdateCacheDir/res/game/libcard/lib_sendcard1_6.lua ,然而手机文件浏览器并没有发现它的踪影。
这里尝试了很久很久,因为我对手机游戏开发并不熟。后来才发现在没网的情况下才会去使用本地的牌组定义。
那么最终找到了路径assets\src\bianfeng\hotupdate 中的HotUpdateManager.luac 文件。
做出了两处修改,不能再改多了,否则程序会卡在主界面。


添加这段代码的灵感来自于下图。

这样就能禁用App的热更新了。
找到文件assets\src\game\logic\ShuffleLogic.luac ,改动如下图所示。好在注释里有手动添加牌点数的代码。

注意这里不能像内存修改那样了全部都是大王、小王,程序会在你出牌后卡住。两张王,然后若干个炸弹足够我们赢了。
既然我们的胜利是板上钉钉的事情,那么我们的确该改改倍数,从而令自己赚更多钱。
改动的地方如下图所示。文件是src\game\rule\DDZRunRule.luac


实现了手动内存、静态改牌和倍数的修改。相关的luac文件修改完毕后重新打包签名即可。
下图分别是修改前、后。注意观察倍数和牌型。
修改前:低倍数,一般的牌。底牌是随机的,赢钱少则几百块,多的刚刚过万。想升级称号是很难的。

修改后:高倍数,把把都是自己定义的好牌(出千了),赢钱一般都在千万级别。

这里也能隐约看出另外两个人的牌特差哈哈,所以它们从主动不叫地主的。

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);
}
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);
}
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);
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);
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("sendcard") >= 0)
{
hookCocos();
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) {
}
}
);
}
function hookCocos()
{
Interceptor.attach(Process.getModuleByName('libcocos2dcpp.so').getExportByName('lua_pcall'),
{
onEnter: function (args) {
LOG("*********************************************");
console.log('Call stack:\n' + Thread.backtrace(this.context, Backtracer.ACCURATE).map(DebugSymbol.fromAddress).join('\n'));
console.log('\n');
LOG("*********************************************");
},
onLeave: function (retval) {
}
}
);
}
setImmediate(my_hook_open);
function LOG(sth){
console.log(sth);
}
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("sendcard") >= 0)
{
hookCocos();
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) {
}
}
);
}
function hookCocos()
{
Interceptor.attach(Process.getModuleByName('libcocos2dcpp.so').getExportByName('lua_pcall'),
{
onEnter: function (args) {
LOG("*********************************************");
console.log('Call stack:\n' + Thread.backtrace(this.context, Backtracer.ACCURATE).map(DebugSymbol.fromAddress).join('\n'));
console.log('\n');
LOG("*********************************************");
},
onLeave: function (retval) {
}
}
);
}
setImmediate(my_hook_open);
function LOG(sth){
console.log(sth);
}
const targetBytes = [
0x62, 0x69, 0x61, 0x6E, 0x66, 0x65, 0x6E, 0x67, 0x71, 0x69, 0x70, 0x61, 0x69
];
function bytesMatch(buffer, pattern) {
for (let i = 0; i < buffer.length - pattern.length + 1; i++) {
let match = true;
for (let j = 0; j < pattern.length; j++) {
if (buffer[i + j] !== pattern[j]) {
match = false;
break;
}
}
if (match) return true;
}
return false;
}
const freadPtr = Module.findExportByName(null, "fread");
if (!freadPtr) {
console.error("未找到 fread 符号");
} else {
console.log("✅ Hook fread @ " + freadPtr);
Interceptor.attach(freadPtr, {
onEnter(args) {
this.ptr = args[0];
this.size = args[1].toInt32();
this.nmemb = args[2].toInt32();
this.totalSize = this.size * this.nmemb;
},
onLeave(retval) {
const len = retval.toInt32();
if (len > 0 && this.ptr && this.totalSize > 0) {
try {
const buf = Memory.readByteArray(this.ptr, Math.min(this.totalSize, len * this.size));
const data = new Uint8Array(buf);
if (bytesMatch(data, targetBytes)) {
console.log("⚠️ 检测到目标字节序列!");
console.log("读取长度:", len, "字节");
console.log("堆栈回溯:\n" + Thread.backtrace(this.context, Backtracer.FUZZY)
.map(DebugSymbol.fromAddress).join("\n"));
}
} catch (e) {
console.error("读取内存失败:", e);
}
}
}
});
}
const targetBytes = [
0x62, 0x69, 0x61, 0x6E, 0x66, 0x65, 0x6E, 0x67, 0x71, 0x69, 0x70, 0x61, 0x69
];
function bytesMatch(buffer, pattern) {
for (let i = 0; i < buffer.length - pattern.length + 1; i++) {
let match = true;
for (let j = 0; j < pattern.length; j++) {
if (buffer[i + j] !== pattern[j]) {
match = false;
break;
}
}
if (match) return true;
}
return false;
}
const freadPtr = Module.findExportByName(null, "fread");
if (!freadPtr) {
console.error("未找到 fread 符号");
} else {
console.log("✅ Hook fread @ " + freadPtr);
Interceptor.attach(freadPtr, {
onEnter(args) {
this.ptr = args[0];
this.size = args[1].toInt32();
this.nmemb = args[2].toInt32();
this.totalSize = this.size * this.nmemb;
},
onLeave(retval) {
const len = retval.toInt32();
if (len > 0 && this.ptr && this.totalSize > 0) {
try {
const buf = Memory.readByteArray(this.ptr, Math.min(this.totalSize, len * this.size));
const data = new Uint8Array(buf);
if (bytesMatch(data, targetBytes)) {
console.log("⚠️ 检测到目标字节序列!");
console.log("读取长度:", len, "字节");
传播安全知识、拓宽行业人脉——看雪讲师团队等你加入!
最后于 2025-11-17 01:41
被taeyeon_ss编辑
,原因: