October 18, 2026
CVE-2026โ53365 Explained: The Kernel Zerocopy Bug That Only Tracked the Last Fragment
Overview
By Guidancewhite
5 min read
Overview
This one is a different kind of CVE than the web-application flaws covered so far on this blog. The attack surface isn't an HTTP request โ it's kernel socket buffer (skb) reference counting โ and the victim isn't a server; it's the kernel of the machine itself. AF_VSOCK is a socket family that lets a VM and its host, or processes on the same machine, communicate without going through the TCP/IP stack, and MSG_ZEROCOPY speeds that communication up by pinning userspace pages directly instead of copying data into kernel buffers.
Pinning a page is a guarantee: that page won't be reused for anything else until the transfer finishes. Break that guarantee, and you get exactly the setup for a classic Use-After-Free-class memory corruption: userspace still believes it can write to the page, while the kernel has already handed the same physical page off for something else. This CVE starts with the fact that, when a large message gets split into multiple skbs, pin-release tracking worked correctly for only one of them.
Where the flaw lives โ the send loop that splits a large message into skbs
virtio-vsock's virtio_transport_send_pkt_info() fills outgoing skbs sized to fit the virtqueue, one at a time, as it works through the message being sent. For a MSG_ZEROCOPY transfer, each skb that pins a userspace page needs a ubuf_info structure (commonly shortened to uarg) attached to it โ this is what carries the completion notification telling userspace "this skb has been transmitted, so the page it pinned can be reused now."
Step-by-step source analysis
Step 1 โ the correct design: pinning and completion notification must be paired 1:1
MSG_ZEROCOPY's basic contract is simple: pin a page to send it, and the skb carrying that page must fire its completion callback exactly once โ either when it's actually transmitted or when it's discarded โ and the pin gets released inside that callback. Existing transports like TCP honor this contract by attaching a uarg to every single skb, no exceptions.
Step 2 โ the flaw: the uarg is attached only to the "last" skb (before the fix)
When virtio-vsock's send loop split a message across multiple skbs, the pre-fix code only created and attached a uarg under this specific condition (reconstructed from the diff published in the official kernel patch email):
// net/vmw_vsock/virtio_transport_common.c (before the fix, reconstructed from the public patch mail)
while (total_len > 0) {
skb = virtio_transport_alloc_skb(...);
...
/* attach zerocopy completion tracking only if this skb is the LAST fragment of the message */
if (info->msg && info->msg->msg_flags & MSG_ZEROCOPY &&
skb_len == rest_len && info->op == VIRTIO_VSOCK_OP_RW) {
if (virtio_transport_init_zcopy_skb(vsk, skb, info->msg,
pkt_len, can_zcopy)) {
kfree_skb(skb);
ret = -ENOMEM;
break;
}
}
virtio_transport_inc_tx_pkt(vvs, skb);
...
}// net/vmw_vsock/virtio_transport_common.c (before the fix, reconstructed from the public patch mail)
while (total_len > 0) {
skb = virtio_transport_alloc_skb(...);
...
/* attach zerocopy completion tracking only if this skb is the LAST fragment of the message */
if (info->msg && info->msg->msg_flags & MSG_ZEROCOPY &&
skb_len == rest_len && info->op == VIRTIO_VSOCK_OP_RW) {
if (virtio_transport_init_zcopy_skb(vsk, skb, info->msg,
pkt_len, can_zcopy)) {
kfree_skb(skb);
ret = -ENOMEM;
break;
}
}
virtio_transport_inc_tx_pkt(vvs, skb);
...
}Inside virtio_transport_init_zcopy_skb(), a uarg gets allocated via msg_zerocopy_realloc() and attached with skb_zcopy_init(skb, uarg) โ but that entire call only happens when skb_len == rest_len, meaning "filling this skb brings the remaining length to zero, i.e., this is the true last fragment of the message." Whenever a message doesn't fit in a single skb and gets split into several, every skb except the last one skips this block entirely. Those skbs still pin userspace pages exactly the same way and get queued for transmission โ but nothing tracks the completion that pin is supposed to be paired with.
Step 3 โ the root cause: when an untracked skb gets freed, the pin count drops one extra time
The problem isn't just "mid-sequence skbs have no uarg" โ it's what happens when those skbs get freed normally. Even without a uarg, once transmission finishes the kernel frees the skb the ordinary way, and as part of that it calls put_page() on the page it was holding. On the correct path (TCP, for example), the reference bumped at pin time is matched exactly once by the completion callback decrementing it back. On this flawed path, the completion callback that's supposed to be the matching half simply doesn't exist, and put_page() fires on its own.
Correct path (has a uarg): pin(+1) โ skb transmitted โ zerocopy completion fires(-1) โ ref hits 0, safely released
Flawed path (no uarg): pin(+1) โ skb just gets freed โ only put_page() fires โ extra, unpaired decrementCorrect path (has a uarg): pin(+1) โ skb transmitted โ zerocopy completion fires(-1) โ ref hits 0, safely released
Flawed path (no uarg): pin(+1) โ skb just gets freed โ only put_page() fires โ extra, unpaired decrementEvery repeated MSG_ZEROCOPY transfer adds one more "decrement that was never in the original accounting" for each mid-sequence skb. Repeat enough multi-skb sends against the same userspace buffer, and the kernel's pin count (bias) for that page reaches zero faster than userspace could ever expect.
Step 4 โ once the pin count hits zero, that physical page looks "free" to the kernel
Once the pin count reaches zero, the kernel assumes nobody is using the page anymore and can route it back through its normal page-reclaim paths (the per-CPU free list, for instance). In reality, userspace โ the attacker โ still holds an alias to that page and can still write to it. This is exactly the boundary where things become a textbook Use-After-Free problem: the same physical page is simultaneously "a buffer the attacker can write to" and "a resource the kernel believes is entirely free and has reallocated."
A published PoC is reported to have successfully carried this race condition all the way to local privilege escalation, and Red Hat's advisory itself states that this issue "could lead to local privilege escalation." That said, the specific technique for steering the reallocated page toward a chosen target (such as the cached contents of another file) and winning the necessary timing is memory-grooming, race-condition territory โ and isn't covered in this post. The goal here is understanding the root cause: missing completion tracking.
The patch โ adopt what TCP already does: allocate once before the loop, share it across every skb
The kernel patch restructures virtio_transport_send_pkt_info() itself. Instead of allocating the uarg "when filling the last skb," it's now allocated once, before entering the send loop, and inside the loop, every skb calls skb_zcopy_set(skb, uarg, NULL) to add one more reference.
// net/vmw_vsock/virtio_transport_common.c (after the fix, reconstructed from the public patch mail)
uarg = msg_zerocopy_realloc(sk_vsock(vsk), total_len, NULL, false);
...
while (total_len > 0) {
skb = virtio_transport_alloc_skb(...);
...
skb_zcopy_set(skb, uarg, NULL); // every skb gets completion tracking โ not just the last one
virtio_transport_inc_tx_pkt(vvs, skb);
...
}
/* msg_zerocopy_realloc() initializes the ubuf_info refcount to 1.
* skb_zcopy_set() bumps it once per skb, so that initial reference
* is dropped separately afterward to keep the count balanced. */// net/vmw_vsock/virtio_transport_common.c (after the fix, reconstructed from the public patch mail)
uarg = msg_zerocopy_realloc(sk_vsock(vsk), total_len, NULL, false);
...
while (total_len > 0) {
skb = virtio_transport_alloc_skb(...);
...
skb_zcopy_set(skb, uarg, NULL); // every skb gets completion tracking โ not just the last one
virtio_transport_inc_tx_pkt(vvs, skb);
...
}
/* msg_zerocopy_realloc() initializes the ubuf_info refcount to 1.
* skb_zcopy_set() bumps it once per skb, so that initial reference
* is dropped separately afterward to keep the count balanced. */With this change, each skb's completion decrements the uarg's reference count by exactly one, and userspace only receives the completion notification once the total reference count hits zero โ which now happens precisely when every skb has actually finished transmitting. If the loop exits early without sending any data, the allocated uarg is cleanly torn down via net_zcopy_put_abort(), so no reference is ever left dangling.
The principle this case illustrates is straightforward. When code can split "one logical transfer" into several sub-units (skbs here), resource lifetime management โ pin release, in this instance โ has to be designed around the entire logical transfer, not any single piece of it. The assumption "we only need to handle this once, at the last fragment" easily overlooks the fact that every other fragment is holding the exact same resource in the meantime.
Remediation
- Update the kernel to 7.0.11, 6.12.97, or whichever backported version your distribution has released for this CVE.
- The affected code path is only reachable in environments that actually use
MSG_ZEROCOPYover virtio-vsock (or vhost-vsock). Systems that don't useAF_VSOCKat all aren't affected. - On a multi-user system where an immediate update isn't feasible, restricting
/dev/vsockaccess to the users or groups that actually need it can serve as a temporary mitigation โ worth remembering that quite a few distributions ship it at mode0666by default. - If you're exposing
io_uringto unprivileged user namespaces inside containers or VMs, keep in mind that doing so widens the attack surface for this entire class of local privilege-escalation bug.