Contents

Defcon CTF 2022: Self-Reflection

https://i.imgur.com/n4uFMzD.png I didn’t do well on this Defcon, so this isn’t a writeup, but just to shared what I’ve learned during working on the Defcon, especially on the Luajit chall. I apologize in advance if there is some mistake on this article, as I just learn all of it during the Defcon.

During Defcon, I only work on three challenge with the help of my teammates, which are same_old, hash-it, and smuggler’s cove. We submit the flag for same_old and hash-it, and I spend the whole two days on the smuggler’s cove, but failed to solve it.

On this article, I’ll try to summarize what I learned during working on the chall, my failed attempts on smuggler’s cove, and the correct approach which I know after the CTF ended. I write this for my future notes in case I faced this kind of problem again.

Smuggler’s Cove

Initial Analysis

On this chall, we were given Dockerfile, cove.c, dig_up_the_loot.c, and the binaries of the given source code. Let’s take a look on the Dockerfile. The server is running the binary of cove.c.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
FROM ubuntu:20.04

WORKDIR /challenge
ARG DEBIAN_FRONTEND=noninteractive
# upgrade ran at Fri May 27 15:18:42 UTC 2022 (see packages.txt)
RUN apt-get update && apt-get upgrade -y && apt-get install curl -y && apt list --installed > /packages.txt

COPY libluajit-5.1.so.2 /usr/local/lib/libluajit-5.1.so.2
COPY cove dig_up_the_loot /challenge/

RUN ldconfig && chmod 111 /challenge/dig_up_the_loot
RUN adduser --no-create-home --disabled-password --gecos "" user

USER user

ENTRYPOINT ["/challenge/cove"]

So the server was running the most recent of Ubuntu20.04, which got upgraded just on Friday (D-1 Defcon CTF). I think this will help us a lot, as I also use Ubuntu20.04 on my local, and I can simply run update & upgrade to replicate the server environment.

Now, checking the file dig_up_the_loot.c

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#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");
}

Okay, so seems like we need to run ./dig_up_the_loot x marks the spot in order to retrieve the flag. Let’s move to the main source code (cove.c), which we should abuse.

  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
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
#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);
}

I never knew what is Lua before working on this chall, so during working on this chall:

  • I open the Lua documentation
  • I clone the Luajit repo
  • I debug it with GDB one by one to understand better about it.

Based on the file included header, I assume that we are dealing with LuaJIT instead of baseline lua. Which is why I try to clone the Luajit also to understand some method functionality.

Let’s try to analysis the source code first. We start by reading the main func

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
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);
}

So the main function basically will init some initial state of the luajit (I don’t know yet what is lua state?), and we need to pass an arg (?). Let’s take a look on the run_code first, so that we know what is the arg value.

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

Ah, so turn out the arg is the lua script filename. And later, run_code will try to load this code in the memory, and pass it to luaL_dostring. Notes that there is a limit of the lua script size that we can submit. I assume that luaL_dostring is method in the Luajit library, which will execute the given code. Moving back, let’s take a look on what does the init_lua do.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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");
}

I don’t understand at all what this method do as this is the first time I learn Lua. So, what I do is checking the official documentation of Lua. This is what it says about lua_call API. https://i.imgur.com/3gY4Bbu.png

Ah okay! So turn out, you need to push the function that you want to call + the args. So the first four lines is trying to to load LUA_JITLIBNAME library, and then call set_jit_settings, which set the jit configs. Details of what the config means can be found in here and here.

And then, let see the next code

1
2
3
    lua_pushnil(L);
    lua_setglobal(L, "jit");
    lua_pop(L, 1);

https://i.imgur.com/IjmBm8R.png

Based on the documentation, basically it set global variable jit to nil. So that means, we can’t use library jit during passing our code to the binary.

And the last piece of code is

1
2
3
4
    lua_pushcfunction(L, debug_jit);
    lua_setglobal(L, "cargo");
    lua_pushcfunction(L, print);
    lua_setglobal(L, "print");

https://i.imgur.com/i9kw0gg.png

On this code, what it does is basically, if we call cargo or print in our input lua script, the Luajit will call the defined debug_jit and print c-function instead. Let’s take a look on the print method first.

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

Nothing special, basically we can pass one lua element, convert it to string, and print it. Now, let’s take a look on the debug_jit method

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

So based on that code, seems like debug_jit will need 2 arguments. Let’s disass it one by one.

1
2
3
4
5
6
    luaL_checktype(L, 1, LUA_TFUNCTION);

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

So, the first argument needs to be a lua function. Reading through the documentation of lua_topointer https://i.imgur.com/tE0du9z.png What it does is generate a c pointer to the given function.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
    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);

And then, we can see that the second argument is stored as offset with type uint8_t. So offset will never be a negative value. We will back to what offset is actually doing later.

We see that from the retrieved pointer v, we try to get the bytecode, get its op and index, and then the index got passed to getTrace function. And the result isn’t allowed to be nil.

I don’t know at all what is GCtrace, and I don’t know how Luajit working. So let’s try to gather some knowledge on what it is, and what the code is doing.

Notes that what a JIT does is usually parsing the source code into their own bytecode, and then execute it. This bytecodes are machine agnostic. And then, some bytecode will be compiled and stored as native machine code as part of JIT optimization. By doing this, JIT can always ensure that it will always optimize the compiled code based on the target machine architecture.

Based on this nice article about Luajit, I learned that Luajit is a tracing-JIT instead of method-JIT. So, instead of optimizing hot method (method those are frequently called or executed), it will record sequence of executing operations frequency, and then only compile sequence which are frequently to be executed (hot traces). Based on wikipedia, tracing JIT assumes that hot loops (frequent loops or repetition of sequential operations) will make program spend a lot of time.

trace is an object which refer to the JIT-Compiled machine code version of the sequence of the bytecodes

Moving back to the given code, now we can deduce more about what it actually does. So, my deduction is, from our chosen function, it will check its bytecode of the function. Based on the Luajit bytecode documentation, there are two formats on how a bytecode stored. https://i.imgur.com/fPoqo71.png We can see in below image, that for function which is JIT-compiled, it will follow the second format. https://i.imgur.com/MvtxuCa.png Now, we can understand better on what this snippet code does.

1
2
3
4
    uint8_t* bytecode = mref(v->l.pc, void);

    uint8_t op = bytecode[0];
    uint8_t index = bytecode[2];

Basically, what it want to achieve is, it will try to retrieve the trace number (index) of the given function in the debug_jit arg. And then, it will try to get the trace based on the stored trace’s index. Notes that because Luajit is tracing-JIT, based on my understanding, it is possible that a function didn’t get JIT-Compiled, and it is possible that some sequential operation inside of the method is the one who got traced.

Now, Let’s check the getTrace method.

1
2
3
4
5
6
7
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]);
}

Ah from the code, we can deduce that, in the memory, there are an array of pointer, where each pointer points to a trace object (GCtrace). In the given binary, basically this method will be used to return the method GCTrace object if the method got JIT-Compiled. And the trace-index is retrieved from the function header bytecode (which we have retrieved in the previous snippet).

Let’s move to the final code

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
    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;

To understand it better, let’s check the GCTrace struct definition from our cloned Luajit repository.

 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
/* Trace object. */
typedef struct GCtrace {
  GCHeader;
  uint16_t nsnap;	/* Number of snapshots. */
  IRRef nins;		/* Next IR instruction. Biased with REF_BIAS. */
#if LJ_GC64
  uint32_t unused_gc64;
#endif
  GCRef gclist;
  IRIns *ir;		/* IR instructions/constants. Biased with REF_BIAS. */
  IRRef nk;		/* Lowest IR constant. Biased with REF_BIAS. */
  uint32_t nsnapmap;	/* Number of snapshot map elements. */
  SnapShot *snap;	/* Snapshot array. */
  SnapEntry *snapmap;	/* Snapshot map. */
  GCRef startpt;	/* Starting prototype. */
  MRef startpc;		/* Bytecode PC of starting instruction. */
  BCIns startins;	/* Original bytecode of starting instruction. */
  MSize szmcode;	/* Size of machine code. */
  MCode *mcode;		/* Start of machine code. */
  MSize mcloop;		/* Offset of loop start in machine code. */
  uint16_t nchild;	/* Number of child traces (root trace only). */
  uint16_t spadjust;	/* Stack pointer adjustment (offset in bytes). */
  TraceNo1 traceno;	/* Trace number. */
  TraceNo1 link;	/* Linked trace (or self for loops). */
  TraceNo1 root;	/* Root trace of side trace (or 0 for root traces). */
  TraceNo1 nextroot;	/* Next root trace for same prototype. */
  TraceNo1 nextside;	/* Next side trace of same root trace. */
  uint8_t sinktags;	/* Trace has SINK tags. */
  uint8_t topslot;	/* Top stack slot already checked to be allocated. */
  uint8_t linktype;	/* Type of link. */
  uint8_t unused1;
#ifdef LUAJIT_USE_GDBJIT
  void *gdbjit_entry;	/* GDB JIT entry. */
#endif
} GCtrace;

I think the comment code is already self-explanatory. What the code does is basically check whether:

  • The returned GCTrace (which is t) from getTrace function is nil or not
  • The mcode (which is pointer to the start of the compiled machine code) is nil or not
  • The szmcode (which is the size of the compiled machine code) is 0 or not.

Now, the final part is where the offset that we pass during calling the debug_jit is used. Turn out, the debug_jit method will allow us to shift the start of the compiled machine code, with restriction that the shift offset need to be less than the size of the compiled machine code. Finally, we understand what the given binary is actually doing.

Exploitation Plan

To summarize, so basically what the binary do is:

  • Initialize a Luajit
  • Read our given lua script
  • Run it inside Luajit

What make the given binary special is the debug_jit method, where we can use it to shift the start of our method JIT-compiled machine code (Notes that it only allowed us to shift the trace of Lua function which got JIT-Compiled).

So now, it is clear that, what we need to is somehow, use the debug_jit method feature, to do RCE and execute command ./dig_up_the_loot x marks the spot.

Reading this writeup from past CTF gave me a clear idea on how to do it. Basically, what we need to do is to craft a lua method, which the compiled machine code will have controllable constants. For example, what we aim to do is to have the trace machine code contains instruction like this

  • mov rdi, 0x22eb006a61c18348
  • cmp ebp, 0x6161616161
  • etc

And then, reserve the last 2 byte as our jump instruction to the other constant, and use the remaining bytes to craft our shellcode, which will execute our target command.

Failure

Well, at the end, I failed to solve it during the CTF, because I couldn’t craft any payload which satisfy it. What I found during CTF is that if we able to produce bytecode int LE xxx, we can generate an int constant. But somehow, I can only produce it by using for loop, but as we know that Luajit is a tracing-method, it will only traced the for loop instead of the method that I defined. I use luajit to help me debugging it.

https://i.imgur.com/X1H4Ahd.png

Above is the only payload that I found during the CTF that able to produce constants, yet I can’t use it because it is not a method trace (The trace is belong to the for loop sequence, not to the method that I defined).

Reflections

After the CTF end, the chall’s author tell me on the trick how to produce the constants. And also I read two great writeups in ctftime (here and here) which is super great.

I missed out that in Lua, every number is by default treated as floating point, so the hex that I should consider inside the compiled machine code is floating-point hex. And turn out, some tricks that we can use to produce constants:

  • Define the constant as index of an array. Ex m[100]=0
  • Add LL suffix in comparison. Ex: if i == 0xdeadbeefcafebabeLL

I’ve actually tried the index of array trick, but as I mentioned before, my mistake is not treating it as floating point, which make me failed to see the constant during debugging with the luajit. So when I specify m[0x61616161]=0, I expect to see 0x61616161 in the compiled machine code, while what the compiled machine code is converting it to floating-point hex representation due to the fact that Lua treat all numbers as floating point by default, which makes me unable to realize that I actually able to produce constant. This is a great lesson for me, as I failed to notice this during working on the chall.

So after reading the writeup, I managed to try to implement it and solve it in my local. I feel sad because I didn’t solve this chall during working on the CTF, but on the bright part, I learned a lot of new things related to Luajit. I hope that if in the future I face a challenge related to Luajit, I’ll be able to tackle and solve it.

Thanks for the chall’s author and the writeup’s author for providing me a new knowledge about this.

Sources

Social Media

Follow me on twitter