{"repro_id":"REPRO-2026-00388","version":13,"title":"ZDI-CAN-33367: Linux Kernel skb_zerocopy() shared-frag marker loss → NFQUEUE/ESP in-place decrypt → page-cache corruption local privilege escalation","repro_type":"security","status":"published","severity":"high","description":"TITLE (ZDI): \"Linux Kernel skbuff skb_zerocopy Improper Ownership Management Local Privilege Escalation\".","root_cause":"# Root Cause Analysis - ZDI-CAN-33367\n\n**Linux Kernel `skb_zerocopy()` shared-frag marker loss -> NFQUEUE -> in-place IPsec ESP\ndecryption over page-cache backed skb frags -> local privilege escalation**\n\nCanonical identifier: **ZDI-CAN-33367** (no CVE assigned as of 2026-10-26; the identifier is\ndeliberately *not* renamed).\n\n---\n\n## Summary\n\n`skb_zerocopy()` (net/core/skbuff.c) moves fragment descriptors from a source skb into a\ndestination skb and takes a page reference for each transferred frag, but it never merges\n`skb_shinfo(from)->flags & SKBFL_SHARED_FRAG` into `skb_shinfo(to)->flags`.  `skb_zerocopy_clone()`\nonly transfers zerocopy destructor metadata, so the splice-backed shared-fragment marker is\nsilently lost at that boundary.\n\nNFQUEUE builds its `NFQA_PAYLOAD` netlink message with exactly this helper\n(`nfqnl_build_packet_message()` -> `skb_zerocopy()`), so a packet whose frags are page-cache pages\nattached with `splice(2)` produces a *destination* skb that still owns those page references but\nreports `skb_has_shared_frag()` as false.  A netlink tap (`nlmon`) mirrors that message skb onto a\nnetdevice, where traffic-control `pedit` rewrites only the linear prefix into a valid ESP packet and\n`mirred` redirects it to `lo`.  `esp_input()` sees a nonlinear, uncloned skb *without*\n`SKBFL_SHARED_FRAG`, skips `skb_cow_data()`, and performs authenticated AES-CBC decryption in place -\nwriting the attacker-chosen plaintext directly into the page cache of a file the attacker may only\nread.\n\nThe vulnerability is a *gap in the earlier \"Dirty Frag\" fix* (`f4c50a4034e6`, `xfrm: esp: avoid\nin-place decrypt on shared skb frags`, present in the tested 6.12.111 tree at\n`net/ipv4/esp4.c:876`): the marker is only useful if every helper that moves frags between skbs\npropagates it.\n\n## Impact\n\n* Component: Linux kernel networking stack - `skb_zerocopy()` (`net/core/skbuff.c`), consumed by\n  `net/netfilter/nfnetlink_queue.c` and `net/openvswitch/datapath.c`.\n* Affected versions: the helper exists since kernel 3.14 (`af2806f8f90a`); the marker-loss gap is\n  exploitable on any kernel that carries the ESP shared-frag check but not the propagation fix -\n  including 6.12.111 / 6.12.113 (Debian 13) used for this reproduction, and the 7.3-rc1 build the\n  reporter tested.\n* Risk: local privilege escalation.  An unprivileged local user can rewrite the page cache of any\n  file it can read (setuid-root binaries, `/etc/passwd`, ...) and obtain uid 0.  CVSS 7.8\n  (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\n  inode metadata are untouched, so the corruption is invisible to file-integrity tooling until the\n  cached pages are evicted or the system is rebooted.\n* Preconditions: unprivileged user namespaces enabled, and the networking pieces the PoC needs\n  (`nlmon`, `nfnetlink_queue`, `nft_queue`/`xt_NFQUEUE`, `cls_u32`/`cls_matchall`, `act_pedit`,\n  `act_mirred`, `esp4`, `xfrm_user`).  No KASLR leak and no capability beyond what the attacker's own\n  user+network namespace grants are required.\n\n## Impact Parity\n\n* Disclosed/claimed maximum impact: local privilege escalation to root through page-cache corruption\n  of a read-only root-owned file.\n* Reproduced impact from this run: **full** - two independent vulnerable boots rewrite the page cache\n  of a root-owned mode-0444 file (`ZFRG-OK!` at file offset 4160) *and* rewrite the read-only\n  `.rodata` gate of a root-owned mode-4755 binary, which then executes as real uid 0\n  (`ROOTME_GRANTED uid=0 euid=0` + `uid=0(root)` from `id`).  Two independent fixed-kernel boots run\n  the identical input and fail closed (file unchanged, `ROOTME_DENIED uid=1000`).\n* Parity: `full`.\n* Not demonstrated: nothing claimed by the advisory is missing.  (The write primitive is 8 bytes per\n  trigger, exactly as in the public PoC; a real-world attacker repeats it to patch a setuid binary.)\n\n## Root Cause\n\n1. `splice()` from a file into a UDP socket stores the file's page-cache page as an skb frag and sets\n   `SKBFL_SHARED_FRAG` (`skb_splice_from_iter()`, `net/core/skbuff.c`).\n2. The nftables `queue` statement (or `xt_NFQUEUE`) sends the packet to `nfnetlink_queue`.\n   `nfqnl_build_packet_message()` allocates the netlink message skb and calls\n   `skb_zerocopy(msg, entskb, data_len, hlen)` (net/netfilter/nfnetlink_queue.c:938).\n3. `skb_zerocopy()` copies the frag descriptors, calls `skb_frag_ref()` for each one and returns - it\n   never propagates `skb_shinfo(from)->flags & SKBFL_SHARED_FRAG`:\n\n   ```c\n   skb_shinfo(to)->nr_frags = j;      /* frags + page refs moved ... */\n   return 0;                          /* ... marker dropped            */\n   ```\n\n4. `nlmon` taps the message (`netlink_tap` -> `dev_queue_xmit()` on `nlmon0`), tc `pedit` rewrites\n   the first 96 linear bytes into `eth|IPv4(proto=ESP)|ESP|IV|ciphertext[0..38)`, `mirred egress\n   redirect` hands the skb to `lo`, and the loopback receive path routes it into the XFRM inbound\n   policy for 127.0.0.1.\n5. `esp_input()` (net/ipv4/esp4.c) decides between in-place decryption and `skb_cow_data()`:\n\n   ```c\n   if (!skb_cloned(skb)) {\n           if (!skb_is_nonlinear(skb)) { nfrags = 1; goto skip_cow; }\n           else if (!skb_has_frag_list(skb) && !skb_has_shared_frag(skb)) {\n                   nfrags = skb_shinfo(skb)->nr_frags + 1; goto skip_cow;\n           }\n   }\n   err = skb_cow_data(skb, 0, &trailer);   /* never reached */\n   ```\n\n   `skb_has_shared_frag()` is false because of step 3, so the authenticated AES-CBC decryption runs\n   over the retained page-cache frags and the plaintext is written straight into the page cache.\n   (The tc `pedit` action additionally makes the skb writable, which drops `dataref` to 1 and keeps\n   `skb_cloned()` false - the `!skb_cloned()` arm is therefore taken deterministically.)\n\n6. Fix: propagate the marker in the frag-transfer helper, exactly as the other helpers do:\n\n   ```\n   --- a/net/core/skbuff.c\n   +++ b/net/core/skbuff.c\n   @@ skb_zerocopy()\n        skb_shinfo(to)->nr_frags = j;\n   +    skb_shinfo(to)->flags |= skb_shinfo(from)->flags & SKBFL_SHARED_FRAG;\n   ```\n\n   Upstream review patch (posted 2026-10-22, reviewed by Eric Dumazet, not merged as of 2026-10-26):\n   \"net: skbuff: propagate shared-frag marker in skb_zerocopy()\"\n   <https://lore.kernel.org/all/20261022104217.1852191-1-pointerliu@126.com/>.\n   The reproduction's negative control is exactly this one-line change, built from the same tree and\n   the same `.config` (`kbuild/skbuff.diff`).\n\n## Reproduction Steps\n\n1. `bundle/repro/reproduction_steps.sh` (entry point; self-contained, idempotent).\n2. The script:\n   * builds two kernels from the same `linux-6.12.111` tarball with the same `.config`\n     (`bundle/repro/kbuild.sh`): `bzImage.vuln` and `bzImage.fixed` (the one-line fix above);\n   * builds a Debian trixie QEMU guest (`bundle/repro/rootfs.sh`): initramfs with iproute2/nftables,\n     the exploit `skbpoc` (`bundle/repro/skbpoc.c`), a root-owned mode-4755 escalation target\n     (`bundle/repro/rootme.c`) and a 16 MiB ext4 image for the read-only scratch file;\n   * boots the vulnerable kernel twice and the fixed kernel twice in QEMU/KVM with the same initrd\n     and a fresh copy of the ext4 image (`bundle/repro/guest_init.sh` drives the guest);\n   * in every boot `/init` (root) creates the 0444 root-owned target, then runs\n     `skbpoc ctl` which drops to uid 1000 and executes `skbpoc exploit` - the real trigger chain:\n     `unshare(CLONE_NEWUSER|CLONE_NEWNET)` -> sysctls -> `nlmon0` -> XFRM ESP SA + inbound policy ->\n     `nft queue num 0` on LOCAL_OUT -> NFQUEUE helper bound to queue 0 -> tc `clsact` + `matchall`\n     `pedit` (24 u32 munges) + `mirred egress redirect dev lo` -> `splice()` of 8 bytes of the target\n     page plus a 514-byte attacker vector into a UDP socket on 127.0.0.1;\n   * after the trigger the ctl parent re-reads the target (page cache), executes the root-owned\n     setuid binary as uid 1000, then drops the page cache and re-reads to show the on-disk copy is\n     untouched.\n3. Expected evidence on the vulnerable kernel: `ESP_SELFTEST ... inner_udp_received=yes`,\n   `PROBE nfq_msglen=618 linear_prefix=96`, `TRIGGER changed=1 target_exact=1`,\n   `PAGE_CACHE_SHA256_AFTER != TARGET_SHA256_BEFORE`,\n   `DISK_SHA256_AFTER_EVICT == TARGET_SHA256_BEFORE`, `ROOTME_GRANTED uid=0 euid=0` and\n   `id` printing `uid=0(root)`.  On the fixed kernel the same input yields `TRIGGER changed=0`,\n   identical before/after digests and `ROOTME_DENIED uid=1000`.\n\n## Evidence\n\nPer-attempt console logs (final run, exit 0 twice in a row):\n\n* `bundle/logs/attempts/vuln_att1.log`, `vuln_att2.log` - vulnerable kernel\n* `bundle/logs/attempts/fixed_att1.log`, `fixed_att2.log` - fixed kernel (negative control)\n* `bundle/logs/kernel_identity.txt` - kernel/guest build identity and digests\n* `bundle/repro/proof_summary.txt` - condensed machine-readable verdict lines\n* `bundle/repro/runtime_manifest.json` - structured runtime evidence + artifact digests\n* `kbuild/skbuff.diff` (in the prepared project cache) - the one-line kernel differential\n\n### Vulnerable kernel (`logs/attempts/vuln_att1.log`)\n\n```\nUSERNS unshare=OK userns_uid=0 netns=fresh\nESP_SELFTEST icv_variant=0 inner_udp_received=yes\nNFQ_HELPER BIND_ACK err=0\nNFQUEUE_BIND queue=0 copy_mode=NFQNL_COPY_PACKET copy_range=65535 flags=GSO|UID_GID\nNFT_QUEUE_SETUP table=zdi chain=out hook=LOCAL_OUT queue=0\nPROBE nfq_msglen=618 linear_prefix=96 frags=8+514\nNFT_DUMP         counter packets 1 bytes 550 queue to 0\nLAYOUT linear=96 ct_off=38 ciphertext=544 icv_at=602 total=618\nTRIGGER sent=522 target_spliced=8 vector_spliced=514\nTRIGGER nfq_msglen=618\nINNER_UDP_RECEIVED len=528 (decrypted in place, delivered on lo)\nTRIGGER changed=1 target_exact=1 guards_ok=1\n```\n\nA. read-only root-owned scratch file (`/mnt/target.bin`, ext4, uid=0 mode=0444,\n`WRITE_DENIAL_AS_UID1000 ... errno=13 denied=1`):\n\n```\nTARGET_SHA256_BEFORE=ec18472a64d15a76eb7dcb5df6a23659155c01acae552de91bfbc75de1a74189\nPAGE_CACHE_SHA256_AFTER=2e543af3f2834c4ffa6fcbe691bedddbf711dcda3aadcecf6baa0b7a27b65e05\nDISK_SHA256_AFTER_EVICT=ec18472a64d15a76eb7dcb5df6a23659155c01acae552de91bfbc75de1a74189\nSCRATCH_PROOF_EXIT=0\n```\n\nThe 8 bytes at file offset 4160 became `ZFRG-OK!` in the page cache\n(`TARGET_WINDOW_AFTER ...5a4652472d4f4b21...`), the digest changed, and after\n`drop_caches` the window and digest are back to the original: the write landed in the\npage cache only.\n\nB. root-owned mode-4755 binary (`/usr/local/bin/rootme`, `.rodata` gate at file offset 8352):\n\n```\nROOTME_GATE hits=1 offset=8352\nROOTME_DENIED uid=1000 euid=0            <- before the attack\nTARGET_SLICE_ORIGINAL 5a44492d4f4b2100   <- \"ZDI-OK!\"\nDESIRED_SLICE         5a44492d50574e00   <- \"ZDI-PWN\"\nTRIGGER changed=1 target_exact=1 guards_ok=1\nPAGE_CACHE_SHA256_AFTER=86f12e5b18742477be14da2225ca2f2933fe7614bc919298d2e51e9bb6e6c0d9\nROOTME_GRANTED uid=0 euid=0\nuid=0(root) gid=0(root) groups=0(root)\nESCALATION_EXEC /usr/local/bin/rootme ZDI-PWN exit=0\nSETUID_PROOF_EXIT=0\n```\n\nThe 8-byte primitive rewrote the binary's read-only gate inside the page cache; the same\nunprivileged user then executed it and got a real uid 0 shell (`id` printed `uid=0(root)`,\n`/etc/shadow` was readable).  The root process also wrote a per-attempt marker file\n(`ROOTME_MARKER_WRITTEN /root/zdi33367_root_marker.txt ZDI33367_ROOT_MARKER_vuln_att1`), which the\nharness recovered as `bundle/logs/evidence/root_marker_vuln_att{1,2}.txt`; the fixed boots produce\nno marker (`bundle/logs/evidence/negctl_fixed_att{1,2}.json`).\n\n### Fixed kernel, identical input (`logs/attempts/fixed_att1.log`)\n\n```\nESP_SELFTEST icv_variant=0 inner_udp_received=yes\nPROBE nfq_msglen=618 linear_prefix=96 frags=8+514\nINNER_UDP_RECEIVED len=528 (decrypted in place, delivered on lo)\nTRIGGER changed=0 target_exact=0 guards_ok=1\nSCRATCH_PROOF_EXIT=1\nROOTME_DENIED uid=1000 euid=0\nSETUID_PROOF_EXIT=1\n```\n\nThe whole chain still runs and the authenticated ESP packet is still decrypted and\ndelivered (so the only behavioural difference is the write destination), but the target\npage cache is byte-identical before and after (`PAGE_CACHE_SHA256_AFTER` equals\n`TARGET_SHA256_BEFORE`) and the setuid binary still denies - `skb_cow_data()` linearised\nthe frags because the marker was propagated.\n\n## Recommendations / Next Steps\n\n* Apply the upstream propagation fix (`skb_shinfo(to)->flags |= skb_shinfo(from)->flags &\n  SKBFL_SHARED_FRAG;` in `skb_zerocopy()`), or the distribution backports (AlmaLinux\n  `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`).\n* Interim mitigation: `user.max_user_namespaces=0` (blocks the unprivileged namespace setup), or\n  blocking the `nlmon`/`act_pedit` modules where they are not needed.\n* Audit the remaining frag-transfer helpers for the same marker loss; the advisory notes several\n  follow-up reports for other paths.\n* Detection: the syscall sequence (user+net namespace unshare -> nlmon -> XFRM -> NFQUEUE -> tc pedit\n  redirect -> `splice()` to loopback) is highly distinctive for a single unprivileged process.\n\n## Additional Notes\n\n* Idempotency: `kbuild.sh`/`rootfs.sh` cache their outputs under the prepared project cache and can\n  be re-run safely; each boot uses a fresh copy of the ext4 target image, so repeated runs are\n  independent.\n* Limitations: the write primitive transfers 8 attacker-chosen bytes per trigger at a chosen file\n  offset (the public PoC behaves the same way); the demonstration therefore patches a single gate\n  inside a root-owned setuid binary rather than rewriting a whole file.  The corrupted page is not\n  marked dirty, so the on-disk copy stays intact (shown by the drop-cache re-read).\n* Both kernels are built from the same tree, `.config` and compiler; the only difference is the\n  one-line marker propagation, so the vulnerable/fixed difference is not a configuration artefact.\n","cve_id":"","cwe_id":"CWE-668","source_url":"https://www.zerodayinitiative.com/advisories/ZDI-CAN-33367/","reproduced_at":"2026-10-26T21:13:41.837574+00:00","duration_secs":6583.582085,"tool_calls":372,"handoffs":2,"total_cost_usd":1.731823,"agent_costs":{"claim_matcher":0.027127,"judge":0.709887,"learning_policy":0.018036,"repro":0.680699,"support":0.018502,"vuln_variant":0.277572},"cost_breakdown":{"claim_matcher":{"gpt-5.4-mini-2026-03-17":0.027127},"judge":{"gpt-5.6-sol":0.709887},"learning_policy":{"gpt-5.4-mini-2026-03-17":0.018036},"repro":{"accounts/fireworks/models/deepseek-v4p1-flash":0.680699},"support":{"accounts/fireworks/models/deepseek-v4p1-flash":0.018502},"vuln_variant":{"accounts/fireworks/models/deepseek-v4p1-flash":0.277572}},"vulnerable_version_variant_outcome":"not_found","fix_bypass_outcome":"unknown","variant_disclosure_state":"not_applicable","quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"evidence":{"workflow":{"profile":"known_vulnerability","schema_version":2,"stages":["support","claim_contract","repro","judge","vuln_variant"]}},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-10-26T21:13:43.369428+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":13925,"category":"analysis"},{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":13696,"category":"reproduction_script"},{"path":"bundle/logs/attempts/fixed_att2.log","filename":"fixed_att2.log","size":8377,"category":"log"},{"path":"bundle/logs/evidence/negctl_fixed_att2.json","filename":"negctl_fixed_att2.json","size":172,"category":"other"},{"path":"bundle/logs/kernel_identity.txt","filename":"kernel_identity.txt","size":936,"category":"other"},{"path":"bundle/repro/guest_init.sh","filename":"guest_init.sh","size":3560,"category":"other"},{"path":"bundle/repro/kbuild.sh","filename":"kbuild.sh","size":5677,"category":"other"},{"path":"bundle/repro/proof_summary.txt","filename":"proof_summary.txt","size":1209,"category":"other"},{"path":"bundle/repro/rootfs.sh","filename":"rootfs.sh","size":4009,"category":"other"},{"path":"bundle/repro/rootme.c","filename":"rootme.c","size":2329,"category":"other"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":2620,"category":"other"},{"path":"bundle/repro/skbpoc.c","filename":"skbpoc.c","size":42573,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":1854,"category":"other"}]}