Unity游戏xLua逆向实战:从SO版本识别到Lua脚本热替换全链路

Unity游戏xLua逆向实战:从SO版本识别到Lua脚本热替换全链路

本文以两款Unity IL2CPP手游为实战案例,记录一套完整的逆向方法论:通过逆向分析libxlua.so确定xLua版本,下载对应源码编译出兼容的SO,注入Lua脚本导出与热替换机制,最终实现运行时脚本修改。全文聚焦技术原理与代码实现。

  • 项目A(真机):xLua lib_version=105,Lua 5.3.5,修改lauxlib.cluaL_loadbufferx
  • 项目B(模拟器):xLua lib_version=105,Lua 5.3.5,修改xlua.cxluaL_loadbuffer

目录

  1. 整体思路
  2. SO逆向:识别xLua版本
  3. 源码获取与编译环境搭建
  4. 编译兼容的libxlua.so
  5. 二进制对比验证
  6. 注入Lua脚本导出与热替换机制
  7. 部署与使用
  8. Lua脚本修改方法论
  9. [踩坑记录:lua-rapidjson的JSON null问题](#踩坑记录:lua-rapidjson的JSON null问题)
  10. 总结

一、整体思路

复制代码
┌─────────────────────────────────────────────────────────────┐
│                      完整技术链路                             │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  ① 提取目标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 发现过程

  1. objdump全量反汇编原版和编译版(20万+行)
  2. 逐函数对比指令序列(3875个函数)
  3. 筛选出指令数量不同的函数(仅14个)
  4. 发现push_null:原版调用lua_pushnil@plt,编译版调用lua_pushlightuserdata@plt
  5. 确认是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

本文仅用于技术学习和安全研究交流,请勿将相关技术用于任何违法违规用途。

相关推荐
mxwin3 小时前
Unity Shader 水面 Shader 教程 从 Gerstner 波浪到屏幕折射、焦散、泡沫的完整实现
unity·游戏引擎
噗噗夹的TA之旅3 小时前
Shader 学习 21:自定义 Render Feature
学习·游戏·unity·c#·游戏引擎·图形渲染
网络研究院4 小时前
一款利用人工智能将电子游戏翻译成任何语言的桌面应用程序
人工智能·游戏·工具·平台·翻译·软件
程序员-珍8 小时前
力扣 877. 石子游戏
算法·leetcode·游戏
zcmodeltech8 小时前
化工装置沙盘模型多设备协同控制系统设计:基于STM32与Modbus RTU的装置-流程-安全联动方案
大数据·stm32·嵌入式硬件·安全·unity·制造
k4m7v2pz10 小时前
R36S 掌机游戏迁移实录:EmuELEC 双卡整理,只留 FC/SFC/NES/SNES
游戏·rom·r36s·emuelec
暗影凋落1 天前
Unity 射线检测优化:使用 Job System 实现高性能射线批处理
unity·游戏引擎·lucene
淡海水1 天前
12-01-YooAsset工程化-Unity资源管理规范
unity·c#·游戏引擎·yooasset
BuHuaX1 天前
U3D抖微小游戏开发流程 (一)
unity·c#·游戏引擎·游戏策划