[FUSE] Two latent data-integrity defects in the 7.2 iomap read integration — pre-disclosure before large-folio enablement

From: Kanishka De Silva

Date: Sun Aug 30 2026 - 00:32:54 EST


Hi Miklos and Joanne,

I am reporting two latent data-integrity defects in the FUSE iomap
read integration introduced in Linux 7.2. Both defects are unreachable
in stock 7.2.2 today, but activate the moment partially-uptodate
folios exist in FUSE — which is precisely what the announced "fuse:
support large folios" series (Joanne) and any sub-page-blocksize
invalidation semantics will create. I'm reporting now, before that
series merges, so the fixes can land alongside or before the
enablement.

Full report, suggested fixes patch, PoC source, and runtime evidence
logs are attached as plain text files.

Summary:

The 7.2 series "fuse: use iomap for buffered reads + readahead"
introduced, for the first time, FUSE read paths where a folio read can
be a sub-range of that folio (desc.offset > 0 and/or desc.length <
folio_size). Two legacy helpers were not adapted:

Defect A — fuse_copy_folio() whole-folio zeroing (fs/fuse/dev.c):
When zeroing is set and count < folio_size, the function calls
folio_zero_range(folio, 0, size) — zeroing the ENTIRE folio before
copying the reply at [offset, offset+count). With sub-folio read
ranges this destroys valid, cached, uptodate data located before the
requested range in the same folio. Demonstrated end-to-end: 1024 bytes
of valid cached data silently replaced by zeros.

Suggested fix (one line):
- folio_zero_range(folio, 0, size);
+ folio_zero_range(folio, offset, size - offset);

Defect B — fuse_send_readpages() ignores descs[0].offset (fs/fuse/file.c):
The readahead sender computes the FUSE_READ request start as
folio_pos(ap->folios[0]), ignoring ap->descs[0].offset. If the first
folio of a batch is prefix-partial, the kernel asks the server for
data beginning at the folio start instead of the first invalid block.
The reply is copied at the wrong position — every byte of the batch is
silently shifted. Both sibling paths handle this correctly:
- writeback: folio_pos(folio) + offset (fuse_writepage_args_setup)
- sync read: folio_pos(folio) + off (fuse_do_readfolio)
The readahead path is the odd one out.

Suggested fix (one line):
- loff_t pos = folio_pos(ap->folios[0]);
+ loff_t pos = folio_pos(ap->folios[0]) + ap->descs[0].offset;

Reachability today (why latent):
Stock 7.2.2 cannot construct a partially-uptodate folio in FUSE —
order-0 folios only, no code path clears per-block uptodate bits, and
the VFS readahead core only hands freshly-allocated fully-non-uptodate
folios to ->readahead(). Both defects activate exactly when the
large-folio series lands or any sub-page invalidation semantics are
introduced.

Runtime verification:
Full reproduction in a User-Mode-Linux build of unmodified Linux 7.2.2
(KASAN enabled) with a raw /dev/fuse server:
- Pristine control run (poc-run16-pristine.log): all 16384 bytes
correct — confirms defects are latent in stock 7.2.2
- Precondition demonstration run (poc-run15-precondition.log): with a
minimal 19-line patch that drops per-block uptodate bits on
invalidation (the semantics the large-folio series requires), Defect A
reproduces: 1024 of 16384 bytes silently corrupted, first mismatch at
file offset 0x1000
- No KASAN splat — this is a logic/data-integrity bug, not memory unsafety

Connection to the writeback fix:
This is the read-side analogue of commit c3880a7b10e4 ("fuse: fix
writeback array overflow when max_pages is one", 7.1), which hardened
the writeback path after the same class of sub-folio desc issue was
found there. The read path has two equivalent unadapted sites
remaining.

Attached plain text files:
1) VENDOR-REPORT-fuse-7.2.2.md — full write-up with root cause,
reachability analysis, runtime evidence, audit scope
2) 0001-suggested-fixes.patch — two one-line fixes plus optional
capacity-guard hardening
3) 0000-demo-precondition-uptodate-invalidation.patch — minimal
19-line precondition patch used to demonstrate Defect A
4) fusepoc.c — raw /dev/fuse server + fuseblk mounter + trigger
5) init.c — minimal UML guest init
6) poc-run16-pristine.log — pristine 7.2.2 control run
7) poc-run15-precondition.log — precondition demonstration run (Defect
A reproduced)
8) poc-run13.log — intermediate instrumentation run
9) build-fuse.log — kernel build log
10) config-um-fuse — UML kernel config used
11) run-instructions.txt — end-to-end reproduction steps

I would suggest fixing both sites before or together with the
large-folio enablement to avoid the fixes needing to chase the feature
into stable series after the fact — the same pattern that produced
c3880a7b10e4 on the writeback side.

One additional request: once the large-folio series merges into
mainline and these defects become reachable, could you let me know? At
that point I would like to request a CVE for both defects through the
appropriate channel, referencing the prior disclosure date and the fix
commits. If the fixes land before or with the large-folio series that
would be the cleaner outcome for everyone.

Happy to answer any questions or provide additional detail.

Best regards,
Kanishka De Silva (HEXER / H3X4R)
Independent Security Researcher — HEXERLAB
kpskanna1915@xxxxxxxxx
hexer.is-a.dev
GitHub: hexer365
// fusepoc.c — PoC for Linux 7.2.2 FUSE readahead wrong-offset defect
// (fuse_send_readpages() ignores ap->descs[0].offset; live for sub-page
// blocksize fuseblk mounts once partially-uptodate folios exist —
// demonstrated via a minimal precondition patch mirroring the announced
// FUSE large-folio support)
//
// One binary, two roles (fork model, like fusermount):
// child = raw /dev/fuse protocol server (no libfuse), serves one file
// "target": each 16-byte record at offset R contains 16-char hex
// of R; punched ranges are zeros (hole semantics)
// parent = sets up a loop device, mounts it as fuseblk (blksize=512 or
// 4096) with the inherited /dev/fuse fd, then runs the trigger:
// T1: read whole file (all folios cached uptodate)
// T2: punch [0,4096) -> folio 0 fully dropped
// punch [5120,8192) -> folio 1 becomes prefix-partial
// (blocks 0..1 uptodate, 2..7 not,
// with sub-page uptodate tracking)
// T3: pread(0,16384) -> readahead fires (folio 0 missing)
// correct kernel: FUSE_READ(5120, 12288) [plus 4096 read]
// buggy kernel: FUSE_READ(4096, 12288) <- 1024 too low
// T4: byte-exact verification against expected content
//
// Build: gcc -static -O2 -o fusepoc fusepoc.c

#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdarg.h>
#include <stdint.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <signal.h>
#include <sys/mount.h>
#include <sys/ioctl.h>
#include <sys/stat.h>
#include <sys/statvfs.h>
#include <sys/wait.h>
#include <sys/mman.h>
#include <linux/loop.h>

/* ---------------- FUSE protocol (uapi/linux/fuse.h, v7.2) ---------------- */

struct fuse_in_header {
uint32_t len, opcode;
uint64_t unique, nodeid;
uint32_t uid, gid, pid;
uint16_t total_extlen, padding;
};
struct fuse_out_header { uint32_t len; int32_t error; uint64_t unique; };

enum {
FUSE_LOOKUP = 1, FUSE_FORGET = 2, FUSE_GETATTR = 3, FUSE_SETATTR = 4,
FUSE_OPEN = 14, FUSE_READ = 15, FUSE_STATFS = 17, FUSE_RELEASE = 18,
FUSE_FLUSH = 25, FUSE_INIT = 26, FUSE_DESTROY = 38, FUSE_FALLOCATE = 43,
};

struct fuse_attr {
uint64_t ino, size, blocks, atime, mtime, ctime;
uint32_t atimensec, mtimensec, ctimensec, mode, nlink, uid, gid, rdev, blksize, flags;
};
struct fuse_attr_out { uint64_t attr_valid; uint32_t attr_valid_nsec, dummy; struct fuse_attr attr; };
struct fuse_entry_out {
uint64_t nodeid, generation, entry_valid, attr_valid;
uint32_t entry_valid_nsec, attr_valid_nsec;
struct fuse_attr attr;
};
struct fuse_forget_in { uint64_t nlookup; };
struct fuse_init_in { uint32_t major, minor, max_readahead, flags, flags2, unused[11]; };
struct fuse_init_out {
uint32_t major, minor, max_readahead, flags;
uint16_t max_background, congestion_threshold;
uint32_t max_write, time_gran;
uint16_t max_pages, map_alignment;
uint32_t flags2, max_stack_depth;
uint16_t request_timeout, unused[11];
};
struct fuse_open_in { uint32_t flags, open_flags; };
struct fuse_open_out { uint64_t fh; uint32_t open_flags; int32_t backing_id; };
struct fuse_read_in { uint64_t fh, offset; uint32_t size, read_flags; uint64_t lock_owner; uint32_t flags, padding; };
struct fuse_setattr_in {
uint32_t valid, padding; uint64_t fh, size, lock_owner, atime, mtime, ctime;
uint32_t atimensec, mtimensec, ctimensec, mode, unused4, uid, gid, unused5;
};
struct fuse_fallocate_in { uint64_t fh, offset, length; uint32_t mode, padding; };

#define FUSE_KERNEL_VERSION 7
#define FUSE_ASYNC_READ (1 << 0)
#define FUSE_BIG_WRITES (1 << 5)
#define FUSE_MAX_PAGES (1 << 22)
#define FATTR_SIZE (1 << 3)
#define FOPEN_KEEP_CACHE (1 << 1)

/* ---------------- server ---------------- */

#define TGT_NODEID 2
static uint64_t g_size = 16384;
static int g_srv_fd;
static FILE *g_log;
static const char *g_tag;

/* server-side file state: up to 8 punch ranges zeroed (hole semantics) */
#define MAX_PZ 8
static struct { uint64_t s, e; } g_pz[MAX_PZ];
static int g_npz;

static void pz_add(uint64_t s, uint64_t e)
{
for (int k = 0; k < g_npz; k++) { /* merge/extend */
if (s <= g_pz[k].e && e >= g_pz[k].s) {
if (s < g_pz[k].s) g_pz[k].s = s;
if (e > g_pz[k].e) g_pz[k].e = e;
return;
}
}
if (g_npz < MAX_PZ) { g_pz[g_npz].s = s; g_pz[g_npz].e = e; g_npz++; }
}

static int pz_is_zero(uint64_t f)
{
for (int k = 0; k < g_npz; k++)
if (f >= g_pz[k].s && f < g_pz[k].e) return 1;
return 0;
}

/* pattern: each 16-byte aligned record holds 16-char hex of its own offset;
* punched ranges read back as zeros (hole semantics) */
static void pattern_fill(char *buf, uint64_t off, uint32_t len)
{
for (uint32_t i = 0; i < len; i++) {
uint64_t f = off + i;
if (pz_is_zero(f)) {
buf[i] = 0;
continue;
}
uint64_t rec = f & ~15ULL;
if ((f & 15) == 0 && i + 16 <= len) {
snprintf(buf + i, 17, "%016llx", (unsigned long long)rec);
i += 15;
continue;
}
buf[i] = '.';
}
}

static void slog(const char *fmt, ...)
{
va_list ap;
fprintf(g_log, "[%s] ", g_tag);
va_start(ap, fmt);
vfprintf(g_log, fmt, ap);
va_end(ap);
fputc('\n', g_log);
fflush(g_log);
}

static void reply(const void *hdr_plus, uint32_t total_len)
{
if (write(g_srv_fd, hdr_plus, total_len) != (ssize_t)total_len) {
slog("write reply failed: %s", strerror(errno));
exit(1);
}
}

/* @err is the final (negative) errno, or 0 for success */
static void reply_err(uint64_t unique, int err)
{
struct { struct fuse_out_header h; } r;
memset(&r, 0, sizeof(r));
r.h.len = sizeof(r); r.h.error = err; r.h.unique = unique;
reply(&r, sizeof(r));
}

static void fill_attr(struct fuse_attr *a)
{
memset(a, 0, sizeof(*a));
a->ino = TGT_NODEID;
a->size = g_size;
a->blocks = (g_size + 511) / 512;
a->atime = a->mtime = a->ctime = 1000000;
a->mode = 0100644;
a->nlink = 1;
a->blksize = 512;
}

struct read_reply {
struct fuse_out_header h;
char data[1 << 20];
};

static void server_loop(void)
{
static char inbuf[1 << 20];
static char outbuf[1 << 20];

for (;;) {
ssize_t n = read(g_srv_fd, inbuf, sizeof(inbuf));
if (n < 0) {
if (errno == EINTR) continue;
if (errno == EPERM) {
/* 7.2: read before mount returns EPERM unless
* sync_init — retry until the mount happens */
usleep(10000);
continue;
}
slog("server read err %s", strerror(errno));
exit(1);
}
if (n == 0) { slog("server: EOF (device closed)"); exit(0); }

struct fuse_in_header *ih = (struct fuse_in_header *)inbuf;
if ((size_t)n < sizeof(*ih)) continue;

switch (ih->opcode) {
case FUSE_INIT: {
struct fuse_init_in *ia = (struct fuse_init_in *)(inbuf + sizeof(*ih));
struct { struct fuse_out_header h; struct fuse_init_out o; } r;
memset(&r, 0, sizeof(r));
r.h.len = sizeof(r); r.h.error = 0; r.h.unique = ih->unique;
r.o.major = FUSE_KERNEL_VERSION;
r.o.minor = ia->minor;
r.o.max_readahead = 131072;
r.o.flags = FUSE_ASYNC_READ | FUSE_BIG_WRITES | FUSE_MAX_PAGES;
r.o.max_background = 16;
r.o.congestion_threshold = 12;
r.o.max_write = 131072;
r.o.max_pages = 256;
slog("REQ INIT minor=%u flags=%#x", ia->minor, ia->flags);
reply(&r, sizeof(r));
break;
}
case FUSE_LOOKUP: {
const char *name = inbuf + sizeof(*ih);
slog("REQ LOOKUP nodeid=%llu name=\"%s\"",
(unsigned long long)ih->nodeid, name);
if (ih->nodeid == 1 && strcmp(name, "target") == 0) {
struct { struct fuse_out_header h; struct fuse_entry_out e; } r;
memset(&r, 0, sizeof(r));
r.h.len = sizeof(r); r.h.unique = ih->unique;
r.e.nodeid = TGT_NODEID;
r.e.generation = 1;
r.e.entry_valid = 1; r.e.attr_valid = 1;
fill_attr(&r.e.attr);
reply(&r, sizeof(r));
} else {
reply_err(ih->unique, -ENOENT);
}
break;
}
case FUSE_GETATTR: {
slog("REQ GETATTR nodeid=%llu", (unsigned long long)ih->nodeid);
struct { struct fuse_out_header h; struct fuse_attr_out o; } r;
memset(&r, 0, sizeof(r));
r.h.len = sizeof(r); r.h.unique = ih->unique;
r.o.attr_valid = 1;
fill_attr(&r.o.attr);
reply(&r, sizeof(r));
break;
}
case FUSE_SETATTR: {
struct fuse_setattr_in *sa = (struct fuse_setattr_in *)(inbuf + sizeof(*ih));
if (sa->valid & FATTR_SIZE && ih->nodeid == TGT_NODEID) {
g_size = sa->size;
slog("REQ SETATTR nodeid=%llu -> size=%llu",
(unsigned long long)ih->nodeid,
(unsigned long long)g_size);
} else {
slog("REQ SETATTR nodeid=%llu valid=%#x",
(unsigned long long)ih->nodeid, sa->valid);
}
struct { struct fuse_out_header h; struct fuse_attr_out o; } r;
memset(&r, 0, sizeof(r));
r.h.len = sizeof(r); r.h.unique = ih->unique;
r.o.attr_valid = 1;
fill_attr(&r.o.attr);
reply(&r, sizeof(r));
break;
}
case FUSE_OPEN: {
slog("REQ OPEN nodeid=%llu", (unsigned long long)ih->nodeid);
struct { struct fuse_out_header h; struct fuse_open_out o; } r;
memset(&r, 0, sizeof(r));
r.h.len = sizeof(r); r.h.unique = ih->unique;
r.o.fh = 1;
r.o.open_flags = FOPEN_KEEP_CACHE;
reply(&r, sizeof(r));
break;
}
case FUSE_READ: {
struct fuse_read_in *ri = (struct fuse_read_in *)(inbuf + sizeof(*ih));
slog("REQ READ nodeid=%llu offset=%llu size=%u",
(unsigned long long)ih->nodeid,
(unsigned long long)ri->offset, ri->size);
uint64_t off = ri->offset;
uint32_t sz = ri->size;
if (off >= g_size) sz = 0;
else if (off + sz > g_size) sz = (uint32_t)(g_size - off);
struct read_reply *r = (struct read_reply *)outbuf;
r->h.len = sizeof(struct fuse_out_header) + sz;
r->h.error = 0;
r->h.unique = ih->unique;
pattern_fill(r->data, off, sz);
reply(r, r->h.len);
break;
}
case FUSE_FALLOCATE: {
struct fuse_fallocate_in *fa = (struct fuse_fallocate_in *)(inbuf + sizeof(*ih));
slog("REQ FALLOCATE nodeid=%llu offset=%llu length=%llu mode=%#x",
(unsigned long long)ih->nodeid,
(unsigned long long)fa->offset,
(unsigned long long)fa->length, fa->mode);
if (ih->nodeid == TGT_NODEID && (fa->mode & 0x2 /*PUNCH_HOLE*/))
pz_add(fa->offset, fa->offset + fa->length);
reply_err(ih->unique, 0);
break;
}
case FUSE_FLUSH: {
slog("REQ FLUSH nodeid=%llu", (unsigned long long)ih->nodeid);
reply_err(ih->unique, 0);
break;
}
case FUSE_RELEASE: {
slog("REQ RELEASE nodeid=%llu", (unsigned long long)ih->nodeid);
reply_err(ih->unique, 0);
break;
}
case FUSE_FORGET: {
struct fuse_forget_in *fi = (struct fuse_forget_in *)(inbuf + sizeof(*ih));
slog("REQ FORGET nodeid=%llu n=%llu (no reply)",
(unsigned long long)ih->nodeid,
(unsigned long long)fi->nlookup);
break; /* no reply */
}
case FUSE_DESTROY: {
slog("REQ DESTROY");
reply_err(ih->unique, 0);
break;
}
default:
slog("REQ opcode=%u nodeid=%llu -> ENOSYS",
ih->opcode, (unsigned long long)ih->nodeid);
reply_err(ih->unique, -ENOSYS);
break;
}
}
}

/* ---------------- mounter + trigger ---------------- */

/* the two punch ranges used by the trigger (also enforced server-side) */
#define PZ1_S 0ULL
#define PZ1_E 4096ULL
#define PZ2_S 5120ULL
#define PZ2_E 8192ULL

/* client-side expected content: pattern with punched zeros */
static char expected_byte(uint64_t f)
{
if (pz_is_zero(f)) return 0;
if ((f & 15) == 0) return '0'; /* records are hex digits; enough for compare */
{
char hex[17];
snprintf(hex, sizeof(hex), "%016llx", (unsigned long long)(f & ~15ULL));
return hex[f & 15];
}
}

static int make_loop_dev(char *loop_path, size_t psz)
{
int ctl = open("/dev/loop-control", O_RDWR);
if (ctl >= 0) {
int idx = ioctl(ctl, LOOP_CTL_GET_FREE);
close(ctl);
if (idx >= 0) {
snprintf(loop_path, psz, "/dev/loop%d", idx);
return open(loop_path, O_RDWR);
}
}
for (int i = 0; i < 8; i++) {
snprintf(loop_path, psz, "/dev/loop%d", i);
int fd = open(loop_path, O_RDWR);
if (fd >= 0) return fd;
}
return -1;
}

static int run_test(const char *mnt, const char *blksize, const char *tag)
{
char loop_path[64];
int fusefd, loopfd, imgfd, status;
pid_t srv;
char mntdata[256];
char path[256];
static char buf[1 << 20];

printf("=============== TEST %s (blksize=%s) ===============\n", tag, blksize);

fusefd = open("/dev/fuse", O_RDWR);
if (fusefd < 0) { perror("open /dev/fuse"); return 1; }

g_srv_fd = fusefd;
g_tag = tag;
g_size = 16384;
g_npz = 0;

unlink("/tmp/disk.img");
imgfd = open("/tmp/disk.img", O_CREAT | O_RDWR, 0600);
if (imgfd < 0) { perror("create disk.img"); return 1; }
if (ftruncate(imgfd, 1 << 20) < 0) { perror("ftruncate img"); return 1; }

loopfd = make_loop_dev(loop_path, sizeof(loop_path));
if (loopfd < 0) { printf("FAIL: no loop device available\n"); return 1; }
if (ioctl(loopfd, LOOP_SET_FD, imgfd) < 0) {
perror("LOOP_SET_FD");
return 1;
}
printf("[MNT] loop device: %s\n", loop_path);

srv = fork();
if (srv == 0) {
char logpath[256];
snprintf(logpath, sizeof(logpath), "/tmp/server-%s.log", tag);
g_log = fopen(logpath, "w");
if (!g_log) g_log = stderr;
close(imgfd); close(loopfd);
server_loop();
_exit(0);
}
/* parent keeps fusefd open: mount data "fd=%d" is fget()ed in mounter */

usleep(200000);

snprintf(mntdata, sizeof(mntdata),
"fd=%d,rootmode=040755,user_id=0,group_id=0,blksize=%s",
fusefd, blksize);
if (mount(loop_path, mnt, "fuseblk", 0, mntdata) < 0) {
printf("FAIL: mount(fuseblk): %s\n", strerror(errno));
kill(srv, SIGKILL); waitpid(srv, &status, 0);
ioctl(loopfd, LOOP_CLR_FD, 0);
return 1;
}
printf("[MNT] mounted %s on %s (fd=%d, blksize=%s)\n",
loop_path, mnt, fusefd, blksize);
{
struct statvfs sv;
if (statvfs(mnt, &sv) == 0)
printf("[MNT] statvfs: f_bsize=%lu f_frsize=%lu\n",
(unsigned long)sv.f_bsize,
(unsigned long)sv.f_frsize);
}

snprintf(path, sizeof(path), "%s/target", mnt);

/* ---- T1: full read -> all folios cached uptodate ---- */
int fd = open(path, O_RDWR);
if (fd < 0) { printf("FAIL: open target: %s\n", strerror(errno)); goto out; }
ssize_t r = pread(fd, buf, 16384, 0);
printf("[T1 ] pread(0, 16384) = %zd (expected 16384)\n", r);
if (r != 16384) { printf("FAIL: short read in T1\n"); goto out; }
close(fd);

/* ---- T2: punch [0,4096) (drops folio 0) and [5120,8192)
* (makes folio 1 prefix-partial with sub-page uptodate tracking:
* blocks 0..1 = [4096,5120) stay uptodate,
* blocks 2..7 = [5120,8192) invalidated) ---- */
fd = open(path, O_RDWR);
if (fd < 0) { printf("FAIL: open for punch: %s\n", strerror(errno)); goto out; }
if (fallocate(fd, 0x02 /*PUNCH_HOLE*/ | 0x01 /*KEEP_SIZE*/, PZ1_S, PZ1_E - PZ1_S) < 0) {
printf("FAIL: fallocate#1: %s\n", strerror(errno));
goto out;
}
if (fallocate(fd, 0x02 /*PUNCH_HOLE*/ | 0x01 /*KEEP_SIZE*/, PZ2_S, PZ2_E - PZ2_S) < 0) {
printf("FAIL: fallocate#2: %s\n", strerror(errno));
goto out;
}
printf("[T2 ] fallocate(PUNCH [0,4096) + [5120,8192)) ok\n");

/* ---- T3: pread(0, 16384) ----
* folio 0 is missing -> generic readahead fires over [0,4) and the rac
* includes the prefix-partial folio 1. Correct request for the second
* readahead batch: FUSE_READ(5120, 12288).
* Defect: fuse_send_readpages() uses folio_pos(folios[0]) == 4096 and
* ignores descs[0].offset == 512 -> FUSE_READ(4096, 12288).
*/
r = pread(fd, buf, 16384, 0);
printf("[T3 ] pread(0, 16384) = %zd (expected 16384)\n", r);
if (r != 16384) { printf("FAIL: short read in T3\n"); goto out; }

/* ---- T4: byte-exact verification ---- */
{
/* mirror the server-side punch ranges in the client check */
g_npz = 0;
pz_add(PZ1_S, PZ1_E);
pz_add(PZ2_S, PZ2_E);

uint32_t bad = 0, first_bad = 0, i;
char got_rec[17] = {0}, want_rec[17] = {0};
for (i = 0; i < 16384; i++) {
char want = expected_byte(i);
if (buf[i] != want) {
if (bad == 0) {
first_bad = i;
memcpy(got_rec, buf + (i & ~15), 16);
memcpy(want_rec, buf + (i & ~15), 0);
{
char hex[17];
snprintf(hex, sizeof(hex), "%016llx",
(unsigned long long)(i & ~15ULL));
memcpy(want_rec, hex, 16);
}
}
bad++;
}
}
if (bad == 0) {
printf("[T4 ] VERIFY: all 16384 bytes CORRECT\n");
} else {
printf("[T4 ] VERIFY: DATA CORRUPT — %u of 16384 bytes wrong!\n", bad);
printf("[T4 ] first mismatch at file offset %#x\n", first_bad);
printf("[T4 ] got record \"%s\" (content of file offset %s)\n",
got_rec, got_rec);
printf("[T4 ] want record \"%s\"\n", want_rec);
printf("[T4 ] data is shifted: kernel copied data beginning at the "
"folio start instead of the partially-uptodate folio's "
"first invalid block\n");
}
}
close(fd);

out:
if (fusefd >= 0) close(fusefd);
if (umount2(mnt, MNT_FORCE) == 0) printf("[MNT] unmounted\n");
else printf("[MNT] umount: %s\n", strerror(errno));
usleep(300000);
kill(srv, SIGKILL);
waitpid(srv, &status, 0);
ioctl(loopfd, LOOP_CLR_FD, 0);
close(loopfd); close(imgfd);

{
char logpath[256];
snprintf(logpath, sizeof(logpath), "/tmp/server-%s.log", tag);
FILE *f = fopen(logpath, "r");
if (f) {
printf("---- server log (%s) ----\n", tag);
char line[512];
while (fgets(line, sizeof(line), f)) printf(" %s", line);
fclose(f);
}
}
printf("=============== END %s ===============\n\n", tag);
return 0;
}

int main(int argc, char **argv)
{
if (argc > 1 && strcmp(argv[1], "server") == 0) {
g_srv_fd = atoi(argv[2]);
g_tag = "srv";
g_log = stderr;
server_loop();
return 0;
}

mkdir("/mnt1", 0755);
mkdir("/mnt2", 0755);

/* MAIN: default fuseblk blksize (512) — sub-page blocksize regime */
run_test("/mnt1", "512", "bs512");

/* CONTROL: blksize=4096 — no sub-page uptodate tracking */
run_test("/mnt2", "4096", "bs4096");

printf("PoC finished.\n");
return 0;
}
# Security Research Report — Linux Kernel 7.2.2 FUSE iomap Read-Path Audit

**Two latent data-corruption defects in the FUSE iomap read integration
(dead code today; activated by the announced large-folio follow-up series),
plus a full verification pass over the 7.2 FUSE rework**

| | |
|---|---|
| **Target** | Linux 7.2.2 (latest upstream stable, released 2026-08-28) |
| **Subsystem** | FUSE (fs/fuse) + iomap buffered-I/O integration |
| **Classification** | Defect A: CWE-697/CWE-681-class (incorrect computation → silent data destruction). Defect B: wrong-offset read (silent wrong-data serving). Both **latent** in 7.2.2 (see §4 Reachability). |
| **Impact class** | Data integrity / silent wrong data (no memory corruption found in audited scope) |
| **Novelty** | Not reported upstream as of 2026-08-30 (checked: lore search, LWN, 7.0/7.1/7.2 + stable changelogs, NVD/syzbot web sweep — see §7) |
| **Introduced by** | The 7.2 merge-window series *“fuse: use iomap for buffered reads + readahead”* (Joanne Koong) — specifically the patches *“fuse: use iomap for readahead”* and *“fuse: remove fc->blkbits workaround for partial writes”* |
| **Verification** | Full runtime reproduction in a User-Mode-Linux build of 7.2.2 (KASAN enabled) with a raw `/dev/fuse` server; pristine-control run + precondition-demonstration run; sources verified byte-identical to the kernel.org tarball for the final control build |

---

## 1. Executive summary

Between Linux 6.18 and 7.2, the FUSE read path was converted to iomap
(series *“fuse: use iomap for buffered reads + readahead”*), and the
long-standing workaround that forced fuseblk superblock blocksize to
PAGE_SIZE was removed (*“fuse: remove fc->blkbits workaround for partial
writes”*). Together these changes introduced, for the first time, FUSE code
paths in which a read of a folio can be a **sub-range** of that folio
(`desc.offset > 0` and/or `desc.length < folio_size`) instead of the
historical whole-folio read.

Two legacy FUSE helpers were not adapted to that new reality:

* **Defect A — destructive whole-folio zeroing.**
`fuse_copy_folio()` (fs/fuse/dev.c) pre-zeroes the **entire folio**
(`folio_zero_range(folio, 0, size)`) whenever the reply-copy runs with
`zeroing` set and the requested `count` is smaller than the folio. With
sub-folio read ranges this wipes **valid, cached, uptodate data located
before the requested range inside the same folio**. Demonstrated
end-to-end: 1024 bytes of valid cached data silently replaced by zeros
(§5, run `poc-run15-precondition.log`).

* **Defect B — wrong-offset readahead request.**
`fuse_send_readpages()` (fs/fuse/file.c) computes the FUSE_READ request
start as `folio_pos(ap->folios[0])`, ignoring `ap->descs[0].offset`. If a
readahead batch’s first folio is partially uptodate, the kernel asks the
server for data beginning at the **folio start** instead of the first
invalid block, and the reply is copied into the page cache at the wrong
position — every byte of the batch is silently shifted. The writeback
path (`fuse_writepage_args_setup()`: `folio_pos(folio) + offset`) and the
synchronous read path (`fuse_do_readfolio()`: `folio_pos(folio) + off`)
both handle this correctly; the readahead path is the odd one out.

**Both defects are unreachable in stock 7.2.2** because partially-uptodate
folios cannot currently exist in FUSE (order-0 folios only; no code path
clears per-block uptodate bits; the VFS readahead core only ever hands
freshly-allocated, fully-non-uptodate folios to `->readahead()`). They become
live the moment partially-uptodate folios exist — which is precisely the state
the announced follow-up series **“fuse: support large folios”** (Joanne Koong,
LWN 1021311) and any sub-page-blocksize invalidation semantics create. A
one-line-precondition demonstration (included) reproduces Defect A today on a
default `mount -t fuseblk` configuration (blksize=512 is the fuseblk default).

**Recommended action:** fix both sites before or together with the large-folio
enablement; suggested one-line fixes are included
(`0001-suggested-fixes.patch`).

---

## 2. Technical root-cause analysis

### 2.1 Background: what changed in 7.2

The series converted `fuse_readahead()`/`fuse_read_folio()` to the iomap
iterator with a custom `->read_folio_range` callback
(`fuse_iomap_read_folio_range_async()`), which fills a `fuse_io_args` batch
(`ap->folios[]` + `ap->descs[]`) where **each entry describes one adjusted
range, not one folio**:

```c
static int fuse_handle_readahead(struct folio *folio, ..., loff_t pos, size_t len)
{
size_t off = offset_in_folio(folio, pos);
...
ap->folios[ap->num_folios] = folio;
ap->descs[ap->num_folios].offset = off; /* can be non-zero */
ap->descs[ap->num_folios].length = len; /* can be < folio_size */
...
}
```

`iomap_adjust_read_range()` produces a mid-folio start (off > 0) whenever the
folio has a **prefix of already-uptodate blocks** — the granular-uptodate
tracking the series was built for (per its cover letter: “so that only the
non-uptodate portions of the folio need to be read in”).

Historically (≤ 6.18) FUSE read descs were always whole-folio
(`offset == 0`, `length == folio_size`); several helpers still assume that.

### 2.2 Defect A — `fuse_copy_folio()` whole-folio zeroing

fs/fuse/dev.c, `fuse_copy_folio()`:

```c
int fuse_copy_folio(struct fuse_copy_state *cs, struct folio **foliop,
unsigned offset, unsigned count, int zeroing)
{
...
if (folio) {
size = folio_size(folio);
if (zeroing && count < size)
folio_zero_range(folio, 0, size); /* ← whole folio */
}
```

`zeroing` is `ap->args.page_zeroing`, set by both `fuse_do_readfolio()` and
`fuse_send_readpages()`. For a sub-folio sync read
(`fuse_do_readfolio(file, folio, off=1024, len=3072)` on a 4096-byte folio),
the folio is fully zeroed **before** the reply is copied at
`[offset, offset+count)` — destroying the still-valid cached blocks
`[0, offset)` of that folio. The completion then marks the read range
uptodate, and subsequent reads return zeros for data that was never
invalidated.

Legacy rationale for whole-folio zeroing (tail-beyond-request must be zeroed
because folio-level uptodate is set for `!ifs` folios) is preserved by the
suggested fix `folio_zero_range(folio, offset, size - offset)`.

### 2.3 Defect B — `fuse_send_readpages()` ignores `descs[0].offset`

fs/fuse/file.c, `fuse_send_readpages()`:

```c
static void fuse_send_readpages(struct fuse_io_args *ia, struct file *file,
unsigned int count, bool async)
{
...
loff_t pos = folio_pos(ap->folios[0]); /* ← desc offset ignored */
...
fuse_read_args_fill(ia, file, pos, count, FUSE_READ);
```

Compare the two sibling paths, both of which are correct:

* writeback: `fuse_write_args_fill(&wpa->ia, ff, folio_pos(folio) + offset, 0)`
(in `fuse_writepage_args_setup()`), and
* sync read: `loff_t pos = folio_pos(folio) + off;` (in `fuse_do_readfolio()`).

If the first folio of a readahead batch is prefix-partial with
`descs[0].offset == k * blocksize`, the FUSE_READ request is issued at
`folio_pos` — **k blocks too early**. The server’s reply (data for the wrong
range) is then copied into the folios at the desc positions, silently shifting
the whole batch’s content. Additionally, if the (wrongly placed) request runs
past EOF, `fuse_short_read()` → `fuse_read_update_size()` shrinks the visible
file size based on a request that should never have started that low.

### 2.4 Why the writeback side is not affected

The writeback path (`fuse_iomap_writeback_range()`) also produces sub-folio
descs, but (a) it computes the request offset correctly
(`folio_pos(folio) + offset`), (b) its batch capacity is grown on demand
(`fuse_pages_realloc()`) and the send guard was already hardened in 7.1 by
commit c3880a7b10e4 (“fuse: fix writeback array overflow when max_pages is
one”) — the read-side analogue of that hardening (capacity check in
`fuse_handle_readahead()`) was **not** added; this is noted as hardening in
the suggested-fix patch (the read path is today bounded one-range-per-folio,
but that invariant is not enforced locally).

---

## 3. Attack/trigger model (once partial folios exist)

1. A fuseblk filesystem is mounted — **`blksize=512` is the default** for
`mount -t fuseblk` (`FUSE_DEFAULT_BLKSIZE`), e.g. every ntfs-3g-style
deployment.
2. A file is read (folio cached, fully uptodate).
3. A **sub-folio range is invalidated** — with the announced large-folio
support this happens naturally (e.g. partial writeback/invalidate
granularity), and any sub-page-blocksize invalidation semantics produce it
on order-0 folios as well. The folio becomes prefix-partial.
4. The file region is re-read:
* via the synchronous path → **Defect A** (valid cached prefix destroyed,
reads return zeros / stale content),
* via readahead (e.g. `posix_fadvise(WILLNEED)`, or a VFS change that
lets `->readahead()` see partially-uptodate folios, such as adopting
`readahead_expand()`) → **Defect B** (wrong-offset FUSE_READ; all
returned bytes shifted).
5. Neither the FUSE server nor the application did anything wrong; the
kernel silently serves wrong data from a correctly-behaving server.

---

## 4. Reachability analysis (why “latent” today)

Stock 7.2.2 cannot construct a partially-uptodate folio in FUSE:

* FUSE does not enable large folios (no `mapping_set_large_folios()`;
the series cover letter states large-folio support is a separate, future
patchset).
* With `bs == PAGE_SIZE` (non-fuseblk mounts) `i_blocks_per_folio() == 1`,
so no per-block state (`ifs`) exists at all.
* With fuseblk `bs < PAGE_SIZE`, `ifs` exists and per-block uptodate bits are
*set* by read completions — but **nothing ever clears them**:
`iomap_invalidate_folio()` clears only the dirty half of the bitmap, and
partial truncation (`truncate_inode_partial_folio()`) therefore leaves the
folio marked folio-uptodate with a zeroed tail (correct hole semantics).
* The VFS readahead core (`page_cache_ra_unbounded()`) **flushes the batch at
any already-present folio** — `->readahead()` only ever receives
freshly-allocated, fully-non-uptodate folios, so every readahead desc has
`offset == 0` today; partially-uptodate folios are always re-read through
the synchronous `->read_folio` path.

Consequently: the defective code is dead code in 7.2.2 as shipped, and both
defects activate exactly when the announced “fuse: support large folios”
series (or equivalent sub-page invalidation semantics) lands. Reporting this
**before** that enablement is the point of this disclosure — the same class
of bug (sub-page writeback array overflow, c3880a7b10e4) was already found
and fixed on the writeback side after merge; the read side has two analogous
unadapted sites remaining.

---

## 5. Runtime evidence

Environment: User-Mode-Linux build of the unmodified Linux 7.2.2 tarball
(x86_64, `CONFIG_KASAN=y`, `CONFIG_FUSE_FS=y`, `CONFIG_BLK_DEV_LOOP=y`),
hostfs-rooted guest, test harness = self-contained raw-`/dev/fuse` server +
fuseblk mounter + trigger (`poc/fusepoc.c`, ~700 lines, no libfuse).

Reproducer file: `target`, 16384 bytes; every 16-byte record at offset R
contains the 16-char hex of R; punched ranges are holes (zeros).

Trigger sequence (per mount):

| Phase | Operation | Purpose |
|---|---|---|
| T1 | `pread(0, 16384)` | cache all folios, fully uptodate |
| T2 | `fallocate(PUNCH_HOLE\|KEEP_SIZE, 0, 4096)` | drop folio 0 entirely |
| T2 | `fallocate(PUNCH_HOLE\|KEEP_SIZE, 5120, 3072)` | invalidate blocks 2..7 of folio 1 (needs precondition), blocks 0..1 ([4096,5120)) stay valid |
| T3 | `pread(0, 16384)` | re-read; folio 0 missing ⇒ readahead fires; folio 1 partially uptodate ⇒ sub-folio read of `[5120, 8192)` |
| T4 | byte-exact verification | expected = pattern with two zero ranges |

### 5.1 Pristine control run (`logs/poc-run16-pristine.log`)

```
=============== TEST bs512 (blksize=512) ===============
[T1 ] pread(0, 16384) = 16384 (expected 16384)
[T2 ] fallocate(PUNCH [0,4096) + [5120,8192)) ok
[T3 ] pread(0, 16384) = 16384 (expected 16384)
[T4 ] VERIFY: all 16384 bytes CORRECT
[bs512] REQ READ nodeid=2 offset=0 size=16384
[bs512] REQ FALLOCATE nodeid=2 offset=0 length=4096 mode=0x3
[bs512] REQ FALLOCATE nodeid=2 offset=5120 length=3072 mode=0x3
[bs512] REQ READ nodeid=2 offset=0 size=4096
=============== TEST bs4096 (blksize=4096) ===============
... [T4 ] VERIFY: all 16384 bytes CORRECT ...
```

All bytes correct on both configurations; the partially-truncated folio 1
remains folio-uptodate with a zeroed tail (correct hole semantics), no
sub-folio read is issued. **Confirms the defects are latent in stock 7.2.2.**
(Build verified: all audited/touched source files byte-identical to
`linux-7.2.2.tar.xz` from cdn.kernel.org.)

### 5.2 Precondition demonstration run (`logs/poc-run15-precondition.log`)

Same kernel + PoC, plus the minimal demonstration precondition
(`0000-demo-precondition-uptodate-invalidation.patch`, 19 lines:
`iomap_invalidate_folio()` also drops the per-block uptodate bits of the
invalidated range — the semantics any correct sub-page/large-folio
invalidation must have, and what the announced large-folio series requires):

```
=============== TEST bs512 (blksize=512) ===============
[T1 ] pread(0, 16384) = 16384 (expected 16384)
[T2 ] fallocate(PUNCH [0,4096) + [5120,8192)) ok
[T3 ] pread(0, 16384) = 16384 (expected 16384)
[T4 ] VERIFY: DATA CORRUPT — 1024 of 16384 bytes wrong!
[T4 ] first mismatch at file offset 0x1000
[T4 ] want record "0000000000001000"
[bs512] REQ READ nodeid=2 offset=0 size=16384
[bs512] REQ FALLOCATE nodeid=2 offset=0 length=4096 mode=0x3
[bs512] REQ FALLOCATE nodeid=2 offset=5120 length=3072 mode=0x3
[bs512] REQ READ nodeid=2 offset=0 size=4096
[bs512] REQ READ nodeid=2 offset=5120 size=3072 ← correct sub-folio read request
=============== TEST bs4096 (blksize=4096) ===============
[T4 ] VERIFY: all 16384 bytes CORRECT ← control passes
```

Interpretation (Defect A, end-to-end):

* The kernel correctly requests only the missing sub-range
(`REQ READ offset=5120 size=3072`) — the iomap layer did its job.
* File offsets **[0x1000, 0x1400) ([4096, 5120)) — data that was never
invalidated and was still cached and uptodate — are returned as zeros**:
`fuse_copy_folio()` zeroed the whole folio before copying the 3072-byte
hole reply at folio offset 1024.
* 1024 of 16384 bytes silently corrupted; the FUSE server is fully
well-behaved.
* The bs4096 control configuration is unaffected (no per-block state).

Defect B is exercised by the same reproducer whenever the partially-uptodate
folio enters a readahead batch; under the current VFS readahead core this
requires the rac to include existing folios (e.g. via `readahead_expand()`).
Its code-level proof is the asymmetry documented in §2.3; the suggested
one-line fix makes the readahead path symmetric with the two correct sibling
paths.

---

## 6. Scope of the audit and verified-safe areas

The full 6.18.48 → 7.2.2 FUSE delta (~4,600 changed lines) was reviewed:

* **fuse_chan/fuse_dev architecture rework** (dev.c, req.c, req_timeout.c):
fud/fch reference counting, `FUSE_DEV_CHAN_DISCONNECTED` sentinel,
clone-ioctl atomicity, timeout-worker cancellation, abort/release ordering —
verified balanced in all paths examined.
* **iomap writeback integration**: `write_bytes_pending` accounting
(set = folio_size; subtract not-submitted at iteration end; subtract
desc lengths at completion) — verified balanced across send/crop/error
paths; array growth (`fuse_pages_realloc`) guarded (post-c3880a7b10e4).
* **iomap read integration**: `read_bytes_pending` accounting verified
balanced; batch capacity bounded today (one range per folio; noted
hardening).
* **notify.c**: 7.2 added S_ISREG guards and folio-uptodate checks (hardening
relative to 6.18) — no regressions found.
* **dir.c dentry rework** (heap `struct fuse_dentry` with rcu/rb-node union,
global dentry tree, `inval_wq` worker): lifetime and locking discipline
verified; machinery is opt-in (`fuse.inval_wq` module parameter, default
off); `__move_to_shrink_list()` usage is legal for referenced dentries.
* **inode.c**: fuse_dev_grab mount-fd handling, conn init/free ordering,
fuseblk `sb_set_blocksize` path, INIT negotiation clamps — verified.
* **dev_uring.c**: mechanical `fc`→`fch` refactor (behind
`CONFIG_FUSE_IO_URING`); no new defect identified from the diff review.
* **readdir.c / control.c / cuse.c / poll.c**: mechanical or hardening
changes; nothing adverse found.

No memory-safety violation (KASAN) was triggered across the runs, including
the precondition demonstration (the defect is a logic/data-integrity bug, not
memory unsafety).

## 7. Novelty checks performed (as of 2026-08-30)

* lore.kernel.org: direct search blocked by anti-bot challenge (Anubis);
searched via web index — no prior report of either defect located.
* LWN article + v1–v5 series discussions for
“fuse: use iomap for buffered reads + readahead”: the analogous **writeback**
overflow was reported and fixed (c3880a7b10e4, 7.1); the read-side offset
and zeroing issues are not mentioned in the series or its review.
* ChangeLogs for 7.0, 7.1, 7.2 (kernel.org): no commit touching
`fuse_send_readpages()` offset handling or `fuse_copy_folio()` zeroing
semantics; the only readahead-path restructure (3372eb0384b7, 7.2-rc2)
preserved both defective lines.
* NVD/CVE web sweep for 2026 FUSE CVEs: nothing matching (closest:
readahead reclaim deadlock fix, different bug).

## 8. Suggested fixes

See `0001-suggested-fixes.patch`: two one-line fixes
(`pos = folio_pos(ap->folios[0]) + ap->descs[0].offset`;
`folio_zero_range(folio, offset, size - offset)`) plus an optional
capacity-guard hardening for `fuse_handle_readahead()`.

## 9. Package contents

```
report/
VENDOR-REPORT-fuse-7.2.2.md this report
0000-demo-precondition-uptodate-invalidation.patch
0001-suggested-fixes.patch
poc/
fusepoc.c raw /dev/fuse server + fuseblk mounter + trigger
init.c minimal UML guest init
Makefile build helper
logs/
poc-run16-pristine.log pristine 7.2.2 control run (all correct)
poc-run15-precondition.log precondition run (Defect A reproduced)
poc-run13.log intermediate instrumentation run (punch/partial state)
build-fuse.log kernel build log
kernel/
config-um-fuse UML kernel .config used (KASAN + FUSE + loop)
run-instructions.txt how to reproduce end-to-end
```

## 10. Reporter

kernel security audit (static diff review + runtime verification
harness), performed on 2026-08-29/30 against the official
`linux-7.2.2.tar.xz` from cdn.kernel.org. All materials in this package are
provided for coordinated responsible disclosure to the FUSE maintainers
(Miklos Szeredi <mszeredi@xxxxxxxxxx>, linux-fsdevel@xxxxxxxxxxxxxxx,
Joanne Koong <joannelkoong@xxxxxxxxx>) and security@xxxxxxxxxx as appropriate.

Attachment: 0001-suggested-fixes.patch
Description: Binary data

Attachment: 0000-demo-precondition-uptodate-invalidation.patch
Description: Binary data

// init.c — minimal UML guest init for the FUSE readahead offset bug PoC
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <sys/mount.h>
#include <sys/stat.h>
#include <sys/reboot.h>
#include <sys/wait.h>
#include <sys/sysmacros.h>

static void mount_or_die(const char *src, const char *dst, const char *type)
{
if (mount(src, dst, type, 0, NULL) < 0)
fprintf(stderr, "[init] mount %s on %s: %s (continuing)\n",
type, dst, strerror(errno));
}

static void mknod_if_missing(const char *path, int maj, int min, mode_t mode)
{
struct stat st;
if (stat(path, &st) == 0) return;
if (mknod(path, mode | 0600, makedev(maj, min)) < 0)
fprintf(stderr, "[init] mknod %s: %s\n", path, strerror(errno));
}

int main(void)
{
setsid();
setvbuf(stdout, NULL, _IONBF, 0);
setvbuf(stderr, NULL, _IONBF, 0);

printf("[init] Linux FUSE readahead-offset PoC guest booting\n");
printf("[init] kernel: %s\n", "Linux 7.2.2 (UML, KASAN)");

mount_or_die("proc", "/proc", "proc");
mount_or_die("sysfs", "/sys", "sysfs");
mount_or_die("devtmpfs", "/dev", "devtmpfs");

/* make sure required device nodes exist (devtmpfs usually has them) */
mknod_if_missing("/dev/fuse", 10, 229, S_IFCHR);
mknod_if_missing("/dev/loop-control", 10, 237, S_IFCHR);
mknod_if_missing("/dev/loop0", 7, 0, S_IFBLK);
mknod_if_missing("/dev/loop1", 7, 1, S_IFBLK);
mkdir("/dev/shm", 0755);
mount_or_die("tmpfs", "/dev/shm", "tmpfs");
printf("[init] kernel cmdline: ");
{
char buf[2048] = "";
int fd = open("/proc/cmdline", O_RDONLY);
if (fd >= 0) {
read(fd, buf, sizeof(buf) - 1);
close(fd);
printf("%s", buf);
}
}

/* run the PoC (direct execve — no shell in guest) */
printf("[init] ===== running /poc/fusepoc =====\n");
{
pid_t p = fork();
if (p == 0) {
char *cargv[] = { "/poc/fusepoc", NULL };
char *cenvp[] = { "HOME=/", "PATH=/bin", NULL };
execve("/poc/fusepoc", cargv, cenvp);
fprintf(stderr, "[init] execve failed: %s\n", strerror(errno));
_exit(127);
}
int st = 0;
waitpid(p, &st, 0);
printf("[init] fusepoc exit status: %d (signal %d)\n",
WIFEXITED(st) ? WEXITSTATUS(st) : -1,
WIFSIGNALED(st) ? WTERMSIG(st) : 0);
}

sync();
printf("[init] powering off\n");
reboot(RB_POWER_OFF);
return 0;
}

Attachment: poc-run13.log
Description: Binary data

Attachment: poc-run16-pristine.log
Description: Binary data

Attachment: poc-run15-precondition.log
Description: Binary data

Attachment: build-fuse.log
Description: Binary data

End-to-end reproduction instructions
Linux 7.2.2 FUSE iomap read-path audit — runtime verification harness
======================================================================

Prerequisites
-------------
1. Linux 7.2.2 source: linux-7.2.2.tar.xz from cdn.kernel.org
2. A UML-capable build environment (gcc, flex, bison, bc)
3. The kernel config from ../kernel/config-um-fuse (ARCH=um, KASAN,
FUSE_FS=y, BLK_DEV_LOOP=y, HOSTFS root)

Kernel build
------------
tar xf linux-7.2.2.tar.xz && cd linux-7.2.2
cp <path>/config-um-fuse .config
make ARCH=um -j$(nproc) # tools: flex/bison/bc needed on PATH

Guest staging tree
------------------
mkdir -p staging/poc staging/tmp staging/dev staging/proc staging/sys
cp <path>/poc/fusepoc staging/poc/
cp <path>/poc/init staging/init

Run (pristine control)
----------------------
cd linux-7.2.2
TMPDIR=<abs path to staging> ./linux rootfstype=hostfs \
rootflags=<abs path to staging> rw init=/init \
con0=fd:0,fd:1 con=none mem=1024M < /dev/null \
> pristine.log 2>&1

Expected (see logs/poc-run16-pristine.log):
TEST bs512 -> [T4] VERIFY: all 16384 bytes CORRECT
TEST bs4096 -> [T4] VERIFY: all 16384 bytes CORRECT
(both defects latent in stock 7.2.2)

Run (precondition demonstration — Defect A)
-------------------------------------------
cd linux-7.2.2
patch -p1 < <path>/0000-demo-precondition-uptodate-invalidation.patch
make ARCH=um -j$(nproc)
# run as above -> see logs/poc-run15-precondition.log
# Expected:
# TEST bs512: [T4] VERIFY: DATA CORRUPT — 1024 of 16384 bytes wrong!
# first mismatch at file offset 0x1000
# server log: REQ READ offset=5120 size=3072 (correct sub-folio read)
# TEST bs4096: all bytes CORRECT (control)
patch -R -p1 < <path>/0000-demo-precondition-uptodate-invalidation.patch
make ARCH=um -j$(nproc) # restore pristine

What the PoC does
-----------------
- fusepoc (fork model, like fusermount):
child = raw /dev/fuse protocol server (INIT/LOOKUP/GETATTR/SETATTR/
OPEN/READ/FALLOCATE/FLUSH/RELEASE/FORGET/DESTROY); serves one
16384-byte file "target" of 16-byte offset-hex records; punched
ranges are holes (zeros); every request is logged.
parent = creates a loop device, mounts it as fuseblk (blksize=512 /
blksize=4096), runs the T1..T4 trigger (see report section 5)
and verifies the returned data byte-for-byte.
- init: minimal guest init (proc/sys/devtmpfs, device nodes, execve of the
PoC, poweroff).

Notes
-----
- /dev/shm in the build container is noexec; UML needs TMPDIR pointing at an
exec-able directory (the staging tree works).
- The server replies FOPEN_KEEP_CACHE so the page cache survives close (the
default close-time cache drop would mask the trigger).
- The 7.2 fuse_get_dev() change returns EPERM for pre-mount reads of
/dev/fuse (non-sync_init); the PoC server retries until the mount completes.

Attachment: config-um-fuse
Description: Binary data