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
272 changes: 272 additions & 0 deletions pocs/linux/kernelctf/CVE-2026-53362_lts/docs/exploit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
# Exploit

The `lts-6.12.85` exploit turns the Fraggap out-of-bounds write in
`__ip6_append_data()` into an undercounted pipe page, reclaims that page as a
leaf page-table page, and uses forged PTEs to map physical memory. It then
locates and overwrites `core_pattern`; running exactly `./exploit` causes a
memfd-backed core-dump helper to print `/flag`.

## Exploit Summary

- **Triggering Fraggap** -> Two pipe-to-UDPv6 splices make a 15-byte
`fraggap` copy run past the new skb's linear tail into
`skb_shared_info`.
- **Planting a stale fragment descriptor** -> A groomed
`skbuff_small_head` leaves a source-pipe page descriptor in `frags[0]`,
while the out-of-bounds copy changes `nr_frags` from zero to one.
- **Undercounting the pipe page** -> The remaining second-splice payload is
installed normally as `frags[1]`; freeing the skb therefore drops one stale
reference from the source page and one valid reference from the trigger
page.
- **Reclaiming a leaf PTE page** -> Closing eight holding pipes reduces the
source page's actual refcount to zero while the original pipe buffer still
points to it, allowing the page to be reused as a leaf page table.
- **Mapping physical memory** -> Writes through the stale pipe buffer install
attacker-selected PTEs in fresh virtual slots, producing physical read and
write access without RIP control.
- **Executing the flag helper** -> The exploit finds the relocated
`core_pattern`, writes `|/proc/%P/fd/666 %P`, and crashes a child so the
kernel executes the memfd-backed exploit as the root core helper.

## Exploit Details

### Triggering the Fraggap out-of-bounds write

The vulnerable path is the UDPv6 corking path in `__ip6_append_data()`. The
exploit creates an unprivileged IPv6 datagram socket on loopback, disables PMTU
discovery, sets the socket MTU to `1287`, installs a 320-byte type-4 IPv6
routing header, and enables `UDP_CORK`. Data is then sent from pipes with
`splice(..., SPLICE_F_MORE)`, which reaches the `MSG_SPLICE_PAGES` paged
allocation path.

The trigger geometry is:

| Value | Purpose |
| --- | --- |
| `IPV6_EXT_LEN = 320` | Makes `fragheaderlen` equal to 360 bytes, including the IPv6 header |
| `TARGET_MTU = 1287` | Produces `maxfraglen = 1272` |
| `FIRST_SPLICE_LEN = 919` | Builds a 1287-byte first skb, 15 bytes beyond `maxfraglen` |
| `SECOND_SPLICE_LEN = 20` | Enters the vulnerable new-skb path and then appends one valid page fragment |
| `SHINFO_INBUF_OFF = 904` | Selects the first payload byte copied beyond the fragment boundary |

The first skb contains 360 bytes of IPv6 headers, an 8-byte UDP header, and
919 bytes of pipe-backed payload. Its length is therefore 1287 bytes, while
`maxfraglen` is 1272, so the second splice computes:

```text
fraggap = skb_prev->len - maxfraglen = 1287 - 1272 = 15
```

For the new paged skb, the vulnerable branch accounts for that gap in
`datalen` but not in the linear allocation:

```text
datalen = length + fraggap
alloclen = fragheaderlen + transhdrlen
pagedlen = datalen - transhdrlen
data = skb_put(skb, fraglen - pagedlen)
copy fraggap bytes to data + transhdrlen
```

On this second skb, `transhdrlen` is zero. Consequently,
`fraglen - pagedlen` collapses to the 360-byte fragment-header area, and
`data + transhdrlen` points at the end of the linear data. The subsequent
15-byte `skb_copy_and_csum_bits()` operation copies the tail of the previous
skb directly into offsets `0x00..0x0e` of the trailing
`struct skb_shared_info`.

The copied bytes come from first-splice payload offsets `904..918`. The input
is zeroed except for:

```c
trig[SHINFO_INBUF_OFF + 2] = 1;
```

Offset `0x02` of `struct skb_shared_info` is `nr_frags`, so the out-of-bounds
copy changes it to one. The fragment array starts at offset `0x30` and is not
overwritten by the 15-byte copy.

### From stale skb metadata to a dangling pipe page

The initial skb setup clears the leading `skb_shared_info` fields, including
`nr_frags`, but it does not clear the later `frags[]` array. The exploit uses
that partial initialization to preserve an old `skb_frag_t` whose page field
points to a selected pipe page.

The page and skb grooming proceed in this order:

1. The exploit writes 256 bytes of `0x41` into the source pipe `wp`, allocating
one order-0 pipe page.
2. Eight calls to `tee()` clone that pipe buffer into eight holding pipes.
These references keep the page alive while its descriptor is planted and
reused.
3. One corked grooming socket receives
`904 + 16 * 912 = 15496` pipe-backed bytes. After the first 904 bytes, the
sixteen 912-byte portions create sixteen target skb heads whose
`frags[0]` descriptors refer to the source pipe page.
4. The grooming socket is closed immediately before the two trigger splices.
This releases the sixteen target heads together; their page references are
balanced, but descriptor-shaped bytes remain in the freed objects.
5. The vulnerable second skb reuses one of those heads. The 15-byte Fraggap
overwrite sets `nr_frags = 1` while leaving the stale `frags[0]`
descriptor at offset `0x30` intact.
6. The second `splice()` still has 20 bytes to consume. On the next append
iteration, `skb_splice_from_iter()` installs the trigger-pipe page as the
valid `frags[1]` entry and advances `nr_frags` to two.
7. Closing the trigger socket frees the corked write queue. The skb release
path walks both entries: `frags[0]` performs an unmatched page put on the
source pipe page, while `frags[1]` releases the valid reference acquired
for the second trigger pipe.

The target heads come from the dedicated `skbuff_small_head` cache. Each
704-byte object provides 384 bytes of linear head space followed by the
320-byte `skb_shared_info`. The 320-byte routing header and its option storage
use `kmalloc-512`, while the first trigger skb requires `kmalloc-1k`. The
vulnerable second skb is the 384-byte small-head case, so those unrelated
allocations do not consume the sixteen groomed target objects.

The relevant release path is:

```text
close(trigger socket)
-> udp_v6_flush_pending_frames()
-> ip6_flush_pending_frames()
-> kfree_skb()
-> skb_release_data()
-> __skb_frag_unref(frags[0]) # stale source-pipe page
-> __skb_frag_unref(frags[1]) # valid trigger-pipe page
```

Ignoring temporary grooming references, which are acquired and released in
balanced pairs, the source page's refcount evolves as follows:

| Point | Logical owners | Intended refcount | Actual refcount |
| --- | ---: | ---: | ---: |
| Source pipe contains the page | 1 | 1 | 1 |
| Eight holding pipes created with `tee()` | 9 | 9 | 9 |
| Corrupted skb is released | 9 | 9 | 8 |
| Eight holding pipes are closed | 1 | 1 | 0 |

At the final point, the page allocator sees a free page, but the original
`wp` pipe buffer still contains its page pointer, offset, length, and merge
flag. This dangling pipe buffer is the input primitive for the page-table
reclaim.

### Reclaiming the dangling page as a leaf PTE page

The exploit is pinned to CPU 0 so the skb grooming, page free, and page-table
allocation use the same CPU-local allocator state. Before dropping the holding
references, it reserves a 3 GiB anonymous mapping, aligns a 2 GiB working
window to a 1 GiB boundary, and marks the mapping `MADV_NOHUGEPAGE`. One page
in each GiB is touched to populate the upper page-table levels without
allocating the leaf page used by the reclaim attempt.

After the source page's refcount reaches zero, the exploit faults 31 pages at
4 KiB intervals beginning 2 MiB into the aligned window. These faults require
a fresh order-0 leaf PTE page. If the allocator returns the recently freed
pipe page, the stale `wp` buffer and the page-table walker now refer to the
same physical page.

The source pipe originally covered 256 bytes filled with `0x41`. Reading
31 PTE-sized values, or 248 bytes, through the stale pipe checks the reclaim:

- If all 248 bytes are still `0x41`, the selected page was not reused as the
leaf page table.
- If the bytes changed and at least one entry is present with a plausible
physical address, the pipe is exposing the new PTE page.

The 248-byte read also advances the pipe buffer to offset 248 with eight bytes
remaining. The exploit then appends eight known-good PTE values. Because the
buffer retains `PIPE_BUF_FLAG_CAN_MERGE`, those 64 bytes are written at page
offsets 256 through 319, corresponding to PTE slots 32 through 39. The next
pipe write therefore begins at PTE slot 40, beyond the 31 entries keeping the
reclaim mapping alive.

If the small-head reuse or page-table reclaim does not occur, the exploit does
not retry in-process. It pauses instead of unmapping or closing the stale
objects, avoiding unsafe teardown after a partially successful attempt.

### Converting the reclaimed page table into physical access

At this point the exploit uses the standard dirty-pagetable transition. Pipe
writes begin at PTE slot 40; the exploit preserves the permission and NX bits
from a real entry, replaces the physical frame number, and uses a fresh virtual
slot for each physical page. This yields the physical read/write primitive
used by the final payload without a kernel-text leak or RIP control.

### core_pattern flag path

kernelXDK supplies the `lts-6.12.85` link-time physical address of
`core_pattern` (`0x4611740`) and its 16 MiB physical-KASLR alignment. The
exploit checks 224 aligned candidates across the target's low and high RAM
ranges, matching `core_name_size == 0x80`, the live value read from
`/proc/sys/kernel/core_pattern`, and its terminating NUL. It then writes:

```text
|/proc/%P/fd/666 %P
```

The exploit has already copied itself into memfd 666. Crashing a dumpable
child makes the kernel execute that memfd as the core helper; the helper
receives the crashing PID as an argument, reconnects the child's standard
descriptors with pidfds, and prints `/flag`. The main process remains alive to
avoid tearing down the stale page-table state.

## Additional Notes

### Build, run, and verification

The package Makefile builds the exploit, and its normal execution command is:

```text
./exploit
```

The normal flag path uses no command-line options, wrapper script, environment
variable, or manual setup step.

The recorded local verification campaign on `2026-07-15` used:

| Item | Value |
| --- | --- |
| Target | `lts-6.12.85` |
| Kernel image | `runs/remote/lts-6.12.85/bzImage` |
| Harness command | Pre-migration: `REMOTE=1 ./run.sh lts-6.12.85`; current equivalent: `./run.sh Fraggap lts-6.12.85` |
| Guest command | `./exploit` |
| Reported kernel | `Linux 6.12.85`, built `2026-04-30` |
| Result | 20 flag-producing runs in 20 valid exploit attempts |

One additional harness launch failed to mount the guest root filesystem before
a shell appeared, so no exploit process ran on that boot and it was excluded
from the denominator; a later diagnostic boot completed normally. The 20/20
figure is a recorded local QEMU result, not a formal kernelCTF evaluator
result or a guarantee of universal 100% reliability. Per-run raw transcripts
are not included in this draft package.

### kernelXDK

The source embeds `exploit/lts-6.12.85/target_db.kxdb`, initializes
`TargetDb`, and registers the two target values used by the physical scan. It
uses `AutoDetectTarget()` with an explicit fallback and retrieves the values
through `GetSymbolOffset()`. The Makefile compiles the source as C++ against
`exploit/lts-6.12.85/libxdk-v0.1/` and links `-lkernelXDK`; no ROP-related
libxdk API is needed by this data-only chain.

### Mitigation notes

The vulnerability is fixed by accounting for `fraggap` in both `alloclen` and
`pagedlen`, which keeps the carried bytes inside the new skb's linear area.
Blocking `splice` prevents this exploit's `MSG_SPLICE_PAGES` trigger but is not
a substitute for that fix. A static usermode-helper configuration can block
the `core_pattern` payload used here, but it does not remove the physical
memory primitive obtained from the reclaimed PTE page.

### Submission placeholders

The original-archive provenance remains a draft field.
The user-filled archive values are intentionally blank:

```text
original.tar.gz:
submitted_sha256:
```
13 changes: 13 additions & 0 deletions pocs/linux/kernelctf/CVE-2026-53362_lts/docs/vulnerability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Vulnerability Details

- **Requirements**:
- **Capabilities**: None
- **Kernel configuration**: `CONFIG_IPV6=y`
- **User namespaces required**: No
- **Introduced by**: https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=773ba4fe9104a64a54d1c00f0fb6ffb95def2b03
- **Fixed by**: https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=736b380e28d0480c7bc3e022f1950f31fe53a7c5
- **Affected Version**: `v6.6-rc1 - v6.12.94`
- **Affected Component**: ipv6
- **Syscall to disable**: `splice`
- **Cause**: Out-of-bounds
- **Description**: An out-of-bounds write was discovered in the Linux kernel's IPv6 UDP corking path. When `MSG_SPLICE_PAGES` makes `__ip6_append_data()` enter the paged allocation path, a later append to an already corked skb can compute a non-zero `fraggap` from the previous skb length. The vulnerable paged branch sets `alloclen = fragheaderlen + transhdrlen` and `pagedlen = datalen - transhdrlen` without reserving or subtracting that `fraggap`, even though `datalen` already includes the gap. The new skb linear area is therefore short by `fraggap` bytes while the later copy still writes previous skb tail bytes into `data + transhdrlen`. This can overwrite trailing `struct skb_shared_info` metadata.
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
KERNELXDK_DIR ?= ./libxdk-v0.1
CXXFLAGS ?= -O2 -Wall -static -I$(KERNELXDK_DIR)
LDFLAGS ?= -L$(KERNELXDK_DIR)
LDLIBS ?= -lkernelXDK -pthread

exploit: exploit.c target_db.kxdb $(KERNELXDK_DIR)/libkernelXDK.a
g++ $(CXXFLAGS) -x c++ $< -o $@ $(LDFLAGS) $(LDLIBS)

all: exploit

.PHONY: prerequisites
prerequisites:

.PHONY: run
run: exploit
./exploit

.PHONY: clean
clean:
rm -f exploit
Binary file not shown.
Loading
Loading