对一手游的自定义 luajit 字节码的研究
前言
最近闲下来之后无聊研究起了一个unity手游 大量使用了 lua (或者说就是 lua 写的 ) 看到网上已有的一些针对方案 都觉得太不方便 于是深入研究了一下 他自定义的 luajit
情况研究
首先 这是一个 unity的 传统手游 这里就跳过较为前期的部分
像是 libtersafe . libbugly . libcri_ware 这些都是老熟人了 都跳过
unity 的 lua 通信方案
对于 unity游戏来说 特别是商业手游 热更新几乎是必须的
由此诞生很多方案 这里简单介绍几个重点
hybridclr
-
- c# 原生热更新
- xlua
- 代表新兴 lua 框架
- 有详细的文档 !
- tolua
- 代表老一代 lua 框架
luac 与 luajit 的关系
以下来自 gpt
LuaC 和 LuaJIT 都是与 Lua 编程语言相关的工具,但它们的用途和性能特点有所不同。
LuaC:
- LuaC 是 Lua 的标准字节码编译器。它是 Lua 解释器的一部分,用于将 Lua 源代码编译成字节码。这个过程通常是为了提高代码的加载速度和执行效率。
- LuaC 生成的字节码可以在任何 Lua 虚拟机上运行,确保了跨平台的兼容性。
- 它是 Lua 发行版的标准组成部分,通常用于开发环境或当脚本需要预编译以隐藏源代码时。
LuaJIT:
- LuaJIT 是一个独立的 Lua 编译器,它使用即时编译(JIT)技术来提高 Lua 代码的执行速度。LuaJIT 能够在运行时将 Lua 字节码转换成机器码,这通常可以显著提高性能。
- LuaJIT 与 Lua 5.1 版本高度兼容,但也引入了一些自己的扩展和优化。
- 它特别适合于性能敏感的应用,如高性能服务器、游戏、或实时系统。
- LuaJIT 的性能通常远超标准 Lua 解释器,尤其是在复杂或计算密集型任务中。
总的来说,LuaC 更多地关注于代码的移植性和标准化,而 LuaJIT 更专注于提高执行效率和性能。用户可以根据自己的需要选择使用 LuaC 还是 LuaJIT。
luac 和luajit的字节码一致吗 api呢
LuaC 和 LuaJIT 在字节码和 API 方面存在一些差异:
字节码兼容性:
LuaC 和 LuaJIT 生成的字节码不完全一致。LuaC 遵循 Lua 官方的标准,为特定版本的 Lua(如 Lua 5.1, Lua 5.2 等)生成相应的字节码。LuaJIT 也能够编译为字节码,但它的字节码格式是为 JIT 编译器优化的。
由于 LuaJIT 主要与 Lua 5.1 兼容,它的字节码在某种程度上与 Lua 5.1 的字节码相似。然而,由于 JIT 优化的原因,LuaJIT 生成的字节码可能在某些情况下与标准 Lua 字节码不兼容。
这意味着用 LuaC 编译的字节码可能无法在 LuaJIT 上正确运行,反之亦然。
API 兼容性:在 API 层面,LuaJIT 与 Lua 5.1 高度兼容。它支持 Lua 5.1 的全部标准 API,并且引入了一些自己的扩展。这些扩展主要是为了提高性能和扩展功能。
如果你的 Lua 代码是针对 Lua 5.1 API 编写的,那么它在 LuaJIT 上应该可以无缝运行。但如果你使用了 LuaJIT 的特定扩展,那么这部分代码可能无法在标准 Lua 解释器上运行。
综上所述,虽然 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`)) } }
faq 我怎么知道是 main.lua 你可以先打印他们的名字啊
faq 为什么要在执行 main.lua 之后执行 因为这样才能获取到他代码注册的内容
题外话 记得去把 log hook 了 才能看到输出
frida-il2cpp 提供了 log
Il2Cpp.installExceptionListener("all");
lua 框架大概率也有logger 可以 hook lua框架的 logger 将输出复一份到 frida上面来
然而 很神奇的事情发生了 程序直接崩溃了
在反复排除了各种东西之后 不得不打开 ida 分析 so 库
好在lua 框架是讲他自己的代码链接到 luajit 上的 也就是说我们可以对照 luajit 的源码
题外话 win 上可以使用 msvc 编译 luajit 参考 luajit 官网教程 记得把 -O2 改成 -Od 开启 debug 模式
直接定位到核心的 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 |
|
题外话 手游的 so 库 开了优化 一些没有注明要内联的函数 也被内联了 看着会有些不一样
不难发现 他直接少了 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 不过在分析加密的之前 我们得先搞清楚原始的
题外话 大佬提供的 010 的 bt 模板 在我这里似乎有版本兼容问题
没有函数 parentof() 即获取节点的父节点
不过这个可以直接在父节点处
把父节点自己作为参数传给子节点 来绕过这个函数
自定义 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 给出逆函数
当然,让我总结一下我们找到 (fl) 函数反函数的过程:
问题描述: 您提供了一个名为 (fl) 的函数,它对一个四元素的元组 (t)(具体为 (0, 1, 2, 3))进行一系列异或(XOR)操作,生成一个新的四元素元组。您询问如何找到 (fl) 函数的反函数,即如何从 (fl) 函数的输出恢复出原始输入。
(fl) 函数的分析: (fl) 函数通过以下方式操作:
- (o1 = t[0])
- (o2 = t[1] \oplus o1)
- (o3 = t[2] \oplus o2)
- (o4 = t[3] \oplus o3)
逆函数的构建: 我们尝试了几种不同的方法来构建这个逆函数,关键在于理解 XOR 操作的自反性和如何正确地逆向每一步操作。
最终解决方案: 经过一系列尝试和错误,我们找到了正确的逆函数。这个逆函数通过以下方式恢复原始 (t) 值:
- 从 (o4) 开始,因为 (o4 = t[3] \oplus o3),所以 (t3 = o4 \oplus o3)。
- 依此类推,我们逆向每一步操作,最终恢复出 (t0, t1, t2, t3)。
验证和结果: 逆函数成功地验证了,它能够准确地从 (fl) 函数的输出恢复出原始输入 (0, 1, 2, 3)。
1
2
3
4
5
6
7
8
9
10
11
12
13
def
fl(t):
o1
=
t[
0
]
o2
=
t[
1
] ^ o1
o3
=
t[
2
] ^ o2
o4
=
t[
3
] ^ o3
return
o1 , o2 , o3 , o4
def
fl_inverse(t):
t3
=
t[
3
] ^ t[
2
]
t2
=
t[
2
] ^ t[
1
]
t1
=
t[
1
] ^ t[
0
]
t0
=
t[
0
]
return
t0, t1, t2, t3
这样 我们可以先编写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 其他部分就都简化了文章来源:https://www.toymoban.com/news/detail-814481.html
有什么问题欢迎在文章下提问文章来源地址https://www.toymoban.com/news/detail-814481.html
到了这里,关于对一手游的自定义 luajit 字节码的研究的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!