Virtual vulnerability mechanic: how one argument gives arbitrary read and write
Format string attack occurs in exactly one scenario: the programmer transmits the user input to the first argument in printf() or a related function (sprintf, fprintf, snprintf, syslog). The function awaits a format string describing the types and number of subsequent arguments. If instead of a fixed string comes a controlled buffer, the attacker dictates what the printf will do with the memory of the process.
Memory leak through format string: reading stacks and libc addresses
The first step when operating the format string bug is to read the stack through printf. The goal is double: to find an offset (the position of your own input on the stack) and to pull out the libc addresses to bypass the ASLR.
Minimum vulnerable binary to reproduce all examples:
#include <stdio.h>
int main() {
char buf[256];
fgets(buf, 256, stdin);
printf(buf);
return 0;
}
Compilation: gcc -fno-stack-protector -no-pie -Wl,-z,norelro -o vuln vuln.c. Flag -fno-stack-protector removes stack canary, -no-pie fixing the addresses of the sections, -z,norelro leaves GOT available for record. This configuration is the standard for training format string CTF pwn tasks. Advanced tasks include PIE and Full RELRO, and there the attack vector is changing radically.
Search for offset: manual method via %p and direct parameter access
Offset — position on the stack where the printf finds the beginning of custom input. Without the correct offset, no record via %n will not work, and leaks will read not those data. This is the first thing to find, and the first thing people get stuck on.
Manual method: enter AAAAAAAA %p %p %p %p %p %p %p %p and look in the conclusion value 0x4141414141414141 (ASCII code A = 0x41, eight bytes = eight characters A). If this value appeared on the sixth position, the offset is 6. Go through the long chain %p %p %p... – working approach, but there is a way faster.
Syntax direct parameter access %N$p refers immediately to the N-th argument printf. Enter AAAAAAAA %6$p – if on the way out 0x4141414141414141, means offset = 6. No, we try 7, 8, 9 and beyond. According to the course CS6265 (Georgia Tech), without direct parameter access, the length of payload is limited by the size of the buffer: dozens %p will not fit into the 64-byte input, and %6$p It only takes 4 characters. The same syntax works with any specifier: %6$x, %6$s, %6$n.
32-bit vs 64-bit: why offset on x86_64 significantly more
On 32-bit binary, offsets are usually 4–7. The reason is simple: all the arguments of the cdecl functions are transmitted through the stack. User input lies close to the printf reading point, literally through a few words.
On 64-bit - more often 6-10 and above. On System V AMD64 ABI the first argument printf (format string) goes into rdi, the following five variac-arguments — in rsi, rdx, rcx, r8, r9. Only from the sixth variadic-argument printf proceeds to reading from the stack. Between the register arguments and the user buffer on the stack can be a bunch of data: local variables, saved registers, frames of other functions.
In the task, picoCTF 2024 Format String 3 was 38. Thirty-eight positions – and this is a real task at competitions, not a synthetic example. If you are going through %N$p and reached 15 without a result - do not give up, continue. On one CTF I reached 42 before I saw mine 0x41.
What exactly is extracted through the leakage of the stack:
Libc Features Addresses – for calculating libc base and bypassing ASLR. Leaked address setvbuf, __libc_start_main or any other function minus its known offset gives base
Stack canary - usually recognized by zero junior byte (\x00), you need to bypass the stack protector when combining the format string + buffer overflow
Return addresses – to understand the layout stack and potential overwriting return address
The contents of local variables – in simple tasks, the flag lies directly on the stack as a string, and %N$s Reading it directly (yes, it is so simple)
%s allows you to read not only the stack, but also arbitrary memory addresses. It interprets the value on the stack as a pointer and reads the line to this address. If you place the target address in your input on the stack (and we know its offset) and refer to it through %N$s – printf will read the contents of this address. Full-fledged read arbitrary primitive. But if the value turns out to be an invalid address - segfault and goodbye. For safe exploration – %p, to %s move with specific verified addresses.
Automation in pwntools: class FmtStr provides the method leak_stack(offset, prefix=b'') to read values from the stack on a specific offset. At competitions manual check through %N$p takes 30 seconds and gives absolute confidence in the result - on the CTF often prefer with hands, and the automation is connected only for recording.
Replaying the printf memory: specifier %n and byte recording
Specificator %n – the very mechanism that turns the format string from a leak tool into a full-fledged arbitrary write primitive. It records at the address lying on the stack in the position of the relevant argument, the number of characters that the printf has already printed by this point. As stated in the OWASP documentation: %n an integer to locations in the process' memory.
Control of the recorded value through the width of the field
The number of characters printed is controlled through the width specifier. Recording %100c makes the printf print print one character, supplemented with spaces up to width 100 — a total of 100 printed characters. After %100c%N$hhn the N-th argument address will record a value equal to the current printf counter (including everything that was printed before %100c).
As described in Vickie Li (vickieli.dev), width-control formatting specifiers allow you to avoid extremely long-lasting exploit strings and record arbitrary integers without stuffing real characters.
Algorithm of manual byte recording through %hhn:
Spread the target value into individual bytes. For system() = 0x7ffff7c58750: bytes 0x50, 0x87, 0xc5, 0xf7, 0xff, 0x7f
Sort bytes by ascending (printf counter only grows within one call). If the next byte is less than the current counter, twist through 256: you need to print (256 - текущий_счётчик_mod_256 + нужный_байт) symbols
For each byte add %Nc to set the desired amount and %M$hhn to write to the M-th argument address
Place target addresses (GOT + 0, GOT + 1, ..., GOT + 5) at the end of the payload and refer to them through direct parameter access
Manual payload build from six byte records can take tens of minutes and give out 100+ bytes of format string. At competitions it is unacceptable — and it is for the automation of this process that pwntools exist. But it is worth collecting at least one payload with your hands - otherwise the automation will forever remain a black box.
GOT overwrite operation: from leaking libc to shell
The Global Offset Table (GOT) is a table in an ELF binary through which functions from dynamically connected libraries occur. When the program calls puts(), the management is transferred to the address from puts@got – GOT cell containing a real address puts in libc. Re-recording this cell with an address system(), receive: the following call puts("/bin/sh") will accomplish system("/bin/sh"). Shell is received.
GOT/PLT tables — lazy binding foundation in Linux: when the first call of the PLT plug function, it accesses the dynamic bootloader, it resolvites the address and writes it to GOT. In subsequent calls, the control goes directly through the GOT – without repeated resolvation. Overwriting GOT recording via format string replaces this chain once and for all until the process is complete.
Check protections via checksec: RELRO and PIE
GOT overwrite does not always work. Team checksec ./binary will show whether to try at all:
Partial RELRO – GOT is available for recording. Standard for most entry- and mid-level CTF tasks
Full RELRO – GOT is filled in when downloaded and labeled read-only. Recording is impossible. Alternative Objectives: __malloc_hook, __free_hook (deprecated in glibc 2.34+), return address on the stack
No PIE – the addresses of the sections are fixed. The GOT record address is visible statically through objdump -R ./binary or elf.got['puts'] the Pwntools
PIE enabled – addresses are randomized. Need an additional leak of the base address ELF
The Partial RELRO + No PIE configuration is ideal for GOT overwrite. In the picoCTF 2024 Format String 3 task, the defenses were exactly that: Partial RELRO, No PIE, Canary found, NX enabled. Canary and NX do not interfere with format string operation: canary protects against overwriting the stack, NX prohibits the execution of code on the stack, but GOT overwrite bypasses both protections - we do not overwrite anything on the stack and do not perform.
Format string through pwntools and FmtStr class
Full chain of operation for binary with Partial RELRO and No PIE: libc leakage, base calculation, GOT overwriting.
from pwn import *
elf = ELF('./vuln')
libc = ELF('./libc.so.6')
p = process('./vuln')
p.sendline(b'%7$p')
leak = int(p.recvline().strip(), 16)
libc.address = leak - libc.sym['setvbuf']
def do_fmt(pay):
p.sendline(pay)
return p.recv()
fmt = FmtStr(execute_fmt=do_fmt,
offset=6)
fmt.write(elf.got['puts'], libc.sym['system'])
fmt.execute_writes()
Parsing: ELF('./vuln') loads binary and parsit GOT/PLT tables — elf.got['puts'] returns the address GOT-record for puts. The line %7$p reads the 7-th argument printf – in this example it is a leaked address setvbuf from libc (many CTF tasks specifically add such leak before entering). Subtracting libc.sym['setvbuf'] from the leaked address, we get base libc. Class FmtStr accepts callback execute_fmt, which sends payload and receives a response. Method write(addr, data) puts the record in the queue, execute_writes() generates and sends the final payload with byte records through %hhn.
Alternative approach – function fmtstr_payload(offset, writes), which returns the finished byte string without a callback wrapper. More convenient for one-time records within one payload, but FmtStr class gives more control in multiple-rounds of input scenarios.
Debugging format string attack in GDB: typical errors
Three problems that take 80% of the time when solving the format string CTF pwn tasks. I know, because I lost my watch on each of them.
The wrong offset. The most common reason for the non-working payload. On 64-bit systems, the input is aligned by 8 bytes - an extra symbol in prefix breaks the entire markup. Check: AAAAAAAA %6$p must return 0x4141414141414141. If you see offset bytes (0x4141414141414100 or 0x2541414141414141) – offset incorrect or impaired alignment. Add or remove the padding symbols before the marker. On 32-bit input is equalized by 4 bytes: AAAA %4$x must give 41414141.
Zero bytes in addresses. On 64-bit addresses contain zeros in senior positions (e.g. 0x00000000004040XX). Zero byte \x00 trims the string when reading through fgets or scanf - all after \x00 lost. Solution: place addresses at the end of payload, after all %Nc%N$hhn Specifiers. The first part of payload is format specifiers (clean ASCII, without zeros), the second part is packaged addresses (can contain \x00, but the printf has already processed the entire format line by this point). Pwntools automatically takes this into account when generating payload.
ASLR and the need for two rounds. When the LIBC addresses are included, libc addresses are randomized at each start. Standard strategy: Round 1 – libc address leak through %N$p, calculation base; round 2 — overwriting GOT with the calculated address system(). If the program gives only one input (no cycle) - you need to either find a libc leak in the output before entering (the authors of CTF tasks often specifically add such a leak), or overwrite a return address instead of GOT (does not require a libc address if the target is a jump function to the function inside the binary).
Visualization of the stack in pwndbg and problem search
GDB with pwndbg extension is the main tool for debugging format string. Installation: git clone https://github.com/pwndbg/pwndbg && cd pwndbg && ./setup.sh. Put a breakpoint before calling printf, enter a test payload and watch the stack:
pwndbg> b *main+42
pwndbg> r <<< "AAAABBBB %p %p %p %p"
pwndbg> stack 15
00:0000| rsp 0x7ffe1230 -> 0x7ffe1250 ('AAAABBBB...')
01:0008| 0x7ffe1238 -> 0x7f3a9e2a0 (__libc_start)
02:0010| 0x7ffe1240 <- 0x1
03:0018| 0x7ffe1248 <- 0x4242424241414141
04:0020| 0x7ffe1250 <- 0x2070252042424242
Team stack 15 Shows 15 stack records with addresses and values. Looking for 0x4141414141414141 (our A symbols) is his position regarding the beginning of reading printf and there is an offset. In the tutorial CS6265 shows: in the output of the stack, both user input, and pointers on libc, and GOT addresses are all potential targets for leakage and recording. Team telescope in pwndbg allows pointers to several levels deep, showing where each address leads. For format string debugging, this is critically useful: you can immediately see which values are pointers on libc, which are local variables and which are our custom input.
Protection against format string bug and compiler countermeasures
Format string attack occurs in exactly one scenario: the programmer transmits the user input to the first argument in printf() or a related function (sprintf, fprintf, snprintf, syslog). The function awaits a format string describing the types and number of subsequent arguments. If instead of a fixed string comes a controlled buffer, the attacker dictates what the printf will do with the memory of the process.
Memory leak through format string: reading stacks and libc addresses
The first step when operating the format string bug is to read the stack through printf. The goal is double: to find an offset (the position of your own input on the stack) and to pull out the libc addresses to bypass the ASLR.
Minimum vulnerable binary to reproduce all examples:
#include <stdio.h>
int main() {
char buf[256];
fgets(buf, 256, stdin);
printf(buf);
return 0;
}
Compilation: gcc -fno-stack-protector -no-pie -Wl,-z,norelro -o vuln vuln.c. Flag -fno-stack-protector removes stack canary, -no-pie fixing the addresses of the sections, -z,norelro leaves GOT available for record. This configuration is the standard for training format string CTF pwn tasks. Advanced tasks include PIE and Full RELRO, and there the attack vector is changing radically.
Search for offset: manual method via %p and direct parameter access
Offset — position on the stack where the printf finds the beginning of custom input. Without the correct offset, no record via %n will not work, and leaks will read not those data. This is the first thing to find, and the first thing people get stuck on.
Manual method: enter AAAAAAAA %p %p %p %p %p %p %p %p and look in the conclusion value 0x4141414141414141 (ASCII code A = 0x41, eight bytes = eight characters A). If this value appeared on the sixth position, the offset is 6. Go through the long chain %p %p %p... – working approach, but there is a way faster.
Syntax direct parameter access %N$p refers immediately to the N-th argument printf. Enter AAAAAAAA %6$p – if on the way out 0x4141414141414141, means offset = 6. No, we try 7, 8, 9 and beyond. According to the course CS6265 (Georgia Tech), without direct parameter access, the length of payload is limited by the size of the buffer: dozens %p will not fit into the 64-byte input, and %6$p It only takes 4 characters. The same syntax works with any specifier: %6$x, %6$s, %6$n.
32-bit vs 64-bit: why offset on x86_64 significantly more
On 32-bit binary, offsets are usually 4–7. The reason is simple: all the arguments of the cdecl functions are transmitted through the stack. User input lies close to the printf reading point, literally through a few words.
On 64-bit - more often 6-10 and above. On System V AMD64 ABI the first argument printf (format string) goes into rdi, the following five variac-arguments — in rsi, rdx, rcx, r8, r9. Only from the sixth variadic-argument printf proceeds to reading from the stack. Between the register arguments and the user buffer on the stack can be a bunch of data: local variables, saved registers, frames of other functions.
In the task, picoCTF 2024 Format String 3 was 38. Thirty-eight positions – and this is a real task at competitions, not a synthetic example. If you are going through %N$p and reached 15 without a result - do not give up, continue. On one CTF I reached 42 before I saw mine 0x41.
What exactly is extracted through the leakage of the stack:
Libc Features Addresses – for calculating libc base and bypassing ASLR. Leaked address setvbuf, __libc_start_main or any other function minus its known offset gives base
Stack canary - usually recognized by zero junior byte (\x00), you need to bypass the stack protector when combining the format string + buffer overflow
Return addresses – to understand the layout stack and potential overwriting return address
The contents of local variables – in simple tasks, the flag lies directly on the stack as a string, and %N$s Reading it directly (yes, it is so simple)
%s allows you to read not only the stack, but also arbitrary memory addresses. It interprets the value on the stack as a pointer and reads the line to this address. If you place the target address in your input on the stack (and we know its offset) and refer to it through %N$s – printf will read the contents of this address. Full-fledged read arbitrary primitive. But if the value turns out to be an invalid address - segfault and goodbye. For safe exploration – %p, to %s move with specific verified addresses.
Automation in pwntools: class FmtStr provides the method leak_stack(offset, prefix=b'') to read values from the stack on a specific offset. At competitions manual check through %N$p takes 30 seconds and gives absolute confidence in the result - on the CTF often prefer with hands, and the automation is connected only for recording.
Replaying the printf memory: specifier %n and byte recording
Specificator %n – the very mechanism that turns the format string from a leak tool into a full-fledged arbitrary write primitive. It records at the address lying on the stack in the position of the relevant argument, the number of characters that the printf has already printed by this point. As stated in the OWASP documentation: %n an integer to locations in the process' memory.
Control of the recorded value through the width of the field
The number of characters printed is controlled through the width specifier. Recording %100c makes the printf print print one character, supplemented with spaces up to width 100 — a total of 100 printed characters. After %100c%N$hhn the N-th argument address will record a value equal to the current printf counter (including everything that was printed before %100c).
As described in Vickie Li (vickieli.dev), width-control formatting specifiers allow you to avoid extremely long-lasting exploit strings and record arbitrary integers without stuffing real characters.
Algorithm of manual byte recording through %hhn:
Spread the target value into individual bytes. For system() = 0x7ffff7c58750: bytes 0x50, 0x87, 0xc5, 0xf7, 0xff, 0x7f
Sort bytes by ascending (printf counter only grows within one call). If the next byte is less than the current counter, twist through 256: you need to print (256 - текущий_счётчик_mod_256 + нужный_байт) symbols
For each byte add %Nc to set the desired amount and %M$hhn to write to the M-th argument address
Place target addresses (GOT + 0, GOT + 1, ..., GOT + 5) at the end of the payload and refer to them through direct parameter access
Manual payload build from six byte records can take tens of minutes and give out 100+ bytes of format string. At competitions it is unacceptable — and it is for the automation of this process that pwntools exist. But it is worth collecting at least one payload with your hands - otherwise the automation will forever remain a black box.
GOT overwrite operation: from leaking libc to shell
The Global Offset Table (GOT) is a table in an ELF binary through which functions from dynamically connected libraries occur. When the program calls puts(), the management is transferred to the address from puts@got – GOT cell containing a real address puts in libc. Re-recording this cell with an address system(), receive: the following call puts("/bin/sh") will accomplish system("/bin/sh"). Shell is received.
GOT/PLT tables — lazy binding foundation in Linux: when the first call of the PLT plug function, it accesses the dynamic bootloader, it resolvites the address and writes it to GOT. In subsequent calls, the control goes directly through the GOT – without repeated resolvation. Overwriting GOT recording via format string replaces this chain once and for all until the process is complete.
Check protections via checksec: RELRO and PIE
GOT overwrite does not always work. Team checksec ./binary will show whether to try at all:
Partial RELRO – GOT is available for recording. Standard for most entry- and mid-level CTF tasks
Full RELRO – GOT is filled in when downloaded and labeled read-only. Recording is impossible. Alternative Objectives: __malloc_hook, __free_hook (deprecated in glibc 2.34+), return address on the stack
No PIE – the addresses of the sections are fixed. The GOT record address is visible statically through objdump -R ./binary or elf.got['puts'] the Pwntools
PIE enabled – addresses are randomized. Need an additional leak of the base address ELF
The Partial RELRO + No PIE configuration is ideal for GOT overwrite. In the picoCTF 2024 Format String 3 task, the defenses were exactly that: Partial RELRO, No PIE, Canary found, NX enabled. Canary and NX do not interfere with format string operation: canary protects against overwriting the stack, NX prohibits the execution of code on the stack, but GOT overwrite bypasses both protections - we do not overwrite anything on the stack and do not perform.
Format string through pwntools and FmtStr class
Full chain of operation for binary with Partial RELRO and No PIE: libc leakage, base calculation, GOT overwriting.
from pwn import *
elf = ELF('./vuln')
libc = ELF('./libc.so.6')
p = process('./vuln')
p.sendline(b'%7$p')
leak = int(p.recvline().strip(), 16)
libc.address = leak - libc.sym['setvbuf']
def do_fmt(pay):
p.sendline(pay)
return p.recv()
fmt = FmtStr(execute_fmt=do_fmt,
offset=6)
fmt.write(elf.got['puts'], libc.sym['system'])
fmt.execute_writes()
Parsing: ELF('./vuln') loads binary and parsit GOT/PLT tables — elf.got['puts'] returns the address GOT-record for puts. The line %7$p reads the 7-th argument printf – in this example it is a leaked address setvbuf from libc (many CTF tasks specifically add such leak before entering). Subtracting libc.sym['setvbuf'] from the leaked address, we get base libc. Class FmtStr accepts callback execute_fmt, which sends payload and receives a response. Method write(addr, data) puts the record in the queue, execute_writes() generates and sends the final payload with byte records through %hhn.
Alternative approach – function fmtstr_payload(offset, writes), which returns the finished byte string without a callback wrapper. More convenient for one-time records within one payload, but FmtStr class gives more control in multiple-rounds of input scenarios.
Debugging format string attack in GDB: typical errors
Three problems that take 80% of the time when solving the format string CTF pwn tasks. I know, because I lost my watch on each of them.
The wrong offset. The most common reason for the non-working payload. On 64-bit systems, the input is aligned by 8 bytes - an extra symbol in prefix breaks the entire markup. Check: AAAAAAAA %6$p must return 0x4141414141414141. If you see offset bytes (0x4141414141414100 or 0x2541414141414141) – offset incorrect or impaired alignment. Add or remove the padding symbols before the marker. On 32-bit input is equalized by 4 bytes: AAAA %4$x must give 41414141.
Zero bytes in addresses. On 64-bit addresses contain zeros in senior positions (e.g. 0x00000000004040XX). Zero byte \x00 trims the string when reading through fgets or scanf - all after \x00 lost. Solution: place addresses at the end of payload, after all %Nc%N$hhn Specifiers. The first part of payload is format specifiers (clean ASCII, without zeros), the second part is packaged addresses (can contain \x00, but the printf has already processed the entire format line by this point). Pwntools automatically takes this into account when generating payload.
ASLR and the need for two rounds. When the LIBC addresses are included, libc addresses are randomized at each start. Standard strategy: Round 1 – libc address leak through %N$p, calculation base; round 2 — overwriting GOT with the calculated address system(). If the program gives only one input (no cycle) - you need to either find a libc leak in the output before entering (the authors of CTF tasks often specifically add such a leak), or overwrite a return address instead of GOT (does not require a libc address if the target is a jump function to the function inside the binary).
Visualization of the stack in pwndbg and problem search
GDB with pwndbg extension is the main tool for debugging format string. Installation: git clone https://github.com/pwndbg/pwndbg && cd pwndbg && ./setup.sh. Put a breakpoint before calling printf, enter a test payload and watch the stack:
pwndbg> b *main+42
pwndbg> r <<< "AAAABBBB %p %p %p %p"
pwndbg> stack 15
00:0000| rsp 0x7ffe1230 -> 0x7ffe1250 ('AAAABBBB...')
01:0008| 0x7ffe1238 -> 0x7f3a9e2a0 (__libc_start)
02:0010| 0x7ffe1240 <- 0x1
03:0018| 0x7ffe1248 <- 0x4242424241414141
04:0020| 0x7ffe1250 <- 0x2070252042424242
Team stack 15 Shows 15 stack records with addresses and values. Looking for 0x4141414141414141 (our A symbols) is his position regarding the beginning of reading printf and there is an offset. In the tutorial CS6265 shows: in the output of the stack, both user input, and pointers on libc, and GOT addresses are all potential targets for leakage and recording. Team telescope in pwndbg allows pointers to several levels deep, showing where each address leads. For format string debugging, this is critically useful: you can immediately see which values are pointers on libc, which are local variables and which are our custom input.
Protection against format string bug and compiler countermeasures