前阵子无意中接触到ZMXS这款游戏,玩了两天后觉得挺有意思的,就想着能不能深入研究一下它的网络协议实现。这一折腾就是一个多星期,踩了不少坑,但收获也挺大的。整个分析过程涉及到移动游戏逆向的很多典型场景,记录下来跟大家分享一下经验。
从APK开始的探索之旅
拿到APK后第一件事就是引擎识别,这步真的很关键。做过几个游戏分析的都知道,不同引擎的逆向套路完全不一样,走错了方向就是浪费时间。
解压APK直接奔向lib目录,几个关键文件一下子就映入眼帘:

1.png (18.34 KB, 下载次数: 2)
下载附件
2025-9-3 12:15 上传
看到这个组合我心里就有数了。Unity作为渲染引擎负责底层,真正的游戏业务逻辑全在Lua脚本里。这种架构现在挺流行的,好处是热更新方便,但从逆向分析的角度来说,反而提供了更多的入口点。
Unity IL2CPP层面的挖掘
既然确认是Unity引擎,那就直接上IL2CPPDumper了。这工具专门用来处理Unity的IL2CPP编译后的产物,能从native代码中还原出C#的类型信息和方法签名。
需要准备两个关键文件:

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层实现。
不过这里还是有几个有价值的发现:
跟反调试保护的第一次交锋
游戏集成了libmsaoaidsec.so这个反调试库,直接用Frida肯定会被检测到。这种保护挺常见的,主要检测这些:
[ol]
[/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的模块化资源管理方案,有这些优势:
要找到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]
[/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]
[/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)