某修仙肉鸽游戏协议逆向分析折腾记录
前阵子无意中接触到ZMXS这款游戏,玩了两天后觉得挺有意思的,就想着能不能深入研究一下它的网络协议实现。这一折腾就是一个多星期,踩了不少坑,但收获也挺大的。整个分析过程涉及到移动游戏逆向的很多典型场景,记录下来跟大家分享一下经验。
从APK开始的探索之旅
拿到APK后第一件事就是引擎识别,这步真的很关键。做过几个游戏分析的都知道,不同引擎的逆向套路完全不一样,走错了方向就是浪费时间。
解压APK直接奔向lib目录,几个关键文件一下子就映入眼帘:
libil2cpp.so - 看到这个就知道是Unity IL2CPP编译的,基础引擎确定了
libxlua.so - 这个更有意思,说明游戏逻辑是用Lua实现的
libmsaoaidsec.so - 一看就是反调试保护,待会儿肯定要跟它斗智斗勇
1.png (18.34 KB, 下载次数: 1)
下载附件
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, 下载次数: 1)
下载附件
2025-9-3 12:15 上传
运行IL2CPPDumper后,还好这个游戏没有在IL2CPP层面做保护,顺利dump出了所有的类型信息。用dnSpy打开生成的DLL文件一看,果然跟我预想的一样:
3.png (18.87 KB, 下载次数: 1)
下载附件
2025-9-3 12:15 上传
Assembly-CSharp.dll里几乎找不到什么游戏逻辑代码,大部分都是Unity基础类库和一些框架代码。这进一步印证了我的判断——游戏的核心业务逻辑确实在Lua层实现。
不过这里还是有几个有价值的发现:
XLua相关的桥接代码,说明C#和Lua之间有完整的交互机制,这为后面的分析提供了线索
YF.AssetBundles命名空间,这明显是游戏的资源管理系统,后面分析资源结构会用到
网络相关的基础类,虽然具体协议处理在Lua层,但底层socket还是C#实现的
跟反调试保护的第一次交锋
游戏集成了libmsaoaidsec.so这个反调试库,直接用Frida肯定会被检测到。这种保护挺常见的,主要检测这些:
调试器特征 - ptrace、JDWP等调试接口的使用情况
Hook框架特征 - Frida、Xposed等工具的内存特征
运行环境检测 - 模拟器、root环境的各种蛛丝马迹
完整性校验 - 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目录占据了绝大部分空间,显然游戏的核心资源都打包在这里。
4.png (33.72 KB, 下载次数: 0)
下载附件
2025-9-3 12:15 上传
对于做过Unity开发的人来说,AssetBundle并不陌生。它是Unity的模块化资源管理方案,有这些优势:
按需加载:只在需要时加载特定资源,节省内存
热更新支持:可以动态更新游戏内容而不用重新发布APK
平台优化:针对不同硬件平台优化资源格式
版本控制:方便管理不同版本的游戏资源
要找到Lua脚本的具体位置,关键是分析游戏的资源加载流程。从IL2CPP分析结果中,AssetBundleManager这个类很值得研究:
5.png (43.71 KB, 下载次数: 0)
下载附件
2025-9-3 12:15 上传
这个类中的LoadAssetAsync方法是关键入口点。通过IDA Pro进行静态分析,可以还原出资源加载的完整逻辑:
6.png (41.95 KB, 下载次数: 1)
下载附件
2025-9-3 12:16 上传
总结下来这个函数的作用就是
1) 将 assetPath 映射为 (assetBundleName, assetName)
2) 取出/创建一个 AssetAsyncLoader,并放入“进行中加载列表”
3) 如果资源已在缓存,则直接完成该 loader;否则先异步加载对应的 AssetBundle
经过详细分析,我把资源加载流程总结为这几个步骤:
路径映射阶段:将逻辑资源路径(比如"luagame.assetpkg")映射为物理文件路径
缓存检查:查看目标资源是否已经在内存缓存中
异步加载:如果缓存未命中,创建异步加载器从磁盘读取AssetBundle
解密处理:对加密的AssetBundle进行解密操作
资源实例化:将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打开目标文件,结果失败了。用十六进制编辑器检查,发现内容已经被加密:
7.png (30.91 KB, 下载次数: 1)
下载附件
2025-9-3 12:16 上传
文件头部没有标准的Unity AssetBundle签名,数据分布看起来完全是随机的,说明采用了某种加密算法。这下麻烦了,得想办法把加密给破了。
继续分析AssetBundleManager类,终于找到了关键的解密方法。通过IDA的交叉引用功能,追踪到了Decryption函数:
8.png (10.68 KB, 下载次数: 0)
下载附件
2025-9-3 12:16 上传
深入分析解密逻辑后,发现用的居然是最简单的XOR异或加密:
9.png (14.37 KB, 下载次数: 1)
下载附件
2025-9-3 12:16 上传
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格式:
10.png (8.97 KB, 下载次数: 1)
下载附件
2025-9-3 12:16 上传
用AssetStudio一解析,整个Lua脚本库都出现在眼前:
11.png (135.38 KB, 下载次数: 1)
下载附件
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]
这里有几个有意思的设计差异:
发送和接收的包长度字段大小不同(2字节 vs 4字节)
发送的消息体可能进行AES-ECB加密,接收的消息体是明文
加密策略由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(' protocol_id = struct.unpack(' 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分析到协议还原,整个流程走通了,数据包解析完全正确! 折腾完的一些感想 搞了一个多星期,总算把ZMXS这个游戏的协议给摸透了。回头想想这个过程,还是挺有意思的,踩了不少坑,但也学到了不少东西。 现在手游的技术架构 这次分析让我对现在手游的技术选择有了更清楚的认识。基本上主流游戏都是Unity做底子,然后业务逻辑全扔到Lua或者JS里去实现。这样搞确实有它的道理: 首先开发效率高得多,脚本语言写起来快,改起来也方便,不像C++那样编译半天。然后就是热更新这个杀手锏,服务端随时推个脚本更新,客户端马上就能用上新功能,根本不用重新发包。还有就是一套脚本能跑各个平台,省了不少适配的功夫。 但这样做也有代价。脚本这玩意儿相对来说比较好逆向,像这次我基本上把整个游戏逻辑都扒出来了。要是游戏公司把一些关键的数值计算或者反作弊逻辑放在客户端脚本里,那就给外挂开发者提供了很大便利。 关于资源保护这块 这个游戏的AssetBundle保护说实话挺一般的。就是简单的XOR异或,密钥还直接用文件名,这种保护强度对新手可能有点用,但对稍微有点经验的人来说基本没啥阻止作用。 要是我来做的话,至少得换成AES-256这种强一点的算法,密钥也不能这么简单粗暴。最好是搞个复杂点的密钥推导过程,再加上完整性校验,防止别人篡改资源文件。当然最根本的还是把重要资源放服务端,需要的时候动态下发,这样就算客户端被破解了也影响不大。 网络协议设计的一些细节 分析这个游戏的协议时发现了几个挺有意思的设计: 首先是加密策略不对称,客户端发送的数据可能会AES加密,但服务端返回的数据是明文。开始我还纳闷为啥这样设计,后来想想可能是性能考虑。服务端资源充足,解密不是问题,但客户端特别是低端设备,能省点计算就省点。 然后是选择性加密,不是所有协议都加密,而是通过一个EncryptFilter来判断哪些协议需要保护。这个设计挺实用的,敏感操作加密保护,普通操作明文传输,在安全性和性能之间找了个平衡点。 还有就是Protobuf + AES的组合使用。先序列化再加密,这个顺序是对的。Protobuf负责高效的数据序列化,AES负责数据保护,各司其职。 逆向分析的一些心得 这次分析下来,我觉得移动游戏的逆向大概有这么几个步骤: 先是引擎识别,这个很关键。看lib目录下的so文件基本就能判断出用的什么技术栈。确定了引擎就知道该用什么工具,走什么路线。 然后是代码层面的分析。Unity的话就用IL2CPPDumper,其他引擎有其他对应的工具。这一步主要是理解代码结构,找出关键的类和方法。 如果发现是脚本化架构,那重点就转到脚本提取上。Hook脚本加载函数是一种方法,但更彻底的还是找到脚本的存储位置,把完整的脚本库搞出来。 资源分析也很重要,特别是对于使用AssetBundle的游戏。搞清楚资源的加载流程,找到加密解密的关键点,这样就能把所有资源都搞到手。 协议逆向最好是基于源码分析,有了完整的脚本或者反编译代码,协议格式和加密机制基本上就一目了然了。 最后就是实战验证,拿真实的数据包来测试解析结果,确保分析的正确性。 整个过程下来,我觉得最关键的还是要结合静态分析和动态调试。光看代码不行,光Hook也不行,得两者结合才能突破各种保护。 一些想法 通过这次分析,我对Unity+Lua这种架构有了更深的理解。这种模式在手游行业确实很流行,开发效率高,热更新方便。但从安全角度来说,也确实存在一些问题。 不过话说回来,安全和效率本身就是矛盾的。游戏公司肯定是要在开发成本、运营成本和安全性之间找平衡。对于大部分休闲游戏来说,现在这种保护强度可能就够了。真正核心的数值和逻辑还是放在服务端比较安全。 随着技术的发展,保护和破解之间的对抗肯定还会继续下去。新的保护技术出来,新的破解方法也会跟上。这就是技术圈的魅力所在吧,永远有新的挑战等着你去解决。