😋
Lua 逆向总结
Lua
逆向总的来说在 CTF
中好像并不是多么常见,印象比较深刻的可能就四次吧,第一次遇到是在 2022 年的RCTF
,然后还有今年的巅峰极客、柏鹭杯和 N1CTF
。分别是 luac
文件结构修改、LuaJIT
、Lua VM Opcodes
顺序修改 、Lua VM Opcodes
实现修改 ,基本上也概况了常见的情况了。
1. Lua 和 C 的基本交互
虽然 Lua
的源码称得上很小,但是全看还是没什么性价比,这里我们从CTF
中常见的一种场景,C 语言调用 Lua
脚本中的函数开始分析,主要有这么几步:
C 语言和
Lua
的交互是通过lua_State
来实现的,所以首先需要创建一个lua_State
把
Lua
脚本加载进Lua
虚拟机,这里Lua
提供了三个常用的函数1
2
3
4
5
6
7
8LUALIB_API int (luaL_loadstring) (lua_State *L, const char *s);
LUALIB_API int (luaL_loadbufferx) (lua_State *L, const char *buff, size_t sz, const char *name, const char *mode);
LUALIB_API int (luaL_loadfilex) (lua_State *L, const char *filename, const char *mode);当然,最后这三个函数最后走的都是
lua_load
函数,把经过编译(luac文件就不编译了)后的代码放在栈顶。执行加载到栈顶的
Lua
代码,因为上述函数只是将程序加载到了栈顶,只有在执行了之后才能变成虚拟机中的函数、变量。由于代码已经在栈顶了,也没有需要传入的参数,所以只需要调用lua_pcall
即可。1
int lua_pcall (lua_State *L, int nargs, int nresults, int msgh);
对于步骤 2、3
Lua
中也提供了一套合并的函数,luaL_dofile
和luaL_dostring
调用指定函数。调用约定如下:
首先把要调用的函数压入栈中,然后按顺序将要传入的参数压入栈中(从左到右),最后调用
lua_call
(当然也可以调用lua_pcall
等)。当函数返回时,所有的参数和要调用的函数以及被出栈了,而且函数调用的返回值被压入栈中了,返回值的数量就是传入的nresults
的值,除非传入的是LUA_MULTRET
下面是一个简单的代码实现
1 |
|
其中还有一些细节,比如 lua_tonumber(L, -1)
中 index = -1
代表的是栈顶,这里正索引表示绝对堆栈位置,从 1 开始,作为堆栈的底部;负索引表示相对于堆栈顶部的偏移量,详细可以参考官方文档的 4.1 节。
参考:
2. Luac 文件结构修改
2.1 Lua 代码加载流程分析
因为我是比较喜欢 luaL_loadbufferx
,所以我们还是从 luaL_loadbufferx
开始分析。整体流程如下:
luaL_loadbufferx
-> lua_load
-> luaD_protectedparser
-> f_parser
-> luaU_undump
( luac
二进制文件) | luaY_parser
(lua
脚本文件,这个还没见过改的,也没看过)
整个流程代码太多了,贴出来也没什么意义,需要注意的也就从 luaD_protectedparser
调用 f_parser
是用 luaD_pcall
调用的,而不是直接调用的。一般做题目,研究这个流程也就是为了顺利跟到 luaU_undump
这个函数,进而分析 luac
文件结构修改了哪部分,是不是中间存在什么加密或者字段顺序上的调整。
2.2 通杀方法
对于这种文件结构上的调整,这里我们有一个通杀的方法,那就是在 load
完成之后,执行之前(此时加载后的程序位于栈顶),调用同版本的、标准的(也就是未经任何修改的)lua_dump
函数,即可得到标准格式的 luac
文件。简单来说,先用题目给的 load
然后用标准的 dump
。
这里以 2022 年 RCTF
的 picStore
作为例子, 首先 Lua
的版本为 5.3.3
,(直接 shift + F12 就搜索就行),先编译一个官方的,这里为了方便 dump
添加一个导出函数
1 | static int my_writer(lua_State* L, const void* p, size_t size, void* u) { |
然后修改一下 src
目录里的Makefile
编译个 so
出来,
- 创建个变量
LUA_SO= liblua.so
,位置随便 CFLAGS
后面加个fPIC
ALL_T
后面把$(LUA_SO)
加上- 写一下
LUA_SO
的编译参数
1 | $(LUA_SO): $(BASE_O) |
重新 make
一手,拿到 liblua.so
,这里Hook
操作还是使用 Frida
1 | var liblua = Module.load("/home/s1nk/CTF/rev/FridaCode/liblua.so"); |
直接启动 frida -l ./lua_dump.js -f picStore
,然后就会发现,标准格式的 luac
已经出现了
unluac
一下就可以看到源码了
3. VM Opcodes 顺序修改
这里以 2024 年 WMCTF
easy_android 为例,这是一个 LuaJIT
的题目,且打乱了 opcode
顺序。
3.1 字符串解密
这部分和 Lua 逆向无关可以直接跳过。以这个题目为例所以还是提一下,这里因为题目的字符串加密方法基本都是以 ADRL
、LDARB
指令开始,所以只需要写个脚本把字符串加密函数的都执行一遍然后把 data 段 dump
出来就行了,下面是一种实现。
1 | from idaapi import * |
3.2 如何找到每个指令的实现逻辑
LuaJIT 的虚拟机对于每条指令的实现都是用汇编实现的,通过分析可以找到指令的分发是通过 下图中的 ins_callt
实现的。
注意这三条指令,其中
1 | add TMP1, GL, INS, uxtb #3 |
GL 是 LuaJIT 中的 Global state,可以通过 lua_State 的 glref 取得;
1 | /* Global state, main thread and extra fields are allocated together. */ |
INS 是 opcode 的值;GG_G2DISP 是 Global state 与 dispatch表之间的偏移。
1 |
所以这三条指令的意思就是 jmp dispatch[opcode]
,所以只需要拿到 GL
和 GG_G2DISP
即可算出dispatch表的地址,找到每条指令的实现地址。这里我使用 unidbg
获取
1 | emulator.getBackend().hook_add_new(new CodeHook() { |
找到之后给每个分支改个名:
1 | from idaapi import * |
3.3 分支识别
因为 LuaJIT 的虚拟机实现是使用汇编写的,每个分支指令非常固定,一般来说大部分指令不会随着编译器版本、混淆等外界因素发生变化。因此只要提取每个分支处理逻辑的的指令序列形成指令的特征进行特征识别即可识别大部分指令。
首先,是在 Android 上编译一个 LuaJIT 这个很简单官方提供了详细的说明不再赘述,此外 LuaJIT 还提供了一个 lj_bc_ofs
其中记录了每个指令距离第一个指令处理逻辑(lj_vm_asm_begin
)的偏移,可以根据这个结构大概的不准确的
计算出每个指令所包含的指令。
基于此,可以先根据编译出的标准版提取出每个指令的特征,我这里简单的实现了一个提取特征的函数基本可以匹配大部分,有几个识别不到的要手动一下。
1 | def get_feature(addr, size): |
提取特征:
1 | lj_bc_ofs = [0x0000, 0x0080, 0x0100, 0x0180, 0x0200, 0x0284, 0x0304, 0x0354, 0x03A4, 0x0430, 0x04BC, 0x0500, 0x0544, 0x0584, |
匹配特征:
1 | lj_bc_ofs = [0x0000, 0x0038, 0x005C, 0x0094, 0x010C, 0x0178, 0x01C0, 0x0244, 0x02C4, 0x0314, 0x0380, 0x03AC, 0x03D8, 0x041C, |
结合手动分析了几个分支的出最后的 opcode 顺序(大部分基于自动化识别,不保证正确性):
1 | (0x00, instructions.ISF), |
对于 LuaJIT 的反编译一般使用 但是这个项目中对于 opcode 的分类全是基于标准 opcode 直接的数值关系做的,所以修起来很难受,总之就是把所有的大于小于判断替换成对应的 luajit-decompiler
这个项目,in (x, y, z….)
最终反编译的结果是这样的,感觉哪里错了,也解不出来 flag,我也懒得分析了,大概就是这样。
1 | jit.off() |
后面得知,问题是 luajit-decompiler
项目导致的,使用luajit-decompiler-v2
即可
1 | jit.off() |
3.4 求解
虽然解不出来,但是流密码直接 hook bxor 实现把密钥流拿一下就行,bit 库的实现在lib_bit.c
这个文件,里面没有 bit_bxor 的实现,实现就在汇编里。
1 | LJLIB_ASM_(bit_bor) LJLIB_REC(bit_nary IR_BOR) |
![image.png](https://raw.githubusercontent.com/tac-eL/tacel.github.io/img/typoraAutoUpload/image 3.png)
直接搜一下指令序列找到之后 hook 即可。
1 | emulator.getBackend().hook_add_new(new CodeHook() { |
4. 后记
对于 Lua 的指令opcode的识别,相对来说更加复杂。但是如果没有混淆在解释器中的话,可以通过 DIE 等工具尽可能的识别编译器的确切版本、编译参数等外界因素来进行类似的识别,柏鹭杯中的那个题我就曾使用这种方法成功实现识别,但是当时未能及时记录,时间过了好久也懒得重新写了。
除了这些,我博客中对于 2023 年巅峰极客 ezlua
的复现过程,也是学习 LuaJIT 的较好的资料。