Unity游戏xLua逆向实战:从SO版本识别到Lua脚本热替换全链路
本文以两款Unity IL2CPP手游为实战案例,记录一套完整的逆向方法论:通过逆向分析libxlua.so确定xLua版本,下载对应源码编译出兼容的SO,注入Lua脚本导出与热替换机制,最终实现运行时脚本修改。全文聚焦技术原理与代码实现。
- 项目A(真机):xLua lib_version=105,Lua 5.3.5,修改
lauxlib.c的luaL_loadbufferx- 项目B(模拟器):xLua lib_version=105,Lua 5.3.5,修改
xlua.c的xluaL_loadbuffer
目录
- 整体思路
- SO逆向:识别xLua版本
- 源码获取与编译环境搭建
- 编译兼容的libxlua.so
- 二进制对比验证
- 注入Lua脚本导出与热替换机制
- 部署与使用
- Lua脚本修改方法论
- [踩坑记录:lua-rapidjson的JSON null问题](#踩坑记录:lua-rapidjson的JSON null问题)
- 总结
一、整体思路
┌─────────────────────────────────────────────────────────────┐
│ 完整技术链路 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ① 提取目标SO → strings/readelf分析 → 确定xLua/Lua版本 │
│ ↓ │
│ ② 下载对应版本xLua源码 + 第三方扩展源码 │
│ ↓ │
│ ③ NDK交叉编译 → 生成arm64-v8a的libxlua.so │
│ ↓ │
│ ④ objdump全量反汇编对比 → 确认二进制兼容性 │
│ ↓ │
│ ⑤ 修改lauxlib.c/xlua.c → 注入导出+热替换Hook │
│ ↓ │
│ ⑥ 替换设备上的SO → 启动应用 → 自动导出所有Lua脚本 │
│ ↓ │
│ ⑦ 分析Lua脚本逻辑 → 修改校验函数 → 热替换生效 │
│ │
└─────────────────────────────────────────────────────────────┘
核心原理 :Unity游戏使用xLua作为Lua虚拟机时,所有Lua脚本的加载最终都会经过luaL_loadbufferx(或xLua封装的xluaL_loadbuffer)。只要在这个函数入口插入Hook,就能拦截所有脚本的加载过程------先导出明文,再检查是否有替换文件。
二、SO逆向:识别xLua版本
2.1 提取SO文件
从APK或设备中提取libxlua.so:
bash
# 方法1:从APK解压
unzip target.apk lib/arm64-v8a/libxlua.so -d ./extracted/
# 方法2:从已安装应用提取(需root)
adb shell "su -c 'cp /data/app/<package_path>/lib/arm64/libxlua.so /sdcard/'"
adb pull /sdcard/libxlua.so
2.2 确定Lua版本
bash
# 搜索Lua版本字符串
strings libxlua.so | grep -i "Lua 5"
# 输出示例: Lua 5.3.5
2.3 确定xLua lib_version
xLua有一个版本标识函数xlua_get_lib_version,返回一个整数版本号:
bash
# 确认符号存在
readelf -s libxlua.so | grep xlua_get_lib_version
# 用IDA/Ghidra反编译该函数,查看返回值
# 例如返回105,对应xLua v2.1.15.x
版本对应关系(常用):
| lib_version | xLua版本 | Lua版本 |
|---|---|---|
| 105 | v2.1.15.1 | 5.3.5 |
| 104 | v2.1.14 | 5.3.5 |
| 103 | v2.1.13 | 5.3.4 |
2.4 识别内嵌C模块
通过luaopen_前缀的导出符号,可以确定SO中编译了哪些Lua C模块:
bash
readelf -s libxlua.so | grep "luaopen_"
典型输出:
luaopen_base # Lua标准库
luaopen_coroutine
luaopen_debug
luaopen_io
luaopen_math
luaopen_os
luaopen_package
luaopen_string
luaopen_table
luaopen_utf8
luaopen_bit32
luaopen_xlua # xLua核心
luaopen_i64lib # 64位整数支持
luaopen_rapidjson # JSON解析
luaopen_socket_core # LuaSocket
luaopen_mime_core # MIME支持
这一步非常关键 ------它决定了你编译时需要包含哪些第三方库。如果原版有luaopen_rapidjson而你的编译版没有,运行时require("rapidjson")就会失败。
2.5 确认编译器和架构
bash
# 查看ELF头信息
readelf -h libxlua.so
# Class: ELF64, Machine: AArch64 → arm64-v8a
# 查看编译信息(如果有.comment段)
readelf -p .comment libxlua.so
# 输出示例: Android clang version 9.0.9 (based on LLVM 9.0.9)
# → 对应NDK r21e
# 查看文件大小,作为编译产物的参考基准
ls -la libxlua.so
# 示例: 8,867,424 bytes
2.6 检查特殊修改
有些游戏会对xLua做自定义修改。通过反编译关键函数可以发现:
bash
# 用objdump反汇编,搜索关键函数
aarch64-linux-gnu-objdump -d libxlua.so > disasm.txt
# 在IDA中打开,查看push_null函数的实现
# 如果调用的是lua_pushnil → 自定义修改过
# 如果调用的是lua_pushlightuserdata → 标准lua-rapidjson v0.7.x
三、源码获取与编译环境搭建
3.1 下载xLua源码
根据版本识别结果,下载对应的源码:
bash
# 主仓库(包含完整构建系统和常用扩展)
git clone https://github.com/chexiongsheng/build_xlua_with_libs.git build_xlua
cd build_xlua
# 或者分别下载各组件
git clone https://github.com/Tencent/xLua.git # xLua核心
git clone https://github.com/xpol/lua-rapidjson.git # JSON绑定
git clone https://github.com/lunarmodules/luasocket.git # 网络库
关键组件版本匹配:
| 组件 | 仓库 | 版本选择依据 |
|---|---|---|
| xLua | Tencent/xLua | lib_version=105 → v2.1.15.1 |
| Lua | xLua内置 | strings确认5.3.5 |
| lua-rapidjson | xpol/lua-rapidjson | 反编译push_null确认v0.7.x |
| LuaSocket | lunarmodules/luasocket | 符号表确认存在 |
3.2 编译环境
bash
# WSL Ubuntu-22.04(Windows下推荐)
wsl --install -d Ubuntu-22.04
# 安装基础工具
sudo apt-get update
sudo apt-get install -y build-essential cmake git wget unzip
# 安装NDK(版本需匹配原版编译器)
cd /opt
sudo wget https://dl.google.com/android/repository/android-ndk-r21e-linux-x86_64.zip
sudo unzip android-ndk-r21e-linux-x86_64.zip
# 设置环境变量
echo 'export ANDROID_NDK_HOME=/opt/android-ndk-r21e' >> ~/.bashrc
source ~/.bashrc
NDK版本选择原则 :通过readelf -p .comment查看原版SO的编译器版本,选择对应的NDK。clang 9.0.9对应NDK r21e。
四、编译兼容的libxlua.so
4.1 CMake配置
bash
NDK=/opt/android-ndk-r21e
SRC=~/build_xlua/xLua/build
RJ=~/build_xlua/lua-rapidjson
BUILD=/tmp/build_xlua
cmake -H"$SRC" -B"$BUILD" \
-DCMAKE_TOOLCHAIN_FILE="$NDK/build/cmake/android.toolchain.cmake" \
-DANDROID_ABI=arm64-v8a \
-DANDROID_PLATFORM=android-21 \
-DANDROID_TOOLCHAIN=clang \
-DCMAKE_BUILD_TYPE=Release \
-DTHIRDPART_SRC="$RJ/src/rapidjson.cpp;$RJ/src/Document.cpp;$RJ/src/Schema.cpp;$RJ/src/values.cpp" \
-DTHIRDPART_INC="$RJ/src;$RJ/rapidjson/include"
关键参数说明:
| 参数 | 值 | 原因 |
|---|---|---|
| ANDROID_ABI | arm64-v8a | 必须与原版SO架构一致 |
| ANDROID_PLATFORM | android-21 | 最低API级别,保证兼容性 |
| CMAKE_BUILD_TYPE | Release | -O2优化,与原版一致 |
| ANDROID_STL | c++_static | 静态链接libc++,避免运行时依赖 |
4.2 编译
bash
cmake --build "$BUILD" --config Release -- -j$(nproc)
4.3 链接Android日志库
如果后续要添加日志输出功能,需要在CMakeLists.txt中链接log库:
cmake
if ( ANDROID )
target_link_libraries(xlua log)
# Android 15+ 需要16KB页面对齐
target_link_options(xlua PRIVATE -Wl,-z,max-page-size=16384 -Wl,-z,common-page-size=16384)
endif ( )
五、二进制对比验证
编译完成后,必须与原版SO进行对比验证,确保二进制兼容。
5.1 符号表对比
bash
# 导出两个SO的动态符号
readelf --dyn-syms original.so | awk '{print $8}' | sort > orig_syms.txt
readelf --dyn-syms compiled.so | awk '{print $8}' | sort > new_syms.txt
# 对比差异
diff orig_syms.txt new_syms.txt
要求 :所有luaopen_*符号和xlua_*符号必须完全一致。
5.2 全量反汇编对比(高级)
bash
# 反汇编两个SO
aarch64-linux-gnu-objdump -d original.so > orig_disasm.txt
aarch64-linux-gnu-objdump -d compiled.so > new_disasm.txt
# 逐函数对比指令序列(需要脚本辅助)
# 重点关注:
# 1. 函数数量是否一致
# 2. 每个函数的指令数是否一致
# 3. 调用的PLT函数是否一致
实际对比结果示例(3875个共有函数):
| 分类 | 数量 | 说明 |
|---|---|---|
| 完全相同 | 325 | 指令序列完全一致 |
| 仅地址差异 | ~3536 | 指令相同,仅重定位地址不同(正常) |
| 结构性差异 | 14 | 需要逐一分析原因 |
5.3 luaconf.h配置确认
c
/* 必须确认此宏未定义! */
/* #define LUA_COMPAT_FLOATSTRING */
启用此宏会导致浮点整数转字符串时丢失.0后缀(如1001.0→"1001"),影响配置表键匹配。
六、注入Lua脚本导出与热替换机制
这是整个方案的核心------修改Lua加载函数,实现脚本的自动导出和运行时替换。
6.1 修改位置选择
根据xLua版本不同,修改位置有两种:
| xLua版本 | 修改文件 | 修改函数 |
|---|---|---|
| 较新版本(使用标准Lua API) | lauxlib.c |
luaL_loadbufferx |
| 较旧版本(使用xLua封装) | xlua.c |
xluaL_loadbuffer |
判断方法 :在原版SO中搜索哪个函数被C#层调用。如果符号表中有xluaL_loadbuffer,说明C#层调用的是xLua封装版本。
6.2 完整实现代码
以修改lauxlib.c为例(适用于Lua 5.3.x):
c
/* ===== 文件头部添加 ===== */
#ifdef __ANDROID__
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
#include <libgen.h>
#include <android/log.h>
#define LOG_TAG "XLUA_MOD"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
/* 项目标识(用于区分不同项目的目录) */
#define PROJECT_NAME "myproject"
#define DUMP_DIR "/data/local/tmp/lua_dump/" PROJECT_NAME
#define OVERRIDE_DIR "/data/local/tmp/lua_override/" PROJECT_NAME
/* 递归创建目录 */
static void mkdirs(const char *path) {
char tmp[1024];
char *p = NULL;
size_t len;
snprintf(tmp, sizeof(tmp), "%s", path);
len = strlen(tmp);
if (tmp[len - 1] == '/') tmp[len - 1] = 0;
for (p = tmp + 1; *p; p++) {
if (*p == '/') {
*p = 0;
mkdir(tmp, 0755);
*p = '/';
}
}
mkdir(tmp, 0755);
}
/* 导出Lua脚本到文件系统 */
static void dump_lua_script(const char *name, const char *buff, int size) {
char filepath[1024];
char dirpath[1024];
const char *p;
FILE *fp;
/* 跳过@前缀(Lua chunk name的标准格式) */
p = (name && name[0] == '@') ? name + 1 : name;
if (!p) return;
snprintf(filepath, sizeof(filepath), "%s/%s", DUMP_DIR, p);
snprintf(dirpath, sizeof(dirpath), "%s", filepath);
mkdirs(dirname(dirpath));
fp = fopen(filepath, "wb");
if (fp) {
fwrite(buff, 1, size, fp);
fclose(fp);
LOGI("DUMP: %s (%d bytes)", filepath, size);
}
}
/* 尝试从替换目录加载脚本 */
static int try_load_override(const char *name, const char **new_buff, int *new_size) {
char filepath[1024];
const char *p;
FILE *fp;
long file_size;
p = (name && name[0] == '@') ? name + 1 : name;
if (!p) return 0;
snprintf(filepath, sizeof(filepath), "%s/%s", OVERRIDE_DIR, p);
/* 快速检查文件是否存在 */
if (access(filepath, R_OK) != 0) {
return 0;
}
fp = fopen(filepath, "rb");
if (!fp) return 0;
fseek(fp, 0, SEEK_END);
file_size = ftell(fp);
fseek(fp, 0, SEEK_SET);
if (file_size <= 0) {
fclose(fp);
return 0;
}
char *buffer = (char *)malloc(file_size);
if (!buffer) {
fclose(fp);
return 0;
}
size_t read_size = fread(buffer, 1, file_size, fp);
fclose(fp);
if (read_size != (size_t)file_size) {
free(buffer);
return 0;
}
*new_buff = buffer;
*new_size = (int)file_size;
LOGI("OVERRIDE: %s (%d bytes)", filepath, (int)file_size);
return 1;
}
#endif /* __ANDROID__ */
6.3 修改luaL_loadbufferx
c
LUALIB_API int luaL_loadbufferx (lua_State *L, const char *buff, size_t size,
const char *name, const char *mode) {
#ifdef __ANDROID__
const char *override_buff = NULL;
int override_size = 0;
int result;
/* 步骤1:导出原始脚本明文 */
dump_lua_script(name, buff, (int)size);
/* 步骤2:检查是否有替换脚本 */
if (try_load_override(name, &override_buff, &override_size)) {
LoadS ls;
ls.s = override_buff;
ls.size = override_size;
result = lua_load(L, getS, &ls, name, mode);
free((void *)override_buff); /* 加载后立即释放 */
return result;
}
#endif
/* 步骤3:无替换则加载原始脚本 */
LoadS ls;
ls.s = buff;
ls.size = size;
return lua_load(L, getS, &ls, name, mode);
}
6.4 工作原理
应用启动 → C#层调用xluaL_loadbuffer加载Lua脚本
↓
luaL_loadbufferx 被调用
↓
┌───────────────────────────────────┐
│ dump_lua_script() │
│ 将明文源码写入 lua_dump/ 目录 │
└───────────────────────────────────┘
↓
┌───────────────────────────────────┐
│ try_load_override() │
│ 检查 lua_override/ 是否有同名文件 │
│ 有 → 加载替换版本 │
│ 无 → 加载原始版本 │
└───────────────────────────────────┘
↓
lua_load() 编译并压栈
关键设计点:
- 导出的buffer是解密后的明文 ------无论AssetBundle有多少层加密,到达
luaL_loadbufferx时已经是明文 - 替换匹配基于脚本路径名(去掉
@前缀),精确匹配 - 替换脚本加载后立即
free,无内存泄漏 - 性能影响极小(
access()系统调用,微秒级)
七、部署与使用
7.1 推送SO到设备
powershell
# 推送编译产物
adb push libxlua.so /data/local/tmp/libxlua.so
# 备份原始SO
adb shell "su -c 'cp /data/app/<package>/lib/arm64/libxlua.so /data/local/tmp/libxlua_backup.so'"
# 替换(需root)
adb shell "su -c 'cp /data/local/tmp/libxlua.so /data/app/<package>/lib/arm64/libxlua.so'"
adb shell "su -c 'chmod 755 /data/app/<package>/lib/arm64/libxlua.so'"
7.2 导出脚本
powershell
# 启动应用,所有Lua脚本自动导出
adb shell "am start -n <package>/<activity>"
# 查看导出日志
adb logcat -s XLUA_MOD
# 拉取导出的脚本到本地
adb pull /data/local/tmp/lua_dump/myproject/ ./lua_scripts/
7.3 热替换脚本
powershell
# 修改脚本后,推送到替换目录(路径必须与导出路径一致)
adb push ./modified_script.lua /data/local/tmp/lua_override/myproject/path/to/script.lua
# 重启应用生效
adb shell "am force-stop <package>"
adb shell "am start -n <package>/<activity>"
# 确认替换成功(日志中应出现OVERRIDE记录)
adb logcat -s XLUA_MOD
7.4 恢复原始状态
powershell
# 恢复原始SO
adb shell "su -c 'cp /data/local/tmp/libxlua_backup.so /data/app/<package>/lib/arm64/libxlua.so'"
# 或者清空替换目录(保留导出功能)
adb shell "su -c 'rm -rf /data/local/tmp/lua_override/myproject/*'"
八、Lua脚本修改方法论
导出脚本后,如何高效地定位和修改目标功能?以下是经过实战验证的方法论。
8.1 核心原则:只改校验,不改数据
✗ 错误:修改数据结构/伪造服务器协议
✓ 正确:修改校验函数的返回值
✗ 错误:使用不存在的ID(如id=99999)
✓ 正确:使用配置表中真实存在的ID(如id=1)
8.2 三种修改模式
模式A:直接返回true(适用于纯校验函数)
lua
-- 场景:函数只做"是否允许"的判断,无副作用
-- 原始
function Manager:IsFeatureUnlocked(id)
return self:GetUnlockLevel(id) >= requiredLevel
end
-- 修改
function Manager:IsFeatureUnlocked(id)
return true
end
模式B:替换默认值(适用于返回ID/状态的函数)
lua
-- 场景:函数返回的ID会被后续代码用于查表
-- 关键:替换值必须是数据表中真实存在的ID
-- 原始
function Manager:GetItemId(entityId)
local info = self:GetEntityInfo(entityId)
if info then
return info.itemId
else
return 0 -- 0表示"无",后续代码会跳过
end
end
-- 修改
function Manager:GetItemId(entityId)
local info = self:GetEntityInfo(entityId)
if info then
return info.itemId
else
return 1 -- 使用配置表中真实存在的ID,避免后续GetEntity(0)返回nil
end
end
模式C:补充伪造数据(适用于列表/集合类数据)
lua
-- 场景:UI遍历列表构建界面,不在列表中的项目完全不可见
-- 关键:用pcall保护,伪造数据的字段必须完整
function Manager:OnDataSync(serverData)
self.dataList = serverData.items -- 保留服务器真实数据
-- 补充缺失的条目
local ok, allConfig = pcall(function()
return ConfigManager:GetAllConfigList()
end)
if ok and allConfig then
for _, cfg in ipairs(allConfig) do
local found = false
for _, item in ipairs(self.dataList) do
if item.id == cfg.id then
found = true
break
end
end
if not found then
table.insert(self.dataList, {
id = cfg.id,
level = 1, -- 最低有效等级
exp = 0,
unlocked = {} -- 空列表,后续校验已绕过
})
end
end
end
end
8.3 防崩溃要点
| 风险点 | 防护措施 |
|---|---|
| 伪造ID不存在于配置表 | 先确认配置表中该ID存在 |
| 配置表未初始化时调用 | 用pcall包裹 |
| 配置数据为nil | 增加and前置判断 |
| 服务器数据被覆盖 | 伪造数据追加在服务器数据之后 |
8.4 定位目标函数的技巧
bash
# 在导出的脚本中搜索关键词
grep -r "IsUnlock\|IsActive\|CanEnter" ./lua_scripts/
# 搜索UI入口(按钮点击回调)
grep -r "OnClick\|OnBtn\|AddListener" ./lua_scripts/
# 搜索服务器数据同步点
grep -r "OnSync\|OnRecv\|OnResponse" ./lua_scripts/
九、踩坑记录:lua-rapidjson的JSON null问题
9.1 问题现象
替换编译版SO后,应用部分功能异常:某些UI列表不显示、配置读取失败。
9.2 根因
lua-rapidjson v0.7.x有一个breaking change :JSON null的Lua表示方式从nil改为了lightuserdata哨兵值。
| 原版SO(自定义修改) | 标准lua-rapidjson v0.7.x | |
|---|---|---|
push_null() |
lua_pushnil(L) |
lua_pushlightuserdata(L, &null) |
| JSON null解码后 | Lua nil | Lua lightuserdata |
脚本中if v == nil |
正确匹配 | 不匹配! |
9.3 修复
cpp
// rapidjson.cpp - 修改push_null
namespace values {
int push_null(lua_State* L) {
lua_pushnil(L); // 而非 lua_pushlightuserdata
return 1;
}
}
// values.hpp - 修改isnull
inline bool isnull(lua_State* L, int idx) {
return lua_isnil(L, idx) != 0; // 而非比较lightuserdata
}
9.4 发现过程
- objdump全量反汇编原版和编译版(20万+行)
- 逐函数对比指令序列(3875个函数)
- 筛选出指令数量不同的函数(仅14个)
- 发现
push_null:原版调用lua_pushnil@plt,编译版调用lua_pushlightuserdata@plt - 确认是lua-rapidjson版本差异导致的breaking change
教训:编译第三方库时,不能只看大版本号,必须通过反汇编对比确认关键函数的实际行为。
十、总结
技术要点回顾
| 阶段 | 关键技术 | 工具 |
|---|---|---|
| 版本识别 | strings/readelf/IDA | binutils, IDA Pro |
| 源码编译 | NDK交叉编译, CMake | NDK r21e, CMake |
| 二进制验证 | objdump全量反汇编对比 | objdump, diff |
| Hook注入 | 修改luaL_loadbufferx | C, Android NDK |
| 脚本修改 | 校验函数绕过 | Lua |
适用条件
- 目标应用使用Unity + xLua架构
- 设备已root(或使用模拟器)
- Lua脚本以明文形式传入
luaL_loadbufferx(即C#层已完成解密)
项目仓库
本文涉及的两个实战项目完整代码和编译脚本:
| 项目 | 环境 | 仓库地址 |
|---|---|---|
| 项目A(真机,修改lauxlib.c) | K40 / Magisk Root | https://gitee.com/lxt_henan/qianjiao-reverse |
| 项目B(模拟器,修改xlua.c) | 雷电模拟器 / Root | https://github.com/boring-brother/girlwar-xlua-reverse |
相关开源项目
| 项目 | 地址 |
|---|---|
| xLua | https://github.com/Tencent/xLua |
| build_xlua_with_libs | https://github.com/chexiongsheng/build_xlua_with_libs |
| lua-rapidjson | https://github.com/xpol/lua-rapidjson |
| LuaSocket | https://github.com/lunarmodules/luasocket |
| Lua 5.3.5 | https://www.lua.org/ftp/lua-5.3.5.tar.gz |
本文仅用于技术学习和安全研究交流,请勿将相关技术用于任何违法违规用途。