[修正版]某修仙肉鸽游戏协议逆向分析折腾记录

查看 87|回复 11
作者:wuhuaguo888   
某修仙肉鸽游戏协议逆向分析折腾记录
前阵子无意中接触到ZMXS这款游戏,玩了两天后觉得挺有意思的,就想着能不能深入研究一下它的网络协议实现。这一折腾就是一个多星期,踩了不少坑,但收获也挺大的。整个分析过程涉及到移动游戏逆向的很多典型场景,记录下来跟大家分享一下经验。
从APK开始的探索之旅
拿到APK后第一件事就是引擎识别,这步真的很关键。做过几个游戏分析的都知道,不同引擎的逆向套路完全不一样,走错了方向就是浪费时间。
解压APK直接奔向lib目录,几个关键文件一下子就映入眼帘:
  • libil2cpp.so - 看到这个就知道是Unity IL2CPP编译的,基础引擎确定了
  • libxlua.so - 这个更有意思,说明游戏逻辑是用Lua实现的
  • libmsaoaidsec.so - 一看就是反调试保护,待会儿肯定要跟它斗智斗勇



    1.png (18.34 KB, 下载次数: 2)
    下载附件
    2025-9-3 12:15 上传

    看到这个组合我心里就有数了。Unity作为渲染引擎负责底层,真正的游戏业务逻辑全在Lua脚本里。这种架构现在挺流行的,好处是热更新方便,但从逆向分析的角度来说,反而提供了更多的入口点。
    Unity IL2CPP层面的挖掘
    既然确认是Unity引擎,那就直接上IL2CPPDumper了。这工具专门用来处理Unity的IL2CPP编译后的产物,能从native代码中还原出C#的类型信息和方法签名。
    需要准备两个关键文件:
  • lib/arm64-v8a/libil2cpp.so - 编译后的原生代码
  • assets/bin/Data/Managed/Metadata/global-metadata.dat - 元数据信息



    2.png (14.13 KB, 下载次数: 2)
    下载附件
    2025-9-3 12:15 上传

    运行IL2CPPDumper后,还好这个游戏没有在IL2CPP层面做保护,顺利dump出了所有的类型信息。用dnSpy打开生成的DLL文件一看,果然跟我预想的一样:


    3.png (18.87 KB, 下载次数: 2)
    下载附件
    2025-9-3 12:15 上传

    Assembly-CSharp.dll里几乎找不到什么游戏逻辑代码,大部分都是Unity基础类库和一些框架代码。这进一步印证了我的判断——游戏的核心业务逻辑确实在Lua层实现。
    不过这里还是有几个有价值的发现:
  • XLua相关的桥接代码,说明C#和Lua之间有完整的交互机制,这为后面的分析提供了线索
  • YF.AssetBundles命名空间,这明显是游戏的资源管理系统,后面分析资源结构会用到
  • 网络相关的基础类,虽然具体协议处理在Lua层,但底层socket还是C#实现的

    跟反调试保护的第一次交锋
    游戏集成了libmsaoaidsec.so这个反调试库,直接用Frida肯定会被检测到。这种保护挺常见的,主要检测这些:
    [ol]
  • 调试器特征 - ptrace、JDWP等调试接口的使用情况
  • Hook框架特征 - Frida、Xposed等工具的内存特征
  • 运行环境检测 - 模拟器、root环境的各种蛛丝马迹
  • 完整性校验 - APK签名和关键so文件的hash验证
    [/ol]
    碰到这种保护,一般有几种应对思路:
    方案一:线程暂停
    找到libmsaoaidsec.so创建的检测线程,直接暂停或kill掉。这种方法简单粗暴,但风险是可能影响游戏稳定性,有时候会莫名其妙崩溃。
    方案二:函数patch
    定位到具体的检测函数,用内存patch的方式将其NOP掉。这种方法效果最好,但工作量大,每个版本的libmsaoaidsec.so都需要重新分析。
    方案三:去特征工具
    使用修改过的Frida版本,比如Rusda,它移除了Frida的特征字符串,修改了默认端口等。
    权衡了一下,我选择了Rusda这种方案。虽然可能随着保护升级会失效,但对付当前这个版本的libmsaoaidsec.so还是够用的,而且相对来说比较省事。
    深入Lua脚本的世界
    Unity+XLua架构中,所有基于Lua虚拟机的脚本加载都会经过luaL_loadbufferx这个函数。这是标准的Lua C API,对做过Lua逆向的人来说应该很熟悉:
    int luaL_loadbufferx (
        lua_State *L,      // Lua虚拟机状态
        const char *buff,  // 脚本内容指针
        size_t sz,         // 脚本内容大小
        const char *name,  // 脚本文件名
        const char *mode   // 加载模式(text/binary/both)
    );
    通过Hook这个函数,理论上可以捕获到游戏动态加载的所有Lua脚本。写了个Frida脚本:
    // Lua脚本动态捕获
    function ensureDirectoryExists(filePath) {
        const pathComponents = filePath.split('/').slice(0, -1);
        let currentPath = '';
        for (const component of pathComponents) {
            currentPath += component + '/';
            try {
                const file = new File(currentPath, "r");
                if (!file.exists()) {
                    file.close();
                    // 创建目录
                    const dir = new File(currentPath, "w");
                    dir.close();
                } else {
                    file.close();
                }
            } catch(e) {
                console.log("Directory creation error: " + e);
            }
        }
    }
    Interceptor.attach(Module.findExportByName('libxlua.so', "luaL_loadbufferx"), {
        onEnter: function (args) {
            const scriptName = args[3].readUtf8String();
            const contentSize = args[2].toInt32();
            const contentPtr = args[1];
            // 只处理.lua文件
            if (!scriptName || !scriptName.endsWith(".lua")) {
                return;
            }
            const outputPath = "/sdcard/lua_analysis/" + scriptName;
            ensureDirectoryExists(outputPath);
            try {
                const fileHandle = new File(outputPath, "wb");
                const scriptContent = contentPtr.readByteArray(contentSize);
                if (scriptContent) {
                    fileHandle.write(scriptContent);
                    fileHandle.flush();
                    fileHandle.close();
                    console.log(`[Lua Capture] ${scriptName} (${contentSize} bytes)`);
                }
            } catch(e) {
                console.log(`[Error] Failed to save ${scriptName}: ${e}`);
            }
        }
    });
    但很快我就发现了这种方法的局限性:只能获取游戏实际执行到的脚本。游戏采用按需加载策略,很多功能模块可能根本不会被触发,这样就无法获取完整的脚本库。
    要彻底解决这个问题,必须找到Lua脚本在APK中的实际存储位置,把完整的脚本库搞出来。
    AssetBundle资源系统的深度挖掘
    现在的Unity游戏基本都用AssetBundle系统管理资源,这游戏也不例外。观察APK结构发现,assets/AssetBundles目录占据了绝大部分空间,显然游戏的核心资源都打包在这里。


    4.png (33.72 KB, 下载次数: 1)
    下载附件
    2025-9-3 12:15 上传

    对于做过Unity开发的人来说,AssetBundle并不陌生。它是Unity的模块化资源管理方案,有这些优势:
  • 按需加载:只在需要时加载特定资源,节省内存
  • 热更新支持:可以动态更新游戏内容而不用重新发布APK
  • 平台优化:针对不同硬件平台优化资源格式
  • 版本控制:方便管理不同版本的游戏资源

    要找到Lua脚本的具体位置,关键是分析游戏的资源加载流程。从IL2CPP分析结果中,AssetBundleManager这个类很值得研究:


    5.png (43.71 KB, 下载次数: 1)
    下载附件
    2025-9-3 12:15 上传

    这个类中的LoadAssetAsync方法是关键入口点。通过IDA Pro进行静态分析,可以还原出资源加载的完整逻辑:


    6.png (41.95 KB, 下载次数: 2)
    下载附件
    2025-9-3 12:16 上传

    总结下来这个函数的作用就是
    1) 将 assetPath 映射为 (assetBundleName, assetName)
    2) 取出/创建一个 AssetAsyncLoader,并放入“进行中加载列表”
    3) 如果资源已在缓存,则直接完成该 loader;否则先异步加载对应的 AssetBundle
    经过详细分析,我把资源加载流程总结为这几个步骤:
    [ol]
  • 路径映射阶段:将逻辑资源路径(比如"luagame.assetpkg")映射为物理文件路径
  • 缓存检查:查看目标资源是否已经在内存缓存中
  • 异步加载:如果缓存未命中,创建异步加载器从磁盘读取AssetBundle
  • 解密处理:对加密的AssetBundle进行解密操作
  • 资源实例化:将AssetBundle中的资源实例化为Unity对象
    [/ol]
    通过Hook LoadAssetAsync方法,我成功获得了资源名称与实际文件的映射关系:
    Game.assetbundle -> 260051b7bf2afd4070031708b056f55d.assetbundle
    gameassetsmap_bytes.assetbundle -> d1bd3d43c8bcb6c1cdf5a5dd34f9046e.assetbundle
    gamedependencies_bytes.assetbundle -> 7a0c77f31bfe78603126a70cb97df4ae.assetbundle
    luagame.assetpkg -> cced8de1b361f40750fbbfcd0e046241.assetpkg  # 就是这个!
    pb.assetbundle -> 09e9b1870b0bec20b4e772eaecdb8831.assetbundle
    看到luagame.assetpkg那一行,我心里一阵兴奋!这个文件应该就包含了游戏的完整Lua脚本库。对应的文件是cced8de1b361f40750fbbfcd0e046241.assetpkg。
    与加密机制的斗智斗勇
    直接用AssetStudio打开目标文件,结果失败了。用十六进制编辑器检查,发现内容已经被加密:


    7.png (30.91 KB, 下载次数: 2)
    下载附件
    2025-9-3 12:16 上传

    文件头部没有标准的Unity AssetBundle签名,数据分布看起来完全是随机的,说明采用了某种加密算法。这下麻烦了,得想办法把加密给破了。
    继续分析AssetBundleManager类,终于找到了关键的解密方法。通过IDA的交叉引用功能,追踪到了Decryption函数:


    8.png (10.68 KB, 下载次数: 1)
    下载附件
    2025-9-3 12:16 上传

    深入分析解密逻辑后,发现用的居然是最简单的XOR异或加密:


    9.png (14.37 KB, 下载次数: 2)
    下载附件
    2025-9-3 12:16 上传

    XOR加密的特点是简单高效,加密和解密使用相同的算法:encrypted_data = original_data ^ key[i % key_length]
    现在最关键的问题是:密钥到底是什么?
    通过向上追踪函数调用链,在更高层的调用中我找到了答案:
    // 关键代码片段的逆向还原
    if (webRequester->isEncryptionEnabled) {
        byte[] encryptedData = webRequester.GetBytes();
        // 解密调用:数据 + AssetBundle名称作为密钥
        byte[] decryptedData = AssetBundleManager.Decryption(
            encryptedData,                           // 加密数据
            webRequester.assetBundleName            // 密钥竟然就是文件名!
        );
        AssetBundle bundle = AssetBundle.LoadFromMemory(decryptedData);
    }
    看到这里我都笑了,密钥竟然就是AssetBundle的文件名。这种设计虽然简单,但对于防止普通用户随意修改资源确实有一定效果。
    有了密钥,写解密脚本就很简单了:
    def decrypt_assetbundle_xor(encrypted_data: bytes, key_string: str) -> bytes:
        """
        AssetBundle XOR解密实现
        Args:
            encrypted_data: 加密的字节数据
            key_string: 密钥字符串(通常为AssetBundle文件名)
        Returns:
            解密后的字节数据
        """
        key_bytes = key_string.encode('utf-8')
        key_length = len(key_bytes)
        decrypted_data = bytearray()
        for i, encrypted_byte in enumerate(encrypted_data):
            key_byte = key_bytes[i % key_length]
            decrypted_byte = encrypted_byte ^ key_byte
            decrypted_data.append(decrypted_byte)
        return bytes(decrypted_data)
    # 实际解密过程
    with open('cced8de1b361f40750fbbfcd0e046241.assetpkg', 'rb') as f:
        encrypted_content = f.read()
    decrypted_content = decrypt_assetbundle_xor(encrypted_content, 'luagame.assetpkg')
    # 验证解密结果
    if decrypted_content.startswith(b'UnityFS'):
        print("解密成功!文件格式:UnityFS")
        with open('luagame_decrypted.assetbundle', 'wb') as f:
            f.write(decrypted_content)
    else:
        print("解密失败,请检查密钥")
    解密成功后,文件显示为标准的UnityFS格式:


    10.png (8.97 KB, 下载次数: 2)
    下载附件
    2025-9-3 12:16 上传

    用AssetStudio一解析,整个Lua脚本库都出现在眼前:


    11.png (135.38 KB, 下载次数: 2)
    下载附件
    2025-9-3 12:16 上传

    看到这个结果,我心里那个激动啊!经过这么多轮的分析和破解,终于拿到了游戏的完整源码。
    网络协议的庐山真面目
    有了完整的Lua源码,协议分析就变得清晰多了。从代码目录结构可以看出,网络相关的代码主要集中在几个关键文件里:
    先看看客户端协议ID定义(net/ccmd.lua):
    local pb = require("pb")
    local function enum(id)
        return pb.enum("com.yofijoy.core.proto.CSProtoId", id)
    end
    local CCmd = {}
    CCmd.HEARTBEAT = enum("CS_HEART_REQ") --心跳
    -- 登录相关
    CCmd.USER_LOGIN = enum("CS_USER_LOGIN_REQ") -- 用户登录
    CCmd.CREATE_ROLE = enum("CS_CREATE_ROLE_REQ") -- 创建角色
    CCmd.ROLE_LOGIN = enum("CS_ROLE_LOGIN_REQ") -- 角色登录
    CCmd.ROLE_OPERATION = enum("CS_ROLE_OPERATION_REQ") -- 操作角色
    CCmd.USER_LOGIN_ACTIVATE_CODE_RPT = enum("CS_USER_LOGIN_ACTIVATE_CODE_RPT")-- 使用激活码登录
    再看看服务端协议ID定义(net/scmd.lua):
    local pb = require("pb")
    local function enum(id)
        return pb.enum("com.yofijoy.core.proto.CSProtoId", id)
    end
    local SCmd = {}
    SCmd.HEARTBEAT = enum("CS_HEART_RESP") --心跳返回
    SCmd.USER_LOGIN = enum("CS_USER_LOGIN_RESP") -- 用户登录返回
    SCmd.CREATE_ROLE = enum("CS_CREATE_ROLE_RESP") -- 创建角色返回
    SCmd.ROLE_LOGIN = enum("CS_ROLE_LOGIN_RESP") -- 角色登录
    然后是Protobuf序列化处理(net/ProtobufParser.lua):
    local current = select(1, ...)
    local EncryptFilter = import(".EncryptFilter")
    local pb = require "pb"
    local ProtobufParser = {}
    local PbBytes = {
        "Pb/base.bytes",
        "Pb/CSProto.bytes",
    }
    function ProtobufParser:coInit()
        -- 协程请求协议二进制文件
        for i, byteFile in ipairs(PbBytes) do
            local asset = ResourcesManager:coLoadAsync(byteFile, typeof(UE.TextAsset))
            if not asset then
                assert(false, "protobuf 资源异步加载失败")
                break
            end
            local ret = pb.load(asset.bytes)
            if ret == false then
                assert(false, "protobuf 二进制数据加载失败")
                break
            end
            -- print("加载", byteFile, "成功")
        end
    还有网络通信管理器(net/LunJianSocketManager.lua),这个文件里有个有意思的发现:
    local HeartbeatInterval = 5 --心跳间隔, 暂时心跳包间隔不设置过快
    local ReconnectTimes = 5   --重连次数
    local ReconnectInterval = 10 -- 重连间隔时间
    local MinRespondTime = 5    --客户端发心跳包后服务器响应时间过长, 超时时间
    local RecvInterval = HeartbeatInterval  --检测收包频率
    LunJianSocketManager.SendOverTime = HeartbeatInterval * 2 --发包异常超时时间
    LunJianSocketManager.RecvOverTime = HeartbeatInterval * 2 + MinRespondTime -- 收包异常超时时间
    local defaultEncryptKey = "spqh4hpstria0q9h"  -- 这个很关键!
    LunJianSocketManager.encryptKey = defaultEncryptKey
    local SocketError = {
        NORMAL = 0,     --正常关闭,在连接前也会关一下
        ERROR_1 = -1,   --C# send线程处理出现异常, 访问已释放的对象
        ERROR_2 = -2,   --C# send线程处理出现 发送发据包, 出现的非访问释放对象异常
        ERROR_3 = -3,   --C# recv线程 连接服务器或者接收到服务器数据读取不到数据, 会产生访问已释放的对象异常, 证明服务器已经关闭链接了
        ERROR_4 = -4,   --C# recv线程 连接或者关闭socket, 或接收数据包读取, 出现的非访问释放对象异常
        ERROR_5 = -5,   --主动断开连接, (重线重连, 非主动断开.忽略该信号)
        ERROR_6 = -6,   --主动连接超时
    }
    看到那个defaultEncryptKey = "spqh4hpstria0q9h",我差点笑出声。这就是网络协议的AES加密密钥!直接硬编码在脚本里,简单粗暴。
    协议格式的真相大白
    仔细分析了LunJianSocketManager.lua中的发送和接收逻辑,终于搞清楚了游戏网络协议的完整格式。
    发送逻辑是这样的:
    function LunJianSocketManager:send(msgCmd, msgData)
        local protoData = nil
        local msgLen = PackSendHeaderLength  -- 发送包头长度常量
        -- Step 1: Protobuf序列化
        if msgData then
            protoData = ProtobufParser:encode(msgCmd, msgData)
            if not protoData then
                LogError("Protobuf编码失败: " .. tostring(msgCmd))
                return false
            end
        end
        -- Step 2: 条件加密处理
        if protoData then
            -- 检查协议是否需要加密
            if EncryptFilter:needEncrypt(msgCmd) then
                protoData = Crypto.AesEncryptECB(protoData, self.encryptKey)
                if not protoData then
                    LogError("AES加密失败: " .. tostring(msgCmd))
                    return false
                end
            end
            msgLen = msgLen + #protoData
        end
        -- Step 3: 构建网络数据包
        self.netData:reset()
        self.netData:writeUShort(msgLen)     -- 包总长度 (2字节)
        self.netData:writeUShort(msgCmd)     -- 协议ID (2字节)
        if protoData then
            self.netData:writeBuffer(protoData)  -- 消息体数据
        end
        -- Step 4: 网络发送
        return self:sendRawPacket(self.netData:getBuffer())
    end
    接收逻辑稍有不同:
    function LunJianSocketManager:onProcessMsg(rawBytes)
        self.netData:setBuffer(rawBytes)
        -- 解析包头信息
        local totalLength = self.netData:readInt()        -- 包总长度 (4字节)
        local protocolId = self.netData:readUShort()      -- 协议ID (2字节)  
        local dataLength = totalLength - PackRecvHeaderLength
        -- 读取消息体
        local protobufData = self.netData:readProtocolBuffer()
        if string.len(protobufData) ~= dataLength then
            LogError("协议数据长度不匹配: expected=" .. dataLength .. ", actual=" .. string.len(protobufData))
            return false
        end
        -- Protobuf反序列化
        local messageData = ProtobufParser:decode(protocolId, protobufData)
        if not messageData then
            LogError("Protobuf解码失败: " .. tostring(protocolId))
            return false
        end
        -- 消息分发处理
        self:dispatchMessage(protocolId, messageData)
        return true
    end
    通过源码分析,游戏协议格式的关键特征总结如下:
    客户端发送格式:
    [包长度:2字节] + [协议ID:2字节] + [消息数据:变长,可能AES加密]
    服务端响应格式:  
    [包长度:4字节] + [协议ID:2字节] + [消息数据:变长,明文Protobuf]
    这里有几个有意思的设计差异:
    [ol]
  • 发送和接收的包长度字段大小不同(2字节 vs 4字节)
  • 发送的消息体可能进行AES-ECB加密,接收的消息体是明文
  • 加密策略由EncryptFilter:needEncrypt()控制,不是所有协议都加密
    [/ol]
    Protobuf协议定义的逆向重建
    游戏使用lua-protobuf库处理消息序列化,协议定义的加载方式是这样的:
    -- ProtobufParser.lua 核心逻辑
    local pb = require("pb")
    -- 预编译的protobuf定义文件
    local ProtobufBinaryFiles = {
        "Pb/base.bytes",        -- 基础消息类型定义
        "Pb/CSProto.bytes",     -- 客户端-服务端协议定义
    }
    function ProtobufParser:initialize()
        -- 异步加载二进制protobuf定义
        for _, binaryFile in ipairs(ProtobufBinaryFiles) do
            local asset = ResourcesManager:loadAssetSync(binaryFile, typeof(UE.TextAsset))
            if asset and asset.bytes then
                local success = pb.load(asset.bytes)
                if not success then
                    LogError("Failed to load protobuf definition: " .. binaryFile)
                    return false
                end
            else
                LogError("Protobuf definition file not found: " .. binaryFile)  
                return false
            end
        end
        LogInfo("Protobuf definitions loaded successfully")
        return true
    end
    这里有个问题:lua-protobuf使用的是预编译的.pb二进制文件,而不是可读的.proto源文件。要想还原出完整的协议定义,需要想办法从二进制格式逆向出可读的文本格式。
    好在lua-protobuf提供了强大的反射机制,可以在运行时查询内存中的协议定义信息。
    [table]
    [tr]
    [td]pb.types()[/td]
    [td]iterator[/td]
    [td]遍历内存数据库里所有的消息类型,返回具体信息[/td]
    [/tr]
    [tr]
    [td]pb.type(type)

    协议, 脚本

  • mengxinb   

    非常优秀的一篇帖子 技术细节饱满 逻辑链条完整 经验价值极高 看完整个人对安卓游戏逆向醍醐灌顶 感谢大佬分享
    lovemoney   

    随着抽丝剥茧的分析,迷雾一步一步慢慢揭开,共鸣了作为开发者熬了一段时间之后成功突破的那种喜悦,同时我们新手逆向开发者需要作者最后这个逆向思路的总结,更需要几个不同引擎自己实战逆向的过程总结,一切要等我的这个外挂小小告一段落之后,好痛苦,加油自己!
    liujw1991   

    厉害了,膜拜下~
    云的彼岸918   

    大佬牛逼
    heigui520   

    大佬牛逼
    我的爱是你   

    看雪的那位作者吗,早上看到了但是帖子里好像少了几张图。
    这次帖子更完整了。
    TM00NB   

    666,大佬牛逼
    Squ4reR   

    大佬牛逼,高质量贴啊
    Hao轩   

    解密后有什么用 可以修改金币之类的数据吗还是什么
    您需要登录后才可以回帖 登录 | 立即注册