|

ZDI-CAN-33367: when an avoided copy writes into a read-only file

On 20 October 2026, a Zero Day Initiative report meant for the Linux kernel security team was mistakenly sent to public mailing lists. It described a local privilege escalation, registered as ZDI-CAN-33367, and a demonstration program circulated soon after. Ten days later, the flaw still has no CVE number, the proposed fix isn’t merged into…

On 20 October 2026, a Zero Day Initiative report meant for the Linux kernel security team was mistakenly sent to public mailing lists. It described a local privilege escalation, registered as ZDI-CAN-33367, and a demonstration program circulated soon after. Ten days later, the flaw still has no CVE number, the proposed fix isn’t merged into the official kernel yet, and my distribution’s kernel published on 28 October doesn’t contain it. It lets any local user modify files they can only read (a setuid program, /etc/passwd) and therefore become root.

This post explains the mechanism, without exploitation detail, because it illustrates a family of flaws that keeps coming back: those where an optimisation that avoids copying data ends up writing where it shouldn’t. It then describes the measure I applied while waiting for a fixed kernel, and why I set aside the one most guides recommend.

A family of flaws

FlawYearThe common principle
Dirty COW (CVE-2016-5195)2016a race in copy-on-write allows writing to a page mapped read-only
Dirty Pipe (CVE-2022-0847)2022a badly reset flag in a pipe allows writing to a file’s page cache pages
Dirty Frag (CVE-2026-43284)2026in-place ESP decryption writes into network fragments pointing at the file cache
ZDI-CAN-33367October 2026the Dirty Frag fix relies on a marker that skb_zerocopy() doesn’t copy

All these flaws exploit the same weak spot: the page cache. When a program reads a file, the kernel keeps a copy in memory, and every later read, by any user, is served from that copy. Access rights are checked when the file is opened; afterwards, the kernel handles pages in memory. If a kernel path writes into one of those pages by mistake, the file’s content changes for the whole system, without the rights ever being consulted, and without the file on disk being modified: an integrity check that rereads the disk after dropping the cache sees nothing.

The mechanism, step by step

1. Avoiding copies

Copying data is expensive. To send a file over the network, the kernel can avoid copying it: with splice() or sendfile(), the network packet doesn’t contain the file’s bytes but a reference to the cache pages holding them. In the kernel, a packet is represented by a structure called an skb (socket buffer), whose data can be split into fragments, each pointing at a memory page. So a fragment can point at a cache page of a file the user is only allowed to read. As long as nobody writes into that fragment, it’s harmless.

2. Decrypting in place

IPsec protects IP packets by encrypting them with the ESP protocol. On receipt, the esp4 (or esp6) module decrypts the packet. To go fast, it decrypts it in place: the plaintext replaces the ciphertext, in the same memory. If that memory is a fragment pointing at a file’s cache page, the “decryption” writes into the file. The attacker controls the key and the encrypted data, since they configure their own IPsec link: so they choose what the decryption produces.

3. The Dirty Frag fix, and its marker

That’s exactly Dirty Frag, fixed earlier in 2026. The fix added a marker, SKBFL_SHARED_FRAG, set on a packet whose fragments point at memory shared with someone else. Before decrypting, ESP checks that marker: if present, it first copies the data into memory of its own (copy-on-write), then decrypts the copy.

4. The lost marker

read-only file         ──read──▶ cache page (memory)
        │ splice() / zero-copy send: the packet REFERENCES the page, it doesn't copy it
        ▼
network packet (skb) ── fragment ──▶ same page          SHARED_FRAG marker set ✔
        │ NFQUEUE ou Open vSwitch : skb_zerocopy() moves the fragment to a new skb
        ▼
new skb ── fragment ──▶ same page                  marker LOST ✘
        │ ESP receive (IPsec): "not shared, I decrypt in place"
        ▼
the "decryption" writes into the cache page ──▶ the file everyone reads is modified

The new flaw fits in one function, skb_zerocopy(), which has existed since kernel 3.14. It transfers a packet’s content to another without copying it, by moving the references to the fragments. It’s used notably by NFQUEUE, the mechanism that sends packets from the firewall to a user-space program, and by Open vSwitch. It transfers the fragments, but not the marker: the new packet points at the same cache pages, without saying they’re shared. ESP, further on, doesn’t see the marker, decrypts in place, and the write reaches the file’s cache. The proposed fix is two lines: copy the marker.

This pattern is common. A fix adds a security attribute to a structure; every path that creates or copies that structure must propagate it; a single omission, in a function written ten years earlier, and the fix no longer protects anything on that path.

Why an ordinary user can do all this

sudo -u nobody unshare -Urn true && echo "user namespace: allowed"
user namespace: allowed
sysctl user.max_user_namespaces
user.max_user_namespaces = 125388

Configuring IPsec, an NFQUEUE queue or traffic control rules normally requires network administration rights (the CAP_NET_ADMIN capability). But user namespaces, enabled by default on most distributions, let an ordinary user create a space where they’re root, with their own network namespace in which they have that capability. They can only act on their own interfaces, but that’s enough: the chain described above runs entirely in their network space, and it’s the kernel, shared by all, that writes into the page cache, also shared by all.

The recommended measure, and why I didn’t take it

The most cited measure is to forbid user namespaces to ordinary users (user.max_user_namespaces=0). It cuts the chain at the root, and not only for this flaw. I set it aside because it breaks things I use: systemd services hardened with PrivateUsers=, and rootless Podman containers on one of my machines, which rely precisely on this mechanism.

Cutting the chain at the right link

# /etc/modprobe.d/zdi-can-33367.conf (deployed by Ansible)
install esp4 /bin/false
blacklist esp4
install esp6 /bin/false
blacklist esp6
install nlmon /bin/false
blacklist nlmon
install act_pedit /bin/false
blacklist act_pedit

The other approach is to prevent loading the kernel modules the chain needs. A module blocked this way can no longer be loaded on demand, not even by root in a namespace: the install … /bin/false directive replaces loading with a command that fails, and blacklist prevents automatic loading by alias. Choosing the modules requires checking what’s actually used.

  • esp4 and esp6: that’s where the write happens. Without ESP, no in-place decryption, so no write into the cache. None of my servers use IPsec: my tunnels are WireGuard, which has nothing to do with ESP;
  • nlmon and act_pedit: two modules used by the published demonstrations to set up the chain, useless on my servers;
  • not nfnetlink_queue: NFQUEUE is one of the two paths that lose the marker, but Suricata uses it in intrusion prevention mode on three of my servers. Blocking it would cut filtering. Cutting the chain at the ESP step is enough.
modprobe -n -v esp4          # what a load would do
install /bin/false           # → blocked
lsmod | grep -E '^(esp4|esp6|nlmon|act_pedit|nfnetlink_queue) '
nfnetlink_queue        36864  1      # loaded: used by Suricata in IPS mode

Checking takes two steps: modprobe -n -v shows what a load would do (here, run /bin/false, so fail), and lsmod confirms none of the blocked modules is already loaded, since a module already in memory isn’t affected by the configuration file. Had one been, it would have needed unloading, or a reboot.

Tracking the real fix

dnf -q repoquery --changelogs --latest-limit 1 kernel-core | grep -ciE 'skb_zerocopy|SHARED_FRAG'
0      # kernel 6.12.0-211.69.1, available on 30 October, doesn't contain the fix

This measure is a stopgap. To know when to remove it, you must watch the distribution’s kernels. Without a CVE number, tools comparing CVE lists are useless: you have to read the kernel package’s changelog, looking for the function or marker name. The kernel published on 28 October fixes sixteen other CVEs but not this one. One Red Hat derivative published a fixed kernel in its testing repository; another offers a live patch. When a fixed kernel arrives, I’ll install it, reboot, then empty the list of blocked modules.

And if you can’t block anything?

On a machine using IPsec, the measure doesn’t apply. What remains is forbidding user namespaces to those who don’t need them, limiting local accounts (the flaw requires local access: an account, or a compromised web application), and watching for signs of exploitation: namespace creation by service accounts, loading of the modules concerned, a setuid binary modification detected by comparing it with the package (rpm -V).

What I take away

  • the page cache is shared by everyone: any accidental write into one of its pages bypasses access rights;
  • “zero-copy” optimisations are the recurring source of these flaws, since Dirty COW;
  • a fix relying on a marker is only complete if every path propagates it;
  • user namespaces turn many “root-only” flaws into flaws open to everyone;
  • blocking a module first requires checking you don’t use it;
  • without a CVE, the changelog tells you whether a kernel is fixed.

Sources: analysis published on GitHub (FranklinF25/zdi-can-33367); posts by TuxCare, CloudLinux, Sysdig and AlmaLinux, October 2026.


This post is part of a series of sysadmin tutorials (tag “how-to”).
Series “How-to”, tutorial 4/57. · ← Previous tutorial · Next tutorial → · Version française

Similar Posts

One Comment

Leave a Reply

Your email address will not be published. Required fields are marked *