ext4: use-after-free read of struct ext4_inode_info in ext4_seq_es_shrinker_info_show() (/proc/fs/ext4/<dev>/es_shrinker_info)

From: 徐晗翔

Date: Tue Oct 27 2026 - 22:59:41 EST


ext4_seq_es_shrinker_info_show() caches a bare pointer to the inode
with the most extent-status entries while holding sbi->s_es_lock, then
drops the lock, runs several percpu counter summations, and only
afterwards dereferences three fields of that pointer (i_ino,
i_es_all_nr, i_es_shk_nr) with no inode reference and no RCU read-side
critical section -- so if the inode is evicted concurrently (evict ->
ES teardown -> list removal -> call_rcu -> grace period ->
kmem_cache_free), the read is a use-after-free. I reproduced it on a
pristine Linux 7.3 KASAN kernel with zero instrumentation: 21
slab-use-after-free reads in 40,000 reads of the (world-readable,
0444) proc file while ordinary file churn ran on other CPUs; the
kernel was "Not tainted" (no modules). A controlled run with a
timing amplifier hit the same instruction 6/60 times. The bug is
unprivileged-triggerable but read-only (no write primitive, minimal
information exposure), so this is a correctness/lifetime fix report;
note that the recent KCSAN report against the same lines (counter
tearing) and its data_race() suggestion do NOT cover this defect --
the pointer outlives its protection, which data_race() cannot fix.
The pointer's validity ("on s_es_list implies alive") holds only
under sbi->s_es_lock, ei->i_es_lock, an inode reference, or an RCU
read critical section; the show path holds none of these at the
dereference. The same list is walked by the ES shrinker itself
(__es_shrink()) under a complete two-lock protocol, so the gap is
specific to the stats/proc read path. Every evict path clears the
extent-status tree and removes the inode from s_es_list before
call_rcu frees it, and an inode on that list by definition holds a
reclaimable ES entry -- so the eviction racing the reader needs no
special construction: create files with a few extents (they join the
list), unlink+close them (immediate evict), and read the stats file
from a CPU with competing load. Fix: snapshot the three fields under
the lock (sketch below).

The detailed description:

* Test environment / affected version
Linux version 7.3.0 (gcc 13.3.0, GNU ld 2.42) #1 SMP PREEMPT_DYNAMIC
-- vanilla mainline 7.3 release tarball (kernel.org), KASAN enabled,
source unmodified, EXT4_FS=y. QEMU/KVM x86_64 (4 vCPU), a 128 MB
ext4 image on virtio-blk mounted at /mnt. The natural-mode run had
NO kernel modules loaded at all ("Not tainted" in the report); the
controlled run loaded a kprobe timing amplifier. The code shape is
long-lived (present since the ES shrinker stats were introduced); no
upstream fix found.

* The code (fs/ext4/extents_status.c:1789-1830, 7.3)
spin_lock(&sbi->s_es_lock); /* :1800 */
list_for_each_entry(ei, &sbi->s_es_list, i_es_list) {
inode_cnt++;
if (max && max->i_es_all_nr < ei->i_es_all_nr)
max = ei; /* :1804 */
else if (!max)
max = ei; /* :1806 */
}
spin_unlock(&sbi->s_es_lock); /* :1808 */
/* :1810-1821: three percpu_counter_sum_positive() calls
* (each walks every CPU's counter segment) plus several
* seq_printf() -- a slow stretch that widens the window */
if (inode_cnt)
seq_printf(seq, "maximum:\n %llu inode (%u objects,
%u reclaimable)\n %llu us max scan time\n",
max->vfs_inode.i_ino, /* :1826 */
max->i_es_all_nr, max->i_es_shk_nr, ...);
The three loads at :1826 are seq_printf() argument evaluations; the
call boundary prevents hoisting them above the unlock, and the
reader is fully preemptible here (procfs read path).

* Why the racing eviction is guaranteed to be possible
- Invariant: an inode is on s_es_list if and only if it holds at
least one reclaimable (non-delayed) ES entry (i_es_shk_nr >= 1;
ext4_es_list_add/del under s_es_lock, extents_status.c:479-507).
So the "max" inode is evictable by construction, and whoever
wants it gone just writes more extents to a victim file (making
it the list maximum), then unlinks it.
- Invalidation chain, verified for all three evict exit paths
(normal delete with dirty success/failure, no_delete cache
shrink): each ends in ext4_es_remove_extent() over the whole
range, dropping i_es_shk_nr to zero and removing the inode from
s_es_list, then destroy_inode() -> call_rcu(&inode->i_rcu) ->
[grace period] -> ext4_free_in_core_inode() ->
kmem_cache_free() (fs/inode.c:380-392, :313-318;
super.c:1468-1477; the cache is not SLAB_TYPESAFE_BY_RCU,
super.c:1527-1530).
- The reader participates in none of the grace-period story: it
holds no rcu_read_lock(), so the grace period completes without
waiting for it.

* Evidence (KASAN, three stacks closed; controlled and natural hits
are the SAME instruction, +0x2a7 = the :1826 loads)
BUG: KASAN: slab-use-after-free in
ext4_seq_es_shrinker_info_show+0x2a7/0x390
Read of size 4 ... by task esrace
ext4_seq_es_shrinker_info_show+0x2a7
seq_read_iter -> proc_reg_read_iter -> vfs_read -> ksys_read
Allocated by task 98 (churn thread):
ext4_alloc_inode -> alloc_inode -> new_inode -> __ext4_new_inode
-> ext4_create -> path_openat -> do_sys_openat2
Freed by task 98:
kmem_cache_free <- ext4_free_in_core_inode (via i_callback)
<- rcu_core <- handle_softirqs <- __irq_exit_rcu
Last potentially related work creation:
__call_rcu_common <- evict+0x29a <- filename_unlinkat
- Natural mode (no modules, kernel "Not tainted"): 21 UAF reads in
40,000 reads of the proc file (~1/1900), with the reader pinned
to a CPU shared with two SCHED_FIFO square-wave spinners (dense
preemption points + a long hold) and file churn on other CPUs.
The first natural hit occurred 125 ms after a drop_caches-driven
eviction burst. drop_caches (root) was only a batch-eviction
accelerator for throughput; plain unlink+close of the victim or
memory-pressure eviction also works and needs no privileges.
- Controlled mode (amplifier attached): 6 UAF reports in 60 reads.
- Negative control: same churn, no timing pressure -- zero splats,
and the "N inodes on list" output dynamically confirms the list
invariant above.

* Attachments and usage
esrace.c -- userspace reproducer, three roles (build: gcc -static
-O2 -o esrace esrace.c):
esrace read <cpu> /proc/fs/ext4/<dev>/es_shrinker_info <iters> [interval_ms]
(CPU-pinned reader)
esrace churn <dir> <nfiles> <seg_kb> <segs> <iters>
(per round: fallocate disjoint segments in each file --
unwritten extents create ES entries and put inodes on
s_es_list -- then close+unlink all of them and
echo 3 > /proc/sys/vm/drop_caches to batch-evict)
esrace spin/spinus (RT FIFO square-wave CPU hogs for the
natural-mode stall)
esstall.c -- controlled-window amplifier (external module): a
kprobe at the show entry sets need_resched on the reader and wakes
an RT FIFO kthread bound to the reader's CPU, which busy-holds it
for 400 ms (interrupts on, so the RCU grace period still advances).
Methodology note: a busy-waiting kprobe pre_handler does NOT work
here -- with IRQs disabled on that CPU the grace period would
never advance and the free would never happen; hence the
need_resched+blocker design.
My sequence: negative control -> controlled (~60 rounds) -> natural
(40,000 reads).

* Relation to the recent KCSAN report
The 2026-04-23 KCSAN report on this function (Shuhao Fu, LKML)
covers the counter *tearing* relative to i_es_lock and suggests
wrapping the reads in data_race(). That addresses the stale-stats
semantics but not this bug: the dereferenced pointer itself is
unprotected, and data_race() does not extend any lifetime. The
snapshot fix below fixes both.



Regards,
Hanxiang Xu
xuhx@xxxxxxxxxxx


// SPDX-License-Identifier: GPL-2.0
/*
* esstall.c - controlled-window instrument for the ext4 es_shrinker_info
* use-after-free case (docs/manual-review-fs-ext4-2026-10-27.md case 3).
*
* The audited window is inside ext4_seq_es_shrinker_info_show():
*
* spin_lock(&sbi->s_es_lock); [extents_status.c:1800]
* ... pick max = ei from s_es_list ...
* spin_unlock(&sbi->s_es_lock); [1808 - window opens]
* ... 3x percpu_counter_sum_positive() + seq_printf() ...
* seq_printf(..., max->vfs_inode.i_ino, ...); [1826 - UAF read]
*
* Why not a kprobe busy-wait stall (unlike r4_stall): the pre_handler runs
* with interrupts disabled, which would stall the RCU grace period on this
* CPU and the inode could never actually be freed - the UAF would be
* unobservable. And a bare set_tsk_need_resched() is not enough either:
* at the 1808 preempt point the runqueue may be empty and the reader gets
* immediately rescheduled back (observed in the first esctl run: kicks
* #1..#5 only ~40us apart).
*
* So this module combines both halves deterministically:
* 1. a kprobe at show() entry sets TIF_NEED_RESCHED on the reader;
* 2. the same pre_handler wakes an RT FIFO blocker kthread pinned to the
* same CPU. The blocker busy-holds the CPU for block_ms (interrupts
* stay enabled - it is a normal SCHED_FIFO kthread, so the RCU grace
* period, the eviction work and the slab free all proceed normally on
* this and other CPUs).
*
* There is no preempt point between the show() entry and the
* spin_unlock() at 1808, so the need-resched flag survives exactly to the
* window boundary; the scheduler deschedules the reader there and the
* blocker keeps it off the CPU while the concurrent evict -> call_rcu ->
* grace period -> kmem_cache_free chain completes. When the reader resumes
* it executes the 1826 dereference on the freed inode.
*
* This enlarges the *timing* of the audited window without touching any
* kernel source and without altering the code path of show() itself.
*/
#define pr_fmt(fmt) "ESSTALL: " fmt

#include <linux/module.h>
#include <linux/init.h>
#include <linux/kernel.h>
#include <linux/kprobes.h>
#include <linux/sched.h>
#include <linux/delay.h>
#include <linux/jiffies.h>
#include <linux/wait.h>
#include <linux/kthread.h>
#include <linux/atomic.h>
#include <linux/cpumask.h>

static int max_hits = 1000000;
module_param(max_hits, int, 0600);
MODULE_PARM_DESC(max_hits, "stop kicking after this many probe hits");

static int blocker_cpu = 3;
module_param(blocker_cpu, int, 0600);
MODULE_PARM_DESC(blocker_cpu, "CPU the blocker kthread is pinned to");

static int block_ms = 300;
module_param(block_ms, int, 0600);
MODULE_PARM_DESC(block_ms, "how long the blocker holds the CPU per wake");


static atomic_t hits = ATOMIC_INIT(0);
static atomic_t wakes = ATOMIC_INIT(0);
static atomic_t wake_req = ATOMIC_INIT(0);
static struct task_struct *blocker;
static DECLARE_WAIT_QUEUE_HEAD(blocker_wq);

static int blocker_fn(void *unused)
{
sched_set_fifo(current);

while (!kthread_should_stop()) {
unsigned long t0;

wait_event_interruptible(blocker_wq,
kthread_should_stop() ||
atomic_read(&wake_req) != 0);
if (kthread_should_stop())
break;
if (!atomic_xchg(&wake_req, 0))
continue;
{
int w = atomic_inc_return(&wakes);

if (w <= 10)
pr_info("hold #%d start j=%lu\n", w, jiffies);
}
/*
* Busy-hold the CPU with interrupts enabled: ticks keep
* coming, RCU grace periods advance, jiffies moves.
*/
t0 = jiffies;
while (time_before(jiffies, t0 + msecs_to_jiffies(block_ms)))
cpu_relax();
/*
* Drop kick requests that arrived while we were holding the
* CPU (they can only come from other CPUs' probe hits or
* stragglers) and take an RT-bandwidth gap. The reader runs
* during this gap; its next kick wakes us again, putting us
* on the runqueue so its 1808 preempt point hands the CPU to
* us for a fresh hold.
*/
atomic_set(&wake_req, 0);
msleep_interruptible(20);
}
return 0;
}

static int es_pre(struct kprobe *p, struct pt_regs *regs)
{
int h = atomic_inc_return(&hits);

if (h <= max_hits) {
if (h <= 5)
pr_warn("kick #%d: reader=%s pid=%d -> need_resched + wake blocker\n",
h, current->comm, current->pid);
set_tsk_need_resched(current);
if (blocker) {
atomic_set(&wake_req, 1);
wake_up_process(blocker);
}
} else if (h == max_hits + 1) {
pr_info("kick limit reached (%d), going passive\n", max_hits);
}
return 0;
}

static struct kprobe kp = {
.symbol_name = "ext4_seq_es_shrinker_info_show",
.pre_handler = es_pre,
};

static int __init esstall_init(void)
{
int ret;

ret = register_kprobe(&kp);
if (ret) {
pr_err("cannot register kprobe on %s (ret %d)\n",
kp.symbol_name, ret);
return ret;
}

blocker = kthread_create(blocker_fn, NULL, "esstall_blocker");
if (IS_ERR(blocker)) {
pr_err("cannot create blocker kthread (%ld)\n", PTR_ERR(blocker));
unregister_kprobe(&kp);
return PTR_ERR(blocker);
}
kthread_bind(blocker, blocker_cpu);
wake_up_process(blocker);

pr_info("armed: kprobe on %s, blocker cpu=%d ms=%d, max_hits=%d\n",
kp.symbol_name, blocker_cpu, block_ms, max_hits);
return 0;
}

static void __exit esstall_exit(void)
{
if (blocker) {
atomic_set(&wake_req, 1);
kthread_stop(blocker);
}
unregister_kprobe(&kp);
pr_info("removed hits=%d wakes=%d\n", atomic_read(&hits),
atomic_read(&wakes));
}

module_init(esstall_init);
module_exit(esstall_exit);
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("controlled-window need_resched+RT-blocker for ext4 es_shrinker_info UAF verification");
// SPDX-License-Identifier: GPL-2.0
/*
* esrace - userspace choreography for the ext4 es_shrinker_info UAF
* verification (docs/manual-review-fs-ext4-2026-10-27.md case 3).
*
* Roles (one process each, orchestrated by scripts/init):
*
* spin <cpu> <spin_ms> <sleep_ms> <prio>
* SCHED_FIFO square-wave CPU holder. While spinning it keeps the
* descheduled reader off the CPU; the sleep gap lets the reader run.
* The reader being preempted at the audited window boundary then
* waiting out one spin phase (100-400 ms) is what gives evict +
* RCU grace period + slab free time to complete before the reader
* resumes and dereferences the freed inode.
*
* spinus <cpu> <spin_us> <sleep_us> <prio>
* Same, microsecond granularity (fast chopper for the natural plan:
* creates frequent preemption points so the reader can be caught
* inside the tiny 1808->1826 window without any instrumentation).
*
* read <cpu> <procfile> <iters>
* CPU-pinned reader loop: open+read+close of the proc entry. Each
* iteration is one pass through ext4_seq_es_shrinker_info_show().
*
* churn <dir> <nfiles> <seg_kb> <segs> <iters>
* Victim churn: create nfiles files, each with `segs` disjoint
* fallocate ranges (unwritten extents -> ES entries -> the inode is
* on s_es_list with i_es_shk_nr > 0), close+unlink them all, then
* drop_caches so every victim inode is evicted (ES tree emptied,
* unlinked from s_es_list, destroy_inode -> call_rcu -> freed).
*
* once <procfile>
* Single read, dump contents (sanity).
*/
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <math.h>
#include <sched.h>
#include <time.h>
#include <sys/stat.h>
#include <sys/syscall.h>

static void pin_cpu(int cpu)
{
cpu_set_t s;

CPU_ZERO(&s);
CPU_SET(cpu, &s);
if (sched_setaffinity(0, sizeof(s), &s))
perror("setaffinity");
}

static void set_rt(int prio)
{
struct sched_param sp = { .sched_priority = prio };

if (sched_setscheduler(0, SCHED_FIFO, &sp))
fprintf(stderr, "setscheduler(SCHED_FIFO %d): %s\n", prio,
strerror(errno));
}

static double now_ms(void)
{
struct timespec ts;

clock_gettime(CLOCK_MONOTONIC, &ts);
return ts.tv_sec * 1000.0 + ts.tv_nsec / 1e6;
}

static void spin_for_ms(double ms)
{
double t0 = now_ms();

while (now_ms() - t0 < ms)
;
}

static void sleep_ms(double ms)
{
struct timespec ts;

ts.tv_sec = (time_t)(ms / 1000.0);
ts.tv_nsec = (long)((ms - (double)ts.tv_sec * 1000.0) * 1e6);
nanosleep(&ts, NULL);
}

static int cmd_spin(int cpu, double spin, double sleep, int prio, int us)
{
pin_cpu(cpu);
set_rt(prio);
printf("spin: cpu=%d wave=%.3f/%.3f ms prio=%d (us-mode=%d) pid=%d\n",
cpu, spin, sleep, prio, us, getpid());
fflush(stdout);
for (;;) {
spin_for_ms(spin);
sleep_ms(sleep);
}
return 0;
}

static int cmd_read(int cpu, const char *path, long iters, double interval)
{
char buf[4096];
long n = 0, fail = 0;

pin_cpu(cpu);
printf("read: cpu=%d path=%s iters=%ld interval=%.1fms pid=%d\n", cpu,
path, iters, interval, getpid());
fflush(stdout);
for (long i = 0; i < iters; i++) {
int fd = open(path, O_RDONLY);
ssize_t r;

if (fd < 0) {
fail++;
sleep_ms(50);
continue;
}
r = read(fd, buf, sizeof(buf));
close(fd);
(void)r;
n++;
if (interval > 0)
sleep_ms(interval);
if ((n % 200) == 0) {
printf("read: %ld reads (%ld fail)\n", n, fail);
fflush(stdout);
}
}
printf("read: DONE %ld reads (%ld fail)\n", n, fail);
fflush(stdout);
return 0;
}

static int cmd_churn(const char *dir, int nfiles, int seg_kb, int segs,
long iters)
{
char p[512];

printf("churn: dir=%s nfiles=%d seg=%dKB x%d iters=%ld pid=%d\n", dir,
nfiles, seg_kb, segs, iters, getpid());
fflush(stdout);
for (long it = 0; it < iters; it++) {
int f, s, dfd;

for (f = 0; f < nfiles; f++) {
snprintf(p, sizeof(p), "%s/v%d", dir, f);
int fd = open(p, O_CREAT | O_TRUNC | O_WRONLY, 0600);

if (fd < 0) {
if ((it % 50) == 0)
perror("open victim");
continue;
}
for (s = 0; s < segs; s++) {
/* disjoint ranges: off = (s+1)*2*seg, len = seg */
off_t off = (off_t)(s + 1) * 2 * seg_kb * 1024;

if (syscall(SYS_fallocate, fd, 0, off,
(off_t)seg_kb * 1024) != 0) {
if ((it % 50) == 0)
perror("fallocate");
}
}
close(fd);
}
for (f = 0; f < nfiles; f++) {
snprintf(p, sizeof(p), "%s/v%d", dir, f);
unlink(p);
}
dfd = open("/proc/sys/vm/drop_caches", O_WRONLY);
if (dfd >= 0) {
if (write(dfd, "3\n", 2) < 0)
{}
close(dfd);
}
if ((it % 25) == 0) {
printf("churn: iter %ld done\n", it);
fflush(stdout);
}
}
printf("churn: DONE\n");
fflush(stdout);
return 0;
}

static int cmd_once(const char *path)
{
char buf[8192];
int fd = open(path, O_RDONLY);
ssize_t r;

if (fd < 0) {
perror("open");
return 1;
}
r = read(fd, buf, sizeof(buf) - 1);
close(fd);
if (r > 0) {
buf[r] = '\0';
printf("%s", buf);
} else {
printf("(empty read: %zd)\n", r);
}
fflush(stdout);
return 0;
}

static void usage(void)
{
fprintf(stderr,
"usage: esrace spin <cpu> <spin_ms> <sleep_ms> <prio>\n"
" esrace spinus <cpu> <spin_us> <sleep_us> <prio>\n"
" esrace read <cpu> <procfile> <iters>\n"
" esrace churn <dir> <nfiles> <seg_kb> <segs> <iters>\n"
" esrace once <procfile>\n");
exit(2);
}

int main(int argc, char **argv)
{
if (argc < 2)
usage();
if (!strcmp(argv[1], "spin") && argc == 6)
return cmd_spin(atoi(argv[2]), atof(argv[3]), atof(argv[4]),
atoi(argv[5]), 0);
if (!strcmp(argv[1], "spinus") && argc == 6)
return cmd_spin(atoi(argv[2]), atof(argv[3]) / 1000.0,
atof(argv[4]) / 1000.0, atoi(argv[5]), 1);
if (!strcmp(argv[1], "read") && argc == 5)
return cmd_read(atoi(argv[2]), argv[3], atol(argv[4]), 0);
if (!strcmp(argv[1], "read") && argc == 6)
return cmd_read(atoi(argv[2]), argv[3], atol(argv[4]),
atof(argv[5]));
if (!strcmp(argv[1], "churn") && argc == 7)
return cmd_churn(argv[2], atoi(argv[3]), atoi(argv[4]),
atoi(argv[5]), atol(argv[6]));
if (!strcmp(argv[1], "once") && argc == 3)
return cmd_once(argv[2]);
usage();
return 2;
}