你要dump Lua你得先确定,该游戏是否是Cocos2dxLua开发的。
bash
pm path com.bf.sgs.hdexp
adb pull base.apk /tmp/com.bf.sgs.hdexp.apk
zipinfo -1 /tmp/com.bf.sgs.hdexp.apk | rg -i 'lua|luac|cocos|script|src'
strings -a classes.dex | rg -i 'Cocos2dxLua|LuaJavaBridge|AppActivity'
strings -a libsgs.so/libgame.so | rg -i 'lua|LuaEngine|LuaStack|tolua|xxtea'
接着查看一下包名的热更新目录。
bash
redfin:/data/data/com.bf.sgs.hdexp/files/lua_dump # cd /data/user/0/com.bf.sgs.hdexp/files/upd/hotfix2/
redfin:/data/user/0/com.bf.sgs.hdexp/files/upd/hotfix2 # ls
framework.lib framework.lib32 game_diff.lib game_diff.lib32
发现两个壳,个人感觉意义不大。因为如果要查看这两个壳就需要IDA 查看libsgs.so。我想这样太浪费我的时间。
我直接继续查找资料,发现两个非常有意思的函数。luaL_loadbuffer和luaL_loadbufferx。
这两个函数的作用:把一段内存 buffer 当成 Lua chunk 加载到 Lua 虚拟机里。
那么就意味着 到这个函数的时候lua已经解密出来了!
bash
int luaL_loadbuffer(lua_State *L,
const char *buff,
size_t sz,
const char *name);
# 解释参数
L = Lua 虚拟机状态,也就是 lua_State*
buff = Lua chunk 数据所在的内存地址
sz = Lua chunk 数据大小
name = chunk 名字,主要用于调试信息和报错信息
# JavaScript 语法
var buf = args[1];
var size = args[2].toInt32();
var name = args[3].readCString();
var data = buf.readByteArray(size);
bash
int luaL_loadbufferx(lua_State *L,
const char *buff,
size_t sz,
const char *name,
const char *mode);
#参数解释
L = Lua 虚拟机状态
buff = Lua/LuaJIT chunk 数据地址
sz = buffer 大小
name = chunk 名字
mode = 加载模式
#JavaScript
this.buf = args[1];
this.size = args[2].toInt32();
this.name = args[3].readCString();
this.mode = args[4].readCString();
流程图:

那么现在我也只需要写hook luaL_loadbuffer/luaL_loadbufferx
javascript
'use strict';
/**
* SGS Lua 运行时 dump 脚本
*
* 作用:
* 1. 等待游戏加载 libsgs.so / libgame.so。
* 2. hook luaL_loadbuffer / luaL_loadbufferx。
* 3. 当游戏把 Lua/LuaJIT chunk 送进 Lua 虚拟机前,把内存中的 buffer 保存到文件。
* 4. 顺便 hook XXTEA key/sign 设置函数和 ZIP chunk 加载函数,用来辅助判断脚本包来源。
*
* dump 输出目录:
* /data/user/0/com.bf.sgs.hdexp/files/lua_dump
*
* 注意:
* - 这个脚本不是直接解密 framework.lib/game_diff.lib。
* - 它是在游戏运行时,抓"已经解密/解包后,即将执行"的 Lua buffer。
*/
// 手机上保存 dump 出来的 Lua/LuaJIT chunk 的目录。
const OUT_DIR = '/data/user/0/com.bf.sgs.hdexp/files/lua_dump';
// dump 文件序号。输出文件会变成 00000_xxx.lua / 00001_xxx.luac 这种形式。
let seq = 0;
// 去重表。防止同一个 Lua chunk 被重复保存很多次。
// key 由 size + 前 16 字节 hex 组成,不是绝对唯一,但足够过滤大量重复加载。
const seen = {};
// 标记核心 hook 是否已经安装成功。
let installed = false;
// 标记 dlopen/android_dlopen_ext 是否已经 hook,避免重复 hook loader。
let loaderHooked = false;
// meta.tsv 写入失败时只警告一次,避免刷屏。
let metaWarned = false;
console.log('[lua-dump:init] script loaded pid=' + Process.id);
/**
* 获取导出符号地址。
*
* Frida 17 里不推荐继续用旧版 Module.findExportByName(null, name)。
* 这里封装成兼容当前脚本的写法:
*
* moduleName === null:
* 从所有已加载模块的全局导出符号里找,比如 mkdir、dlopen、android_dlopen_ext。
*
* moduleName !== null:
* 从指定 so 里找,比如 libsgs.so 里的 luaL_loadbuffer。
*/
function moduleExport(moduleName, symbolName) {
try {
if (moduleName === null) return Module.getGlobalExportByName(symbolName);
return Process.getModuleByName(moduleName).findExportByName(symbolName);
} catch (_) {
return null;
}
}
// 找 libc 里的 mkdir,用 NativeFunction 包装出来。
// 用它在手机 App 私有目录里创建 lua_dump 目录。
const mkdirPtr = moduleExport(null, 'mkdir');
const mkdirFn = mkdirPtr ? new NativeFunction(mkdirPtr, 'int', ['pointer', 'int']) : null;
/**
* 递归创建目录。
*
* 例如 path = /data/user/0/com.bf.sgs.hdexp/files/lua_dump
* 会依次 mkdir:
* /data
* /data/user
* /data/user/0
* ...
* /data/user/0/com.bf.sgs.hdexp/files/lua_dump
*
* 448 是十进制权限,对应八进制 0700。
* App 私有目录用 0700 通常够用。
*/
function mkdirp(path) {
if (!mkdirFn) return;
const parts = path.split('/').filter(Boolean);
let cur = '';
for (const part of parts) {
cur += '/' + part;
mkdirFn(Memory.allocUtf8String(cur), 448);
}
}
/**
* 安全读取 C 字符串。
*
* ptr:C 字符串指针。
* len:可选长度。如果传了 len,就按指定长度读取;否则读取 \0 结尾字符串。
*
* 用途:
* - 读取 Lua chunk name。
* - 读取 XXTEA key/sign。
* - 读取 dlopen 加载的 so 路径。
*/
function readStr(ptr, len) {
if (ptr.isNull()) return '';
try {
if (len !== undefined && len >= 0) return ptr.readUtf8String(len);
return ptr.readUtf8String();
} catch (_) {
return '';
}
}
/**
* 清洗 chunk 名字,让它能作为文件名或相对路径保存。
*
* Lua 里 chunk name 常见形式:
* @src/main.lua
* =chunk
* /data/user/0/com.xxx/files/upd/xxx.lua
*
* 这里会做:
* - 去掉 @ 或 = 前缀。
* - 去掉 assets/ 前缀。
* - 去掉 App files 绝对路径前缀。
* - 过滤 ..,避免目录穿越。
* - 替换 Windows/Android 文件名非法字符。
* - 如果没有扩展名,根据 magic 判断补 .lua 或 .luac。
*/
function cleanName(name, bytes) {
let n = name || 'chunk';
if (n[0] === '@' || n[0] === '=') n = n.slice(1);
n = n.replace(/^assets\//, '');
n = n.replace(/^\/data\/user\/0\/com\.bf\.sgs\.hdexp\/files\//, '');
n = n.replace(/^\/data\/data\/com\.bf\.sgs\.hdexp\/files\//, '');
n = n.replace(/\\/g, '/');
n = n.replace(/\.\./g, '__');
n = n.replace(/[:*?"<>|]/g, '_');
n = n.replace(/^\/+/, '');
if (n.length === 0) n = 'chunk';
if (!/\.(lua|luac|txt|bin)$/i.test(n)) {
n += extFor(bytes);
}
return n;
}
/**
* 判断 buffer 是否是 Lua 字节码。
*
* 标准 Lua bytecode magic:
* 1B 4C 75 61,也就是 \x1bLua
*
* LuaJIT bytecode magic:
* 1B 4C 4A,也就是 \x1bLJ
*/
function isLuaBytecode(bytes) {
if (bytes.length >= 4 && bytes[0] === 0x1b && bytes[1] === 0x4c && bytes[2] === 0x75 && bytes[3] === 0x61) return true;
return bytes.length >= 3 && bytes[0] === 0x1b && bytes[1] === 0x4c && bytes[2] === 0x4a;
}
/**
* 根据 buffer 类型决定扩展名。
*
* 如果是 Lua/LuaJIT 字节码,用 .luac。
* 否则按文本 Lua 保存为 .lua。
*/
function extFor(bytes) {
return isLuaBytecode(bytes) ? '.luac' : '.lua';
}
/**
* 生成用于日志显示的短名字。
*
* 防止 chunk name 很长,或者里面带换行导致日志乱掉。
*/
function shortDisplayName(name) {
let n = name || '';
n = n.replace(/\r/g, '\\r').replace(/\n/g, '\\n');
if (n.length > 120) n = n.slice(0, 120) + '...';
return n;
}
/**
* 判断传入的 name 像不像一个正常 chunk 名字。
*
* 为什么要判断?
* 因为有些游戏调用 luaL_loadbuffer 时,args[3] 不一定是干净文件名,
* 可能是奇怪字符串,甚至可能误读成一段源码文本。
*
* 认为"像正常名字"的情况:
* - 有路径分隔符 / 或 \\
* - 以 .lua/.luac/.txt/.bin 结尾
* - 像 module.name 这样的模块名
* - 像普通标识符,比如 MainScene
*/
function looksLikeChunkName(name) {
if (!name) return false;
let n = name;
if (n[0] === '@' || n[0] === '=') n = n.slice(1);
if (n.length === 0 || n.length > 180) return false;
if (/[\r\n]/.test(n)) return false;
if (/\b(function|local|return|module|require)\b/.test(n)) return false;
if (/[\/\\]/.test(n) || /\.(lua|luac|txt|bin)$/i.test(n)) return true;
if (/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)+$/.test(n)) return true;
return /^[A-Za-z_][A-Za-z0-9_]{0,79}$/.test(n);
}
/**
* 把 chunk name 变成扁平化安全文件名。
*
* 原本 cleanName 可能保留路径层级,比如 a/b/c.lua。
* 这里会把 / 全部替换成 __,避免创建很多层目录,也避免路径问题。
*
* 例如:
* src/app/main.lua -> src__app__main.lua
*/
function flatSafeName(name, bytes) {
let n = looksLikeChunkName(name) ? cleanName(name, bytes) : 'chunk' + extFor(bytes);
n = n.replace(/\\/g, '/').replace(/^\/+/, '');
n = n.replace(/\//g, '__');
n = n.replace(/[^A-Za-z0-9._-]/g, '_');
n = n.replace(/_+/g, '_');
if (n.length === 0) n = 'chunk' + extFor(bytes);
if (n.length > 120) {
const ext = /\.(lua|luac|txt|bin)$/i.exec(n);
const suffix = ext ? ext[0] : extFor(bytes);
n = n.slice(0, 120 - suffix.length) + suffix;
}
return n;
}
/**
* 清洗 meta.tsv 字段。
*
* meta.tsv 是一个制表符分隔文件:
* dump文件名 \t size \t 原始chunk名
*
* 所以字段里不能直接出现 tab、回车、换行。
*/
function metaField(value) {
return String(value || '').replace(/\t/g, '\\t').replace(/\r/g, '\\r').replace(/\n/g, '\\n');
}
/**
* 追加一行元信息到 meta.tsv。
*
* 方便后续知道每个 dump 文件原始对应的 chunk name 是什么。
*/
function appendMeta(outName, size, name) {
try {
const storedName = looksLikeChunkName(name) ? name : shortDisplayName(name);
const f = new File(OUT_DIR + '/meta.tsv', 'a');
f.write(outName + '\t' + size + '\t' + metaField(storedName) + '\n');
f.flush();
f.close();
} catch (e) {
if (!metaWarned) {
metaWarned = true;
console.log('[lua-dump:meta-error] ' + e);
}
}
}
/**
* 取 buffer 前 16 个字节,转成 hex 字符串。
*
* 用于去重 key。
*/
function hex4(bytes) {
const n = Math.min(bytes.length, 16);
let s = '';
for (let i = 0; i < n; i++) s += ('0' + bytes[i].toString(16)).slice(-2);
return s;
}
/**
* 真正执行 dump 的函数。
*
* 参数:
* - name:Lua chunk 名字,来自 luaL_loadbuffer 的第 4 个参数。
* - buf:Lua 数据地址,来自 luaL_loadbuffer 的第 2 个参数。
* - size:Lua 数据大小,来自 luaL_loadbuffer 的第 3 个参数。
*
* 主要步骤:
* 1. 检查 size 和 buf 是否合理。
* 2. 从目标进程内存读取 size 字节。
* 3. 用 size + 前 16 字节做去重。
* 4. 生成安全文件名。
* 5. 写入 OUT_DIR。
* 6. 写 meta.tsv 记录映射关系。
*/
function dumpChunk(name, buf, size) {
if (size <= 0 || size > 64 * 1024 * 1024 || buf.isNull()) return;
// 从目标进程内存中读取 Lua buffer。
const data = buf.readByteArray(size);
if (data === null) return;
// 转成 Uint8Array,方便判断 magic 和计算 hex。
const bytes = new Uint8Array(data);
// 简单去重:同样大小 + 同样前 16 字节,认为是重复 chunk。
const key = String(size) + ':' + hex4(bytes);
if (seen[key]) return;
seen[key] = true;
// 生成文件名,例如:00000_main.luac。
const safeName = flatSafeName(name, bytes);
const outName = String(seq++).padStart(5, '0') + '_' + safeName;
const outPath = OUT_DIR + '/' + outName;
try {
const f = new File(outPath, 'wb');
f.write(data);
f.flush();
f.close();
// 写入元信息,方便后续反查原始 chunk 名。
appendMeta(outName, size, name || '');
// 前 30 个全部打印,后面每 500 个打印一次,避免刷屏太严重。
if (seq <= 30 || seq % 500 === 0) {
console.log('[lua-dump] ' + outName + ' size=' + size + ' name=' + shortDisplayName(name || ''));
}
} catch (e) {
console.log('[lua-dump:error] seq=' + seq + ' size=' + size + ' name=' + shortDisplayName(name || '') + ' ' + e);
}
}
/**
* 在指定 so 的符号表里搜索包含某个字符串的符号。
*
* 用途:
* - 有些 C++ 符号是 mangled name,不一定能直接精确导出。
* - 所以用 contains 模糊匹配,比如找 LuaStack18setXXTEAKeyAndSign。
*/
function findSymbol(moduleName, contains) {
let mod = null;
try {
mod = Process.getModuleByName(moduleName);
} catch (_) {
return null;
}
const symbols = mod.enumerateSymbols();
for (const s of symbols) {
if (s.name.indexOf(contains) !== -1) return s.address;
}
return null;
}
/**
* hook luaL_loadbuffer / luaL_loadbufferx。
*
* 两个函数前 4 个参数兼容:
* args[0] = lua_State *L
* args[1] = const char *buff
* args[2] = size_t sz
* args[3] = const char *name
*
* luaL_loadbufferx 还有:
* args[4] = const char *mode
*
* 这里不关心 mode,只 dump buff + size。
*
* 为什么在 onLeave dump?
* - onEnter 也可以 dump。
* - onLeave 的好处是函数已经处理过这块 buffer,至少说明这次调用走到了返回点。
* - 但如果某些游戏在函数内部修改/释放 buffer,onEnter 更稳。
* - 当前脚本选择 onLeave,一般够用。
*/
function hookLoadBuffer(addr, label) {
if (!addr) return false;
Interceptor.attach(addr, {
onEnter(args) {
this.buf = args[1];
this.size = args[2].toInt32();
this.name = readStr(args[3]);
},
onLeave(_) {
dumpChunk(this.name || label, this.buf, this.size);
}
});
console.log('[hook] ' + label + ' @ ' + addr);
return true;
}
/**
* hook XXTEA key/sign 设置函数。
*
* 作用:
* - 打印游戏设置的脚本解密 key 和 sign。
* - 这对静态解包 framework.lib/game_diff.lib 有帮助。
*
* 注意:
* - 这里不负责 dump Lua。
* - 它只是辅助记录 key/sign。
*/
function hookSetKey(addr, label, fileUtilsStyle) {
if (!addr) return false;
Interceptor.attach(addr, {
onEnter(args) {
const keyPtr = args[1];
const keyLen = args[2].toInt32();
const signPtr = args[3];
const signLen = args[4].toInt32();
console.log('[key] ' + label + ' key="' + readStr(keyPtr, keyLen) + '" sign="' + readStr(signPtr, signLen) + '"' +
(fileUtilsStyle ? ' offset=' + args[5].toString() : ''));
}
});
console.log('[hook] ' + label + ' @ ' + addr);
return true;
}
/**
* hook Cocos Lua 的 ZIP chunk 加载函数。
*
* 作用:
* - 打印它加载哪个脚本包。
* - 例如 framework.lib / game_diff.lib / 某个 hotfix 包。
*
* 这也是辅助定位,不是最终 dump 点。
*/
function hookZipLoader(addr, label) {
if (!addr) return false;
Interceptor.attach(addr, {
onEnter(args) {
console.log('[zip] ' + label + ' ' + readStr(args[1]));
}
});
console.log('[hook] ' + label + ' @ ' + addr);
return true;
}
/**
* 安装所有核心 hook。
*
* 逻辑:
* 1. 如果已经 installed,直接返回。
* 2. 先确认 libsgs.so 已加载。
* 3. 创建 OUT_DIR。
* 4. hook libsgs.so/libgame.so 里的 luaL_loadbuffer/luaL_loadbufferx。
* 5. hook XXTEA key/sign。
* 6. hook loadChunksFromZIPNew/loadChunksFromZIP。
*/
function installHooks() {
if (installed) return true;
// libsgs.so 是关键 so。它没加载时,Lua 相关符号一般还找不到。
try {
Process.getModuleByName('libsgs.so');
} catch (_) {
return false;
}
// 确保输出目录存在。
mkdirp(OUT_DIR);
let count = 0;
// 主要 dump 点:libsgs.so 里的 luaL_loadbuffer/luaL_loadbufferx。
count += hookLoadBuffer(moduleExport('libsgs.so', 'luaL_loadbuffer') || findSymbol('libsgs.so', 'luaL_loadbuffer'), 'luaL_loadbuffer') ? 1 : 0;
count += hookLoadBuffer(moduleExport('libsgs.so', 'luaL_loadbufferx') || findSymbol('libsgs.so', 'luaL_loadbufferx'), 'luaL_loadbufferx') ? 1 : 0;
// 备用 dump 点:libgame.so 里也可能静态带 Lua loader。
count += hookLoadBuffer(moduleExport('libgame.so', 'luaL_loadbuffer') || findSymbol('libgame.so', 'luaL_loadbuffer'), 'libgame_luaL_loadbuffer') ? 1 : 0;
count += hookLoadBuffer(moduleExport('libgame.so', 'luaL_loadbufferx') || findSymbol('libgame.so', 'luaL_loadbufferx'), 'libgame_luaL_loadbufferx') ? 1 : 0;
// 辅助点:抓脚本加密 key/sign。
count += hookSetKey(findSymbol('libsgs.so', 'LuaStack18setXXTEAKeyAndSign'), 'LuaStack::setXXTEAKeyAndSign', false) ? 1 : 0;
count += hookSetKey(findSymbol('libsgs.so', 'FileUtils19setSecretKeyAndSign'), 'FileUtils::setSecretKeyAndSign', true) ? 1 : 0;
// 辅助点:观察脚本包加载路径。
count += hookZipLoader(findSymbol('libsgs.so', 'loadChunksFromZIPNew'), 'LuaStack::loadChunksFromZIPNew') ? 1 : 0;
count += hookZipLoader(findSymbol('libsgs.so', 'loadChunksFromZIPEPKc'), 'LuaStack::loadChunksFromZIP') ? 1 : 0;
installed = count > 0;
if (installed) console.log('[ready] hooks=' + count + ' out=' + OUT_DIR);
return installed;
}
/**
* hook so 加载器。
*
* 为什么需要 hook android_dlopen_ext / dlopen?
* 因为 Frida 脚本注入时,libsgs.so 可能还没加载。
* 如果直接找 luaL_loadbuffer,会找不到。
*
* 所以这里先 hook loader:
* - 一旦发现 libsgs.so 被加载
* - 就立刻调用 installHooks()
*/
function hookLoader() {
if (loaderHooked) return;
loaderHooked = true;
['android_dlopen_ext', 'dlopen'].forEach(function (name) {
const p = moduleExport(null, name);
if (!p) return;
Interceptor.attach(p, {
onEnter(args) {
this.path = readStr(args[0]);
},
onLeave(_) {
if (this.path && this.path.indexOf('libsgs.so') !== -1) {
console.log('[loader] ' + name + ' loaded ' + this.path);
installHooks();
}
}
});
console.log('[hook] loader ' + name + ' @ ' + p);
});
}
/**
* 周期性尝试安装 hook。
*
* 双保险:
* 1. hookLoader():等 dlopen 加载 libsgs.so 后安装。
* 2. installHooks():每 100ms 主动试一次,如果 libsgs.so 已经在了,就安装。
*
* 安装成功后 clearInterval(timer),停止轮询。
*/
function tickInstall() {
try {
hookLoader();
if (installHooks()) clearInterval(timer);
} catch (e) {
console.log('[lua-dump:init-error] ' + e.stack || e);
}
}
// Frida 脚本加载后立刻尝试一次。
setImmediate(tickInstall);
// 每 100ms 尝试一次,直到 hook 安装成功。
const timer = setInterval(tickInstall, 100);
其实我突然写文章是因为我有些焦虑,因为这所有的思路。dump lua+解密都是ai做的。而且ai只花了半个小时不到,你可能觉得人,根本不需要半个小时。我直接在网络上找一篇js脚本就能解决。可是ai才发展多久啊