Skip to content
183 changes: 183 additions & 0 deletions pocs/linux/kernelctf/CVE-2026-43501_cos/docs/exploit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
# Vulnerability

## Summary

`ipv6_rpl_srh_rcv()` in `net/ipv6/exthdrs.c` processes incoming RPL type-3 routing headers. It decompresses the SRH, swaps the current segment into `ipv6_hdr(skb)->daddr`, recompresses, pulls the old header, then pushes the new IPv6 header and the recompressed SRH back.

The bug is in when `pskb_expand_head()` is called:

```c
skb_pull(skb, ((hdr->hdrlen + 1) << 3));
...
if (unlikely(!hdr->segments_left)) {
if (pskb_expand_head(skb,
sizeof(struct ipv6hdr) + ((chdr->hdrlen + 1) << 3),
0, GFP_ATOMIC))
...
oldhdr = ipv6_hdr(skb);
}
skb_push(skb, ((chdr->hdrlen + 1) << 3) + sizeof(struct ipv6hdr));
skb_reset_network_header(skb);
skb_mac_header_rebuild(skb);
```

The expand only happens when `segments_left` hits zero. On an intermediate hop `segments_left` stays non-zero, but the recompressed SRH can still grow. The `skb_push()` then moves `data` to within `mac_len` bytes of `head`, or past it. `skb_mac_header_rebuild()` calls `skb_set_mac_header(skb, -skb->mac_len)`, the negative offset wraps in the 16-bit `skb->mac_header`, and the MAC `memmove()` writes out of bounds. This is a controllable OOB write, not the DoS the same path is known for.

## Vulnerability Analysis

### One step further from Route of Death (CVE-2023-2156)

This is the same receive path as CVE-2023-2156 ("Route of Death"). The RPL SRH receive code was added in [`8610c7c6e3bd`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=8610c7c6e3bd) ("ipv6: add rpl sr tunnel", v5.7), and the [original writeup](https://www.interruptlabs.co.uk/articles/linux-ipv6-route-of-death) covers the path with good figures for how the OOB happens.

The 2023 fix only closed the `segments_left == 0` case. The intermediate-hop case was left open, and it is the more interesting one: the same `mac_header` wrap fires, except here it is no longer a `skb_under` panic, this time we can get a *controllable* **OOB write**. With the right packet setup the panic can be dodged and we can pivot the OOB write into LPE. It was finally fixed in [`9e6bf146b559`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=9e6bf146b559), affecting `v5.7-rc1` to `v7.1-rc1`.

The path is reachable from an unprivileged user. It is gated by `net.ipv6.conf.*.rpl_seg_enabled`, which needs `CAP_NET_ADMIN`, but that capability is free inside a fresh user + network namespace.
> The `CONFIG_IPV6_RPL_LWTUNNEL` config is NOT necessary as if `CONFIG_IPV6` is enabled, the vulnerable code will be compiled into kernel.

### Root cause

`pskb_expand_head()` is called only when `segments_left` drops to zero, on the assumption that the recompressed SRH can only grow at the final hop. That assumption is wrong. On an intermediate hop, changing which segment gets swapped into `daddr` changes the shared prefix length between the last segment and `daddr`, which changes `CmprE`, which changes the recompressed size.

When the recompressed SRH is larger, the following `skb_push()` leaves less headroom than `mac_len`. `skb_set_mac_header(skb, -skb->mac_len)` stores a negative value into the 16-bit `skb->mac_header`, it wraps to a large offset, and `skb_mac_header_rebuild()` memmoves `mac_len` bytes to that wrapped offset, writing before `head`.

### Triggering the wrap

The following SRH parameters produce a 16-byte growth:

- `CmprI = 5`, `CmprE = 15`
- `N = 5` compressed segments
- `segments_left = 2`
- no padding

Original SRH:

```text
old_segdata_len = 5 * (16 - 5) + (16 - 15) = 56
old_rh_len = 8 + 56 = 64
```

`segments_left` after the decrement is 1, so the expand branch is skipped. The selected segment index is:

```text
i = N - (segments_left - 1) = 4
```

Two bytes of the compressed segment data are controlled:

```c
rh->data[4 * (16 - CMPRI)] = 0x44;
rh->data[N * (16 - CMPRI)] = 0x77;
```

After the destination swap the last segment no longer shares the original 15-byte prefix with the new `daddr`. `CmprE` drops to 5, the compressed segment data grows to 66 bytes, and padding rounds the SRH to 80 bytes: 16 bytes of growth.

Raw IPv6 with `IPV6_HDRINCL` on loopback puts the IPv6 header at `head + 16` (`hard_header_len = 14`, `LL_RESERVED_SPACE()` rounds to 16). On loopback receive `eth_type_trans()` leaves `skb->mac_len = 14`. The pull/push sequence then leaves:

```text
final data - head = 16 + old_srh_len - new_srh_len = 16 - growth
```

To wrap `skb->mac_header`, `data - head` must be below 14. To pass the `skb_push()` underflow check it must be non-negative. SRH lengths are 8-byte aligned, so only two growth values are useful:

```text
growth = 8 -> data - head = 8 -> mac_header = 0xfffa -> page offset ffa
growth = 16 -> data - head = 0 -> mac_header = 0xfff2 -> page offset ff2
```

This exploit uses **growth = 16**, landing the wrapped write at page offset `ff2`.

# Exploit

## Exploit Summary

- **prefetch** -> Leak the KASLR base (via KernelXDK).
- **CVE-2026-43501** -> Splice the high bytes of a kernel `.text` address into a TPACKET `pg_vec` pointer, keeping its low 16 bits.
- **pg_vec remap** -> `mmap()` the corrupted ring so the aliased pointer maps that kernel `.text` page RW into userspace, then we can overwrite a syscall path to our kernel shellcode.
- **core_pattern** -> Trigger the matching syscall, then finish from userspace.

## Exploit Details

### Shaping the OOB write into a pg_vec pointer

The full packet is the trigger SRH with 2000 bytes of payload appended, which places the skb head in the allocation shape used for the overwrite:

```text
IPv6 header = 40
RPL SRH = 64
payload = 2000
total length = 2104
```

The target is a TPACKET_V3 RX ring:

```c
#define PAGE_NUM ((4096 - 8) / 8) // 511
packet_socket_setup(0x1000, 2048, PAGE_NUM, 0, 10000);
```

`alloc_pg_vec()` allocates an array of `struct pgv`, one pointer per block. The array fits in a single page, so with `PAGE_NUM = 511` the last pointer `pg_vec[510]` starts at offset `0xff0`, leaving 8 bytes of tail slack.

The ring map is one-shot: if 511 entries are allocated, mapping only a prefix is not possible. That's the main reason we choose offset `ff2` instead of `ffa`. A 14-byte MAC-header copy from `ff2` overwrites bytes 2..7 of `pg_vec[510].buffer`, splicing the destination MAC into the high bytes of the pointer while leaving its low 16 bits intact:

```text
pg_vec[510].buffer:
byte 0 byte 1 byte 2 .. byte 7
preserved preserved overwritten by the MAC bytes
```

> A copy from `ffa` would land entirely in the 8-byte slack past the object and corrupt nothing useful.

The loopback hardware address carries the high bytes of the KASLR-adjusted text target:

```c
target = leaked_base + 0x1e0000;
lo_addr_pattern[0] = target >> 16;
lo_addr_pattern[1] = target >> 24;
lo_addr_pattern[2] = 0xff;
lo_addr_pattern[3] = 0xff;
lo_addr_pattern[4] = 0xff;
lo_addr_pattern[5] = 0xff;
```

The netdevice setter forces the first MAC byte to a unicast, locally-administered value. In the chosen window that byte is `0x1e`, which already satisfies the rule, so no adjustment is needed. After the copy the pointer is:

```text
before: [old0 old1 |old2 old3 | old4 old5 old6 old7]
after: [old0 old1 |target16 target24| 0xff 0xff 0xff 0xff]
```

### Patching a kernel .text page

`pg_vec[i].buffer` is a kernel virtual address, and `packet_mmap()` resolves it through `virt_to_page()` and maps the backing physical page into userspace RW. Point the pointer at a kernel `.text` address and the `mmap()` hands back a writable alias of read-only kernel text.

The preserved low 16 bits mean the aliased page is one of 16 4K pages in the 64K window at `leaked_base + 0x1e0000` (the page-aligned original buffer supplies bits 12..15, picking the page). To learn which one landed, WE read the first qword and match it against 16 known page signatures.

Only **pages 0..8** are used. These are the nine pages that lie on a path reachable by a plain syscall, each with a fixed in-page patch offset:

```text
page 0 -> 0xde0 -> pidfd_send_signal
page 1 -> 0xbe0 -> umask
page 2 -> 0x860 -> uname
page 3 -> 0x7f0 -> sysinfo
page 4 -> 0x100 -> setpriority
page 5 -> 0x6b0 -> prlimit64
page 6 -> 0xd30 -> setuid
page 7 -> 0xea0 -> getrusage
page 8 -> 0x100 -> prctl
```

The remaining pages are dropped. Page 10 has no clean patch site, and pages 9 and 11..15 sit on workqueue / module-autoload code, which we found painful to write shellcode for them.

The shellcode is 9 qwords (72 bytes) and position-independent. We used the `rdmsr` + `core_pattern` + `msleep` route from [CVE-2024-36972_lts_cos](https://github.com/google/security-research/blob/master/pocs/linux/kernelctf/CVE-2024-36972_lts_cos/docs/exploit.md#achieve-container-escape). After patching, the matching syscall is fired up to 20 times until `core_pattern` reads back the payload.

## Disclosure Notes

We sent this bug to `security@kernel.org` on February 22, 2026, with the root cause, the affected configuration, a full PoC, and shortly after, a patch draft.

It did not get handled as a real-world bug. Part of the early discussion treated COS as out of scope. The exploit above runs on a stock COS image.

Once the patch draft was on the table, the conversation moved to whether "serious" deployments would have user namespaces disabled anyway. That is not a valid triage argument for a memory corruption bug reachable from an unprivileged user via a fresh user and network namespace.

The fix landed 60 days after our patch, on the back of a later report credited to Anthropic. The bug being fixed is what matters, and we are glad it is. We still think our patch handled the functionality side more cleanly, but that is a secondary point.

What is harder to accept: a report with root cause, full PoC, and a patch draft had to wait for a second submission from a louder name before it moved.
13 changes: 13 additions & 0 deletions pocs/linux/kernelctf/CVE-2026-43501_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_IPV6=y, CONFIG_IPV6_RPL_LWTUNNEL=y`
- **User namespaces required**: Yes
- **Introduced by**: https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=8610c7c6e3bd647ff98d21c8bc0580e77bc2f8b3
- **Fixed by**: https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=9e6bf146b55999a095bb14f73a843942456d1adc
- **Affected Version**: `v5.7-rc1 - v7.1-rc1`
- **Affected Component**: `net/ipv6: RPL source routing header receive path`
- **Syscall to disable**: `unshare`
- **Cause**: Out-of-bounds write
- **Description**: An out-of-bounds write vulnerability was discovered in the Linux kernel's IPv6 RPL routing-header receive path. `ipv6_rpl_srh_rcv()` can recompress a type-3 RPL Source Routing Header into a larger header while `segments_left` remains non-zero. The old code only expanded skb headroom when `segments_left` became zero, so the later `skb_push()` can leave less than `skb->mac_len` bytes before `skb->data`. `skb_mac_header_rebuild()` then wraps the 16-bit `skb->mac_header` offset and copies a 14-byte MAC header out of bounds.
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
CXX ?= g++
CPPFLAGS ?= -Ikernel-research/libxdk/include
CXXFLAGS ?= -std=gnu++17 -static -O2
LDFLAGS ?= -static -s -Lkernel-research/libxdk/lib
LDLIBS ?= -lkernelXDK

TARGETS := exploit exploit_debug
SRC := exploit.c

.PHONY: all prerequisites run clean

all: prerequisites exploit

prerequisites: target_db.kxdb kernel-research/libxdk/lib/libkernelXDK.a

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

kernel-research:
git clone --depth 1 https://github.com/google/kernel-research.git $@

kernel-research/libxdk/lib/libkernelXDK.a: | kernel-research
cd kernel-research/libxdk && ./build.sh

exploit: prerequisites $(SRC)
$(CXX) $(CPPFLAGS) $(CXXFLAGS) $(SRC) -o $@ $(LDFLAGS) $(LDLIBS)

exploit_debug: CXXFLAGS += -g
exploit_debug: prerequisites $(SRC)
$(CXX) $(CPPFLAGS) $(CXXFLAGS) $(SRC) -o $@ $(LDFLAGS) $(LDLIBS)

run: exploit
./$<

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