type
Post
status
Published
date
Nov 21, 2023
summary
slug
tags
逆向工程
CTF
Lua
category
技术分享
icon
password
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提供了三个常用的函数
当然,最后这三个函数最后走的都是
lua_load 函数,把经过编译(luac文件就不编译了)后的代码放在栈顶。- 执行加载到栈顶的
Lua代码,因为上述函数只是将程序加载到了栈顶,只有在执行了之后才能变成虚拟机中的函数、变量。由于代码已经在栈顶了,也没有需要传入的参数,所以只需要调用lua_pcall即可。
对于步骤 2、3Lua中也提供了一套合并的函数,luaL_dofile和luaL_dostring
- 调用指定函数。调用约定如下:
首先把要调用的函数压入栈中,然后按顺序将要传入的参数压入栈中(从左到右),最后调用
lua_call (当然也可以调用 lua_pcall 等)。当函数返回时,所有的参数和要调用的函数以及被出栈了,而且函数调用的返回值被压入栈中了,返回值的数量就是传入的 nresults的值,除非传入的是 LUA_MULTRET下面是一个简单的代码实现
其中还有一些细节,比如
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 添加一个导出函数然后修改一下
src 目录里的Makefile 编译个 so 出来,- 创建个变量
LUA_SO= liblua.so,位置随便
CFLAGS后面加个fPIC
ALL_T后面把$(LUA_SO)加上
- 写一下
LUA_SO的编译参数
重新
make 一手,拿到 liblua.so,这里Hook操作还是使用 Frida直接启动
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 出来就行了,下面是一种实现。3.2 如何找到每个指令的实现逻辑
LuaJIT 的虚拟机对于每条指令的实现都是用汇编实现的,通过分析可以找到指令的分发是通过 下图中的
ins_callt 实现的。
注意这三条指令,其中
GL 是 LuaJIT 中的 Global state,可以通过 lua_State 的 glref 取得;
INS 是 opcode 的值;GG_G2DISP 是 Global state 与 dispatch表之间的偏移。
所以这三条指令的意思就是
jmp dispatch[opcode] ,所以只需要拿到 GL 和 GG_G2DISP 即可算出dispatch表的地址,找到每条指令的实现地址。这里我使用 unidbg 获取找到之后给每个分支改个名:
3.3 分支识别
因为 LuaJIT 的虚拟机实现是使用汇编写的,每个分支指令非常固定,一般来说大部分指令不会随着编译器版本、混淆等外界因素发生变化。因此只要提取每个分支处理逻辑的的指令序列形成指令的特征进行特征识别即可识别大部分指令。
首先,是在 Android 上编译一个 LuaJIT 这个很简单官方提供了详细的说明不再赘述,此外 LuaJIT 还提供了一个
lj_bc_ofs 其中记录了每个指令距离第一个指令处理逻辑(lj_vm_asm_begin)的偏移,可以根据这个结构大概的不准确的计算出每个指令所包含的指令。

基于此,可以先根据编译出的标准版提取出每个指令的特征,我这里简单的实现了一个提取特征的函数基本可以匹配大部分,有几个识别不到的要手动一下。
提取特征:
匹配特征:
结合手动分析了几个分支的出最后的 opcode 顺序(大部分基于自动化识别,不保证正确性):
对于 LuaJIT 的反编译一般使用 这个项目,但是这个项目中对于 opcode 的分类全是基于标准 opcode 直接的数值关系做的,所以修起来很难受,总之就是把所有的大于小于判断替换成对应的
luajit-decompilerin (x, y, z….) 最终反编译的结果是这样的,感觉哪里错了,也解不出来 flag,我也懒得分析了,大概就是这样。后面得知,问题是
luajit-decompiler 项目导致的,使用luajit-decompiler-v2 即可luajit-decompiler-v2
marsinator358 • Updated Jun 21, 2025
3.4 求解
虽然解不出来,但是流密码直接 hook bxor 实现把密钥流拿一下就行,bit 库的实现在
lib_bit.c 这个文件,里面没有 bit_bxor 的实现,实现就在汇编里。
直接搜一下指令序列找到之后 hook 即可。
4. 后记
对于 Lua 的指令opcode的识别,相对来说更加复杂。但是如果没有混淆在解释器中的话,可以通过 DIE 等工具尽可能的识别编译器的确切版本、编译参数等外界因素来进行类似的识别,柏鹭杯中的那个题我就曾使用这种方法成功实现识别,但是当时未能及时记录,时间过了好久也懒得重新写了。
除了这些,我博客中对于 2023 年巅峰极客
ezlua 的复现过程,也是学习 LuaJIT 的较好的资料。