开启辅助访问 切换到宽版

精易论坛

 找回密码
 注册

QQ登录

只需一步,快速开始

用微信号发送消息登录论坛

新人指南 邀请好友注册 - 我关注人的新帖 教你赚取精币 - 每日签到


求职/招聘- 论坛接单- 开发者大厅

论坛版规 总版规 - 建议/投诉 - 应聘版主 - 精华帖总集 积分说明 - 禁言标准 - 有奖举报

查看: 6603|回复: 4
收起左侧

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

[复制链接]
发表于 2025-9-2 20:39:27 | 显示全部楼层 |阅读模式   江苏省扬州市
本帖最后由 wuhuaguo888 于 2025-9-2 20:57 编辑

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

前阵子无意中接触到ZMXS这款游戏,玩了两天后觉得挺有意思的,就想着能不能深入研究一下它的网络协议实现。这一折腾就是一个多星期,踩了不少坑,但收获也挺大的。整个分析过程涉及到移动游戏逆向的很多典型场景,记录下来跟大家分享一下经验。

从APK开始的探索之旅

拿到APK后第一件事就是引擎识别,这步真的很关键。做过几个游戏分析的都知道,不同引擎的逆向套路完全不一样,走错了方向就是浪费时间。

解压APK直接奔向lib目录,几个关键文件一下子就映入眼帘:

  • libil2cpp.so - 看到这个就知道是Unity IL2CPP编译的,基础引擎确定了
  • libxlua.so - 这个更有意思,说明游戏逻辑是用Lua实现的
  • libmsaoaidsec.so - 一看就是反调试保护,待会儿肯定要跟它斗智斗勇

看到这个组合我心里就有数了。Unity作为渲染引擎负责底层,真正的游戏业务逻辑全在Lua脚本里。这种架构现在挺流行的,好处是热更新方便,但从逆向分析的角度来说,反而提供了更多的入口点。

Unity IL2CPP层面的挖掘

既然确认是Unity引擎,那就直接上IL2CPPDumper了。这工具专门用来处理Unity的IL2CPP编译后的产物,能从native代码中还原出C#的类型信息和方法签名。

需要准备两个关键文件:

  • lib/arm64-v8a/libil2cpp.so - 编译后的原生代码
  • assets/bin/Data/Managed/Metadata/global-metadata.dat - 元数据信息

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

Assembly-CSharp.dll里几乎找不到什么游戏逻辑代码,大部分都是Unity基础类库和一些框架代码。这进一步印证了我的判断——游戏的核心业务逻辑确实在Lua层实现。

不过这里还是有几个有价值的发现:

  • XLua相关的桥接代码,说明C#和Lua之间有完整的交互机制,这为后面的分析提供了线索
  • YF.AssetBundles命名空间,这明显是游戏的资源管理系统,后面分析资源结构会用到
  • 网络相关的基础类,虽然具体协议处理在Lua层,但底层socket还是C#实现的

跟反调试保护的第一次交锋

游戏集成了libmsaoaidsec.so这个反调试库,直接用Frida肯定会被检测到。这种保护挺常见的,主要检测这些:

  1. 调试器特征 - ptrace、JDWP等调试接口的使用情况
  2. Hook框架特征 - Frida、Xposed等工具的内存特征
  3. 运行环境检测 - 模拟器、root环境的各种蛛丝马迹
  4. 完整性校验 - APK签名和关键so文件的hash验证

碰到这种保护,一般有几种应对思路:

方案一:线程暂停
找到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目录占据了绝大部分空间,显然游戏的核心资源都打包在这里。

对于做过Unity开发的人来说,AssetBundle并不陌生。它是Unity的模块化资源管理方案,有这些优势:

  • 按需加载:只在需要时加载特定资源,节省内存
  • 热更新支持:可以动态更新游戏内容而不用重新发布APK
  • 平台优化:针对不同硬件平台优化资源格式
  • 版本控制:方便管理不同版本的游戏资源

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

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

总结下来这个函数的作用就是

1) 将 assetPath 映射为 (assetBundleName, assetName)
2) 取出/创建一个 AssetAsyncLoader,并放入“进行中加载列表”
3) 如果资源已在缓存,则直接完成该 loader;否则先异步加载对应的 AssetBundle

经过详细分析,我把资源加载流程总结为这几个步骤:

  1. 路径映射阶段:将逻辑资源路径(比如"luagame.assetpkg")映射为物理文件路径
  2. 缓存检查:查看目标资源是否已经在内存缓存中
  3. 异步加载:如果缓存未命中,创建异步加载器从磁盘读取AssetBundle
  4. 解密处理:对加密的AssetBundle进行解密操作
  5. 资源实例化:将AssetBundle中的资源实例化为Unity对象

通过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打开目标文件,结果失败了。用十六进制编辑器检查,发现内容已经被加密:

文件头部没有标准的Unity AssetBundle签名,数据分布看起来完全是随机的,说明采用了某种加密算法。这下麻烦了,得想办法把加密给破了。

继续分析AssetBundleManager类,终于找到了关键的解密方法。通过IDA的交叉引用功能,追踪到了Decryption函数:

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

XOR加密的特点是简单高效,加密和解密使用相同的算法:encrypted_data[i] = original_data[i] ^ 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格式:

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

看到这个结果,我心里那个激动啊!经过这么多轮的分析和破解,终于拿到了游戏的完整源码。

网络协议的庐山真面目

有了完整的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]

这里有几个有意思的设计差异:

  1. 发送和接收的包长度字段大小不同(2字节 vs 4字节)
  2. 发送的消息体可能进行AES-ECB加密,接收的消息体是明文
  3. 加密策略由EncryptFilter:needEncrypt()控制,不是所有协议都加密

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提供了强大的反射机制,可以在运行时查询内存中的协议定义信息。

pb.types() iterator 遍历内存数据库里所有的消息类型,返回具体信息
pb.type(type) 详情见下 返回内存数据库特定消息类型的具体信息
pb.fields(type) iterator 遍历特定消息里所有的域,返回具体信息
pb.field(type, string) 详情见下 返回特定消息里特定域的具体信息
pb.field(type, number) 详情见下 返回特定消息里特定域的具体信息

利用这些反射接口,可以写个协议信息提取器:

-- Protobuf协议信息提取器
function extract_protobuf_schema()
    local schema_database = {}
    local enum_types = {}
    local message_types = {}

    -- 遍历所有类型定义
    for full_typename, base_typename, type_kind in pb.types() do
        local type_entry = {
            full_name = full_typename,
            base_name = base_typename,
            type_kind = type_kind,  -- "enum" or "message"
            fields = {},
            package = extract_package_name(full_typename)
        }

        -- 提取字段定义信息
        for field_name, field_number, field_type, default_val, field_flags, oneof_name, oneof_idx in pb.fields(full_typename) do
            local field_entry = {
                name = field_name,
                number = field_number,
                type = field_type,
                default_value = default_val,
                flags = field_flags,  -- "optional", "required", "repeated"
                oneof_name = oneof_name,
                oneof_index = oneof_idx
            }

            type_entry.fields[field_number] = field_entry
        end

        schema_database[full_typename] = type_entry

        -- 按类型分类
        if type_kind == "enum" then
            enum_types[full_typename] = type_entry
        elseif type_kind == "message" then
            message_types[full_typename] = type_entry
        end
    end

    return {
        all_types = schema_database,
        enums = enum_types, 
        messages = message_types
    }
end

function extract_package_name(full_typename)
    local parts = {}
    for part in string.gmatch(full_typename, "[^%.]+") do
        table.insert(parts, part)
    end

    if #parts > 1 then
        -- 移除最后一个部分(类型名),剩下的是包名
        table.remove(parts, #parts)
        return table.concat(parts, ".")
    else
        return ""
    end
end

通过这个提取器,拿到了详细的协议信息JSON数据。然后又写了Python脚本将其重建为标准的.proto文件:

def rebuild_protobuf_definition(schema_data: dict) -> str:
    """
    将提取的协议信息重建为.proto文件格式
    """
    proto_lines = [
        'syntax = "proto3";',
        '',
    ]

    # 按包名组织类型定义
    packages = {}
    for type_name, type_info in schema_data['all_types'].items():
        package_name = type_info.get('package', '')
        if package_name not in packages:
            packages[package_name] = {'enums': [], 'messages': []}

        if type_info['type_kind'] == 'enum':
            packages[package_name]['enums'].append(type_info)
        elif type_info['type_kind'] == 'message':
            packages[package_name]['messages'].append(type_info)

    # 生成各个包的定义
    for package_name, types in packages.items():
        if package_name:
            proto_lines.append(f'package {package_name};')
            proto_lines.append('')

        # 生成枚举定义
        for enum_info in types['enums']:
            proto_lines.extend(build_enum_definition(enum_info))
            proto_lines.append('')

        # 生成消息定义
        for message_info in types['messages']:
            proto_lines.extend(build_message_definition(message_info, package_name))
            proto_lines.append('')

    return '\n'.join(proto_lines)

def build_enum_definition(enum_info: dict) -> list:
    lines = [f"enum {enum_info['base_name']} {{"]

    # 按字段编号排序
    sorted_fields = sorted(enum_info['fields'].items(), key=lambda x: int(x[0]))

    for field_num, field_info in sorted_fields:
        lines.append(f"    {field_info['name']} = {field_info['number']};")

    lines.append("}")
    return lines

def build_message_definition(message_info: dict, package_name: str) -> list:
    lines = [f"message {message_info['base_name']} {{"]

    # 按字段编号排序
    sorted_fields = sorted(message_info['fields'].items(), key=lambda x: int(x[0]))

    for field_num, field_info in sorted_fields:
        field_type = normalize_field_type(field_info['type'], package_name)
        field_prefix = ""

        # 处理repeated字段
        if field_info['flags'] == 'repeated':
            field_prefix = "repeated "

        lines.append(f"    {field_prefix}{field_type} {field_info['name']} = {field_info['number']};")

    lines.append("}")
    return lines

def normalize_field_type(original_type: str, current_package: str) -> str:
    """规范化字段类型名称,处理包引用"""
    if original_type.startswith('.'):
        # 绝对路径类型引用
        if original_type.startswith(f'.{current_package}.'):
            # 同包引用,移除包前缀
            return original_type[len(f'.{current_package}.'):]
        else:
            # 跨包引用,保留相对路径
            return original_type[1:]  # 移除开头的点
    else:
        # 基础类型或相对引用
        return original_type

经过这一番折腾,终于成功重建出了完整的.proto文件,包括协议ID枚举:

syntax = "proto3";

package com.yofijoy.core.proto;

// 客户端-服务端协议ID枚举
enum CSProtoId {
    CS_FIRSTID = 0;

    // 基础协议
    CS_HEART_REQ = 10001;              // 心跳请求
    CS_HEART_RESP = 10002;             // 心跳响应
    CS_USER_LOGIN_REQ = 10003;         // 登录请求
    CS_USER_LOGIN_RESP = 10004;        // 登录响应
    CS_CREATE_ROLE_REQ = 10005;        // 创建角色请求  
    CS_CREATE_ROLE_RESP = 10006;       // 创建角色响应

    // 游戏功能协议
    CS_NPC_DOUBLE_CULTIVATE_REQ = 11125;   // NPC双修请求
    CS_NPC_DOUBLE_CULTIVATE_RESP = 11126;  // NPC双修响应
    // ... 更多协议定义
}

// NPC双修请求消息结构
message CS_NpcDoubleCultivateReq {
    uint32 objId = 1;        // 对象ID
    uint32 type = 2;         // 双修类型
    bool tenTimes = 3;       // 是否进行十倍操作
}

// NPC双修响应消息结构
message CS_NpcDoubleCultivateResp {
    uint32 result_code = 1;      // 操作结果码
    string result_message = 2;   // 结果描述信息
    RewardInfo rewards = 3;      // 获得的奖励信息
}

实战验证:真刀真枪解数据包

现在万事俱备,该验证一下分析结果的正确性了。拿个实际抓到的数据包来测试:

1400752b05e3ce1f940f618eb295ea6c6c9c26cc

按照分析的协议格式,写了个完整的解析程序:

import struct
from Crypto.Cipher import AES
import CS_NpcDoubleCultivateReq_pb2  # 生成的protobuf类

def parse_game_packet(hex_data: str):
    """解析游戏网络数据包"""
    packet_bytes = bytes.fromhex(hex_data)

    # Step 1: 解析包头
    packet_length = struct.unpack('<H', packet_bytes[0:2])[0]  # 小端序2字节
    protocol_id = struct.unpack('<H', packet_bytes[2:4])[0]    # 小端序2字节
    message_data = packet_bytes[4:]

    print(f"数据包长度: {packet_length}")
    print(f"协议ID: {protocol_id} (0x{protocol_id:04x})")
    print(f"消息体长度: {len(message_data)}")

    # Step 2: 协议ID映射
    protocol_mapping = {
        11125: ("CS_NPC_DOUBLE_CULTIVATE_REQ", "CS_NpcDoubleCultivateReq"),
        11126: ("CS_NPC_DOUBLE_CULTIVATE_RESP", "CS_NpcDoubleCultivateResp"),
        # ... 更多映射
    }

    if protocol_id not in protocol_mapping:
        print(f"未知协议ID: {protocol_id}")
        return None

    protocol_name, message_class = protocol_mapping[protocol_id]
    print(f"协议名称: {protocol_name}")
    print(f"消息类型: {message_class}")

    # Step 3: 数据解密(如果需要)
    decrypted_data = message_data
    if is_encrypted_protocol(protocol_id):
        # 这里使用从游戏中提取的AES密钥
        encryption_key = "spqh4hpstria0q9h".encode('utf-8')[:16]  # AES-128需要16字节密钥
        decrypted_data = aes_decrypt_ecb(message_data, encryption_key)
        print("数据已解密")

    # Step 4: Protobuf反序列化
    try:
        if message_class == "CS_NpcDoubleCultivateReq":
            message_obj = CS_NpcDoubleCultivateReq_pb2.CS_NpcDoubleCultivateReq()
            message_obj.ParseFromString(decrypted_data)

            result = {
                "objId": message_obj.objId,
                "type": message_obj.type,
                "tenTimes": message_obj.tenTimes
            }

            print("解析结果:", result)
            return result

    except Exception as e:
        print(f"Protobuf解析失败: {e}")
        return None

def aes_decrypt_ecb(encrypted_data: bytes, key: bytes) -> bytes:
    """AES-ECB解密"""
    cipher = AES.new(key, AES.MODE_ECB)
    decrypted = cipher.decrypt(encrypted_data)

    # 去除PKCS7填充
    padding_length = decrypted[-1]
    return decrypted[:-padding_length]

def is_encrypted_protocol(protocol_id: int) -> bool:
    """判断协议是否需要解密"""
    # 这个逻辑需要根据游戏的EncryptFilter实现来确定
    encrypted_protocols = {11125, 11126, 10003, 10005}  # 示例
    return protocol_id in encrypted_protocols

# 解析示例数据包
parse_game_packet("1400752b05e3ce1f940f618eb295ea6c6c9c26cc")

运行结果如下:

数据包长度: 20
协议ID: 11125 (0x2b75)
消息体长度: 16
协议名称: CS_NPC_DOUBLE_CULTIVATE_REQ  
消息类型: CS_NpcDoubleCultivateReq
数据已解密
解析结果: {'objId': 237291675, 'type': 1, 'tenTimes': False}

看到这个结果,心里别提多高兴了。从APK分析到协议还原,整个流程走通了,数据包解析完全正确!

完整样本和代码参考

因为东西太杂和样本太大,全部上传到github,有兴趣的可以到github上查看

https://github.com/wuhuaguo888/zmxs

折腾完的一些感想

搞了一个多星期,总算把ZMXS这个游戏的协议给摸透了。回头想想这个过程,还是挺有意思的,踩了不少坑,但也学到了不少东西。

现在手游的技术架构

这次分析让我对现在手游的技术选择有了更清楚的认识。基本上主流游戏都是Unity做底子,然后业务逻辑全扔到Lua或者JS里去实现。这样搞确实有它的道理:

首先开发效率高得多,脚本语言写起来快,改起来也方便,不像C++那样编译半天。然后就是热更新这个杀手锏,服务端随时推个脚本更新,客户端马上就能用上新功能,根本不用重新发包。还有就是一套脚本能跑各个平台,省了不少适配的功夫。

但这样做也有代价。脚本这玩意儿相对来说比较好逆向,像这次我基本上把整个游戏逻辑都扒出来了。要是游戏公司把一些关键的数值计算或者反作弊逻辑放在客户端脚本里,那就给外挂开发者提供了很大便利。

关于资源保护这块

这个游戏的AssetBundle保护说实话挺一般的。就是简单的XOR异或,密钥还直接用文件名,这种保护强度对新手可能有点用,但对稍微有点经验的人来说基本没啥阻止作用。

要是我来做的话,至少得换成AES-256这种强一点的算法,密钥也不能这么简单粗暴。最好是搞个复杂点的密钥推导过程,再加上完整性校验,防止别人篡改资源文件。当然最根本的还是把重要资源放服务端,需要的时候动态下发,这样就算客户端被破解了也影响不大。

网络协议设计的一些细节

分析这个游戏的协议时发现了几个挺有意思的设计:

首先是加密策略不对称,客户端发送的数据可能会AES加密,但服务端返回的数据是明文。开始我还纳闷为啥这样设计,后来想想可能是性能考虑。服务端资源充足,解密不是问题,但客户端特别是低端设备,能省点计算就省点。

然后是选择性加密,不是所有协议都加密,而是通过一个EncryptFilter来判断哪些协议需要保护。这个设计挺实用的,敏感操作加密保护,普通操作明文传输,在安全性和性能之间找了个平衡点。

还有就是Protobuf + AES的组合使用。先序列化再加密,这个顺序是对的。Protobuf负责高效的数据序列化,AES负责数据保护,各司其职。

逆向分析的一些心得

这次分析下来,我觉得移动游戏的逆向大概有这么几个步骤:

先是引擎识别,这个很关键。看lib目录下的so文件基本就能判断出用的什么技术栈。确定了引擎就知道该用什么工具,走什么路线。

然后是代码层面的分析。Unity的话就用IL2CPPDumper,其他引擎有其他对应的工具。这一步主要是理解代码结构,找出关键的类和方法。

如果发现是脚本化架构,那重点就转到脚本提取上。Hook脚本加载函数是一种方法,但更彻底的还是找到脚本的存储位置,把完整的脚本库搞出来。

资源分析也很重要,特别是对于使用AssetBundle的游戏。搞清楚资源的加载流程,找到加密解密的关键点,这样就能把所有资源都搞到手。

协议逆向最好是基于源码分析,有了完整的脚本或者反编译代码,协议格式和加密机制基本上就一目了然了。

最后就是实战验证,拿真实的数据包来测试解析结果,确保分析的正确性。

整个过程下来,我觉得最关键的还是要结合静态分析和动态调试。光看代码不行,光Hook也不行,得两者结合才能突破各种保护。

一些想法

通过这次分析,我对Unity+Lua这种架构有了更深的理解。这种模式在手游行业确实很流行,开发效率高,热更新方便。但从安全角度来说,也确实存在一些问题。

不过话说回来,安全和效率本身就是矛盾的。游戏公司肯定是要在开发成本、运营成本和安全性之间找平衡。对于大部分休闲游戏来说,现在这种保护强度可能就够了。真正核心的数值和逻辑还是放在服务端比较安全。

随着技术的发展,保护和破解之间的对抗肯定还会继续下去。新的保护技术出来,新的破解方法也会跟上。这就是技术圈的魅力所在吧,永远有新的挑战等着你去解决。

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有帐号?注册

x

评分

参与人数 1好评 +1 收起 理由
风雪飞扬 + 1 精彩文章希望继续努力

查看全部评分

本帖被以下淘专辑推荐:

结帖率:0% (0/11)

签到天数: 2 天

发表于 昨天 15:53 | 显示全部楼层   四川省成都市
可以留个联系方式吗?大佬
回复 支持 反对

使用道具 举报

结帖率:100% (4/4)

签到天数: 10 天

发表于 2025-11-14 08:01:02 | 显示全部楼层   山东省日照市
感谢楼主,让我熄灭了胸中熊熊的逆向之火
回复 支持 反对

使用道具 举报

结帖率:0% (0/1)
发表于 2025-10-5 21:51:17 | 显示全部楼层   广东省惠州市
感谢楼主
回复 支持 反对

使用道具 举报

签到天数: 12 天

发表于 2025-9-7 12:50:24 高大上手机用户 | 显示全部楼层   广东省深圳市
来学习学习
回复 支持 反对

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则 致发广告者

发布主题 收藏帖子 返回列表

sitemap| 易语言源码| 易语言教程| 易语言论坛| 易语言模块| 手机版| 广告投放| 精易论坛
拒绝任何人以任何形式在本论坛发表与中华人民共和国法律相抵触的言论,本站内容均为会员发表,并不代表精易立场!
论坛帖子内容仅用于技术交流学习和研究的目的,严禁用于非法目的,否则造成一切后果自负!如帖子内容侵害到你的权益,请联系我们!
防范网络诈骗,远离网络犯罪 违法和不良信息举报QQ: 793400750,邮箱:wp@125.la
网站简介:精易论坛成立于2009年,是一个程序设计学习交流技术论坛,隶属于揭阳市揭东区精易科技有限公司所有。
Powered by Discuz! X3.4 揭阳市揭东区精易科技有限公司 ( 粤ICP备2025452707号) 粤公网安备 44522102000125 增值电信业务经营许可证 粤B2-20192173

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