type
Post
status
Published
date
Nov 21, 2023
summary
slug
tags
逆向工程
CTF
Lua
category
技术分享
icon
password
Lua 逆向总的来说在 CTF 中好像并不是多么常见,印象比较深刻的可能就四次吧,第一次遇到是在 2022 年的RCTF,然后还有今年的巅峰极客、柏鹭杯和 N1CTF。分别是 luac 文件结构修改、LuaJITLua VM Opcodes顺序修改 、Lua VM Opcodes实现修改 ,基本上也概况了常见的情况了。

1. Lua 和 C 的基本交互

虽然 Lua 的源码称得上很小,但是全看还是没什么性价比,这里我们从CTF中常见的一种场景,C 语言调用 Lua 脚本中的函数开始分析,主要有这么几步:
  1. C 语言和 Lua 的交互是通过 lua_State来实现的,所以首先需要创建一个 lua_State
  1. Lua 脚本加载进 Lua 虚拟机,这里 Lua 提供了三个常用的函数
    1. 当然,最后这三个函数最后走的都是 lua_load 函数,把经过编译(luac文件就不编译了)后的代码放在栈顶。
  1. 执行加载到栈顶的 Lua 代码,因为上述函数只是将程序加载到了栈顶,只有在执行了之后才能变成虚拟机中的函数、变量。由于代码已经在栈顶了,也没有需要传入的参数,所以只需要调用 lua_pcall 即可。
    1. 对于步骤 2、3 Lua 中也提供了一套合并的函数,luaL_dofileluaL_dostring
  1. 调用指定函数。调用约定如下:
    1. 首先把要调用的函数压入栈中,然后按顺序将要传入的参数压入栈中(从左到右),最后调用 lua_call (当然也可以调用 lua_pcall 等)。当函数返回时,所有的参数和要调用的函数以及被出栈了,而且函数调用的返回值被压入栈中了,返回值的数量就是传入的 nresults的值,除非传入的是 LUA_MULTRET
下面是一个简单的代码实现
其中还有一些细节,比如 lua_tonumber(L, -1) 中 index = -1 代表的是栈顶,这里正索引表示绝对堆栈位置,从 1 开始,作为堆栈的底部;负索引表示相对于堆栈顶部的偏移量,详细可以参考官方文档的 4.1 节。
参考:
  1. Lua5.4 Reference Manual Part4
  1. C程序优雅地调用Lua?一篇博客带你全面了解 lua_State

2. Luac 文件结构修改

2.1 Lua 代码加载流程分析

因为我是比较喜欢 luaL_loadbufferx,所以我们还是从 luaL_loadbufferx 开始分析。整体流程如下:
luaL_loadbufferx -> lua_load -> luaD_protectedparser -> f_parser -> luaU_undump ( luac 二进制文件) | luaY_parserlua 脚本文件,这个还没见过改的,也没看过)
整个流程代码太多了,贴出来也没什么意义,需要注意的也就从 luaD_protectedparser 调用 f_parser 是用 luaD_pcall 调用的,而不是直接调用的。一般做题目,研究这个流程也就是为了顺利跟到 luaU_undump 这个函数,进而分析 luac 文件结构修改了哪部分,是不是中间存在什么加密或者字段顺序上的调整。

2.2 通杀方法

对于这种文件结构上的调整,这里我们有一个通杀的方法,那就是在 load 完成之后,执行之前(此时加载后的程序位于栈顶),调用同版本的、标准的(也就是未经任何修改的)lua_dump 函数,即可得到标准格式的 luac文件。简单来说,先用题目给的 load 然后用标准的 dump
这里以 2022 年 RCTFpicStore 作为例子, 首先 Lua 的版本为 5.3.3,(直接 shift + F12 就搜索就行),先编译一个官方的,这里为了方便 dump 添加一个导出函数
然后修改一下 src 目录里的Makefile 编译个 so 出来,
  1. 创建个变量 LUA_SO= liblua.so,位置随便
  1. CFLAGS 后面加个 fPIC
  1. ALL_T 后面把 $(LUA_SO)加上
  1. 写一下LUA_SO的编译参数
重新 make 一手,拿到 liblua.so,这里Hook操作还是使用 Frida
直接启动 frida -l ./lua_dump.js -f picStore ,然后就会发现,标准格式的 luac 已经出现了
notion image
unluac 一下就可以看到源码了
notion image

3. VM Opcodes 顺序修改

这里以 2024 年 WMCTF easy_android 为例,这是一个 LuaJIT 的题目,且打乱了 opcode 顺序。

3.1 字符串解密

这部分和 Lua 逆向无关可以直接跳过。以这个题目为例所以还是提一下,这里因为题目的字符串加密方法基本都是以 ADRLLDARB 指令开始,所以只需要写个脚本把字符串加密函数的都执行一遍然后把 data 段 dump 出来就行了,下面是一种实现。

3.2 如何找到每个指令的实现逻辑

LuaJIT 的虚拟机对于每条指令的实现都是用汇编实现的,通过分析可以找到指令的分发是通过 下图中的 ins_callt 实现的。
notion image
注意这三条指令,其中
GL 是 LuaJIT 中的 Global state,可以通过 lua_State 的 glref 取得;
INS 是 opcode 的值;GG_G2DISP 是 Global state 与 dispatch表之间的偏移。
所以这三条指令的意思就是 jmp dispatch[opcode] ,所以只需要拿到 GLGG_G2DISP 即可算出dispatch表的地址,找到每条指令的实现地址。这里我使用 unidbg 获取
找到之后给每个分支改个名:

3.3 分支识别

因为 LuaJIT 的虚拟机实现是使用汇编写的,每个分支指令非常固定,一般来说大部分指令不会随着编译器版本、混淆等外界因素发生变化。因此只要提取每个分支处理逻辑的的指令序列形成指令的特征进行特征识别即可识别大部分指令。
首先,是在 Android 上编译一个 LuaJIT 这个很简单官方提供了详细的说明不再赘述,此外 LuaJIT 还提供了一个 lj_bc_ofs 其中记录了每个指令距离第一个指令处理逻辑(lj_vm_asm_begin)的偏移,可以根据这个结构大概的不准确的计算出每个指令所包含的指令。
notion image
notion image
基于此,可以先根据编译出的标准版提取出每个指令的特征,我这里简单的实现了一个提取特征的函数基本可以匹配大部分,有几个识别不到的要手动一下。
提取特征:
匹配特征:
结合手动分析了几个分支的出最后的 opcode 顺序(大部分基于自动化识别,不保证正确性):
对于 LuaJIT 的反编译一般使用 luajit-decompiler 这个项目,但是这个项目中对于 opcode 的分类全是基于标准 opcode 直接的数值关系做的,所以修起来很难受,总之就是把所有的大于小于判断替换成对应的 in (x, y, z….) 最终反编译的结果是这样的,感觉哪里错了,也解不出来 flag,我也懒得分析了,大概就是这样。
后面得知,问题是 luajit-decompiler 项目导致的,使用luajit-decompiler-v2 即可
luajit-decompiler-v2
marsinator358Updated Jun 21, 2025

3.4 求解

虽然解不出来,但是流密码直接 hook bxor 实现把密钥流拿一下就行,bit 库的实现在lib_bit.c 这个文件,里面没有 bit_bxor 的实现,实现就在汇编里。
notion image
直接搜一下指令序列找到之后 hook 即可。
 

4. 后记

对于 Lua 的指令opcode的识别,相对来说更加复杂。但是如果没有混淆在解释器中的话,可以通过 DIE 等工具尽可能的识别编译器的确切版本、编译参数等外界因素来进行类似的识别,柏鹭杯中的那个题我就曾使用这种方法成功实现识别,但是当时未能及时记录,时间过了好久也懒得重新写了。
 
除了这些,我博客中对于 2023 年巅峰极客 ezlua 的复现过程,也是学习 LuaJIT 的较好的资料。