Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions pocs/linux/kernelctf/CVE-2026-31418_cos/docs/exploit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# **Vulnerability**

## Summary
In `net/netfilter/ipset/ip_set_hash_gen.h`, a logically empty bucket can survive deletion, shrink into a zero-slot bucket, and later be reused by `mtype_add()` with `forceadd` enabled, leading to an Out-of-Bounds Write vulnerability. This allows an attacker to write 4 controlled bytes immediately past the end of the allocated storage.

Specifically, in `mtype_del()`, `k` counts unused slots in `[0, n->pos)`. After deleting an element, the code frees the bucket only when `n->pos == 0 && k == 0`. That condition is wrong: the bucket is logically empty whenever all slots in `[0, n->pos)` are unused, i.e. when `k == n->pos`. Once `mtype_del()` fails to recognize the bucket as empty and shrinks it instead, if `n->size == AHASH_INIT_SIZE`, the shrink path allocates only the bucket header, i.e. no element storage, sets `tmp->size = 0`, and installs a zero-sized bucket. Later, when the set is full and `forceadd` is enabled, `mtype_add()` takes the reuse/forceadd path before attempting to grow the bucket. If no reusable index is found, it forces `j = 0`. `ahash_data(n, 0, set->dsize)` then points past the end of the zero-sized bucket, and the subsequent `ip_set_ext_destroy()` and element copy access memory out of bounds.

With this 4-byte OOB write, we can overwrite a neighboring PTE and achieve arbitrary physical memory read/write.

## **Vulnerability Analysis**

### BIC
The root cause of this vulnerability was introduced in Linux kernel v4.2-rc1 by commit [18f84d4](https://github.com/torvalds/linux/commit/18f84d41d34f#diff-82b2b1e15c116f3c78055ad0bf240d0fe452e931cce4a13f39efe59958217315R885) ("netfilter: ipset: Introduce RCU locking in hash:* types"), which introduced the wrong classification of logically empty buckets.

The bug trigger path was introduced later in Linux kernel v5.6-rc4 by commit [8af1c6f](https://github.com/torvalds/linux/commit/8af1c6fbd923) ("netfilter: ipset: Fix forceadd evaluation path"), which introduced the last piece that makes `ahash_data(n, j, set->dsize)` point past zero-sized storage and thus leads to the OOB write.

That later change turns the earlier inconsistent bucket state into a directly triggerable out-of-bounds access/write.

### Root cause
In `net/netfilter/ipset/ip_set_hash_gen.h`:

`mtype_del()` can create a bucket with zero data slots in the shrink path:
- The `k >= AHASH_INIT_SIZE` branch allocates `sizeof(*tmp) + (n->size - AHASH_INIT_SIZE) * dsize`.
- If `n->size == AHASH_INIT_SIZE`, then `tmp->size = 0`, and a zero-sized bucket object remains installed.

```c
// mtype_del() shrink path
if (n->pos == 0 && k == 0) {
// This condition is wrong: the bucket is logically empty whenever
// all slots in [0, n->pos) are unused, i.e. when k == n->pos.
t->hregion[r].ext_size -= ext_size(n->size, dsize);
rcu_assign_pointer(hbucket(t, key), NULL);
kfree_rcu(n, rcu);
} else if (k >= AHASH_INIT_SIZE) {
struct hbucket *tmp = kzalloc(sizeof(*tmp) +
(n->size - AHASH_INIT_SIZE) * dsize,
GFP_ATOMIC);
if (!tmp)
goto out;
tmp->size = n->size - AHASH_INIT_SIZE;
for (j = 0, k = 0; j < n->pos; j++) {
if (!test_bit(j, n->used))
continue;
data = ahash_data(n, j, dsize);
memcpy(tmp->value + k * dsize, data, dsize);
set_bit(k, tmp->used);
k++;
}
tmp->pos = k;
t->hregion[r].ext_size -=
ext_size(AHASH_INIT_SIZE, dsize);
rcu_assign_pointer(hbucket(t, key), tmp);
kfree_rcu(n, rcu);
}
```

Later, in `mtype_add()`, when the set is full and `forceadd` is enabled:
- `if (reuse || forceadd)` executes before the grow path.
- `if (j == -1) j = 0;` forces index 0 even when `n->size == 0` and `n->pos == 0`.
- `data = ahash_data(n, j, set->dsize)` then points past zero-sized storage.
- `ip_set_ext_destroy(set, data)` and `memcpy(data, d, sizeof(struct mtype_elem))` perform out-of-bounds access/write.

```c
// mtype_add() forceadd path
if (reuse || forceadd) {
if (j == -1)
j = 0;
data = ahash_data(n, j, set->dsize);
if (!deleted) {
...
ip_set_ext_destroy(set, data);
t->hregion[r].elements--;
}
goto copy_data;
}
...
copy_data:
t->hregion[r].elements++;
...
memcpy(data, d, sizeof(struct mtype_elem));
```

This gives us a 4-byte OOB write at `data`, right past the end of the zero-sized bucket.

The bug was patched in v7.0-rc6 by our team after the kCTF submission.

# Exploit

## Exploit Summary
- **Stage 1:** Spray PTEs so that a PTE page is placed after the vulnerable bucket, then use `CVE-2026-31418` to leak the physical KASLR.
- **Stage 2:** Spray PTEs again so that a PTE page is placed after the vulnerable bucket, then use `CVE-2026-31418` to overwrite `core_pattern`.

## Exploit Details

### Trigger the bug

1. Create a `hash:ip` set with `hashsize=64`, `maxelem=3`, fixed `initval`, `timeout`, and `forceadd`.
2. Add colliding `A` (long timeout), `B` (short timeout), plus filler `C`.
3. Sleep until `B` expires, then add `D` to induce a GC pass under full-set conditions.
4. Delete `A` to hit `mtype_del()` shrink and leave a `size=0` bucket object behind.
5. Add filler `E` to return the set to full.
6. Add colliding `F` so that the `forceadd` path writes via `ahash_data(n, 0, ...)` on the zero-sized bucket and triggers the OOB write.

In our exploit, we first prepare multiple source pages and then recover source-page boundaries from the timing of `del_ip(A)`. Concretely, we burn 8 full source pages and keep 16 candidate source pages. Each source page corresponds to 128 `kmalloc-32` source objects. We select one candidate source page based on timing, free the trailing candidate pages behind it, and then trigger the final OOB write across all 128 sets in the selected source page.

This way, one of the resulting OOB writes can cross a page boundary and overwrite a neighboring PTE entry.

### Spray PTEs
The exploit sprays PTEs with 8192 sparse 4 KiB mappings at a 2 MiB stride, and also prepares 128 separate 2 MiB regions that are later scanned for leaked or retargeted mappings.

As kernelCTF uses 3 GiB of low physical memory, the remaining upper 512 MiB region effectively differs by `0x100000000`, which means our 4-byte write cannot control the fifth byte of the target PTE. As a result, if we overwrite a PTE whose fifth byte is already set, we usually cannot make it point into the kernel image region. For kernel memory, the fifth byte is often zero. Possible exceptions are the physical address of `cpu_entry_area`, whose fifth byte could be set and may also work, and the `module allocation` range. However, kernelCTF does not load `.ko` modules, so the latter is not useful here.

Meanwhile, physical pages are more likely to be allocated first from that upper 512 MiB region. To reduce that probability, we first allocate and touch roughly 500 MiB of memory in order to drain the upper 512 MiB region as much as possible. This gives us a higher chance that the sprayed PTE pages are allocated from the lower 3 GiB region, whose fifth byte is zero and can therefore be controlled by our 4-byte OOB write to point into the kernel image region.

### Leak physical address
There are many fixed physical addresses that contain interesting data across different Linux kernel versions. Here we only use `0x9c000` (`0x9c067` as the PTE value) to leak the physical KASLR. This technique is used in many CTF writeups, including [this one from corCTF](https://uniguri.com/posts/kernel/write-ups/ctf/corctf2022-corjail/).

After we read the value at physical address `0x9c000`, we adjust it by an offset (`0x3a04000` in our case) to recover the real physical base address. This offset differs across different kernel builds, so we currently need to determine it manually for each target. We do not yet know whether there is a fixed physical address that can be used without this per-build adjustment.

### Arbitrary physical read/write
After we recover the physical KASLR, we can calculate the physical address of `core_pattern`, trigger the bug again, overwrite the PTE entry so that it points to `core_pattern`, and then modify it to complete the privilege escalation.

## Additional Notes

### Why not use DirtyPageTable
Currently there is no public writeup for a Dirty {PageTable,Pipe}-style file-overwrite exploit in kCTF, as it seems we cannot directly use it to modify `/etc/passwd` in the kCTF jail environment.

It may still be possible to modify some shared libraries and hope that some root process on the host will later execute them, but since `core_pattern` already works well here, we did not spend time trying to make that path work.

### Why not use libxdk
We only have two hard-coded values that need to be changed per target:
- the offset of the `core_pattern` symbol
- the offset used to adjust the physical ASLR leak (`0x3a04000` in the current exploit)

`libxdk` is powerful. However, the only part we could use it for here is resolving the offset of `core_pattern`.

The physical ASLR leak still requires a per-target adjustment, and that does not seem to be supported by `libxdk`. So integrating `libxdk` would solve only one symbol dependency, while the physical-address dependency would still remain. Instead, we simply keep those two hard-coded values in the exploit.
13 changes: 13 additions & 0 deletions pocs/linux/kernelctf/CVE-2026-31418_cos/docs/vulnerability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Vulnerability Details

- **Requirements**:
- **Capabilities**: `CAP_NET_ADMIN`
- **Kernel configuration**: `CONFIG_NETFILTER=y, CONFIG_NETFILTER_NETLINK=y, CONFIG_IP_SET=y, CONFIG_IP_SET_HASH_IP=y`
- **User namespaces required**: Yes
- **Introduced by**: https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=8af1c6fbd9239877998c7f5a591cb2c88d41fb66
- **Fixed by**: https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=9862ef9ab0a116c6dca98842aab7de13a252ae02
- **Affected Version**: `5.6-rc4 - 7.0-rc7`
- **Affected Component**: `net/netfilter: ip_set`
- **Syscall to disable**: `unshare`
- **Cause**: Out-of-Bounds-Write
- **Description**: An Out-of-Bounds Write vulnerability was discovered in the Linux kernel's netfilter subsystem. mtype_del() could miss buckets whose live entries have all been removed while n->pos still points past deleted slots, leading to a Out-of-Bounds Write vulnerability.
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
CC := g++
CFLAGS := -static
LDLIBS := -lkernelXDK

TARGETS := exploit exploit_debug
SRC := exploit.c

.PHONY: all prerequisites run clean

all: prerequisites exploit

prerequisites: target_db.kxdb

target_db.kxdb:
wget -O $@ https://storage.googleapis.com/kernelxdk/db/kernelctf.kxdb

exploit: $(SRC)
$(CC) $(CPPFLAGS) $(CFLAGS) $< -o $@ $(LDFLAGS) $(LDLIBS)

exploit_debug: CFLAGS += -g
exploit_debug: $(SRC)
$(CC) $(CPPFLAGS) $(CFLAGS) $< -o $@ $(LDFLAGS) $(LDLIBS)

run: exploit
./$<

clean:
rm -rf $(TARGETS) target_db.kxdb
Binary file not shown.
Loading
Loading