找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

2033

积分

0

好友

285

主题
发表于 6 天前 | 查看: 16| 回复: 0

逆向实战:单机斗地主手游发牌逻辑与XXTEA加密Lua脚本分析

一款玩了很久的单机斗地主游戏,时间一长,系统发的牌就变得特别差。因此,我决定深入分析一下这个应用的内部机制,特别是它的发牌逻辑。

注意:这是一个32位的安装包,需要将IDA的32位调试服务器通过 adb push/data/local/tmp 目录中。

开始分析

初步探查

首先将APK文件载入GDA分析器进行初步检查。

APK文件基础信息截图

GDA中显示的Java代码

虽然信息显示它疑似经过腾讯Bugly服务打包,但其Java层代码是可以正常阅读的。使用Android Killer调用apktool对APK进行重打包并签名后,安装运行也没有出现盗版应用提示对话框。

大致浏览后,发现与发牌、金币结算相关的核心逻辑并不在Java层。因此,我推测该游戏使用了某种游戏引擎,关键逻辑应位于Native层的SO库文件中。

分析游戏逻辑

文件加载监控

使用Frida脚本监控应用加载了哪些文件,以寻找线索。

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.lualibsendcard2_2.luac,猜想这可能是负责发牌的脚本。同时,检查APK包内文件,发现存在 libcocos2dcpp.so 文件,且 assets 目录下也有大量 .luac 文件。

assets目录下的luac文件列表

lib目录下的so文件

根据这些信息,基本可以判断这款游戏是基于Cocos2d-x引擎开发的,逻辑脚本由Lua编写,并被编译和加密为 .luac 格式。

在使用Frida Hook open 函数时,虽然能监控到Lua文件的打开操作,但在目标目录下并未找到解密后的明文 .lua 文件。

解密luac文件

因此,下一步需要解密关键的 .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_pcallfread 函数,并匹配文件头 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,这个函数包含了实际的解密逻辑。

cocos2dx_lua_loader函数反编译代码片段

luaLoadBuffer函数关键代码片段

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

AppDelegate初始化函数反编译代码

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

LuaStack::setXXTEAKeyAndSign函数源码

通过动态调试与静态分析相结合,最终提取出了解密所需的密钥和签名:

  • 密钥03f0fdcbf5215b45fc790aaf2b965237
  • 签名bianfengqipai
  • 算法:XXTEA

在APK解压路径 \assets\src\game\libcard 下,有上百个类似命名的 .luac 文件。

libcard目录下的大量luac文件

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

去掉签名头部后的文件十六进制数据

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

ToolsFX工具解密配置界面

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

解密后得到的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_findCardsbianfeng::RunRule::findCardsByNums 函数。在IDA中查看 findCardsByNums 函数,发现其中调用了 bianfeng::CardFunc::getCardNum,这很可能就是进行牌点与数值转换的关键函数。

    findCardsByNums函数反编译代码片段

    getCardNum函数反编译代码

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

    • 方块9 -> 数值 9
    • 梅花9 -> 数值 22
    • 红心9 -> 数值 35
    • 黑桃9 -> 数值 48

    IDA调试中查看红心9的数值

    IDA调试中查看黑桃9的数值

    依此类推,可以总结出完整的映射关系。其中,方块花色数值最小,黑桃花色数值最大。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 函数或其相关调用链上下断点。进入游戏房间,触发断点后,通过寄存器回溯,找到存储手牌数据的容器地址。通常,手牌数据会以数组形式存放在一块连续内存中。

    IDA调试中查看手牌内存数据

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

    修改内存中的牌数值为大小王

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

    游戏界面显示手牌全为大小王

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

    游戏胜利结算界面

    这种方法属于动态修改,每次游戏都需要进行调试操作。

    静态改牌与屏蔽热更新

    更一劳永逸的方法是静态修改游戏资源文件,并阻止其在线更新。

    2.1.1 解密luac文件 中,我们了解到可以通过修改 \assets\src\game\libcard 下的文件来改变牌型库。但直接修改并重打包APK后,发现并未生效。通过Frida Hook文件打开操作,发现游戏实际加载的是位于 /data/user/0/com.june.game.doudizhu.g.baidu/files/HotUpdateCacheDir/ 路径下的文件,这是游戏热更新下载的资源。

    Frida监控到加载热更新路径下的文件

    经过排查,发现在设备无网络连接时,游戏才会回退使用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.handCardsself.baseCards 的赋值即可。

    ShuffleLogic.luac中修改牌型的代码

    注意:不能像内存修改那样全部设置为大小王,这可能导致游戏出牌逻辑异常。设置为包含两个王和若干炸弹的组合,足以保证胜利。

    修改游戏倍数

    既然胜券在握,下一步就是修改游戏倍数,从而获得更多虚拟金币。

    找到文件 src\game\rule\DDZRunRule.luac,解密后修改其中定义基础倍数和计算总倍数的函数。

    • 修改 init 函数中的 self._baseMult(基础倍数)。
    • 修改 getAllMult 函数中的计算逻辑,确保返回极高的倍数。

    DDZRunRule.luac中修改倍数的代码片段

    效果验证

    完成上述静态修改(改牌、禁更新、改倍数)后,将所有修改后的 .lua 文件重新用XXTEA加密,并在文件头部添加签名 bianfengqipai,最后替换原APK中的 .luac 文件,重新打包签名安装。

    修改前后效果对比如下:

    修改前:倍数较低,牌型普通。获胜所得金币较少。
    修改前游戏界面,牌型普通

    修改后:倍数极高(如24576倍),牌型极佳(自定义的炸弹组合)。每局获胜可获得巨额金币。
    修改后游戏界面,牌型极佳且倍数高
    从结算界面可以看到,由于对手牌型很差,他们根本不会叫地主,胜利和巨额收益轻而易举。
    修改后游戏胜利结算界面,获得高额金币

    总结

    本次分析涵盖了从动态Hook、静态逆向到资源修改的完整Android应用安全分析流程。关键点在于:

    1. 使用Frida进行动态行为分析,定位文件加载和关键函数。
    2. 通过逆向Cocos2d-x引擎的SO库,破解其Lua脚本的XXTEA加密机制。
    3. 动态调试分析游戏内存结构,理解牌值映射关系。
    4. 静态修改Lua脚本资源,实现定制发牌和高倍数,并关闭热更新以固化修改。

    整个过程涉及对Cocos2d-x引擎机制、Lua虚拟机以及ARM汇编调试的深入理解,是一次综合性的移动游戏逆向工程实践。值得注意的是,此类技术学习应仅限于安全研究与学习目的。


    本文涉及的技术与方法仅供安全研究与学习之用,请勿用于非法用途。




    上一篇:云原生混沌工程实战指南:使用Chaos Mesh构建Kubernetes系统韧性测试体系
    下一篇:Commodore OS Vision全解析:基于Linux Mint的复古操作系统
    您需要登录后才可以回帖 登录 | 立即注册

    手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

    GMT+8, 2026-1-10 08:51 , Processed in 0.234189 second(s), 40 queries , Gzip On.

    Powered by Discuz! X3.5

    © 2025-2025 云栈社区.

    快速回复 返回顶部 返回列表