M4cCrypt0
back to overview

Format String Exploit — GOT Overwrite to win()

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

c
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, and read() never accepts more than those 256 bytes. This rules out a classic stack overflow via this read() — confirmed via the disassembly of main() (sub $0x100, %rsp, buffer at rbp-0x100, return address at rbp+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

text
%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:

text
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():

asm
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.

python
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

python
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:

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

  1. 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.
  2. No PIE + stripped:no makes target functions trivially reachable (no leak needed) — always check this before setting up a more complex leak strategy.
  3. CET (SHSTK/IBT) specifically blocks ret hijacking, not indirect calls to endbr64 addresses via, for example, the GOT. This steers the exploitation direction.
  4. Tool defaults are not assumptions to rely oncontext.arch is the clearest example here: a silent wrong default gave no error, only an exploit that did nothing.
  5. 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