|
| 1 | +# Exploit: cos-113-18244.521.88 |
| 2 | + |
| 3 | +## Overview |
| 4 | + |
| 5 | +Local privilege escalation exploit targeting Linux 6.1.155+ (COS-113, PREEMPT_NONE, KVM, 2 CPUs, 3.5 GB RAM). The exploit leverages a packet-path use-after-free in nf_tables `nf_tables_addchain()` to hijack RIP via a fake rule blob, overwrite `core_pattern`, and exfiltrate the flag from `/dev/vdb` via `call_usermodehelper`. |
| 6 | + |
| 7 | +This exploit targets the packet-path manifestation of CVE-2026-23231. The CVE also covers a control-plane UAF reachable from `nf_tables_dump_chains()`, but the privilege-escalation path here relies on the transient IPv4 hook installed during `NFPROTO_INET` rollback. |
| 8 | + |
| 9 | +For the public PR, the `exploit` target builds the kernelXDK port in `exploit_xdk.cpp`. The 0-day submission archive of the original non-XDK exploit is carried separately as `original.tar.gz`. |
| 10 | + |
| 11 | +**Stability**: Probabilistic race. Verified with the kernelCTF repro harness without the separate KASLR leak path (`requires_separate_kaslr_leak=false`); may require multiple attempts. |
| 12 | + |
| 13 | +**KASLR handling**: The submitted kernelCTF repro leaks both the kernel text base (`kbase`) and direct-map base (`phbase`) internally using a prefetch side channel. It does not rely on the harness-provided KASLR leak path. |
| 14 | + |
| 15 | +--- |
| 16 | + |
| 17 | +## Step-by-step Exploitation |
| 18 | + |
| 19 | +### Step 1: KASLR Bypass via Prefetch Side Channel |
| 20 | + |
| 21 | +<!-- @step(name="KASLR Bypass via Prefetch Side Channel") --> |
| 22 | + |
| 23 | +The exploit leaks the kernel text base (`kbase`) and direct-map base (`phbase`) using a prefetch side channel. It times `prefetchnta` and `prefetcht2` with `rdtscp`. |
| 24 | + |
| 25 | +- **Intel kbase scan**: Scans `entry_SYSCALL_64` (`kbase + 0x1400080`) with 16 MB KASLR steps. Each vote primes the syscall path, records the lowest prefetch timing over 16 samples per candidate, then majority-votes 7 scans. |
| 26 | + |
| 27 | +- **Intel phbase scan**: Scans the direct map from `0xffff888000000000` to `0xffffc88000000000` using lowest-timing votes with a 1 GB coarse step, then refines around the first hit with a 64 MB step. |
| 28 | + |
| 29 | +- **Fallback scan**: On non-Intel hosts, scans mapped ranges by treating averaged timings above the calibrated threshold as mapped kernel addresses. The threshold is capped at 190 cycles by default and 130 cycles on Xeon hosts, then adjusted down to the measured unmapped baseline plus a 24-cycle margin when the host timing is lower. |
| 30 | + |
| 31 | +- **Vulnerability verification**: The verifier invokes the exploit with `--vuln-trigger` on a `nokaslr` kernel. In that mode the exploit uses fixed `kbase` and `phbase` values (`0xffffffff81000000`, `0xffff888000000000`) so reachability does not depend on the prefetch leak. |
| 32 | + |
| 33 | +- **Candidate arrays**: The race uses the verified primary `kbase`. The direct-map candidate list includes the primary `phbase` and +/- 1 GB neighbors to absorb small direct-map boundary variance. |
| 34 | + |
| 35 | +**Objects**: None (pure side-channel, no kernel objects involved). |
| 36 | + |
| 37 | +### Step 2: Physmap Spray with ROP Blob |
| 38 | + |
| 39 | +<!-- @step(name="Physmap Spray") --> |
| 40 | + |
| 41 | +A 2 GB region is allocated via `mmap(MAP_PRIVATE | MAP_ANONYMOUS | MAP_POPULATE)`. Every 4 KB page is filled with: |
| 42 | + |
| 43 | +- **Bytes 0x000-0x097**: A fake `nft_rule_blob` containing a ROP chain (4 fake `nft_immediate_eval` expressions). See [ROP Chain Details](#rop-chain-details) below. |
| 44 | +- **Byte 0x100** (`PATH_OFFSET`): The payload string `|/bin/dd if=/dev/vdb of=/dev/ttyS0` (for `core_pattern` overwrite). |
| 45 | + |
| 46 | +The direct-map base (`phbase`) provides a 1:1 virtual mapping of physical RAM, so these user pages are accessible from the kernel at predictable KVAs (`phbase + pfn * 0x1000` when pagemap is readable, or `phbase + guessed_offset` otherwise). |
| 47 | + |
| 48 | +If `/proc/self/pagemap` is readable, the exact physical frame number is read to compute the precise KVA. Otherwise, the exploit guesses KVA offsets in the `[1.0, 2.0] GB` range from `phbase` (avoiding the QEMU MMIO hole at 3-4 GB physical). |
| 49 | + |
| 50 | +**Objects**: Physical pages via physmap (direct map). |
| 51 | +**Cache**: N/A (physmap pages, not slab objects). |
| 52 | + |
| 53 | +### Step 3: Namespace Setup |
| 54 | + |
| 55 | +<!-- @step(name="Namespace Setup") --> |
| 56 | + |
| 57 | +The exploit creates user and network namespaces (`unshare(CLONE_NEWUSER | CLONE_NEWNET)`) to gain `CAP_NET_ADMIN` for nf_tables access. UID/GID mappings are written to `/proc/self/uid_map` and `/proc/self/gid_map`. The loopback interface is brought up (required for UDP flood packets to traverse LOCAL_OUT hooks). |
| 58 | + |
| 59 | +### Step 4: Saturate IPv6 LOCAL_OUT Hooks |
| 60 | + |
| 61 | +<!-- @step(name="Saturate IPv6 Hooks") --> |
| 62 | + |
| 63 | +An IPv6 nf_tables table (`t6`) is created, then up to 1024 base chains are added on the `NF_INET_LOCAL_OUT` hookpoint. Once `MAX_HOOK_COUNT` (1024) is reached, any subsequent hook registration for IPv6 LOCAL_OUT returns `-E2BIG`. |
| 64 | + |
| 65 | +This sets up the precondition for triggering the vulnerability: when creating an `NFPROTO_INET` chain, the IPv4 hook succeeds but the IPv6 hook fails with `-E2BIG`. |
| 66 | + |
| 67 | +An INET table (`ti`) is also created for the race loop. |
| 68 | + |
| 69 | +**Syscalls**: `sendto()` on `AF_NETLINK` / `NETLINK_NETFILTER` socket (nfnetlink batch messages with `NFT_MSG_NEWTABLE` and `NFT_MSG_NEWCHAIN`). |
| 70 | + |
| 71 | +### Step 5: Race — Trigger UAF and Reclaim with msg_msg |
| 72 | + |
| 73 | +<!-- @step(name="Race: Trigger UAF and Reclaim") --> |
| 74 | + |
| 75 | +Three threads run concurrently on 2 CPUs: |
| 76 | + |
| 77 | +#### Thread 1: UDP Flood (CPU 1) |
| 78 | + |
| 79 | +Sends UDP packets to `127.0.0.1:12345` via `sendmmsg()` in batches of 128. Each packet traverses `NF_INET_LOCAL_OUT`, calling `nft_do_chain_inet()` → `nft_do_chain()` on any registered IPv4 hook. Yields every 8th batch (`sched_yield()`) to report RCU quiescent states, ensuring `synchronize_rcu()` on CPU 0 completes in ~1 ms instead of blocking for 10+ seconds on a PREEMPT_NONE kernel. |
| 80 | + |
| 81 | +**Purpose**: Provide a continuous stream of packets that hit the transiently-installed IPv4 hook during the race window. |
| 82 | + |
| 83 | +#### Thread 2: INET NEWCHAIN Loop (CPU 0) |
| 84 | + |
| 85 | +Sends nfnetlink batch messages creating `NFPROTO_INET` base chains. Each attempt: |
| 86 | + |
| 87 | +1. Chain is allocated (`nft_base_chain`, kmalloc-256) and published to `table->chains` via `list_add_tail_rcu()`. |
| 88 | +2. IPv4 hook registers successfully — packets can now reach `nft_do_chain()` on this chain. |
| 89 | +3. IPv6 hook registration fails with `-E2BIG` (hooks saturated). |
| 90 | +4. Error path: `nft_chain_del()` removes chain from list, then `nf_tables_chain_destroy()` frees the `nft_base_chain` and its rule blob **without `synchronize_rcu()`**. |
| 91 | +5. The nfnetlink batch abort path calls `synchronize_rcu()` unconditionally in `__nf_tables_abort()`, which **blocks** this thread — allowing the spray thread to run. |
| 92 | + |
| 93 | +**Vulnerable window**: ~2 us between IPv4 hook going live and chain free. On KVM, VMEXITs (5-20 us each) can delay a packet's `nft_do_chain()` blob read past the kfree point. |
| 94 | + |
| 95 | +**Vulnerable object**: `struct nft_base_chain` (kmalloc-cg-256). |
| 96 | +**Vulnerable field**: `chain.blob_gen_0` / `chain.blob_gen_1` (pointers to rule blob, read by `nft_do_chain()` after free). |
| 97 | + |
| 98 | +#### Thread 3: Dedicated msg_msg Spray (CPU 0) |
| 99 | + |
| 100 | +Runs on the **same CPU** as the race thread. When the race thread blocks in `synchronize_rcu()`, the scheduler switches to this thread. It performs: |
| 101 | + |
| 102 | +1. Burst of 4 `msgsnd()` calls: allocates `msg_msg` objects from CPU 0's `kmalloc-cg-256` per-CPU SLUB freelist. The **first** `msgsnd` picks up the just-freed `nft_base_chain` slot (SLUB LIFO order), overwriting it with controlled data. |
| 103 | +2. `sched_yield()`: forces context switch → `rcu_note_context_switch()` → quiescent state for CPU 0, unblocking the pending `synchronize_rcu()`. |
| 104 | + |
| 105 | +**Reclaim object**: `struct msg_msg` (kmalloc-cg-256, 256-byte allocation: 48-byte msg_msg header + 208-byte mtext). |
| 106 | +**Overwritten fields in reclaimed nft_base_chain**: |
| 107 | +| Allocation Offset | Field | Value | mtext Offset | |
| 108 | +|---|---|---|---| |
| 109 | +| +64 | `basechain.policy` | `1` (NF_ACCEPT) | mtext[16] | |
| 110 | +| +80 | `chain.blob_gen_0` | physmap KVA (points to ROP blob page) | mtext[32] | |
| 111 | +| +88 | `chain.blob_gen_1` | physmap KVA (same) | mtext[40] | |
| 112 | +| +164 | `chain.flags` | `0x01` (NFT_CHAIN_BASE) | mtext[116] | |
| 113 | + |
| 114 | +When a racing packet on CPU 1 calls `nft_do_chain()` on the freed-and-reclaimed chain, it reads `blob_gen_X` which now points to our physmap ROP blob page. |
| 115 | + |
| 116 | +#### Synchronization |
| 117 | + |
| 118 | +- **Race thread ↔ Spray thread**: Implicit via `synchronize_rcu()` blocking. When the race thread blocks, the OS scheduler switches to the spray thread on the same CPU. No explicit synchronization primitives. |
| 119 | +- **Flood thread ↔ Race thread**: The flood thread's `sched_yield()` every 8th batch provides RCU quiescent states on CPU 1, allowing `synchronize_rcu()` on CPU 0 to complete quickly. |
| 120 | +- **Termination**: Shared `volatile int race_running` and `race_won` flags. The parent process polls `core_pattern` every 2 seconds. |
| 121 | + |
| 122 | +### Step 6: ROP Chain Execution via nft_immediate_eval |
| 123 | + |
| 124 | +<!-- @step(name="ROP Chain Execution") --> |
| 125 | + |
| 126 | +When the racing packet dereferences the reclaimed `blob_gen_X` pointer, it reaches our physmap page containing a fake `nft_rule_blob`. The blob is structured as: |
| 127 | + |
| 128 | +``` |
| 129 | ++0x000: blob_size = 0x90 (144 bytes) |
| 130 | ++0x008: rule_descriptor_0: dlen=128 (4 expressions x 32 bytes), is_last=0 |
| 131 | ++0x010: expression 0 (32 bytes) |
| 132 | ++0x030: expression 1 (32 bytes) |
| 133 | ++0x050: expression 2 (32 bytes) |
| 134 | ++0x070: expression 3 (32 bytes) |
| 135 | ++0x090: rule_descriptor_1: is_last=1 (end marker) |
| 136 | +``` |
| 137 | + |
| 138 | +Each expression has `ops` set to `nft_immediate_ops` (`kbase + 0x1d433e0`). The `nft_immediate_eval()` function copies `expr->data` into `regs->data[dreg]`. By targeting the `dreg` corresponding to `nft_do_chain()`'s return address on the stack, we hijack RIP. |
| 139 | + |
| 140 | +#### ROP Chain Details |
| 141 | + |
| 142 | +The ROP chain is written into `nft_do_chain()`'s stack frame via the `dreg` mechanism: |
| 143 | + |
| 144 | +| Expr | dreg | Stack Offset | Gadget / Value | Purpose | |
| 145 | +|------|------|-------------|----------------|---------| |
| 146 | +| 0 | 130 (ret addr) | rsp+0x250 | `pop rsi; pop rdi; ret` (`kbase + 0xafc2d1`) | Load args for strcpy | |
| 147 | +| 0 | (data) | | `rsi` = physmap page + 0x100 (path string), value popped by gadget | Source string | |
| 148 | +| 1 | 134 (ret+16) | rsp+0x260 | `rdi` = `kbase + 0x2bb9ec0` (`core_pattern`), then `strcpy` (`kbase + 0x12b59f0`) | Overwrite core_pattern | |
| 149 | +| 2 | 138 (ret+32) | rsp+0x270 | `pop rdi; ret` (`kbase + 0x195d8c`), `rdi` = 0x7FFFFFFF | Load msleep argument | |
| 150 | +| 3 | 142 (ret+48) | rsp+0x280 | `msleep` (`kbase + 0x243850`), `return_thunk` (`kbase + 0x16054b0`) | Sleep ~25 days (keeps system alive) | |
| 151 | + |
| 152 | +**Constant explanations**: |
| 153 | +- `DREG_RET = 130`: `(0x250 - 0x48) / 4`. `nft_do_chain()` has `sub $0x220, %rsp` + 6 pushes (48 bytes). `regs` is at `rsp+0x48`, return address at `rsp+0x250`. Register file is `uint32_t regs.data[]`, so offset `(0x250 - 0x48) / 4 = 130`. |
| 154 | +- `PATH_OFFSET = 0x100`: Offset within each physmap page where the payload string is stored, chosen to be past the 0x98-byte blob. |
| 155 | + |
| 156 | +#### Kernel Symbol Offsets (cos-113-18244.521.88 vmlinux) |
| 157 | + |
| 158 | +| Define | Offset | Symbol | |
| 159 | +|--------|--------|--------| |
| 160 | +| `OFF_CORE_PATTERN` | `0x2bb9ec0` | `core_pattern` (global variable) | |
| 161 | +| `OFF_MODPROBE` | `0x2a76a20` | `modprobe_path` (unused, fallback) | |
| 162 | +| `OFF_NFT_IMM_OPS` | `0x1d433e0` | `nft_immediate_ops` (expression ops vtable) | |
| 163 | +| `OFF_POP_RSI_RDI` | `0xafc2d1` | `pop rsi; pop rdi; ret` gadget | |
| 164 | +| `OFF_POP_RDI` | `0x195d8c` | `pop rdi; ret` gadget | |
| 165 | +| `OFF_STRCPY` | `0x12b59f0` | `strcpy` | |
| 166 | +| `OFF_MSLEEP` | `0x243850` | `msleep` | |
| 167 | +| `OFF_RETURN_THUNK` | `0x16054b0` | `__x86_return_thunk` | |
| 168 | + |
| 169 | +### Step 7: core_pattern Overwrite and Flag Exfiltration |
| 170 | + |
| 171 | +<!-- @step(name="core_pattern Overwrite and Flag Exfiltration") --> |
| 172 | + |
| 173 | +After the ROP chain executes `strcpy(core_pattern, "|/bin/dd if=/dev/vdb of=/dev/ttyS0")`, the `msleep(0x7FFFFFFF)` keeps the system alive (BUGs with "scheduling while atomic" but `schedule()` still executes, keeping the system up for ~120 seconds until `hung_task` watchdog panics). |
| 174 | + |
| 175 | +The parent process (forked before entering the exploit child) polls `/proc/sys/kernel/core_pattern` every 2 seconds. Once it detects the overwrite: |
| 176 | + |
| 177 | +1. Sets `RLIMIT_CORE` to unlimited. |
| 178 | +2. Sets `PR_SET_DUMPABLE` to 1. |
| 179 | +3. Forks a child that calls `raise(SIGSEGV)`. |
| 180 | +4. The kernel's core dump handler invokes `call_usermodehelper` with the pipe command from `core_pattern`. |
| 181 | +5. `/bin/dd if=/dev/vdb of=/dev/ttyS0` copies the flag from the virtio block device to the serial console. |
| 182 | +6. The kctf infrastructure captures the flag from serial output. |
| 183 | + |
| 184 | +**Why `core_pattern` instead of `modprobe_path`**: Inside kctf's nsjail container, `modprobe_path` resolution happens in the init mount namespace. The exploit process's memfd (`/proc/PID/fd/N`) is not visible from the init PID namespace (different PID ns), and `/tmp/pw` is on a separate tmpfs. `core_pattern` with `|/bin/dd` uses only init-namespace-visible paths (`/bin/dd` on rootfs, `/dev/vdb` and `/dev/ttyS0` on devtmpfs). |
| 185 | + |
| 186 | +**Why `msleep()` instead of `do_exit()`**: `do_exit()` from softirq context kills the current task and closes all file descriptors (including memfd FDs). `msleep()` BUGs with "scheduling while atomic" but `schedule()` still executes, keeping the system alive long enough for the parent to detect the overwrite and trigger core dumps. |
| 187 | + |
| 188 | +--- |
| 189 | + |
| 190 | +## Environmental Requirements |
| 191 | + |
| 192 | +- **CPU affinity**: The race thread and spray thread are pinned to CPU 0; the flood thread is pinned to CPU 1. This is critical because: |
| 193 | + - SLUB uses per-CPU freelists. The spray thread must run on the same CPU as the race thread to pick up the freed `nft_base_chain` slot (LIFO order). |
| 194 | + - The flood thread on a separate CPU ensures packets traverse the hook path concurrently with chain allocation/free. |
| 195 | + |
| 196 | +- **Namespaces**: User namespace + network namespace are required to gain `CAP_NET_ADMIN` for nf_tables access. The exploit calls `unshare(CLONE_NEWUSER)` then `unshare(CLONE_NEWNET)`. |
| 197 | + |
| 198 | +- **Memory**: The exploit uses 2 GB for physmap spray (auto-scaled to 75% of available RAM if less than 2 GB). The kctf VM has 3.5 GB RAM. |
| 199 | + |
| 200 | +- **SMAP detection**: If SMAP is not present (e.g., TCG mode), the exploit uses a userspace page as the fake blob instead of physmap spray, eliminating the need for phbase scanning entirely. |
| 201 | + |
| 202 | +## Separation of Concerns |
| 203 | + |
| 204 | +| Code Section | Purpose | |
| 205 | +|---|---| |
| 206 | +| `do_entrybleed()` / `leak_entrybleed()`, `leak_kernel_text_prefetch()`, `leak_direct_mapping_prefetch()` | KASLR bypass (information leak) | |
| 207 | +| `setup_ns()` | Environment setup (namespace creation) | |
| 208 | +| `saturate_ipv6_hooks()` | Vulnerability setup (creating precondition for -E2BIG failure) | |
| 209 | +| `inet_race_thread()` | Vulnerability trigger (INET NEWCHAIN creating the UAF) | |
| 210 | +| `udp_flood_thread()` | Exploitation support (providing racing packets) | |
| 211 | +| `dedicated_spray_thread()`, `spray_msgs()`, `build_spray_msg()` | Heap spraying (msg_msg reclaim of freed nft_base_chain) | |
| 212 | +| `physmap_spray()`, `physmap_fill_rop()`, `build_blob()` | ROP payload construction | |
| 213 | +| `try_core_dump()`, `try_modprobe()` | Post-exploitation (flag exfiltration) | |
0 commit comments