# 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()"
   <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.
