Dump Lua

你要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才发展多久啊

相关推荐
我材不敲代码2 小时前
简单聊聊 Python 字典的基础用法
开发语言·python
月诸清酒2 小时前
AI 加剧了 Rust 替换前端基建的脚步:AI 时代,开发语言何去何从
开发语言·人工智能·rust
Cx330❀2 小时前
【Linux网络】从以太网碰撞到 Socket 套接字与网络字节序的深度解析
xml·linux·运维·服务器·开发语言·网络·c++
我滴老baby2 小时前
Agent上线后不知道效果好不好?用Python搭建A/B测试+效果评估平台完整实战
开发语言·人工智能·python·ab测试
基哥的奋斗历程2 小时前
Maven install Java.lang.StackOverflowError
java·开发语言·maven
threelab2 小时前
Three.js 几何体类型效果 | 三维可视化 / AI 提示词
开发语言·javascript·人工智能
周杰伦fans2 小时前
不支持目标框架: C#项目面向不再受支持的.NET Framework4.6.2
开发语言·c#·.net
один but you2 小时前
++ 后端面试核心:Lambda / 仿函数 /function/bind 深度解析
java·开发语言
richard_yuu3 小时前
C#零基础通关第六篇:吃透静态、常量与只读,分清静态与实例的本质差异
开发语言·c#