Read-only audit script for the Linux page-cache local privilege escalation family — a class of kernel bugs where zero-copy / shared-page invariants fail, allowing an unprivileged local user to corrupt page-cache memory and escalate to root by poisoning cached setuid binaries (/bin/su, sudo, ...).
____ _ ____ _____ ____ _ ____ _ _ _____
| _ \ / \ / ___| ____/ ___| / \ / ___| | | | ____|
| |_) / _ \| | _| _|| | / _ \| | | |_| | _|
| __/ ___ \ |_| | |__| |___ / ___ \ |___| _ | |___
|_| /_/ \_\____|_____\____/_/ \_\____|_| |_|_____|
C V E - A U D I T | Linux page-cache LPE family
v1.1 | by Quaerendir
| CVE | Name | Subsystem | Disclosed | Mitigation surface |
|---|---|---|---|---|
| CVE-2016-5195 | Dirty COW | mm/gup (COW race) |
2016-10-19 | none (kernel patch only) |
| CVE-2022-0847 | Dirty Pipe | fs/splice + pipe_buffer |
2022-03-07 | none (kernel patch; seccomp-block splice()) |
| CVE-2026-31431 | Copy Fail | crypto AF_ALG / AEAD |
2026-04-29 | install algif_aead /bin/false (a blacklist line does not stop the autoload) |
| CVE-2026-43284 | Dirty Frag | net/xfrm + net/rxrpc |
2026-05-07 | blacklist esp4, esp6, rxrpc |
| CVE-2026-43500 | Dirty Frag (rxrpc half) | net/rxrpc |
2026-05-07 | same as above |
| CVE-2026-46300 | Fragnesia | net/xfrm ESP-in-TCP + skb_try_coalesce |
2026-05-13 | blacklist esp4, esp6 |
| CVE-2026-43503 | DirtyClone | net/core __pskb_copy_fclone + skb_shift |
2026-05-23 | blacklist esp4, esp6 (same as Fragnesia) |
| CVE-2026-46331 | pedit COW | net/sched act_pedit |
2026-06-16 | install act_pedit /bin/false (blacklist works only on 6.9+) |
The shared shape: each one is a kernel fast path that touches pages belonging to the page cache while failing an ownership invariant. Each one chains through unshare(CLONE_NEWUSER | CLONE_NEWNET) to obtain CAP_NET_ADMIN in a namespace (except Dirty COW and Dirty Pipe, which don't need it). Each one corrupts a cached binary in memory while leaving the on-disk file untouched — so file-integrity monitors see nothing.
These bugs share more than a vibe. They share:
- Operational triage flow: identify the running kernel, check vendor advisory status, check module reachability, check userns gate, hunt behavioural IoCs.
- Mitigation primitives:
modprobe install /bin/falsefor the relevant module,sysctl user.max_user_namespaces=0to break the userns chain. - Detection signals: unexpected module loads, namespace creation followed by setuid execution,
tc/ip xfrm/af_algsyscall activity in places it has no business being.
Running six separate scripts is sysadmin theater. One dispatcher with a check-per-CVE plugin layout lets you audit selectively, JSON-aggregate across a fleet, and add the next entry in this family with a single new file in checks/.
linux-pagecache-cve-audit/
├── linux-pagecache-cve-audit.sh # dispatcher
├── lib/
│ ├── common.sh # inventory, output, scoring, module probes
│ ├── ioc.sh # behavioural IoC hunt (auditd + journalctl)
│ └── prometheus.sh # node_exporter textfile output
├── checks/
│ ├── cve-2016-5195.sh # Dirty COW
│ ├── cve-2022-0847.sh # Dirty Pipe
│ ├── cve-2026-31431.sh # Copy Fail
│ ├── cve-2026-43284.sh # Dirty Frag (+ CVE-2026-43500)
│ ├── cve-2026-46300.sh # Fragnesia
│ ├── cve-2026-43503.sh # DirtyClone
│ └── cve-2026-46331.sh # pedit COW
├── examples/
│ ├── sample-output.txt
│ ├── sample-output.json
│ ├── sample-output-quiet.txt
│ ├── sample-output.prom
│ └── cve-list.txt
├── tests/
│ ├── run_tests.sh # regression test driver
│ ├── mock_bin/ # deterministic mock binaries
│ └── fixtures/ # os-release and modprobe.d samples
└── .github/workflows/shellcheck.yml
Each checks/cve-XXXX-YYYY.sh exposes two functions: cve_XXXX_YYYY_check (runs the audit, calls record_result) and cve_XXXX_YYYY_mitigation (prints mitigation guidance). The dispatcher sources lib/common.sh, sources each selected check, and aggregates verdicts.
The common library handles:
- Host inventory (kernel release, distro, kernel package version via
rpm/dpkg). - Module reachability across four signals: loaded, modular-and-loadable, built-in, blocked by an
installoverride, or byblacklistwhen the kernel autoloads the module through an alias (esp4/esp6, rxrpc, act_pedit on 6.9+; not algif_aead or older act_pedit, which are requested by name). - Kernel config detection (
/boot/config-$(uname -r)or/proc/config.gz). - Version comparison via
dpkg --compare-versionswithsort -Vfallback. - Userns probes including the Ubuntu 24.04+/26.04 AppArmor userns gates.
- Netns probes (
user.max_net_namespaces) used as an additional userns-gated chain break. - Test-mode fixture paths controlled by
__UNIT_TEST=1for the regression suite. - Scoring:
compute_verdict <vendor_status> <surface_score> <userns_required>returnsverdict|score.
lib/ioc.sh adds an opt-in behavioural pass over auditd (if rules were installed) and journalctl looking for module-load events, public-PoC names, and userns + setuid execution patterns. lib/prometheus.sh emits node_exporter textfile collector output.
# Full audit, all six CVEs.
sudo ./linux-pagecache-cve-audit.sh
# Single CVE.
sudo ./linux-pagecache-cve-audit.sh --cve 2026-46331
# Subset.
sudo ./linux-pagecache-cve-audit.sh --cve 2026-46331,2026-46300,2026-43284
# Only this year, or everything up to a given year.
sudo ./linux-pagecache-cve-audit.sh --since 2026
sudo ./linux-pagecache-cve-audit.sh --until 2022
# Add behavioural IoC pass over auditd + journalctl.
sudo ./linux-pagecache-cve-audit.sh --hunt
sudo ./linux-pagecache-cve-audit.sh --hunt --hunt-since 2026-06-01
# Machine-readable.
sudo ./linux-pagecache-cve-audit.sh --json
# One-line per CVE, for fleet aggregation.
sudo ./linux-pagecache-cve-audit.sh --quiet
# node_exporter textfile collector format.
sudo ./linux-pagecache-cve-audit.sh --prometheus
# Print mitigation guidance for a specific CVE.
./linux-pagecache-cve-audit.sh --mitigation CVE-2026-46331
# List known CVEs.
./linux-pagecache-cve-audit.sh --list
# Print version.
./linux-pagecache-cve-audit.sh --version
# Paste-ready auditd rules and Prometheus alerting rules.
./linux-pagecache-cve-audit.sh --print-audit-rules
./linux-pagecache-cve-audit.sh --print-alert-rules| Code | Meaning |
|---|---|
| 0 | All PATCHED or NOT_APPLICABLE |
| 1 | At least one MITIGATED |
| 2 | At least one VULNERABLE |
| 3 | At least one UNKNOWN and no VULNERABLE |
| 4 | ERROR (missing files, bad args) |
The exit code is the worst verdict, so failed_when: rc >= 2 in Ansible is the conservative gate.
See examples/. Abbreviated:
== Summary ==
CVE Name Verdict Score Detail
--- ---- ------- ----- ------
CVE-2016-5195 Dirty COW PATCHED 20 6.18 >= 4.9 (well-backported)
CVE-2022-0847 Dirty Pipe PATCHED 25 6.18: above all 5.x stable fix lines
CVE-2026-31431 Copy Fail UNKNOWN 30 Ubuntu: check USN
CVE-2026-43284 Dirty Frag UNKNOWN 45 Ubuntu: check USN per release stream
CVE-2026-46300 Fragnesia UNKNOWN 45 Ubuntu: check USN
CVE-2026-46331 pedit COW UNKNOWN 45 Ubuntu 24.04: check USN per variant
Overall verdict: UNKNOWN (worst case across all CVEs)
A regression suite lives under tests/. It runs the dispatcher against mock
binaries (tests/mock_bin/) and fixture files (tests/fixtures/) so the
results are deterministic and do not depend on the host kernel:
./tests/run_tests.shThe suite also serves as documentation for how the script behaves on different
distros and kernel versions. shellcheck is run over linux-pagecache-cve-audit.sh,
lib/*.sh, checks/*.sh, tests/run_tests.sh, and tests/mock_bin/*.
- name: audit page-cache LPE family
hosts: all
become: true
tasks:
- name: run audit (JSON)
ansible.builtin.script: linux-pagecache-cve-audit.sh --json
register: audit
changed_when: false
failed_when: false
- name: parse result
ansible.builtin.set_fact:
pagecache_audit: "{{ audit.stdout | from_json }}"
- name: fail on VULNERABLE hosts
ansible.builtin.fail:
msg: "Host has VULNERABLE CVEs: {{ pagecache_audit.results | selectattr('verdict','equalto','VULNERABLE') | map(attribute='cve') | list }}"
when: pagecache_audit.overall_verdict == 'VULNERABLE'# Collect from many hosts.
for h in $(cat hosts.txt); do
ssh "$h" "sudo /usr/local/bin/linux-pagecache-cve-audit.sh --json" 2>/dev/null \
| jq --arg h "$h" '. + {host: $h}'
done > audit.jsonl
# Hosts vulnerable to pedit COW.
jq -r 'select(.results[] | select(.cve == "CVE-2026-46331" and .verdict == "VULNERABLE")) | .host' audit.jsonl
# Verdict matrix.
jq -r '[.host, (.results[] | .cve + "=" + .verdict)] | @tsv' audit.jsonlNative output via --prometheus. Drop into a cron and node_exporter picks it up:
# /etc/cron.daily/pagecache-cve-audit
#!/bin/sh
/usr/local/bin/linux-pagecache-cve-audit.sh --prometheus --hunt \
> /var/lib/node_exporter/textfile_collector/pagecache_cve.prom.tmp
mv /var/lib/node_exporter/textfile_collector/pagecache_cve.prom{.tmp,}Metrics emitted:
| Metric | Labels | Meaning |
|---|---|---|
linux_pagecache_cve_risk_score |
cve, name, verdict |
0-100 score |
linux_pagecache_cve_status |
cve, name, verdict |
1 = this CVE has this verdict |
linux_pagecache_cve_audit_overall |
verdict |
1 for the worst-case verdict |
linux_pagecache_cve_audit_timestamp |
(none) | unix epoch of last run |
linux_pagecache_cve_audit_ioc_hits |
(none) | IoC count from --hunt |
Get paste-ready alerting rules:
./linux-pagecache-cve-audit.sh --print-alert-rules > /etc/prometheus/rules/pagecache_cve.yml--hunt runs an opt-in pass over auditd (if rules are installed) and journalctl looking for:
- Public-PoC names:
packet_edit_meme,dirtyfrag,fragnesia,copyfail,dirtypipe,dirtycow. - Module-load events for
act_pedit,algif_aead,esp4,esp6,rxrpc. unshare(CLONE_NEWUSER|CLONE_NEWNET)followed by setuid execution.
For auditd-based detection you need rules installed beforehand. Get paste-ready rules:
./linux-pagecache-cve-audit.sh --print-audit-rules > /etc/audit/rules.d/99-pagecache-cve.rules
sudo augenrules --loadThe hunt is triage, not proof. False positives are expected on hosts where tc, IPsec, or AF_ALG are legitimately used.
A weighted sum, capped at 100:
| Signal | Weight |
|---|---|
Vendor verdict VULNERABLE |
+50 |
Vendor verdict UNKNOWN |
+30 |
Vendor verdict NOT_APPLICABLE |
0 (kernel predates the bug) |
| Module built into kernel | +25 |
| Module loaded right now | +25 |
| Module loadable, no override | +20-22 |
| Module loadable, override active | +5 |
| Unprivileged userns reachable, no AppArmor gate | +15 |
| Unprivileged userns reachable, AppArmor gate active | +8 |
Unprivileged netns blocked (user.max_net_namespaces=0) |
can downgrade VULNERABLE to MITIGATED for netns-gated CVEs |
For multi-module CVEs (Dirty Frag, Fragnesia), surface from each independent path stacks up to a cap of 50.
For Dirty COW and Dirty Pipe, the "module surface" is conceptually replaced with "primitive is always reachable" (fixed +20-25), because the bug lives in core mm or splice paths with no module to disable.
This bug family is not "Linux is broken". It's a structural debt from a 1990s design choice: zero-copy plus the page cache as universal intermediary between filesystems and everything that reads files. Every kernel fast path that touches skb, pipe_buffer, iov_iter, or bvec operates on pages that might be page-cache-backed. The invariant "before you write, prove the page is yours" has to be maintained at hundreds of call sites. Each one missed gives you one of these CVEs.
| CVE | Year | Primitive | Page-ownership invariant violated |
|---|---|---|---|
| CVE-2016-5195 | 2016 | mm/gup COW race |
private/shared distinction during fault retry |
| CVE-2022-0847 | 2022 | pipe buffer flag leak | PIPE_BUF_FLAG_CAN_MERGE survives across pipe contexts |
| CVE-2026-31431 | 2026 | AF_ALG in-place AEAD | source vs destination mapping conflated |
| CVE-2026-43284 | 2026 | xfrm ESP + rxrpc | shared-frag marker dropped during coalesce |
| CVE-2026-46300 | 2026 | skb_try_coalesce |
same marker, different call site, the Dirty Frag fix didn't cover it |
| CVE-2026-43503 | 2026 | __pskb_copy_fclone + skb_shift |
SKBFL_SHARED_FRAG lost during clone; Fragnesia fix didn't cover these paths |
| CVE-2026-46331 | 2026 | act_pedit typed-key offset |
COW range computed before final offset is known |
Each fix addresses the proximate cause. None addresses the root cause. Rust-in-kernel targets exactly this class of bug via ownership types in the type system. It will not show up in net/sched or crypto or mm tomorrow, but the trajectory matters.
This script:
- Never loads kernel modules.
- Never modifies
sysctl,modprobe.d, or any other persistent state. - Never executes a public PoC, ever.
- Is read-only by design; running it on production has no side effects beyond CPU cycles and a few
lsmod/modinfo/sysctl -ncalls.
For real authorisation-bounded exploitation testing, use the published PoCs in an isolated lab and not via this script.
- CVE-2016-5195 Dirty COW: https://dirtycow.ninja/
- CVE-2022-0847 Dirty Pipe: https://dirtypipe.cm4all.com/
- CVE-2026-31431 Copy Fail: https://www.microsoft.com/en-us/security/blog/2026/05/01/cve-2026-31431-copy-fail-vulnerability-enables/
- CVE-2026-43284 Dirty Frag: https://www.microsoft.com/en-us/security/blog/2026/05/08/active-attack-dirty-frag-linux-vulnerability-expands-post-compromise-risk/
- CVE-2026-46300 Fragnesia: https://almalinux.org/blog/2026-05-13-fragnesia-cve-2026-46300/
- CVE-2026-46331 pedit COW: https://access.redhat.com/security/vulnerabilities/RHSB-2026-008
- Red Hat eBPF LSM mitigations for OpenShift: https://access.redhat.com/solutions/7142250
MIT. See LICENSE.