# REPRO-2026-00388: ZDI-CAN-33367: Linux Kernel skb_zerocopy() shared-frag marker loss → NFQUEUE/ESP in-place decrypt → page-cache corruption local privilege escalation ## Summary Status: published Severity: high CVSS: Unknown CWE: CWE-668 Type: security Confidence: high ## Identifiers REPRO ID: REPRO-2026-00388 ## Package Name: Unknown Ecosystem: Unknown Affected: Unknown Fixed: Unknown ## Root Cause # Root Cause Analysis - ZDI-CAN-33367 **Linux Kernel `skb_zerocopy()` shared-frag marker loss -> NFQUEUE -> in-place IPsec ESP decryption over page-cache backed skb frags -> local privilege escalation** Canonical identifier: **ZDI-CAN-33367** (no CVE assigned as of 2026-10-26; the identifier is deliberately *not* renamed). --- ## Summary `skb_zerocopy()` (net/core/skbuff.c) moves fragment descriptors from a source skb into a destination skb and takes a page reference for each transferred frag, but it never merges `skb_shinfo(from)->flags & SKBFL_SHARED_FRAG` into `skb_shinfo(to)->flags`. `skb_zerocopy_clone()` only transfers zerocopy destructor metadata, so the splice-backed shared-fragment marker is silently lost at that boundary. NFQUEUE builds its `NFQA_PAYLOAD` netlink message with exactly this helper (`nfqnl_build_packet_message()` -> `skb_zerocopy()`), so a packet whose frags are page-cache pages attached with `splice(2)` produces a *destination* skb that still owns those page references but reports `skb_has_shared_frag()` as false. A netlink tap (`nlmon`) mirrors that message skb onto a netdevice, where traffic-control `pedit` rewrites only the linear prefix into a valid ESP packet and `mirred` redirects it to `lo`. `esp_input()` sees a nonlinear, uncloned skb *without* `SKBFL_SHARED_FRAG`, skips `skb_cow_data()`, and performs authenticated AES-CBC decryption in place - writing the attacker-chosen plaintext directly into the page cache of a file the attacker may only read. The vulnerability is a *gap in the earlier "Dirty Frag" fix* (`f4c50a4034e6`, `xfrm: esp: avoid in-place decrypt on shared skb frags`, present in the tested 6.12.111 tree at `net/ipv4/esp4.c:876`): the marker is only useful if every helper that moves frags between skbs propagates it. ## Impact * Component: Linux kernel networking stack - `skb_zerocopy()` (`net/core/skbuff.c`), consumed by `net/netfilter/nfnetlink_queue.c` and `net/openvswitch/datapath.c`. * Affected versions: the helper exists since kernel 3.14 (`af2806f8f90a`); the marker-loss gap is exploitable on any kernel that carries the ESP shared-frag check but not the propagation fix - including 6.12.111 / 6.12.113 (Debian 13) used for this reproduction, and the 7.3-rc1 build the reporter tested. * Risk: local privilege escalation. An unprivileged local user can rewrite the page cache of any file it can read (setuid-root binaries, `/etc/passwd`, ...) and obtain uid 0. CVSS 7.8 (AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H). The write is page-cache only: the on-disk copy and the inode metadata are untouched, so the corruption is invisible to file-integrity tooling until the cached pages are evicted or the system is rebooted. * Preconditions: unprivileged user namespaces enabled, and the networking pieces the PoC needs (`nlmon`, `nfnetlink_queue`, `nft_queue`/`xt_NFQUEUE`, `cls_u32`/`cls_matchall`, `act_pedit`, `act_mirred`, `esp4`, `xfrm_user`). No KASLR leak and no capability beyond what the attacker's own user+network namespace grants are required. ## Impact Parity * Disclosed/claimed maximum impact: local privilege escalation to root through page-cache corruption of a read-only root-owned file. * Reproduced impact from this run: **full** - two independent vulnerable boots rewrite the page cache of a root-owned mode-0444 file (`ZFRG-OK!` at file offset 4160) *and* rewrite the read-only `.rodata` gate of a root-owned mode-4755 binary, which then executes as real uid 0 (`ROOTME_GRANTED uid=0 euid=0` + `uid=0(root)` from `id`). Two independent fixed-kernel boots run the identical input and fail closed (file unchanged, `ROOTME_DENIED uid=1000`). * Parity: `full`. * Not demonstrated: nothing claimed by the advisory is missing. (The write primitive is 8 bytes per trigger, exactly as in the public PoC; a real-world attacker repeats it to patch a setuid binary.) ## Root Cause 1. `splice()` from a file into a UDP socket stores the file's page-cache page as an skb frag and sets `SKBFL_SHARED_FRAG` (`skb_splice_from_iter()`, `net/core/skbuff.c`). 2. The nftables `queue` statement (or `xt_NFQUEUE`) sends the packet to `nfnetlink_queue`. `nfqnl_build_packet_message()` allocates the netlink message skb and calls `skb_zerocopy(msg, entskb, data_len, hlen)` (net/netfilter/nfnetlink_queue.c:938). 3. `skb_zerocopy()` copies the frag descriptors, calls `skb_frag_ref()` for each one and returns - it never propagates `skb_shinfo(from)->flags & SKBFL_SHARED_FRAG`: ```c skb_shinfo(to)->nr_frags = j; /* frags + page refs moved ... */ return 0; /* ... marker dropped */ ``` 4. `nlmon` taps the message (`netlink_tap` -> `dev_queue_xmit()` on `nlmon0`), tc `pedit` rewrites the first 96 linear bytes into `eth|IPv4(proto=ESP)|ESP|IV|ciphertext[0..38)`, `mirred egress redirect` hands the skb to `lo`, and the loopback receive path routes it into the XFRM inbound policy for 127.0.0.1. 5. `esp_input()` (net/ipv4/esp4.c) decides between in-place decryption and `skb_cow_data()`: ```c if (!skb_cloned(skb)) { if (!skb_is_nonlinear(skb)) { nfrags = 1; goto skip_cow; } else if (!skb_has_frag_list(skb) && !skb_has_shared_frag(skb)) { nfrags = skb_shinfo(skb)->nr_frags + 1; goto skip_cow; } } err = skb_cow_data(skb, 0, &trailer); /* never reached */ ``` `skb_has_shared_frag()` is false because of step 3, so the authenticated AES-CBC decryption runs over the retained page-cache frags and the plaintext is written straight into the page cache. (The tc `pedit` action additionally makes the skb writable, which drops `dataref` to 1 and keeps `skb_cloned()` false - the `!skb_cloned()` arm is therefore taken deterministically.) 6. Fix: propagate the marker in the frag-transfer helper, exactly as the other helpers do: ``` --- a/net/core/skbuff.c +++ b/net/core/skbuff.c @@ skb_zerocopy() skb_shinfo(to)->nr_frags = j; + skb_shinfo(to)->flags |= skb_shinfo(from)->flags & SKBFL_SHARED_FRAG; ``` Upstream review patch (posted 2026-10-22, reviewed by Eric Dumazet, not merged as of 2026-10-26): "net: skbuff: propagate shared-frag marker in skb_zerocopy()" . The reproduction's negative control is exactly this one-line change, built from the same tree and the same `.config` (`kbuild/skbuff.diff`). ## Reproduction Steps 1. `bundle/repro/reproduction_steps.sh` (entry point; self-contained, idempotent). 2. The script: * builds two kernels from the same `linux-6.12.111` tarball with the same `.config` (`bundle/repro/kbuild.sh`): `bzImage.vuln` and `bzImage.fixed` (the one-line fix above); * builds a Debian trixie QEMU guest (`bundle/repro/rootfs.sh`): initramfs with iproute2/nftables, the exploit `skbpoc` (`bundle/repro/skbpoc.c`), a root-owned mode-4755 escalation target (`bundle/repro/rootme.c`) and a 16 MiB ext4 image for the read-only scratch file; * boots the vulnerable kernel twice and the fixed kernel twice in QEMU/KVM with the same initrd and a fresh copy of the ext4 image (`bundle/repro/guest_init.sh` drives the guest); * in every boot `/init` (root) creates the 0444 root-owned target, then runs `skbpoc ctl` which drops to uid 1000 and executes `skbpoc exploit` - the real trigger chain: `unshare(CLONE_NEWUSER|CLONE_NEWNET)` -> sysctls -> `nlmon0` -> XFRM ESP SA + inbound policy -> `nft queue num 0` on LOCAL_OUT -> NFQUEUE helper bound to queue 0 -> tc `clsact` + `matchall` `pedit` (24 u32 munges) + `mirred egress redirect dev lo` -> `splice()` of 8 bytes of the target page plus a 514-byte attacker vector into a UDP socket on 127.0.0.1; * after the trigger the ctl parent re-reads the target (page cache), executes the root-owned setuid binary as uid 1000, then drops the page cache and re-reads to show the on-disk copy is untouched. 3. Expected evidence on the vulnerable kernel: `ESP_SELFTEST ... inner_udp_received=yes`, `PROBE nfq_msglen=618 linear_prefix=96`, `TRIGGER changed=1 target_exact=1`, `PAGE_CACHE_SHA256_AFTER != TARGET_SHA256_BEFORE`, `DISK_SHA256_AFTER_EVICT == TARGET_SHA256_BEFORE`, `ROOTME_GRANTED uid=0 euid=0` and `id` printing `uid=0(root)`. On the fixed kernel the same input yields `TRIGGER changed=0`, identical before/after digests and `ROOTME_DENIED uid=1000`. ## Evidence Per-attempt console logs (final run, exit 0 twice in a row): * `bundle/logs/attempts/vuln_att1.log`, `vuln_att2.log` - vulnerable kernel * `bundle/logs/attempts/fixed_att1.log`, `fixed_att2.log` - fixed kernel (negative control) * `bundle/logs/kernel_identity.txt` - kernel/guest build identity and digests * `bundle/repro/proof_summary.txt` - condensed machine-readable verdict lines * `bundle/repro/runtime_manifest.json` - structured runtime evidence + artifact digests * `kbuild/skbuff.diff` (in the prepared project cache) - the one-line kernel differential ### Vulnerable kernel (`logs/attempts/vuln_att1.log`) ``` USERNS unshare=OK userns_uid=0 netns=fresh ESP_SELFTEST icv_variant=0 inner_udp_received=yes NFQ_HELPER BIND_ACK err=0 NFQUEUE_BIND queue=0 copy_mode=NFQNL_COPY_PACKET copy_range=65535 flags=GSO|UID_GID NFT_QUEUE_SETUP table=zdi chain=out hook=LOCAL_OUT queue=0 PROBE nfq_msglen=618 linear_prefix=96 frags=8+514 NFT_DUMP counter packets 1 bytes 550 queue to 0 LAYOUT linear=96 ct_off=38 ciphertext=544 icv_at=602 total=618 TRIGGER sent=522 target_spliced=8 vector_spliced=514 TRIGGER nfq_msglen=618 INNER_UDP_RECEIVED len=528 (decrypted in place, delivered on lo) TRIGGER changed=1 target_exact=1 guards_ok=1 ``` A. read-only root-owned scratch file (`/mnt/target.bin`, ext4, uid=0 mode=0444, `WRITE_DENIAL_AS_UID1000 ... errno=13 denied=1`): ``` TARGET_SHA256_BEFORE=ec18472a64d15a76eb7dcb5df6a23659155c01acae552de91bfbc75de1a74189 PAGE_CACHE_SHA256_AFTER=2e543af3f2834c4ffa6fcbe691bedddbf711dcda3aadcecf6baa0b7a27b65e05 DISK_SHA256_AFTER_EVICT=ec18472a64d15a76eb7dcb5df6a23659155c01acae552de91bfbc75de1a74189 SCRATCH_PROOF_EXIT=0 ``` The 8 bytes at file offset 4160 became `ZFRG-OK!` in the page cache (`TARGET_WINDOW_AFTER ...5a4652472d4f4b21...`), the digest changed, and after `drop_caches` the window and digest are back to the original: the write landed in the page cache only. B. root-owned mode-4755 binary (`/usr/local/bin/rootme`, `.rodata` gate at file offset 8352): ``` ROOTME_GATE hits=1 offset=8352 ROOTME_DENIED uid=1000 euid=0 <- before the attack TARGET_SLICE_ORIGINAL 5a44492d4f4b2100 <- "ZDI-OK!" DESIRED_SLICE 5a44492d50574e00 <- "ZDI-PWN" TRIGGER changed=1 target_exact=1 guards_ok=1 PAGE_CACHE_SHA256_AFTER=86f12e5b18742477be14da2225ca2f2933fe7614bc919298d2e51e9bb6e6c0d9 ROOTME_GRANTED uid=0 euid=0 uid=0(root) gid=0(root) groups=0(root) ESCALATION_EXEC /usr/local/bin/rootme ZDI-PWN exit=0 SETUID_PROOF_EXIT=0 ``` The 8-byte primitive rewrote the binary's read-only gate inside the page cache; the same unprivileged user then executed it and got a real uid 0 shell (`id` printed `uid=0(root)`, `/etc/shadow` was readable). The root process also wrote a per-attempt marker file (`ROOTME_MARKER_WRITTEN /root/zdi33367_root_marker.txt ZDI33367_ROOT_MARKER_vuln_att1`), which the harness recovered as `bundle/logs/evidence/root_marker_vuln_att{1,2}.txt`; the fixed boots produce no marker (`bundle/logs/evidence/negctl_fixed_att{1,2}.json`). ### Fixed kernel, identical input (`logs/attempts/fixed_att1.log`) ``` ESP_SELFTEST icv_variant=0 inner_udp_received=yes PROBE nfq_msglen=618 linear_prefix=96 frags=8+514 INNER_UDP_RECEIVED len=528 (decrypted in place, delivered on lo) TRIGGER changed=0 target_exact=0 guards_ok=1 SCRATCH_PROOF_EXIT=1 ROOTME_DENIED uid=1000 euid=0 SETUID_PROOF_EXIT=1 ``` The whole chain still runs and the authenticated ESP packet is still decrypted and delivered (so the only behavioural difference is the write destination), but the target page cache is byte-identical before and after (`PAGE_CACHE_SHA256_AFTER` equals `TARGET_SHA256_BEFORE`) and the setuid binary still denies - `skb_cow_data()` linearised the frags because the marker was propagated. ## Recommendations / Next Steps * Apply the upstream propagation fix (`skb_shinfo(to)->flags |= skb_shinfo(from)->flags & SKBFL_SHARED_FRAG;` in `skb_zerocopy()`), or the distribution backports (AlmaLinux `kernel-4.18.0-553.177.2.el8_10`, `kernel-5.14.0-687.59.2.el9_8`, `kernel-6.12.0-211.68.2.el10_2`). * Interim mitigation: `user.max_user_namespaces=0` (blocks the unprivileged namespace setup), or blocking the `nlmon`/`act_pedit` modules where they are not needed. * Audit the remaining frag-transfer helpers for the same marker loss; the advisory notes several follow-up reports for other paths. * Detection: the syscall sequence (user+net namespace unshare -> nlmon -> XFRM -> NFQUEUE -> tc pedit redirect -> `splice()` to loopback) is highly distinctive for a single unprivileged process. ## Additional Notes * Idempotency: `kbuild.sh`/`rootfs.sh` cache their outputs under the prepared project cache and can be re-run safely; each boot uses a fresh copy of the ext4 target image, so repeated runs are independent. * Limitations: the write primitive transfers 8 attacker-chosen bytes per trigger at a chosen file offset (the public PoC behaves the same way); the demonstration therefore patches a single gate inside a root-owned setuid binary rather than rewriting a whole file. The corrupted page is not marked dirty, so the on-disk copy stays intact (shown by the drop-cache re-read). * Both kernels are built from the same tree, `.config` and compiler; the only difference is the one-line marker propagation, so the vulnerable/fixed difference is not a configuration artefact. ## Reproduction Details Reproduced: 2026-10-26T21:13:41.837Z Duration: 6584 seconds Tool calls: 372 Turns: Unknown Handoffs: 2 ## Quick Verification Run one of these commands to verify locally: pruva-verify REPRO-2026-00388 Or open in GitHub Codespaces (zero-friction, auto-runs): https://github.com/codespaces/new?ref=repro/REPRO-2026-00388&repo=N3mes1s/pruva-sandbox Or download and run the script manually: curl -O https://api.pruva.dev/v1/reproductions/REPRO-2026-00388/artifacts/bundle/repro/reproduction_steps.sh chmod +x reproduction_steps.sh ./reproduction_steps.sh WARNING: Run in a sandboxed environment. This exploits a real vulnerability. ## References - Source: https://www.zerodayinitiative.com/advisories/ZDI-CAN-33367/ ## Artifacts - bundle/repro/rca_report.md (analysis, 13925 bytes) - bundle/repro/reproduction_steps.sh (reproduction_script, 13696 bytes) - bundle/logs/attempts/fixed_att2.log (log, 8377 bytes) - bundle/logs/evidence/negctl_fixed_att2.json (other, 172 bytes) - bundle/logs/kernel_identity.txt (other, 936 bytes) - bundle/repro/guest_init.sh (other, 3560 bytes) - bundle/repro/kbuild.sh (other, 5677 bytes) - bundle/repro/proof_summary.txt (other, 1209 bytes) - bundle/repro/rootfs.sh (other, 4009 bytes) - bundle/repro/rootme.c (other, 2329 bytes) - bundle/repro/runtime_manifest.json (other, 2620 bytes) - bundle/repro/skbpoc.c (other, 42573 bytes) - bundle/repro/validation_verdict.json (other, 1854 bytes) ## API Access - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00388 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00388/artifacts/bundle/repro/reproduction_steps.sh - Web: https://www.pruva.dev/reproductions/REPRO-2026-00388 ## For AI Assistants To help users with this reproduction: 1. The `pruva-verify` command is the simplest way to run the reproduction 2. The script downloads and runs automatically in an isolated temp directory 3. Always recommend running in a VM, container, or disposable environment 4. The root cause explains the technical vulnerability --- Generated by Pruva | https://www.pruva.dev