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.

Vulnerability Summary
CONFIG_IPV6=yis required.
- Two
splice()calls overwrite the first 15 bytes ofskb_shared_infobehind a new skb. - The OOB write makes a leftover pipe address on the heap act as
frags[0]and then triggersput_page()to create a dangling pipe page. - That page is reclaimed as a PTE page so physical memory can be read and written.
- It rewrites
core_patternto 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.

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.
- A UDPv6 loopback socket with a 320-byte routing header and MTU 1287
- A 919-byte pipe payload that makes the first skb 15 bytes larger than the fragment boundary
- 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.
- The first
splice(919, SPLICE_F_MORE)appendslength = 927after adding the 8-byte UDP header. The 320-byte routing header makesfragheaderlen360. The first skb length becomes exactly 1287. UDP_CORKkeeps 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.- The second
splice(20, SPLICE_F_MORE)finds the tail skb in the same queue. The firstcopyis 0 and smaller than the new length 20 so it is recomputed against the boundary ascopy = -15. This value entersalloc_new_skband creates a new skb withfraggap = 15.

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.

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.

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_tthen reallocate it as the vulnerable skb to reuse the uninitializedfrags[0] - Fraggap OOB → flip
nr_fragsfrom 0 to 1 sofrags[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()onfrags[0] - Dirty Pagetable → reallocate the dangling page as a PTE page
- Physical write → change the PTE to find and overwrite
core_patternand 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].

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.

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

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