对一手游的自定义 luajit 字节码的研究
前言
最近闲下来之后无聊研究起了一个unity手游 大量使用了 lua (或者说就是 lua 写的 ) 看到网上已有的一些针对方案 都觉得太不方便 于是深入研究了一下 他自定义的 luajit
情况研究
首先 这是一个 unity的 传统手游 这里就跳过较为前期的部分
像是 libtersafe . libbugly . libcri_ware 这些都是老熟人了 都跳过
unity 的 lua 通信方案
对于 unity游戏来说 特别是商业手游 热更新几乎是必须的
由此诞生很多方案 这里简单介绍几个重点
hybridclr
-
- c# 原生热更新
- xlua
- 代表新兴 lua 框架
- 有详细的文档 !
- tolua
- 代表老一代 lua 框架
luac 与 luajit 的关系
以下来自 gpt
luac 和luajit的字节码一致吗 api呢
准备环境
分析之前 我们现确定一下目标
让使用 luajit 的应用能执行我们提供的 lua 代码
-
luajit 源码
-
库源码
xlua , tolua 等等都是开源的 而且区别主要在和 c#对接的部分 对于我们需要研究的部分 差别不大
-
vs ( 用于分析 c 源码和 c# 源码 )
-
vsc ( 用于分析编写 lua 和 js / ts )
-
python ( 自动化工作流 , frida )
-
node ( 编译 ts )
-
010editor (分析二进制 lua bc)
-
ida ( 分析修改后的luajit )
最好吧 unity 也带上 方便需要问题可以用 unity 实际测试一下
手游分析
app 分析
在 app 中 我们可以直接看到 libxlua.so , libil2cpp.so
frida / frida-il2cpp
直接用 frida 为了方便使用 frida-il2cpp 我们创建一个 node 项目
添加库 并配置 ts 环境
1 2 3 4 5 |
|
添加命令
frida-compile src/index.ts -o dist/_agent.js -c frida -Uf xxx -l dist/_agent.js
( frida js 运行在手机上 运行麻烦 使用 ts 可以避免语法错误 并享受 js 生态 )
在 index.ts 中开始 hook
我们先使用Il2Cpp.perform(()=>{console.log("OK")})
确认il2cpp 能够被正常 hook
然后我们就可以使用 il2cpp 获取由元数据的来的c#代码函数签名信息
const destination = `${Il2Cpp.applicationDataPath}/${dirName}`; for (const assembly of Il2Cpp.Domain.assemblies) { const path = `${destination}/${assembly.name}.cs` const file = new File(path, "w"); for (const klass of assembly.image.classes) { file.write(`${klass}\n\n`); } file.flush(); file.close(); }
这一步其实和文章主题关系不大 这里的手游 c# 层也没有特别的内容
说明在 c#层没有修改内容
接着我们看向 luaEnv 类 这里就有由 lua 框架映射而来的多数 lua基础 api
( 在 so 库中也能看到接口 )
这里我们直接尝试使用 DoString 方法来执行我们提供的 lua 代码
this.AssemblyCSharp = Il2Cpp.Domain.assembly('Assembly-CSharp').image; this.AssemblyCSharp.class("LuaEnv").method("DoString").implementation = function (bytes: Il2Cpp.Array<Il2Cpp.Object>, name: Il2Cpp.String) { if(name !== undefined && name?.content=="@main.lua"){ this.method("DoString").Invoke(Il2Cpp.String.from(`print("我的 lua 代码。。。")`),Il2Cpp.String.from(`@test.lua`)) } }
然而 很神奇的事情发生了 程序直接崩溃了
在反复排除了各种东西之后 不得不打开 ida 分析 so 库
好在lua 框架是讲他自己的代码链接到 luajit 上的 也就是说我们可以对照 luajit 的源码
直接定位到核心的 lua 代码加载函数 lua_loadx -> cpparser
手游的 so 库里面的
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
|
编译的 so 库里面的
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
|
源码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
|
不难发现 他直接少了 t 选项 查阅 lua 官网 可知
lua加载代码分为 b (从字节码加载) t (从文本加载 )
而进一步的分析发现 这个手游直接把 t 模式整个删了(没绷住)
随后进一步的对比分析 发现不仅仅是加载字节码的模式 而是真个字节码都被加密了 下面的章节会详细介绍
已有的破解分析
在网上搜索时 发现了另一种思路
即通过 lua 暴露的 c api 来控制 lua
1 2 3 4 5 |
|
这样也可变相的实现控制逻辑 而且由于 这些暴露的接口对于框架交互来说是必须的 也不用太担心这里会被做手脚 但是这个方案只能进行简单的更改 对于外挂之类的来说可能比较有用
不过这里也提供另类的思路
由于 lua 的特殊性 lua 运行时本身是无状态的 理论上我们可以将 lua_state 直接交给另一个 lua 虚拟机来执行
不过这个方案并不能运用在这里 这里由于是游戏 有大量的网络请求 涉及到协程 lua 会将 协程信息放在 lua 共有的 global 段 中 这样的话 就不是无状态了
另一种思路是 修改一个 lua 虚拟机 将其最终执行的命令记录并转发给我们这里的 lua 虚拟机 得益于 lua 本身的简单 这并非不可能 像 fengari 库 直接在 原始 js 中实现了 lua vm , 如果对他进行一下修改后集成在 frida 中 也许可以实现
原始 luajit bytecode 分析
最后 我们还是老老实实的分析他加密后的 bytecode 不过在分析加密的之前 我们得先搞清楚原始的
自定义 bytecode 分析 与 对应实现
为了更好的分析游戏的 lua bytecode 这里我们需要找一个游戏中有的(加密过后的文件) 同时我们也有源码的 lua 文件(加密前的文件)
这样的文件我们可以去找框架的 lua 代码 让后使用 frida hook loadbuffer 函数 并判断名称 然后 dump 下来
顺带 我们打开一个 python 并编写
这里我们进行超级多开
- ida - 目标游戏的 so 库
- ida - 自编译的 so 库
- vs - luajit 源码
- vsc - python 代码 (编写加解密脚本)
- 010 - 游戏的lua字节码
- 010 - 原始的 lua字节码
我们可知原始 luajit 字节码 的结构
-
GlobalHeader 头部
-
多个 Proto 函数体
-
header 头部
- size
- flags
- arg_count
- framesize
- upvalue_count
- complex_constants_count
- numeric_constants_count
- instructions_count
-
insts 指令
- 4 个字节一组 详见上一张大佬的文章
-
constants 常量
-
upvalue
-
complex
-
CHILD = 0
TAB = 1
- tab 还会进一步细分为键和值 或仅值 (lua特色)
I64 = 2
U64 = 3
COMPLEX = 4
STR = 5 大于 5 的都是字符串 字符串长度为 值-5
-
-
numberic
-
-
-
最后以一个 size 为 0 的 proto 结束
而 luajit 解析这是
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
|
其中 bc 开头的函数都是读取对应部分的函数 重点在于lj_bcread_proto 这个函数
包含了分析 proto 这个重要结构的代码
由于编译器将很多子函数的代码内联了进来 导致这个函数很大 不过不要怕 我们有原程序进行对比 这里就不完整将函数代码贴上来了
第一部分 读取 proto 头部
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
|
不难发现 他进行了异或 将函数头的参数互相异或了 并修改了部分参数的位置
这里我们直接让 gpt 给出逆函数
这样 我们可以先编写proto 的 python
1 2 3 4 5 6 7 8 9 10 11 12 |
|
而对于指令
1 2 3 4 5 6 7 8 9 |
|
结合 010 我们可以发现 对于指令的 4 个数 op , a1 ,a2, a3
- 首先 他 opcode 都更改了 但是是一一对应的
- 其次 根据代码 不难 发现
- a1 为 ~a1&0xff
- a2 不变
- a3 为 a3^idx&0xff 其中 idx 为指令个数
对于指令 好在他虽然打乱顺序了 但是没有完全打乱
他只是按照 lj_bc.h 中指令的大块打乱了 相邻的指令依然是连续的
结合之后获取的更多的 lua 样本和其他模板的解密 指令基本能够恢复
(就算不能完全恢复 常用指令也能够恢复 对于达成目标并不影响)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
|
接下来对于字符串 我们能在 ida 中看到一串很恐怖的大量代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 |
|
这一大串看着多 其实很简单 大部分内容都是由于编译器为了加快异或而生成的代码 下面xmmword这些其实是 SIMD 指令集
整段代码其实就是
1 2 |
|
将字符串按位求反异或 而这个操作的逆函数就是他自身
还有一些其他大大小小的更改 如更换位置 等
这里就不贴上来了
最后 写一个自动编译生成的工作流 结合之前的 ts 代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
在 frida 中 我们直接 hook dll 的对应函数 使用 frida 创建调用
这里核心就 lua_loadbuffer 一个函数 其他都是为了不让我们插入的代码破坏 lua 的原始堆栈引发程序崩溃的保护措施
public LuaLoad(data:Array<number>,chunkName:string){ this.lua_pushtraceback(this.lua_state) const oldTop = this.lua_gettop(this.lua_state) const prtbuffer = Memory.alloc(data.length+1) prtbuffer.writeByteArray(data) const ckn = Memory.allocUtf8String(chunkName) console.log(`allocing ${data.length} mem`) console.log(`[lua] LuaLoad execing as oldTop:${oldTop} and buffer at ${prtbuffer.toString(16)}`) if(this.lua_loadbuffer(this.lua_state,prtbuffer,data.length,ckn)==0){ if(this.lua_pcall(this.lua_state,0,-1,0)==0){ this.lua_settop(this.lua_state,oldTop-1); console.log("[lua] LuaLoad exec finished!") return; } } const errlen = new NativePointer(0) const errptr = this.lua_tolstring(this.lua_state,-1,errlen) let errmsg:string try{ if(errlen.isNull()){ errmsg = errptr.readUtf8String() || "" }else{ errmsg = errptr.readUtf8String(errlen.toInt32()) || "" } console.error(errmsg) console.error("[lua] failed to exec") }catch(e){ console.error(e) console.log("[lua] failed to load errmsg") } this.lua_settop(this.lua_state,oldTop-1); return }
尾声
在成功植入 lua 代码之后 参考 unlua 写了反编译 我们就可以直接使用 lua 代码来 hook 并插入内容了
local org_func = target_class.target_func target_class.target_func = function (self, args) -- doxxx before org_func(self,args) -- doxxx after end
文章没有写的很详细 考虑到文章核心是介绍 lua bc 其他部分就都简化了
有什么问题欢迎在文章下提问