Writeup

Frag Gap

10 min read

Abstract

Fraggap (CVE-2026-53362) is a bug in the Linux UDPv6 corking path that allows a 15-byte OOB write into skb_shared_info. The code is here.

fraggap exploit flow

Vulnerability Summary

CONFIG_IPV6=y is required.

  1. Two splice() calls overwrite the first 15 bytes of skb_shared_info behind a new skb.
  2. The OOB write makes a leftover pipe address on the heap act as frags[0] and then triggers put_page() to create a dangling pipe page.
  3. That page is reclaimed as a PTE page so physical memory can be read and written.
  4. It rewrites core_pattern to run a root helper.

The faulty accounting came in with commit 773ba4fe9104. The current MSG_SPLICE_PAGES trigger became reachable after commit ce650a166335. The IPv6 path was fixed in commit 736b380e28d0.

Vulnerability Analysis

Overview

UDP corking collects several sends into one datagram. A datagram can cross a fragment boundary. Then __ip6_append_data() counts the bytes past the boundary in the previous skb as fraggap and moves them into the linear area of the next skb. The basic skb looks like the following.

pasted image 1784187985518

fraggap is data that will be copied into the linear area. So it must be part of the new skb linear allocation and must be excluded from the length left on the pipe page. The vulnerable code does the opposite. It removes fraggap from the linear data area and puts it into pagedlen.

The kernel copies the fraggap bytes into the linear tail without reserving space in the data area. This lets us set the skb_shared_info right behind the tail to any 15 bytes. Setting only the single nr_frags byte to 1 makes the out-of-range frags[0] treat a leftover pipe page address on the heap as an allocated address.

Root cause

This bug happens because the same bytes are counted differently depending on the path. Building datalen includes fraggap as data the next skb must handle. The paged branch instead treats the same fraggap as part of external data.

datalen = length + fraggap;
fraglen = datalen + fragheaderlen;
pagedlen = 0;

/* ... */

else {
    alloclen = fragheaderlen + transhdrlen;
    pagedlen = datalen - transhdrlen;
}

/* ... */

copy = datalen - transhdrlen - fraggap - pagedlen;
if (copy < 0 && !(flags & MSG_SPLICE_PAGES)) {
    err = -EINVAL;
    goto error;
}

datalen adds fraggap but alloclen does not. pagedlen is derived from datalen so it also includes the gap. This shrinks copy to -fraggap. The negative-copy check is skipped when MSG_SPLICE_PAGES is set.

After that the code ignores that fraggap is non-linear data and does a real linear copy.

data = skb_put(skb, fraglen - pagedlen);
data += fragheaderlen;

if (fraggap) {
    skb->csum = skb_copy_and_csum_bits(
        skb_prev, maxfraglen,
        data + transhdrlen, fraggap);
    /* ... */
}

In the end the paged branch copies the gap content without making room for it.

Triggering the 15-byte OOB

The goal is to make the first skb exactly 15 bytes larger than the fragment boundary. The linear tail of the next skb then lands on the start of skb_shared_info.

  1. A UDPv6 loopback socket with a 320-byte routing header and MTU 1287
  2. A 919-byte pipe payload that makes the first skb 15 bytes larger than the fragment boundary
  3. A second 20-byte pipe payload appended to the same cork queue

A splice() from a pipe into a socket makes splice_to_socket() build a bvec iterator and set MSG_SPLICE_PAGES. SPLICE_F_MORE becomes MSG_MORE. With UDP_CORK this keeps the first skb in the write queue instead of sending it.

msg.msg_flags = MSG_SPLICE_PAGES;
if (flags & SPLICE_F_MORE)
    msg.msg_flags |= MSG_MORE;

/* ... */

iov_iter_bvec(&msg.msg_iter, ITER_SOURCE, bvec, bc,
               len - remain);
ret = sock_sendmsg(sock, &msg);

The call chain is udpv6_sendmsg()ip6_append_data()__ip6_append_data()skb_splice_from_iter(). Plain UDP is used so udpv6_sendmsg() picks ip_generic_getfrag. __ip6_append_data() keeps the page-splice path and sets paged = true. This reaches the vulnerable path that attaches the payload as an skb fragment.

The actual sequence is as follows.

  1. The first splice(919, SPLICE_F_MORE) appends length = 927 after adding the 8-byte UDP header. The 320-byte routing header makes fragheaderlen 360. The first skb length becomes exactly 1287.
  2. UDP_CORK keeps the skb in the queue. The computed fragment boundary is 1272 so the last 15 bytes of the skb tail must move to the next fragment.
  3. The second splice(20, SPLICE_F_MORE) finds the tail skb in the same queue. The first copy is 0 and smaller than the new length 20 so it is recomputed against the boundary as copy = -15. This value enters alloc_new_skb and creates a new skb with fraggap = 15.

pasted image 1784190488457

The needed calculations are below.

fragheaderlen = 40 + 320 = 360
maxfraglen    = ((1287 - 360) & ~7) + 360 - 8 = 1272
first skb len = 360 + 8 + 919 = 1287
fraggap       = 1287 - 1272 = 15

second append: length=20, transhdrlen=0
datalen=35, alloclen=360, pagedlen=35, copy=-15

Adding the 16-byte loopback headroom and the 8-byte fragment-header reserve makes the alloc_skb() request 384. On lts-6.12.85 this request uses a 704-byte skbuff_small_head object. The first 384 bytes are the skb data area and the last 320 bytes are skb_shared_info.

skb_put(360) moves the tail exactly to skb->end and the following data += fragheaderlen points to the same address. So the last 15 bytes of the previous skb are copied into skb_shared_info + 0x00 through +0x0e. This lets us write the 15 bytes we want into skb_shared_info.

pasted image 1784190717346

Primitive to Exploit

We can now write 15 bytes at the start of skb_shared_info. The first skb holds 1272 - 360 - 8 = 904 payload bytes before the boundary. So source payload [904..918] maps to destination [+0x00..+0x0e].

nr_frags sits at skb_shared_info + 0x02. So the exploit sets only source byte 906 to 1 and leaves the rest at 0.

pasted image 1784190867845

nr_frags is at offset 0x02 and frags[0] is at 0x30. The whole skb_shared_info is 320 bytes. The full pahole output is in the Appendix.

With only 15 bytes we can control little more than a flag. So the only useful field is nr_frags. The frags[0] that will hold the real page descriptor is outside the OOB range. How do we solve this?

Solving the problems below gives root.

Exploit Details

Exploit Summary

  • Reuse Uninitialized Variable → free an object that held a valid skb_frag_t then reallocate it as the vulnerable skb to reuse the uninitialized frags[0]
  • Fraggap OOB → flip nr_frags from 0 to 1 so frags[0] is treated as allocated
  • Append Pipe → attach the second pipe page to slot 1 so nr_frags == 2
  • Unmatched Page Free → the destructor runs put_page() on frags[0]
  • Dirty Pagetable → reallocate the dangling page as a PTE page
  • Physical write → change the PTE to find and overwrite core_pattern and run a root helper

Background of the exploit

Why stale frags[] survives?

Allocating a new skb head does not clear all of skb_shared_info. __finalize_skb_around() zeroes only up to dataref.

shinfo = skb_shinfo(skb);
memset(shinfo, 0, offsetof(struct skb_shared_info, dataref));
atomic_set(&shinfo->dataref, 1);

frags[] sits after dataref so descriptor bytes from the previous allocation can survive. But nr_frags is reset to 0 so the stale descriptor is normally unreachable. Setting nr_frags to 1 with the Fraggap OOB makes the kernel treat that page as allocated.

Dirty Pagetable

A page table is a 4 KiB page from the page allocator. Overlapping the dangling pipe page with a PTE page lets a pipe write corrupt the PTE. Changing only the PFN of the PTE maps the physical page we want into userspace.

Planting a stale frags[0]

For now the only field we control is nr_frags and the frags[0] we need is outside the OOB range. So before triggering the bug the exploit builds a valid descriptor in the same cache and reclaims an object where those bytes remain.

First it writes a 256-byte marker into the source pipe wp. Then it tee()s into 8 holding pipes to raise the page refcount to 9. These extra references keep the source page alive during grooming and triggering.

The descriptors are built in a single corked datagram. All paged payload comes from the same source page through tee(). So each target head gets a valid descriptor and a valid page reference in frags[0].

pasted image 1784192239418

The trigger socket and the two pipe payloads are prepared before the groom socket is closed. Then the two splices run with no other allocation in between. The first trigger skb has a 392 request so it uses kmalloc-1k and does not touch the small-head freelist. Only the second 384 request reuses one of the just-freed targets.

The reused object now holds source-page descriptor bytes in frags[0] but stays harmless while nr_frags == 0. The next stage uses the OOB to activate this stale slot.

From nr_frags to a dangling page

Once the Fraggap OOB sets nr_frags = 1 the destructor treats frags[0] as a real fragment. The second splice queues the new skb. Then the next loop iteration attaches the remaining 20 valid bytes to the same skb.

skb_append_pagefrags() uses the current nr_frags as the index of the new descriptor.

int i = skb_shinfo(skb)->nr_frags;

if (skb_can_coalesce(skb, i, page, offset)) {
    skb_frag_size_add(&skb_shinfo(skb)->frags[i - 1], size);
} else if (i < max_frags) {
    get_page(page);
    skb_fill_page_desc_noacc(skb, i, page, offset, size);
}

The index is already 1. The second trigger pipe page differs from the source wp page so it does not coalesce with frags[0] and goes into frags[1]. This path runs a normal get_page() and raises nr_frags to 2.

Closing the socket flushes the cork queue and the skb destructor releases both slots.

for (i = 0; i < shinfo->nr_frags; i++)
    __skb_frag_unref(&shinfo->frags[i], skb->pp_recycle);

So put_page() on frags[0] runs and drops the refcount. Closing the pipe last leaves a dangling page.

pasted image 1784191876526

Reclaiming as a PTE

There is now a 4 KiB page that returned to the allocator but is still pointed to by wp->pipe_buffer. Calling mmap() and then touching it to create a PTE page reclaims the freed page.

Now any physical page can be read and written from userspace.

Finding and overwriting core_pattern

With physical read and write from PTE control we search for core_pattern.

Once we find it we change the value as below. When a child then raises SIGSEGV the kernel runs the command below as root.

|/proc/%P/fd/666 %P

Flag

Fraggap kernelCTF result

Appendix

Verified skb_shared_info layout

struct skb_shared_info {
	__u8                       flags;                /*     0   0x1 */
	__u8                       meta_len;             /*   0x1   0x1 */
	__u8                       nr_frags;             /*   0x2   0x1 */
	__u8                       tx_flags;             /*   0x3   0x1 */
	short unsigned int         gso_size;             /*   0x4   0x2 */
	short unsigned int         gso_segs;             /*   0x6   0x2 */
	struct sk_buff *           frag_list;            /*   0x8   0x8 */
	union {
		struct skb_shared_hwtstamps hwtstamps;   /*  0x10   0x8 */
		struct xsk_tx_metadata_compl xsk_meta;   /*  0x10   0x8 */
	};                                               /*  0x10   0x8 */
	unsigned int               gso_type;             /*  0x18   0x4 */
	u32                        tskey;                /*  0x1c   0x4 */
	atomic_t                   dataref;              /*  0x20   0x4 */
	unsigned int               xdp_frags_size;       /*  0x24   0x4 */
	void *                     destructor_arg;       /*  0x28   0x8 */
	skb_frag_t                 frags[17];            /*  0x30 0x110 */

	/* size: 320, cachelines: 5, members: 14 */
};

Mitigation

The patch

The patch restores the invariant described above. It adds the fraggap that is really copied into the linear area and subtracts the same value on the non-linear side.

- alloclen = fragheaderlen + transhdrlen;
- pagedlen = datalen - transhdrlen;
+ alloclen = fragheaderlen + transhdrlen + fraggap;
+ pagedlen = datalen - transhdrlen - fraggap;

The IPv6 fix also removes the MSG_SPLICE_PAGES exception from the negative copy check. The IPv4 path had the same accounting bug and was fixed in commit eca856950f7c.

Partial mitigations

Restricting splice() works but it is not a good option.

Timeline

DateEvent
2022-07-12The bug was introduced in commit 773ba4fe9104.
2023-08-02The current MSG_SPLICE_PAGES corruption became possible after commit ce650a166335.
2026-05-15We reported the vulnerability privately to security@kernel.org.
2026-06-21The IPv6 fix 736b380e28d0 landed in the netdev net tree.
2026-06-25The fix was merged into Linux mainline.
2026-07-04The Linux kernel CNA published CVE-2026-53362.
2026-07-14We notified the Openwall linux-distros list.
2026-07-16The Linux kernel CNA published CVE-2026-53366.
2026-07-20We published the writeup and exploit.

Acknowledgements

Fraggap was found in IPv4/IPv6 by @physicube and @qwerty and used in kernelCTF. We thank Sultan Alsawaf (team CIQ) for confirming that the IPv4 bug is exploitable.

References

Comments

Loading...