* [PATCH 6/6] exfat: map ptime to exFAT creation time with rename-over
From: Sean Smith @ 2026-04-05 19:50 UTC (permalink / raw)
To: linux-fsdevel
Cc: linux-ext4, linux-btrfs, tytso, dsterba, david, brauner, osandov,
almaz, hirofumi, linkinjeon, Sean Smith
In-Reply-To: <20260405195007.1306-1-DefendTheDisabled@gmail.com>
Map ptime to the exFAT creation time field. exFAT creation time
has 10ms precision.
Getattr: report creation time as ptime.
Setattr: write ptime to i_crtime.
Rename-over: save target creation time before __exfat_rename, restore
after. Preserves creation time across atomic saves.
Signed-off-by: Sean Smith <DefendTheDisabled@gmail.com>
---
fs/btrfs/inode.c | 3 ++-
fs/exfat/file.c | 9 +++++++++
fs/exfat/namei.c | 21 ++++++++++++++++++---
3 files changed, 29 insertions(+), 4 deletions(-)
diff --git a/fs/btrfs/inode.c b/fs/btrfs/inode.c
index dce80561a..918dfd4c5 100644
--- a/fs/btrfs/inode.c
+++ b/fs/btrfs/inode.c
@@ -8715,7 +8715,8 @@ static int btrfs_rename(struct mnt_idmap *idmap,
/* Note: if rename fails below, ptime mutation is harmless —
* the source file keeps its previous ptime=0 semantics since
* the rename didn't complete. The in-memory value will be
- * overwritten on next inode read from disk. */
+ * overwritten on next inode read from disk.
+ */
ret = btrfs_update_inode(trans, BTRFS_I(old_inode));
if (unlikely(ret)) {
diff --git a/fs/exfat/file.c b/fs/exfat/file.c
index 536c8078f..b6438bd79 100644
--- a/fs/exfat/file.c
+++ b/fs/exfat/file.c
@@ -277,6 +277,11 @@ int exfat_getattr(struct mnt_idmap *idmap, const struct path *path,
stat->result_mask |= STATX_BTIME;
stat->btime.tv_sec = ei->i_crtime.tv_sec;
stat->btime.tv_nsec = ei->i_crtime.tv_nsec;
+ if (request_mask & STATX_PTIME) {
+ stat->result_mask |= STATX_PTIME;
+ stat->ptime.tv_sec = ei->i_crtime.tv_sec;
+ stat->ptime.tv_nsec = ei->i_crtime.tv_nsec;
+ }
stat->blksize = EXFAT_SB(inode->i_sb)->cluster_size;
return 0;
}
@@ -337,6 +342,10 @@ int exfat_setattr(struct mnt_idmap *idmap, struct dentry *dentry,
if (attr->ia_valid & ATTR_SIZE)
inode_set_mtime_to_ts(inode, inode_set_ctime_current(inode));
+ if (attr->ia_valid & ATTR_PTIME) {
+ struct exfat_inode_info *exi = EXFAT_I(inode);
+ exi->i_crtime = attr->ia_ptime;
+ }
setattr_copy(idmap, inode, attr);
exfat_truncate_inode_atime(inode);
diff --git a/fs/exfat/namei.c b/fs/exfat/namei.c
index dfe957493..9c0b59e00 100644
--- a/fs/exfat/namei.c
+++ b/fs/exfat/namei.c
@@ -1262,9 +1262,24 @@ static int exfat_rename(struct mnt_idmap *idmap,
old_inode = old_dentry->d_inode;
new_inode = new_dentry->d_inode;
- err = __exfat_rename(old_dir, EXFAT_I(old_inode), new_dir, new_dentry);
- if (err)
- goto unlock;
+ /* ptime rename-over: save target creation time */
+ {
+ struct timespec64 saved_crtime = {};
+ bool inherit_crtime = false;
+
+ if (new_inode && S_ISREG(old_inode->i_mode) &&
+ S_ISREG(new_inode->i_mode) && old_inode->i_nlink == 1) {
+ saved_crtime = EXFAT_I(new_inode)->i_crtime;
+ inherit_crtime = true;
+ }
+
+ err = __exfat_rename(old_dir, EXFAT_I(old_inode), new_dir, new_dentry);
+ if (err)
+ goto unlock;
+
+ if (inherit_crtime)
+ EXFAT_I(old_inode)->i_crtime = saved_crtime;
+ }
inode_inc_iversion(new_dir);
simple_rename_timestamp(old_dir, old_dentry, new_dir, new_dentry);
--
2.53.0
^ permalink raw reply related
* [PATCH 5/6] fat: map ptime to FAT creation time with rename-over
From: Sean Smith @ 2026-04-05 19:50 UTC (permalink / raw)
To: linux-fsdevel
Cc: linux-ext4, linux-btrfs, tytso, dsterba, david, brauner, osandov,
almaz, hirofumi, linkinjeon, Sean Smith
In-Reply-To: <20260405195007.1306-1-DefendTheDisabled@gmail.com>
Map ptime to the FAT/VFAT creation time field. Only active on VFAT
(long filename) mounts since plain FAT12/FAT16 lack creation time.
FAT32 creation time has 2-second precision.
Getattr: report creation time as ptime (VFAT only, via isvfat check).
Setattr: write ptime to i_crtime.
Rename-over: save target creation time before detach, restore to
source after attach. Preserves creation time across atomic saves.
Signed-off-by: Sean Smith <DefendTheDisabled@gmail.com>
---
fs/fat/file.c | 6 ++++++
fs/fat/namei_vfat.c | 20 ++++++++++++++++++--
2 files changed, 24 insertions(+), 2 deletions(-)
diff --git a/fs/fat/file.c b/fs/fat/file.c
index 4fc49a614..9d1fcc554 100644
--- a/fs/fat/file.c
+++ b/fs/fat/file.c
@@ -413,6 +413,10 @@ int fat_getattr(struct mnt_idmap *idmap, const struct path *path,
stat->result_mask |= STATX_BTIME;
stat->btime = MSDOS_I(inode)->i_crtime;
}
+ if (sbi->options.isvfat && (request_mask & STATX_PTIME)) {
+ stat->result_mask |= STATX_PTIME;
+ stat->ptime = MSDOS_I(inode)->i_crtime;
+ }
return 0;
}
@@ -564,6 +568,8 @@ int fat_setattr(struct mnt_idmap *idmap, struct dentry *dentry,
fat_truncate_time(inode, &attr->ia_mtime, S_MTIME);
attr->ia_valid &= ~(ATTR_ATIME|ATTR_CTIME|ATTR_MTIME);
+ if (attr->ia_valid & ATTR_PTIME)
+ MSDOS_I(inode)->i_crtime = attr->ia_ptime;
setattr_copy(idmap, inode, attr);
mark_inode_dirty(inode);
out:
diff --git a/fs/fat/namei_vfat.c b/fs/fat/namei_vfat.c
index 47ff083cf..f1e2eadf8 100644
--- a/fs/fat/namei_vfat.c
+++ b/fs/fat/namei_vfat.c
@@ -975,8 +975,24 @@ static int vfat_rename(struct inode *old_dir, struct dentry *old_dentry,
}
inode_inc_iversion(new_dir);
- fat_detach(old_inode);
- fat_attach(old_inode, new_i_pos);
+ /* ptime rename-over: save target creation time */
+ {
+ struct timespec64 saved_crtime = {};
+ bool inherit_crtime = false;
+
+ if (new_inode && S_ISREG(old_inode->i_mode) &&
+ S_ISREG(new_inode->i_mode) && old_inode->i_nlink == 1) {
+ saved_crtime = MSDOS_I(new_inode)->i_crtime;
+ inherit_crtime = true;
+ }
+
+ fat_detach(old_inode);
+ fat_attach(old_inode, new_i_pos);
+
+ if (inherit_crtime)
+ MSDOS_I(old_inode)->i_crtime = saved_crtime;
+ }
+
err = vfat_sync_ipos(new_dir, old_inode);
if (err)
goto error_inode;
--
2.53.0
^ permalink raw reply related
* [PATCH 4/6] ext4: add dedicated ptime field alongside i_crtime
From: Sean Smith @ 2026-04-05 19:50 UTC (permalink / raw)
To: linux-fsdevel
Cc: linux-ext4, linux-btrfs, tytso, dsterba, david, brauner, osandov,
almaz, hirofumi, linkinjeon, Sean Smith
In-Reply-To: <20260405195007.1306-1-DefendTheDisabled@gmail.com>
Add i_ptime (__le32) and i_ptime_extra (__le32) to the ext4 on-disk
inode structure after i_projid. Total: 8 bytes in the extended inode
area. i_crtime remains untouched as immutable birth time.
This is a native-ptime implementation: ptime and btime are separate
fields. On 256-byte inodes (modern default), both fit easily. On
128-byte inodes, ptime is silently unavailable (same graceful
degradation as i_crtime via EXT4_FITS_IN_INODE).
Uses existing EXT4_EINODE_GET_XTIME/SET_XTIME macros for read/write.
Rename-over: when a file with ptime=0 replaces a file with ptime set,
inherit target ptime (same zero-sentinel logic as Btrfs).
Signed-off-by: Sean Smith <DefendTheDisabled@gmail.com>
---
fs/ext4/ext4.h | 3 +++
fs/ext4/inode.c | 14 ++++++++++++++
fs/ext4/namei.c | 13 +++++++++++++
3 files changed, 30 insertions(+)
diff --git a/fs/ext4/ext4.h b/fs/ext4/ext4.h
index f1c476303..5c2812637 100644
--- a/fs/ext4/ext4.h
+++ b/fs/ext4/ext4.h
@@ -860,6 +860,8 @@ struct ext4_inode {
__le32 i_crtime_extra; /* extra FileCreationtime (nsec << 2 | epoch) */
__le32 i_version_hi; /* high 32 bits for 64-bit version */
__le32 i_projid; /* Project ID */
+ __le32 i_ptime; /* Provenance time */
+ __le32 i_ptime_extra; /* extra Provenance time (nsec << 2 | epoch) */
};
#define EXT4_EPOCH_BITS 2
@@ -1136,6 +1138,7 @@ struct ext4_inode_info {
* struct timespec64 i_{a,c,m}time in the generic inode.
*/
struct timespec64 i_crtime;
+ struct timespec64 i_ptime;
/* mballoc */
atomic_t i_prealloc_active;
diff --git a/fs/ext4/inode.c b/fs/ext4/inode.c
index 625cfbf61..15b6b6dc6 100644
--- a/fs/ext4/inode.c
+++ b/fs/ext4/inode.c
@@ -4753,6 +4753,7 @@ static int ext4_fill_raw_inode(struct inode *inode, struct ext4_inode *raw_inode
EXT4_INODE_SET_MTIME(inode, raw_inode);
EXT4_INODE_SET_ATIME(inode, raw_inode);
EXT4_EINODE_SET_XTIME(i_crtime, ei, raw_inode);
+ EXT4_EINODE_SET_XTIME(i_ptime, ei, raw_inode);
raw_inode->i_dtime = cpu_to_le32(ei->i_dtime);
raw_inode->i_flags = cpu_to_le32(ei->i_flags & 0xFFFFFFFF);
@@ -5409,6 +5410,7 @@ struct inode *__ext4_iget(struct super_block *sb, unsigned long ino,
EXT4_INODE_GET_ATIME(inode, raw_inode);
EXT4_INODE_GET_MTIME(inode, raw_inode);
EXT4_EINODE_GET_XTIME(i_crtime, ei, raw_inode);
+ EXT4_EINODE_GET_XTIME(i_ptime, ei, raw_inode);
if (likely(!test_opt2(inode->i_sb, HURD_COMPAT))) {
u64 ivers = le32_to_cpu(raw_inode->i_disk_version);
@@ -6061,6 +6063,9 @@ int ext4_setattr(struct mnt_idmap *idmap, struct dentry *dentry,
if (!error) {
if (inc_ivers)
inode_inc_iversion(inode);
+ if (attr->ia_valid & ATTR_PTIME)
+ EXT4_I(inode)->i_ptime = attr->ia_ptime;
+
setattr_copy(idmap, inode, attr);
mark_inode_dirty(inode);
}
@@ -6114,6 +6119,15 @@ int ext4_getattr(struct mnt_idmap *idmap, const struct path *path,
stat->btime.tv_nsec = ei->i_crtime.tv_nsec;
}
+ /* Report ptime from dedicated field, not crtime */
+ if ((request_mask & STATX_PTIME) &&
+ EXT4_FITS_IN_INODE(raw_inode, ei, i_ptime) &&
+ (ei->i_ptime.tv_sec || ei->i_ptime.tv_nsec)) {
+ stat->result_mask |= STATX_PTIME;
+ stat->ptime.tv_sec = ei->i_ptime.tv_sec;
+ stat->ptime.tv_nsec = ei->i_ptime.tv_nsec;
+ }
+
/*
* Return the DIO alignment restrictions if requested. We only return
* this information when requested, since on encrypted files it might
diff --git a/fs/ext4/namei.c b/fs/ext4/namei.c
index c4b5e252a..1bfe4df24 100644
--- a/fs/ext4/namei.c
+++ b/fs/ext4/namei.c
@@ -3942,6 +3942,19 @@ static int ext4_rename(struct mnt_idmap *idmap, struct inode *old_dir,
* rename.
*/
inode_set_ctime_current(old.inode);
+
+ /* ptime rename-over: preserve ptime across atomic saves */
+ if (new.inode && S_ISREG(old.inode->i_mode) &&
+ S_ISREG(new.inode->i_mode) && old.inode->i_nlink == 1 &&
+ !(flags & RENAME_WHITEOUT)) {
+ struct ext4_inode_info *old_ei = EXT4_I(old.inode);
+ struct ext4_inode_info *new_ei = EXT4_I(new.inode);
+
+ if (!old_ei->i_ptime.tv_sec && !old_ei->i_ptime.tv_nsec &&
+ (new_ei->i_ptime.tv_sec || new_ei->i_ptime.tv_nsec))
+ old_ei->i_ptime = new_ei->i_ptime;
+ }
+
retval = ext4_mark_inode_dirty(handle, old.inode);
if (unlikely(retval))
goto end_rename;
--
2.53.0
^ permalink raw reply related
* [PATCH 3/6] ntfs3: map ptime to NTFS creation time with rename-over
From: Sean Smith @ 2026-04-05 19:49 UTC (permalink / raw)
To: linux-fsdevel
Cc: linux-ext4, linux-btrfs, tytso, dsterba, david, brauner, osandov,
almaz, hirofumi, linkinjeon, Sean Smith
In-Reply-To: <20260405195007.1306-1-DefendTheDisabled@gmail.com>
Map ptime to the NTFS Date Created field in $STANDARD_INFORMATION.
This is a mapped-ptime implementation: setting ptime overwrites the
creation time. Justified because Windows treats NTFS creation time
as mutable via SetFileTime() - it was never truly immutable.
Getattr: report NTFS creation time as ptime.
Setattr: write ptime to NTFS creation time via frecord cr_time path.
Rename-over: save target creation time before unlink, restore to
source after rename. Replicates Windows behavior where creation
time survives application atomic saves.
Round-trip: NTFS Date Created -> Btrfs ptime -> NTFS Date Created
preserves the original creation date through cross-FS copies.
Signed-off-by: Sean Smith <DefendTheDisabled@gmail.com>
---
fs/ntfs3/file.c | 13 +++++++++++++
fs/ntfs3/frecord.c | 8 ++++++++
fs/ntfs3/namei.c | 14 ++++++++++++++
3 files changed, 35 insertions(+)
diff --git a/fs/ntfs3/file.c b/fs/ntfs3/file.c
index 13d014b87..8688a48b1 100644
--- a/fs/ntfs3/file.c
+++ b/fs/ntfs3/file.c
@@ -161,6 +161,13 @@ int ntfs_getattr(struct mnt_idmap *idmap, const struct path *path,
stat->result_mask |= STATX_BTIME;
stat->btime = ni->i_crtime;
+
+ /* Map NTFS creation time to ptime (provenance time) */
+ if (request_mask & STATX_PTIME) {
+ stat->ptime = ni->i_crtime;
+ stat->result_mask |= STATX_PTIME;
+ }
+
stat->blksize = ni->mi.sbi->cluster_size; /* 512, 1K, ..., 2M */
if (inode->i_flags & S_IMMUTABLE)
@@ -857,6 +864,12 @@ int ntfs_setattr(struct mnt_idmap *idmap, struct dentry *dentry,
i_size_write(inode, newsize);
}
+ /* Accept ptime and store as NTFS creation time */
+ if (ia_valid & ATTR_PTIME) {
+ ni->i_crtime = attr->ia_ptime;
+ ni->ni_flags |= NI_FLAG_UPDATE_PARENT;
+ }
+
setattr_copy(idmap, inode, attr);
if (mode != inode->i_mode) {
diff --git a/fs/ntfs3/frecord.c b/fs/ntfs3/frecord.c
index d5bbd47e1..b164b2f50 100644
--- a/fs/ntfs3/frecord.c
+++ b/fs/ntfs3/frecord.c
@@ -3197,6 +3197,14 @@ int ni_write_inode(struct inode *inode, int sync, const char *hint)
modified = true;
}
+ /* Write creation time (ptime maps to NTFS cr_time) */
+ ts = ni->i_crtime;
+ dup.cr_time = kernel2nt(&ts);
+ if (std->cr_time != dup.cr_time) {
+ std->cr_time = dup.cr_time;
+ modified = true;
+ }
+
dup.fa = ni->std_fa;
if (std->fa != dup.fa) {
std->fa = dup.fa;
diff --git a/fs/ntfs3/namei.c b/fs/ntfs3/namei.c
index b2af8f695..40d06884f 100644
--- a/fs/ntfs3/namei.c
+++ b/fs/ntfs3/namei.c
@@ -292,6 +292,16 @@ static int ntfs_rename(struct mnt_idmap *idmap, struct inode *dir,
return -EINVAL;
}
+ /* ptime rename-over: save target creation time before unlink */
+ struct timespec64 saved_crtime = {};
+ bool inherit_crtime = false;
+
+ if (new_inode && S_ISREG(inode->i_mode) &&
+ S_ISREG(new_inode->i_mode) && inode->i_nlink == 1) {
+ saved_crtime = ntfs_i(new_inode)->i_crtime;
+ inherit_crtime = true;
+ }
+
if (new_inode) {
/* Target name exists. Unlink it. */
dget(new_dentry);
@@ -330,6 +340,10 @@ static int ntfs_rename(struct mnt_idmap *idmap, struct inode *dir,
err = ni_rename(dir_ni, new_dir_ni, ni, de, new_de);
if (!err) {
+ /* ptime rename-over: inherit target creation time */
+ if (inherit_crtime)
+ ni->i_crtime = saved_crtime;
+
simple_rename_timestamp(dir, dentry, new_dir, new_dentry);
mark_inode_dirty(inode);
mark_inode_dirty(dir);
--
2.53.0
^ permalink raw reply related
* [PATCH 2/6] btrfs: add provenance time (ptime) support
From: Sean Smith @ 2026-04-05 19:49 UTC (permalink / raw)
To: linux-fsdevel
Cc: linux-ext4, linux-btrfs, tytso, dsterba, david, brauner, osandov,
almaz, hirofumi, linkinjeon, Sean Smith
In-Reply-To: <20260405195007.1306-1-DefendTheDisabled@gmail.com>
Store ptime as a dedicated field in btrfs_inode_item reserved space:
struct btrfs_timespec (12 bytes) + __le32 pad (4 bytes) = 16 bytes,
consuming 2 of 4 reserved __le64 slots, leaving 2 free.
In-memory: i_ptime_sec/i_ptime_nsec in struct btrfs_inode.
Persistence: delayed-inode read/write path (the primary persistence
path for normal inodes, not fill_inode_item).
Tree-log: ptime written to log tree for fsync crash recovery.
New inode: initialized to zero (ptime unset).
Getattr reports ptime only when non-zero (distinguishes unset from
supported-but-zero). Setattr accepts ATTR_PTIME and sets
BTRFS_FEATURE_COMPAT_RO_PTIME - old kernels see unknown compat_ro
bit and refuse RW mount, protecting ptime data.
Rename-over preservation: when rename(source, target) replaces an
existing regular file, if source has ptime=0 and target has ptime
set, inherit target ptime to source. Guards: S_ISREG both sides,
nlink==1, not RENAME_EXCHANGE/WHITEOUT. Atomic with rename
transaction. Enables atomic-save survival (write-temp + rename).
Signed-off-by: Sean Smith <DefendTheDisabled@gmail.com>
---
fs/btrfs/btrfs_inode.h | 4 ++++
fs/btrfs/delayed-inode.c | 4 ++++
fs/btrfs/fs.h | 3 ++-
fs/btrfs/inode.c | 42 +++++++++++++++++++++++++++++++++
fs/btrfs/tree-log.c | 2 ++
include/uapi/linux/btrfs.h | 1 +
include/uapi/linux/btrfs_tree.h | 4 +++-
7 files changed, 58 insertions(+), 2 deletions(-)
diff --git a/fs/btrfs/btrfs_inode.h b/fs/btrfs/btrfs_inode.h
index 73602ee8d..bac92f766 100644
--- a/fs/btrfs/btrfs_inode.h
+++ b/fs/btrfs/btrfs_inode.h
@@ -334,6 +334,10 @@ struct btrfs_inode {
u64 i_otime_sec;
u32 i_otime_nsec;
+ /* Provenance time - original creation date of file content. */
+ u64 i_ptime_sec;
+ u32 i_ptime_nsec;
+
/* Hook into fs_info->delayed_iputs */
struct list_head delayed_iput;
diff --git a/fs/btrfs/delayed-inode.c b/fs/btrfs/delayed-inode.c
index 7e3d294a6..649de7c29 100644
--- a/fs/btrfs/delayed-inode.c
+++ b/fs/btrfs/delayed-inode.c
@@ -1887,6 +1887,8 @@ static void fill_stack_inode_item(struct btrfs_trans_handle *trans,
btrfs_set_stack_timespec_sec(&inode_item->otime, inode->i_otime_sec);
btrfs_set_stack_timespec_nsec(&inode_item->otime, inode->i_otime_nsec);
+ btrfs_set_stack_timespec_sec(&inode_item->ptime, inode->i_ptime_sec);
+ btrfs_set_stack_timespec_nsec(&inode_item->ptime, inode->i_ptime_nsec);
}
int btrfs_fill_inode(struct btrfs_inode *inode, u32 *rdev)
@@ -1935,6 +1937,8 @@ int btrfs_fill_inode(struct btrfs_inode *inode, u32 *rdev)
inode->i_otime_sec = btrfs_stack_timespec_sec(&inode_item->otime);
inode->i_otime_nsec = btrfs_stack_timespec_nsec(&inode_item->otime);
+ inode->i_ptime_sec = btrfs_stack_timespec_sec(&inode_item->ptime);
+ inode->i_ptime_nsec = btrfs_stack_timespec_nsec(&inode_item->ptime);
vfs_inode->i_generation = inode->generation;
if (S_ISDIR(vfs_inode->i_mode))
diff --git a/fs/btrfs/fs.h b/fs/btrfs/fs.h
index 8ffbc40eb..7c8105ecf 100644
--- a/fs/btrfs/fs.h
+++ b/fs/btrfs/fs.h
@@ -284,7 +284,8 @@ enum {
(BTRFS_FEATURE_COMPAT_RO_FREE_SPACE_TREE | \
BTRFS_FEATURE_COMPAT_RO_FREE_SPACE_TREE_VALID | \
BTRFS_FEATURE_COMPAT_RO_VERITY | \
- BTRFS_FEATURE_COMPAT_RO_BLOCK_GROUP_TREE)
+ BTRFS_FEATURE_COMPAT_RO_BLOCK_GROUP_TREE | \
+ BTRFS_FEATURE_COMPAT_RO_PTIME)
#define BTRFS_FEATURE_COMPAT_RO_SAFE_SET 0ULL
#define BTRFS_FEATURE_COMPAT_RO_SAFE_CLEAR 0ULL
diff --git a/fs/btrfs/inode.c b/fs/btrfs/inode.c
index 13f1f3b52..dce80561a 100644
--- a/fs/btrfs/inode.c
+++ b/fs/btrfs/inode.c
@@ -4029,6 +4029,8 @@ static int btrfs_read_locked_inode(struct btrfs_inode *inode, struct btrfs_path
inode->i_otime_sec = btrfs_timespec_sec(leaf, &inode_item->otime);
inode->i_otime_nsec = btrfs_timespec_nsec(leaf, &inode_item->otime);
+ inode->i_ptime_sec = btrfs_timespec_sec(leaf, &inode_item->ptime);
+ inode->i_ptime_nsec = btrfs_timespec_nsec(leaf, &inode_item->ptime);
inode_set_bytes(vfs_inode, btrfs_inode_nbytes(leaf, inode_item));
inode->generation = btrfs_inode_generation(leaf, inode_item);
@@ -4220,6 +4222,8 @@ static void fill_inode_item(struct btrfs_trans_handle *trans,
btrfs_set_timespec_sec(leaf, &item->otime, BTRFS_I(inode)->i_otime_sec);
btrfs_set_timespec_nsec(leaf, &item->otime, BTRFS_I(inode)->i_otime_nsec);
+ btrfs_set_timespec_sec(leaf, &item->ptime, BTRFS_I(inode)->i_ptime_sec);
+ btrfs_set_timespec_nsec(leaf, &item->ptime, BTRFS_I(inode)->i_ptime_nsec);
btrfs_set_inode_nbytes(leaf, item, inode_get_bytes(inode));
btrfs_set_inode_generation(leaf, item, BTRFS_I(inode)->generation);
@@ -5424,6 +5428,12 @@ static int btrfs_setattr(struct mnt_idmap *idmap, struct dentry *dentry,
}
if (attr->ia_valid) {
+ if (attr->ia_valid & ATTR_PTIME) {
+ BTRFS_I(inode)->i_ptime_sec = attr->ia_ptime.tv_sec;
+ BTRFS_I(inode)->i_ptime_nsec = attr->ia_ptime.tv_nsec;
+ btrfs_set_fs_compat_ro(BTRFS_I(inode)->root->fs_info, PTIME);
+ }
+
setattr_copy(idmap, inode, attr);
inode_inc_iversion(inode);
ret = btrfs_dirty_inode(BTRFS_I(inode));
@@ -8007,6 +8017,8 @@ struct inode *btrfs_alloc_inode(struct super_block *sb)
ei->i_otime_sec = 0;
ei->i_otime_nsec = 0;
+ ei->i_ptime_sec = 0;
+ ei->i_ptime_nsec = 0;
inode = &ei->vfs_inode;
btrfs_extent_map_tree_init(&ei->extent_tree);
@@ -8159,6 +8171,14 @@ static int btrfs_getattr(struct mnt_idmap *idmap,
u32 bi_ro_flags = BTRFS_I(inode)->ro_flags;
stat->result_mask |= STATX_BTIME;
+ if (request_mask & STATX_PTIME) {
+ if (BTRFS_I(inode)->i_ptime_sec ||
+ BTRFS_I(inode)->i_ptime_nsec) {
+ stat->ptime.tv_sec = BTRFS_I(inode)->i_ptime_sec;
+ stat->ptime.tv_nsec = BTRFS_I(inode)->i_ptime_nsec;
+ stat->result_mask |= STATX_PTIME;
+ }
+ }
stat->btime.tv_sec = BTRFS_I(inode)->i_otime_sec;
stat->btime.tv_nsec = BTRFS_I(inode)->i_otime_nsec;
if (bi_flags & BTRFS_INODE_APPEND)
@@ -8675,6 +8695,28 @@ static int btrfs_rename(struct mnt_idmap *idmap,
btrfs_abort_transaction(trans, ret);
goto out_fail;
}
+ /*
+ * ptime rename-over preservation: if a file with no ptime
+ * is being renamed over a file that has ptime (the atomic
+ * save pattern: write-to-temp + rename over original),
+ * inherit the target's ptime so provenance survives.
+ */
+ if (new_inode && S_ISREG(old_inode->i_mode) &&
+ S_ISREG(new_inode->i_mode) && old_inode->i_nlink == 1 &&
+ !(flags & (RENAME_EXCHANGE | RENAME_WHITEOUT))) {
+ struct btrfs_inode *old_bi = BTRFS_I(old_inode);
+ struct btrfs_inode *new_bi = BTRFS_I(new_inode);
+ if (!old_bi->i_ptime_sec && !old_bi->i_ptime_nsec &&
+ (new_bi->i_ptime_sec || new_bi->i_ptime_nsec)) {
+ old_bi->i_ptime_sec = new_bi->i_ptime_sec;
+ old_bi->i_ptime_nsec = new_bi->i_ptime_nsec;
+ }
+ }
+ /* Note: if rename fails below, ptime mutation is harmless —
+ * the source file keeps its previous ptime=0 semantics since
+ * the rename didn't complete. The in-memory value will be
+ * overwritten on next inode read from disk. */
+
ret = btrfs_update_inode(trans, BTRFS_I(old_inode));
if (unlikely(ret)) {
btrfs_abort_transaction(trans, ret);
diff --git a/fs/btrfs/tree-log.c b/fs/btrfs/tree-log.c
index 6c40f48cc..7ed09af22 100644
--- a/fs/btrfs/tree-log.c
+++ b/fs/btrfs/tree-log.c
@@ -4640,6 +4640,8 @@ static void fill_inode_item(struct btrfs_trans_handle *trans,
btrfs_set_timespec_sec(leaf, &item->otime, BTRFS_I(inode)->i_otime_sec);
btrfs_set_timespec_nsec(leaf, &item->otime, BTRFS_I(inode)->i_otime_nsec);
+ btrfs_set_timespec_sec(leaf, &item->ptime, BTRFS_I(inode)->i_ptime_sec);
+ btrfs_set_timespec_nsec(leaf, &item->ptime, BTRFS_I(inode)->i_ptime_nsec);
/*
* We do not need to set the nbytes field, in fact during a fast fsync
diff --git a/include/uapi/linux/btrfs.h b/include/uapi/linux/btrfs.h
index e8fd92789..d2c542425 100644
--- a/include/uapi/linux/btrfs.h
+++ b/include/uapi/linux/btrfs.h
@@ -313,6 +313,7 @@ struct btrfs_ioctl_fs_info_args {
* reducing mount time for large filesystem due to better locality.
*/
#define BTRFS_FEATURE_COMPAT_RO_BLOCK_GROUP_TREE (1ULL << 3)
+#define BTRFS_FEATURE_COMPAT_RO_PTIME (1ULL << 4)
#define BTRFS_FEATURE_INCOMPAT_MIXED_BACKREF (1ULL << 0)
#define BTRFS_FEATURE_INCOMPAT_DEFAULT_SUBVOL (1ULL << 1)
diff --git a/include/uapi/linux/btrfs_tree.h b/include/uapi/linux/btrfs_tree.h
index fc29d2738..719c00363 100644
--- a/include/uapi/linux/btrfs_tree.h
+++ b/include/uapi/linux/btrfs_tree.h
@@ -890,7 +890,9 @@ struct btrfs_inode_item {
* a little future expansion, for more than this we can
* just grow the inode item and version it
*/
- __le64 reserved[4];
+ struct btrfs_timespec ptime;
+ __le32 __reserved_pad;
+ __le64 reserved[2];
struct btrfs_timespec atime;
struct btrfs_timespec ctime;
struct btrfs_timespec mtime;
--
2.53.0
^ permalink raw reply related
* [PATCH 1/6] vfs: add provenance_time (ptime) infrastructure
From: Sean Smith @ 2026-04-05 19:49 UTC (permalink / raw)
To: linux-fsdevel
Cc: linux-ext4, linux-btrfs, tytso, dsterba, david, brauner, osandov,
almaz, hirofumi, linkinjeon, Sean Smith
In-Reply-To: <20260405195007.1306-1-DefendTheDisabled@gmail.com>
Add a new settable inode timestamp, provenance_time (ptime), for tracking
the original creation date of file content across filesystem boundaries.
ptime is distinct from btime (forensic, immutable): it records when file
content first came into existence on any filesystem, and is designed to
be set during cross-filesystem migration and preserved through copies.
VFS changes:
- ATTR_PTIME (bit 19) and ATTR_PTIME_SET (bit 20) in struct iattr
- STATX_PTIME (0x00040000) in struct statx at offset 0xC0
- AT_UTIME_PTIME (0x20000) flag for utimensat()
- ptime field in struct kstat
- Permission model matches mtime (owner or CAP_FOWNER)
- UTIME_NOW and UTIME_OMIT supported for ptime element
- All existing vfs_utimes() callers updated for new flags parameter
Signed-off-by: Sean Smith <DefendTheDisabled@gmail.com>
---
fs/attr.c | 6 +++-
fs/btrfs/volumes.c | 2 +-
fs/init.c | 2 +-
fs/stat.c | 2 ++
fs/utimes.c | 56 +++++++++++++++++++++++++++++---------
include/linux/fs.h | 5 +++-
include/linux/stat.h | 1 +
include/uapi/linux/fcntl.h | 3 ++
include/uapi/linux/stat.h | 4 ++-
init/initramfs.c | 2 +-
10 files changed, 64 insertions(+), 19 deletions(-)
diff --git a/fs/attr.c b/fs/attr.c
index b9ec6b47b..7fa9c01d1 100644
--- a/fs/attr.c
+++ b/fs/attr.c
@@ -206,7 +206,7 @@ int setattr_prepare(struct mnt_idmap *idmap, struct dentry *dentry,
}
/* Check for setting the inode time. */
- if (ia_valid & (ATTR_MTIME_SET | ATTR_ATIME_SET | ATTR_TIMES_SET)) {
+ if (ia_valid & (ATTR_MTIME_SET | ATTR_ATIME_SET | ATTR_PTIME_SET | ATTR_TIMES_SET)) {
if (!inode_owner_or_capable(idmap, inode))
return -EPERM;
}
@@ -466,6 +466,10 @@ int notify_change(struct mnt_idmap *idmap, struct dentry *dentry,
attr->ia_mtime = timestamp_truncate(attr->ia_mtime, inode);
else
attr->ia_mtime = now;
+ if (ia_valid & ATTR_PTIME_SET)
+ attr->ia_ptime = timestamp_truncate(attr->ia_ptime, inode);
+ else if (ia_valid & ATTR_PTIME)
+ attr->ia_ptime = now;
if (ia_valid & ATTR_KILL_PRIV) {
error = security_inode_need_killpriv(dentry);
diff --git a/fs/btrfs/volumes.c b/fs/btrfs/volumes.c
index 052b830a0..0e81f2cc9 100644
--- a/fs/btrfs/volumes.c
+++ b/fs/btrfs/volumes.c
@@ -2117,7 +2117,7 @@ static void update_dev_time(const char *device_path)
struct path path;
if (!kern_path(device_path, LOOKUP_FOLLOW, &path)) {
- vfs_utimes(&path, NULL);
+ vfs_utimes(&path, NULL, 0);
path_put(&path);
}
}
diff --git a/fs/init.c b/fs/init.c
index e0f5429c0..e9a9f4d93 100644
--- a/fs/init.c
+++ b/fs/init.c
@@ -254,7 +254,7 @@ int __init init_utimes(char *filename, struct timespec64 *ts)
error = kern_path(filename, 0, &path);
if (error)
return error;
- error = vfs_utimes(&path, ts);
+ error = vfs_utimes(&path, ts, 0);
path_put(&path);
return error;
}
diff --git a/fs/stat.c b/fs/stat.c
index 6c79661e1..9284bb753 100644
--- a/fs/stat.c
+++ b/fs/stat.c
@@ -728,6 +728,8 @@ cp_statx(const struct kstat *stat, struct statx __user *buffer)
tmp.stx_atime.tv_nsec = stat->atime.tv_nsec;
tmp.stx_btime.tv_sec = stat->btime.tv_sec;
tmp.stx_btime.tv_nsec = stat->btime.tv_nsec;
+ tmp.stx_ptime.tv_sec = stat->ptime.tv_sec;
+ tmp.stx_ptime.tv_nsec = stat->ptime.tv_nsec;
tmp.stx_ctime.tv_sec = stat->ctime.tv_sec;
tmp.stx_ctime.tv_nsec = stat->ctime.tv_nsec;
tmp.stx_mtime.tv_sec = stat->mtime.tv_sec;
diff --git a/fs/utimes.c b/fs/utimes.c
index 86f8ce8cd..50b5ad296 100644
--- a/fs/utimes.c
+++ b/fs/utimes.c
@@ -17,10 +17,10 @@ static bool nsec_valid(long nsec)
return nsec >= 0 && nsec <= 999999999;
}
-int vfs_utimes(const struct path *path, struct timespec64 *times)
+int vfs_utimes(const struct path *path, struct timespec64 *times, int flags)
{
int error;
- struct iattr newattrs;
+ struct iattr newattrs = {};
struct inode *inode = path->dentry->d_inode;
struct delegated_inode delegated_inode = { };
@@ -28,7 +28,11 @@ int vfs_utimes(const struct path *path, struct timespec64 *times)
if (!nsec_valid(times[0].tv_nsec) ||
!nsec_valid(times[1].tv_nsec))
return -EINVAL;
- if (times[0].tv_nsec == UTIME_NOW &&
+ if ((flags & AT_UTIME_PTIME) &&
+ !nsec_valid(times[2].tv_nsec))
+ return -EINVAL;
+ if (!(flags & AT_UTIME_PTIME) &&
+ times[0].tv_nsec == UTIME_NOW &&
times[1].tv_nsec == UTIME_NOW)
times = NULL;
}
@@ -52,6 +56,15 @@ int vfs_utimes(const struct path *path, struct timespec64 *times)
newattrs.ia_mtime = times[1];
newattrs.ia_valid |= ATTR_MTIME_SET;
}
+ if (flags & AT_UTIME_PTIME) {
+ if (times[2].tv_nsec != UTIME_OMIT) {
+ newattrs.ia_valid |= ATTR_PTIME;
+ if (times[2].tv_nsec != UTIME_NOW) {
+ newattrs.ia_ptime = times[2];
+ newattrs.ia_valid |= ATTR_PTIME_SET;
+ }
+ }
+ }
/*
* Tell setattr_prepare(), that this is an explicit time
* update, even if neither ATTR_ATIME_SET nor ATTR_MTIME_SET
@@ -84,7 +97,7 @@ static int do_utimes_path(int dfd, const char __user *filename,
struct path path;
int lookup_flags = 0, error;
- if (flags & ~(AT_SYMLINK_NOFOLLOW | AT_EMPTY_PATH))
+ if (flags & ~(AT_SYMLINK_NOFOLLOW | AT_EMPTY_PATH | AT_UTIME_PTIME))
return -EINVAL;
if (!(flags & AT_SYMLINK_NOFOLLOW))
@@ -97,7 +110,7 @@ static int do_utimes_path(int dfd, const char __user *filename,
if (error)
return error;
- error = vfs_utimes(&path, times);
+ error = vfs_utimes(&path, times, flags);
path_put(&path);
if (retry_estale(error, lookup_flags)) {
lookup_flags |= LOOKUP_REVAL;
@@ -109,13 +122,13 @@ static int do_utimes_path(int dfd, const char __user *filename,
static int do_utimes_fd(int fd, struct timespec64 *times, int flags)
{
- if (flags)
+ if (flags & ~AT_UTIME_PTIME)
return -EINVAL;
CLASS(fd, f)(fd);
if (fd_empty(f))
return -EBADF;
- return vfs_utimes(&fd_file(f)->f_path, times);
+ return vfs_utimes(&fd_file(f)->f_path, times, flags);
}
/*
@@ -144,16 +157,24 @@ long do_utimes(int dfd, const char __user *filename, struct timespec64 *times,
SYSCALL_DEFINE4(utimensat, int, dfd, const char __user *, filename,
struct __kernel_timespec __user *, utimes, int, flags)
{
- struct timespec64 tstimes[2];
+ struct timespec64 tstimes[3];
+
+ if ((flags & AT_UTIME_PTIME) && !utimes)
+ return -EINVAL;
if (utimes) {
if ((get_timespec64(&tstimes[0], &utimes[0]) ||
- get_timespec64(&tstimes[1], &utimes[1])))
+ get_timespec64(&tstimes[1], &utimes[1])))
+ return -EFAULT;
+ if ((flags & AT_UTIME_PTIME) &&
+ get_timespec64(&tstimes[2], &utimes[2]))
return -EFAULT;
/* Nothing to do, we must not even check the path. */
if (tstimes[0].tv_nsec == UTIME_OMIT &&
- tstimes[1].tv_nsec == UTIME_OMIT)
+ tstimes[1].tv_nsec == UTIME_OMIT &&
+ (!(flags & AT_UTIME_PTIME) ||
+ tstimes[2].tv_nsec == UTIME_OMIT))
return 0;
}
@@ -247,14 +268,23 @@ SYSCALL_DEFINE2(utime32, const char __user *, filename,
SYSCALL_DEFINE4(utimensat_time32, unsigned int, dfd, const char __user *, filename, struct old_timespec32 __user *, t, int, flags)
{
- struct timespec64 tv[2];
+ struct timespec64 tv[3];
+
+ if ((flags & AT_UTIME_PTIME) && !t)
+ return -EINVAL;
- if (t) {
+ if (t) {
if (get_old_timespec32(&tv[0], &t[0]) ||
get_old_timespec32(&tv[1], &t[1]))
return -EFAULT;
+ if ((flags & AT_UTIME_PTIME) &&
+ get_old_timespec32(&tv[2], &t[2]))
+ return -EFAULT;
- if (tv[0].tv_nsec == UTIME_OMIT && tv[1].tv_nsec == UTIME_OMIT)
+ if (tv[0].tv_nsec == UTIME_OMIT &&
+ tv[1].tv_nsec == UTIME_OMIT &&
+ (!(flags & AT_UTIME_PTIME) ||
+ tv[2].tv_nsec == UTIME_OMIT))
return 0;
}
return do_utimes(dfd, filename, t ? tv : NULL, flags);
diff --git a/include/linux/fs.h b/include/linux/fs.h
index a01621fa6..07719e216 100644
--- a/include/linux/fs.h
+++ b/include/linux/fs.h
@@ -239,6 +239,8 @@ typedef int (dio_iodone_t)(struct kiocb *iocb, loff_t offset,
#define ATTR_TIMES_SET (1 << 16)
#define ATTR_TOUCH (1 << 17)
#define ATTR_DELEG (1 << 18) /* Delegated attrs. Don't break write delegations */
+#define ATTR_PTIME (1 << 19) /* Set provenance time */
+#define ATTR_PTIME_SET (1 << 20) /* Set provenance time to specific value */
/*
* Whiteout is represented by a char device. The following constants define the
@@ -283,6 +285,7 @@ struct iattr {
struct timespec64 ia_atime;
struct timespec64 ia_mtime;
struct timespec64 ia_ctime;
+ struct timespec64 ia_ptime;
/*
* Not an attribute, but an auxiliary info for filesystems wanting to
@@ -1814,7 +1817,7 @@ int vfs_mkobj(struct dentry *, umode_t,
int vfs_fchown(struct file *file, uid_t user, gid_t group);
int vfs_fchmod(struct file *file, umode_t mode);
-int vfs_utimes(const struct path *path, struct timespec64 *times);
+int vfs_utimes(const struct path *path, struct timespec64 *times, int flags);
#ifdef CONFIG_COMPAT
extern long compat_ptr_ioctl(struct file *file, unsigned int cmd,
diff --git a/include/linux/stat.h b/include/linux/stat.h
index e3d00e7bb..52272000c 100644
--- a/include/linux/stat.h
+++ b/include/linux/stat.h
@@ -48,6 +48,7 @@ struct kstat {
struct timespec64 mtime;
struct timespec64 ctime;
struct timespec64 btime; /* File creation time */
+ struct timespec64 ptime; /* Provenance time */
u64 blocks;
u64 mnt_id;
u64 change_cookie;
diff --git a/include/uapi/linux/fcntl.h b/include/uapi/linux/fcntl.h
index aadfbf6e0..f80ce0295 100644
--- a/include/uapi/linux/fcntl.h
+++ b/include/uapi/linux/fcntl.h
@@ -190,4 +190,7 @@ struct delegation {
#define AT_EXECVE_CHECK 0x10000 /* Only perform a check if execution
would be allowed. */
+/* Flag for utimensat(2): times[2] carries provenance time */
+#define AT_UTIME_PTIME 0x20000
+
#endif /* _UAPI_LINUX_FCNTL_H */
diff --git a/include/uapi/linux/stat.h b/include/uapi/linux/stat.h
index 1686861aa..0c8db3715 100644
--- a/include/uapi/linux/stat.h
+++ b/include/uapi/linux/stat.h
@@ -187,7 +187,8 @@ struct statx {
__u32 __spare2[1];
/* 0xc0 */
- __u64 __spare3[8]; /* Spare space for future expansion */
+ struct statx_timestamp stx_ptime; /* File provenance time */
+ __u64 __spare3[6]; /* Spare space for future expansion */
/* 0x100 */
};
@@ -219,6 +220,7 @@ struct statx {
#define STATX_SUBVOL 0x00008000U /* Want/got stx_subvol */
#define STATX_WRITE_ATOMIC 0x00010000U /* Want/got atomic_write_* fields */
#define STATX_DIO_READ_ALIGN 0x00020000U /* Want/got dio read alignment info */
+#define STATX_PTIME 0x00040000U /* Want/got stx_ptime */
#define STATX__RESERVED 0x80000000U /* Reserved for future struct statx expansion */
diff --git a/init/initramfs.c b/init/initramfs.c
index 6ddbfb17f..e066b1fee 100644
--- a/init/initramfs.c
+++ b/init/initramfs.c
@@ -139,7 +139,7 @@ static void __init do_utime(char *filename, time64_t mtime)
static void __init do_utime_path(const struct path *path, time64_t mtime)
{
struct timespec64 t[2] = { { .tv_sec = mtime }, { .tv_sec = mtime } };
- vfs_utimes(path, t);
+ vfs_utimes(path, t, 0);
}
static __initdata LIST_HEAD(dir_list);
--
2.53.0
^ permalink raw reply related
* [RFC PATCH v1 0/6] provenance_time (ptime): a new settable timestamp for cross-filesystem provenance
From: Sean Smith @ 2026-04-05 19:49 UTC (permalink / raw)
To: linux-fsdevel
Cc: linux-ext4, linux-btrfs, tytso, dsterba, david, brauner, osandov,
almaz, hirofumi, linkinjeon, Sean Smith
This series adds provenance_time (ptime) -- a new settable inode
timestamp that records when a file's content was first created,
preserving this date across copies, moves, and application saves.
This is a working implementation of the concept I proposed in my
RFC in March:
https://lore.kernel.org/linux-fsdevel/CAOx6djP4hb-Cd1Zk07SNfFfLc8irjNmbVqq+58h1Whz+h1wSFA@mail.gmail.com/T/#u
MOTIVATION
Linux has no mechanism to preserve original creation dates when
files move between filesystems. Every copy resets btime to "now."
For workflows involving document migration (NTFS to Btrfs, between
ext4 volumes, to USB drives), creation date provenance is lost.
Since the March RFC, I attempted an xattr-based workaround
(user.provenance_time) and found it structurally unworkable:
1. Application atomic saves destroy xattrs. Programs that save
via write-to-temp + rename() replace the inode, permanently
destroying all extended attributes. Only the VFS sees both
inodes during rename -- no userspace mechanism can intercept
this and copy metadata across.
2. Every tool in the copy chain must explicitly opt in to xattr
preservation. cp requires --preserve=xattr, rsync requires -X,
tar requires --xattrs. Each missing flag causes silent data
loss. Transparent preservation through arbitrary tool flows
is not achievable in userspace.
Atomic saves are the default behavior of mainstream applications
(LibreOffice, Vim, Kate, etc.).
DESIGN
ptime is a separate timestamp from btime. btime remains immutable
and forensic ("when was this inode born on this disk"). ptime is
settable and portable ("when was this content first created").
This resolves the 2019 impasse: Dave Chinner's forensic argument
for immutable btime is fully respected -- btime is untouched on
native Linux filesystems. Ted Ts'o's March 2025 concept of a
settable "crtime" alongside immutable btime is implemented in ext4
with dedicated i_ptime fields.
Two implementation categories:
Native (Btrfs, ext4): Dedicated on-disk ptime field. btime
remains immutable. Full nanosecond precision.
Mapped (ntfs3, FAT32/vfat, exFAT): ptime reads/writes the
existing creation time field. This matches Windows and macOS
behavior, where creation time is already settable via standard
APIs. No new on-disk structures needed.
Key VFS capability -- rename-over preservation: when rename()
overwrites an existing file, the kernel copies ptime from the
old file to the new file. This fixes the atomic-save xattr
destruction problem at its root, for every application on
every supported filesystem.
API
ptime is exposed through existing interfaces with minimal
additions:
- statx: STATX_PTIME (0x00040000U) returns ptime in stx_ptime
- utimensat: AT_UTIME_PTIME (0x20000) flag with times[2]
extension for setting ptime
- setattr_prepare: ATTR_PTIME (bit 19) / ATTR_PTIME_SET (bit 20)
The utimensat extension reuses Sandoval's 2019 pattern. For
upstream, an extensible-struct syscall (utimensat2, following
the clone3/openat2 convention) may be preferred -- I am open
to guidance on the API design.
Permissions follow the existing utimensat model: file owner
or CAP_FOWNER required.
TESTING
This has been running on EndeavourOS (kernel 6.19.11) for daily
use. Test coverage:
- 10 xfstests (7 generic VFS + 3 Btrfs-specific): basic
set/read, persistence, rename-over, permissions, utime-omit,
chmod/truncate survival, snapshots, nlink guards, compat_ro
- Runtime tests across all 5 filesystems: set/read, rename-over,
cp -a preservation, cross-FS copies (Btrfs, ext4, ntfs3,
FAT32, exFAT)
KNOWN LIMITATIONS
- XFS: deferred (separate inode structure analysis needed)
- Btrfs send/receive: not yet patched for ptime
- glibc utimensat() wrapper: cannot pass ptime; tools use raw
syscall()
- Btrfs compat_ro: writing ptime sets a compat_ro flag;
unpatched kernels refuse RW mount (correct Btrfs behavior)
The userspace ecosystem (patched cp, rsync, tar, KDE Dolphin)
and xfstests are available at:
https://github.com/DefendTheDisabled/linux-ptime
This implementation was developed using AI-assisted tooling for
code generation, iterative review, and test infrastructure. I am
responsible for review, testing, and sign-off.
Sean Smith (6):
vfs: add provenance_time (ptime) infrastructure
btrfs: add provenance time (ptime) support
ntfs3: map ptime to NTFS creation time with rename-over
ext4: add dedicated ptime field alongside i_crtime
fat: map ptime to FAT creation time with rename-over
exfat: map ptime to exFAT creation time with rename-over
fs/attr.c | 6 +++-
fs/btrfs/btrfs_inode.h | 4 +++
fs/btrfs/delayed-inode.c | 4 +++
fs/btrfs/fs.h | 3 +-
fs/btrfs/inode.c | 43 +++++++++++++++++++++++++
fs/btrfs/tree-log.c | 2 ++
fs/btrfs/volumes.c | 2 +-
fs/exfat/file.c | 9 ++++++
fs/exfat/namei.c | 21 +++++++++++--
fs/ext4/ext4.h | 3 ++
fs/ext4/inode.c | 14 +++++++++
fs/ext4/namei.c | 13 ++++++++
fs/fat/file.c | 6 ++++
fs/fat/namei_vfat.c | 20 ++++++++++--
fs/init.c | 2 +-
fs/ntfs3/file.c | 13 ++++++++
fs/ntfs3/frecord.c | 8 +++++
fs/ntfs3/namei.c | 14 +++++++++
fs/stat.c | 2 ++
fs/utimes.c | 56 +++++++++++++++++++++++++--------
include/linux/fs.h | 5 ++-
include/linux/stat.h | 1 +
include/uapi/linux/btrfs.h | 1 +
include/uapi/linux/btrfs_tree.h | 4 ++-
include/uapi/linux/fcntl.h | 3 ++
include/uapi/linux/stat.h | 4 ++-
init/initramfs.c | 2 +-
27 files changed, 239 insertions(+), 26 deletions(-)
--
2.53.0
^ permalink raw reply
* [PATCH v3] ext2: use get_random_u32() where appropriate
From: David Carlier @ 2026-04-05 15:47 UTC (permalink / raw)
To: Jan Kara; +Cc: linux-ext4, linux-kernel, David Carlier
Use the typed random integer helpers instead of
get_random_bytes() when filling a single integer variable.
The helpers return the value directly, require no pointer
or size argument, and better express intent.
Signed-off-by: David Carlier <devnexen@gmail.com>
---
fs/ext2/super.c | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/fs/ext2/super.c b/fs/ext2/super.c
index 603f2641fe10..e4136490c883 100644
--- a/fs/ext2/super.c
+++ b/fs/ext2/super.c
@@ -1151,7 +1151,7 @@ static int ext2_fill_super(struct super_block *sb, struct fs_context *fc)
goto failed_mount2;
}
sbi->s_gdb_count = db_count;
- get_random_bytes(&sbi->s_next_generation, sizeof(u32));
+ sbi->s_next_generation = get_random_u32();
spin_lock_init(&sbi->s_next_gen_lock);
/* per filesystem reservation list head & lock */
--
2.53.0
^ permalink raw reply related
* Re: [PATCH 0/2] ext2: fix WARN_ON in drop_nlink() triggered by corrupt images
From: Vasiliy Kovalev @ 2026-04-04 15:27 UTC (permalink / raw)
To: Jan Kara, Andrew Morton, Alexey Dobriyan, linux-ext4
Cc: linux-kernel, lvc-project
In-Reply-To: <20260401220837.2424925-1-kovalev@altlinux.org>
On 4/2/26 01:08, Vasiliy Kovalev wrote:
> A crafted ext2 image can contain a directory entry pointing to an inode
> whose on-disk i_links_count is zero. ext2 mounts such an image without
> error. Any subsequent syscall that decrements i_nlink on that inode
> triggers WARN_ON inside drop_nlink() in fs/inode.c.
>
> These patches prevent the warning by validating i_nlink before decrementing
> it in ext2_unlink() and ext2_rename(), reporting the corruption via
> ext2_error() instead.
>
> The issues were found by Linux Verification Center (linuxtesting.org)
> with Syzkaller.
>
> Vasiliy Kovalev (2):
> ext2: validate i_nlink before decrement in ext2_unlink()
> ext2: guard against zero i_nlink on new_inode in ext2_rename()
Syzkaller found a third trigger via ext2_rmdir(). Rather than adding
another guard in namei.c, I fixed the root cause in ext2_iget() instead
- a single check there covers all three cases at once.
New patch:
https://lore.kernel.org/all/20260404152011.2590197-1-kovalev@altlinux.org/
If the previous two patches have not been picked up yet, please
consider this one as a replacement for the entire series.
> fs/ext2/namei.c | 14 +++++++++++++-
> 1 file changed, 13 insertions(+), 1 deletion(-)
>
> --- [Reproducer for PATCH 1/2: ext2_unlink] ---
> [...]
>
> --- [Reproducer for PATCH 2/2: ext2_rename] ---
> [...]
--
Thanks,
Vasiliy
^ permalink raw reply
* [PATCH] ext2: reject inodes with zero i_nlink and valid mode in ext2_iget()
From: Vasiliy Kovalev @ 2026-04-04 15:20 UTC (permalink / raw)
To: Jan Kara, linux-ext4
Cc: Andrew Morton, Alexey Dobriyan, linux-kernel, lvc-project,
kovalev
ext2_iget() already rejects inodes with i_nlink == 0 when i_mode is
zero or i_dtime is set, treating them as deleted. However, the case of
i_nlink == 0 with a non-zero mode and zero dtime slips through. Since
ext2 has no orphan list, such a combination can only result from
filesystem corruption - a legitimate inode deletion always sets either
i_dtime or clears i_mode before freeing the inode.
A crafted image can exploit this gap to present such an inode to the
VFS, which then triggers WARN_ON inside drop_nlink() (fs/inode.c) via
ext2_unlink(), ext2_rename() and ext2_rmdir():
WARNING: CPU: 3 PID: 609 at fs/inode.c:336 drop_nlink+0xad/0xd0 fs/inode.c:336
CPU: 3 UID: 0 PID: 609 Comm: syz-executor Not tainted 6.12.77+ #1
Call Trace:
<TASK>
inode_dec_link_count include/linux/fs.h:2518 [inline]
ext2_unlink+0x26c/0x300 fs/ext2/namei.c:295
vfs_unlink+0x2fc/0x9b0 fs/namei.c:4477
do_unlinkat+0x53e/0x730 fs/namei.c:4541
__x64_sys_unlink+0xc6/0x110 fs/namei.c:4587
do_syscall_64+0xf5/0x220 arch/x86/entry/common.c:78
entry_SYSCALL_64_after_hwframe+0x77/0x7f
</TASK>
WARNING: CPU: 0 PID: 646 at fs/inode.c:336 drop_nlink+0xad/0xd0 fs/inode.c:336
CPU: 0 UID: 0 PID: 646 Comm: syz.0.17 Not tainted 6.12.77+ #1
Call Trace:
<TASK>
inode_dec_link_count include/linux/fs.h:2518 [inline]
ext2_rename+0x35e/0x850 fs/ext2/namei.c:374
vfs_rename+0xf2f/0x2060 fs/namei.c:5021
do_renameat2+0xbe2/0xd50 fs/namei.c:5178
__x64_sys_rename+0x7e/0xa0 fs/namei.c:5223
do_syscall_64+0xf5/0x220 arch/x86/entry/common.c:78
entry_SYSCALL_64_after_hwframe+0x77/0x7f
</TASK>
WARNING: CPU: 0 PID: 634 at fs/inode.c:336 drop_nlink+0xad/0xd0 fs/inode.c:336
CPU: 0 UID: 0 PID: 634 Comm: syz-executor Not tainted 6.12.77+ #1
Call Trace:
<TASK>
inode_dec_link_count include/linux/fs.h:2518 [inline]
ext2_rmdir+0xca/0x110 fs/ext2/namei.c:311
vfs_rmdir+0x204/0x690 fs/namei.c:4348
do_rmdir+0x372/0x3e0 fs/namei.c:4407
__x64_sys_unlinkat+0xf0/0x130 fs/namei.c:4577
do_syscall_64+0xf5/0x220 arch/x86/entry/common.c:78
entry_SYSCALL_64_after_hwframe+0x77/0x7f
</TASK>
Extend the existing i_nlink == 0 check to also catch this case,
reporting the corruption via ext2_error() and returning -EFSCORRUPTED.
This rejects the inode at load time and prevents it from reaching any
of the namei.c paths.
Found by Linux Verification Center (linuxtesting.org) with Syzkaller.
Fixes: 1da177e4c3f4 ("Linux-2.6.12-rc2")
Cc: stable@vger.kernel.org
Signed-off-by: Vasiliy Kovalev <kovalev@altlinux.org>
---
fs/ext2/inode.c | 14 +++++++++++---
1 file changed, 11 insertions(+), 3 deletions(-)
diff --git a/fs/ext2/inode.c b/fs/ext2/inode.c
index dbfe9098a124..39d972722f5f 100644
--- a/fs/ext2/inode.c
+++ b/fs/ext2/inode.c
@@ -1430,9 +1430,17 @@ struct inode *ext2_iget (struct super_block *sb, unsigned long ino)
* the test is that same one that e2fsck uses
* NeilBrown 1999oct15
*/
- if (inode->i_nlink == 0 && (inode->i_mode == 0 || ei->i_dtime)) {
- /* this inode is deleted */
- ret = -ESTALE;
+ if (inode->i_nlink == 0) {
+ if (inode->i_mode == 0 || ei->i_dtime) {
+ /* this inode is deleted */
+ ret = -ESTALE;
+ } else {
+ ext2_error(sb, __func__,
+ "inode %lu has zero i_nlink with mode 0%o and no dtime, "
+ "filesystem may be corrupt",
+ ino, inode->i_mode);
+ ret = -EFSCORRUPTED;
+ }
goto bad_inode;
}
inode->i_blocks = le32_to_cpu(raw_inode->i_blocks);
--
2.50.1
^ permalink raw reply related
* Re: [PATCH v2 3/3] ext4: derive f_fsid from block device to avoid collisions
From: Anand Jain @ 2026-04-04 8:59 UTC (permalink / raw)
To: Theodore Tso, Christoph Hellwig, Darrick J. Wong
Cc: linux-ext4, linux-btrfs, linux-xfs, Anand Jain
In-Reply-To: <d4a9970b-e7ed-4e74-be9d-2d08400f9d79@gmail.com>
Hi Ted, Christoph, Darrick,
As I prepare v3, I'd appreciate your final thoughts on the mount option
naming and its necessity for ext4.
For the new option, I am considering:
-o nodup_f_fsid
-o unique_f_fsid
Context:
Currently, ext4's f_fsid is consistent across reboots but fails to be
unique when dealing with cloned filesystems (sharing the same UUID). Per
statfs(2) [1], the primary requirement is that the (f_fsid, ino) pair
uniquely identifies a file. The man page makes no explicit guarantee
regarding consistency across mount cycles or reboots.
Proposal:
With this fix, f_fsid becomes f(uuid, dev_t). This ensures OS-wide
uniqueness and maintains consistency as long as the underlying dev_t
remains stable.
Dilemma:
While statfs(2) [1] suggests f_fsid is "some random stuff," we know
userspace (NFS, systemd) often treats it as a persistent handle.
Do you prefer one of the names above, or is there a more idiomatic ext4
naming convention I should follow?
Given the ambiguity in the man page, is gating this behind an -o option
necessary, or should we consider making uniqueness the default behavior?
[1]
----------
statfs(2)
<snap>
Nobody knows what f_fsid is supposed to contain (but see below).
<snap>
The f_fsid field
Solaris, Irix, and POSIX have a system call statvfs(2)
that returns a struct statvfs (defined in <sys/statvfs.h>) containing
an unsigned long f_fsid. Linux, SunOS, HP-UX, 4.4BSD have a system
call statfs() that returns a struct statfs (defined in <sys/vfs.h>)
containing a fsid_t f_fsid, where fsid_t is defined as struct { int
val[2]; }. The same holds for FreeBSD, except that it uses the
include file <sys/mount.h>.
The general idea is that f_fsid contains some random stuff such
that the pair (f_fsid,ino) uniquely determines a file. Some operating
systems use (a variation on) the device number, or the device number
combined with the filesystem type. Several operating systems restrict
giving out the f_fsid field to the superuser only (and zero it for
unprivileged users), because this field is used in the filehandle of
the filesystem when NFS-exported, and giving it out is a security concern.
Under some operating systems, the fsid can be used as the second
argument to the sysfs(2) system call.
----------
Thanks, Anand
^ permalink raw reply
* Re: [SECURITY] e2fsprogs v1.47.4 Vulnerabilities — Orphan File & Extent Handling
From: Theodore Tso @ 2026-04-03 23:11 UTC (permalink / raw)
To: Andreas Dilger; +Cc: 4fqr, linux-ext4@vger.kernel.org
In-Reply-To: <0778B5AC-16ED-4736-AE64-849541629466@dilger.ca>
On Fri, Apr 03, 2026 at 10:17:38AM -0600, Andreas Dilger wrote:
>
> I don't see how this exposes any kind of security vulnerability if it
> requires that the image be specially modified in the first place?
> At that point the "attacker" can directly modify the image in any way
> they want, regardless of how e2fsprogs behaves.
After taking a closer look the "vulnerabilities", I understand where
Andreas is coming from. If the threat model is "the attacker can
modify the file system, then the fact that you can craft the orphan to
"force" the the file system to release an inode isn't interesting ---
because the attacker could just simply overwrite the inode and mark
the blocks as not in use in the block allocation directly.
If there was a way an attempt to pass an inode number which is very
large to release_orphan_inode() could result in a buffer overrun, then
that might be interesting. But the oh, nooes! You might be able to
force the file system to release the resize inode is not interesting;
the attacker could just stomp on the resize inode directly.
Should we add some better bounds checking? Sure, so that we can give
a more user-friendly error message, and reduce the chance of
accidentally making things worse if the file system is corrupted by
chance and metadata checksum is not enabled. But is it a "security
vulnerability"? No.
No, if you could actually force a malicious payload to run due to a
stack overrun attack, that would be interesting. And I was expecting
to find *something* like that in your analysis, only to be
disappointed.
Cheers,
- Ted
^ permalink raw reply
* Re: [PATCH] e2fsck: large dir rehash fix
From: Theodore Ts'o @ 2026-04-03 21:10 UTC (permalink / raw)
To: linux-ext4, Alexander Zarochentsev
Cc: Theodore Ts'o, Andreas Dilger, Artem Blagodarenko,
Alexander Zarochentsev
In-Reply-To: <20260226201334.3260754-1-alexander.zarochentsev@hpe.com>
On Thu, 26 Feb 2026 20:13:34 +0000, Alexander Zarochentsev wrote:
> Use EXT2_I_SIZE() macro instead of direct access to i_size in
> fill_dir_block() and other functions. W/o the fix, e2fsck -D fails to
> optimise 4GB+ directories reporting "EXT2 directory corrupted".
>
> Fixing a defect of overwriting the node limit and count by the first dx
> entry in calculate_tree().
>
> [...]
Applied, thanks!
[1/1] e2fsck: large dir rehash fix
commit: 0e4de3c7cb8cd1a4a26b0cafa647dd380da28864
Best regards,
--
Theodore Ts'o <tytso@mit.edu>
^ permalink raw reply
* Re: [PATCH] libsupport: change get_thread_id return type to unsigned long long
From: Theodore Ts'o @ 2026-04-03 21:10 UTC (permalink / raw)
To: Darrick J. Wong; +Cc: Theodore Ts'o, Ext4 Developers List
In-Reply-To: <20260403151927.GB6192@frogsfrogsfrogs>
On Fri, 03 Apr 2026 08:19:27 -0700, Darrick J. Wong wrote:
> With -Wformat enabled, the e2fsprogs build spews out a lot of warnings
> for fuse2fs:
>
> ../../misc/fuse2fs.c: In function ‘__fuse2fs_finish’:
> ../../misc/fuse2fs.c:152:24: warning: format ‘%llu’ expects argument of type ‘long long unsigned int’, but argument 3 has type ‘uint64_t’ {aka ‘long unsigned int’} [-Wformat=]
> 152 | printf("FUSE2FS (%s): tid=%llu " format, (fuse2fs)->shortdev, get_thread_id(), ##__VA_ARGS__); \
> | ^~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~
> | |
> | uint64_t {aka long unsigned int}
> ../../misc/fuse2fs.c:556:17: note: in expansion of macro ‘dbg_printf’
> 556 | dbg_printf(ff, "%s: libfuse ret=%d\n", func, ret);
> | ^~~~~~~~~~
>
> [...]
Applied, thanks!
[1/1] libsupport: change get_thread_id return type to unsigned long long
commit: 582a5ff290423e6fc11e91ba0534e422c026f018
Best regards,
--
Theodore Ts'o <tytso@mit.edu>
^ permalink raw reply
* Re: [PATCH 1/3] libsupport: fix portability issues with the bthread.c
From: Theodore Tso @ 2026-04-03 21:00 UTC (permalink / raw)
To: Darrick J. Wong; +Cc: Ext4 Developers List
In-Reply-To: <20260403151535.GI6254@frogsfrogsfrogs>
On Fri, Apr 03, 2026 at 08:15:35AM -0700, Darrick J. Wong wrote:
> > It's "autoheader". Whenver you add or remove tests to configure.ac,
> > you need to run "autoconf ; autoheader". If you don't run
> > "autoheader", then even if the autoconf's feature test enables some
> > new feature test, say HAVE_PR_SET_IO_FLUSHER, the feature won't
> > actually be enabled in the #ifdef.
>
> Aha! Thanks for that tip; I'll go fix my dev branch.
Just to give more context, there are two ways that configure scripts
can pass the feature definitions to programs. One is via the command
line of the compiler. The problem with that is if you have dozens of
options like -DHAVE_PR_SET_IO_FLUSHER for every single compile, it
bloats the MAKELOG files and makes it hard to read, and eventually you
can run into the command-line length limits.
The other way is via a config.h file which is then #included in each
source file. But for *that* to work, the config.h.in file needs to
have a template for echo variable that can be #defined. And that's
what autoheader takes care of for you. Simpler projects that don't
use a config.h file and pass everything on the compiler's command line
don't need to use autoheader.
For a demonstration, try editing lib/config.h.in to remove the lines:
-/* Define to 1 if PR_SET_IO_FLUSHER is present */
-#undef HAVE_PR_SET_IO_FLUSHER
Then "rm lib/config.h" and run "./config.status", and examine
lib/config.h. You will see that HAVE_PR_SET_IO_FLUSHER is no longer
defined in the config.h file. Then run "autoheader", and then re-run
"./config.status", and you will see that HAVE_PR_SET_IO_FLUSHER is
properly defined.
Cheers,
- Ted
^ permalink raw reply
* Re: [SECURITY] e2fsprogs v1.47.4 Vulnerabilities — Orphan File & Extent Handling
From: Theodore Tso @ 2026-04-03 18:34 UTC (permalink / raw)
To: Andreas Dilger; +Cc: 4fqr, linux-ext4@vger.kernel.org
In-Reply-To: <0778B5AC-16ED-4736-AE64-849541629466@dilger.ca>
On Fri, Apr 03, 2026 at 10:17:38AM -0600, Andreas Dilger wrote:
>
> I don't see how this exposes any kind of security vulnerability if it
> requires that the image be specially modified in the first place?
> At that point the "attacker" can directly modify the image in any way
> they want, regardless of how e2fsprogs behaves.
We can debate whther or not it is a security vulnerability, and if so,
whether it deserves a CVSS corresponding to a low, medium, or high
severity since that's what people who are worrying about FEDRAMP
compliance need to worry about in terms of time to mitigation.
The reason why it's of interest is because we tell people that if they
have a potentially untrustwothy file system image (say, they if they
pick up a USB stick that was thoughtfully dropped off in the parking
lot by the Chinese MSS or Russian KGB agent), they should run e2fsck
on said file system image before they try mounting it. Asking them to
run e2fsck on the image is only a little bit more likely to work than
telling them to just not pick up the USB stick, or DESTROY IT BY FIRE,
and so the reality is that 99% of all uesrs will just mount the file
system without running fsck --- at which point, when their system is
compromised, we call it a "problem exists between chair and keyboard"
situation.
I don't really care whether we call it a security bug, or a quality of
implementation bug, but that's because I'm not the person responsible
for FEDRAMP compliance at $WORK, and I'm not someone who hands out bug
bounties, or who is trying to keep score of how many security
vulnerabilities that I found in order to demonstrate real world impact
as part of an academic tenure-track evaluation. :-)
As far as I'm concerned, if e2fsck doesn't (a) detect a file system
corruption, or (b) doesn't fix it after a single e2fsck -y run, or
(c) crashes or results in running a malicious payload as a result of a
specially crafted payload, it's a bug, and bugs should get fixed.
Cheers,
- Ted
^ permalink raw reply
* Re: [PATCH 2/3] ext4: show inode orphan list detail information
From: Andreas Dilger @ 2026-04-03 16:22 UTC (permalink / raw)
To: Ye Bin; +Cc: tytso, linux-ext4, jack
In-Reply-To: <20260403082507.1882703-3-yebin@huaweicloud.com>
On Apr 3, 2026, at 02:25, Ye Bin <yebin@huaweicloud.com> wrote:
>
> From: Ye Bin <yebin10@huawei.com>
>
> Some inodes added to the orphan list are due to truncation, while others
> are due to deletion.Therefore, we printed the information of inode as
> follows: inode number/i_nlink/i_size/i_blocks/projid/file path. By using
> this information, it is possible to quickly identify files that have been
> deleted but are still being referenced.
Since the format of this output is still flexible, I would request that
it be formatted in a way that is more easily understood and parsed instead
of just an unformatted list of numbers.
Using YAML combines both human and machine readable properties, without
a lot of overhead, something like:
- ino: INUM, link: NLINK, size: SIZE, blocks: BLOCKS, proj: PROJID, path: "PATH"
- ino: ...
- ino: ...
The PATH should derfinitely be quoted to avoid issues with spaces and
other special characters in the filename.
Cheers, Andreas
>
> Signed-off-by: Ye Bin <yebin10@huawei.com>
> ---
> fs/ext4/orphan.c | 88 ++++++++++++++++++++++++++++++++++++++++++++++--
> 1 file changed, 86 insertions(+), 2 deletions(-)
>
> diff --git a/fs/ext4/orphan.c b/fs/ext4/orphan.c
> index 1d231aeaf282..272de32d1a47 100644
> --- a/fs/ext4/orphan.c
> +++ b/fs/ext4/orphan.c
> @@ -662,25 +662,108 @@ int ext4_orphan_file_empty(struct super_block *sb)
>
> struct ext4_proc_orphan {
> struct ext4_inode_info cursor;
> + bool print_title;
> };
>
> -static void *ext4_orphan_seq_start(struct seq_file *seq, loff_t *pos)
> +static inline bool ext4_is_cursor(struct ext4_inode_info *inode)
> +{
> + return (inode->vfs_inode.i_ino == 0);
> +}
> +
> +static struct inode *ext4_list_next(struct list_head *head, struct list_head *p)
> {
> + struct ext4_inode_info *inode;
> +
> + list_for_each_continue(p, head) {
> + inode = list_entry(p, typeof(*inode), i_orphan);
> + if (!ext4_is_cursor(inode))
> + return &inode->vfs_inode;
> + }
> +
> return NULL;
> }
>
> +static void *ext4_orphan_seq_start(struct seq_file *seq, loff_t *pos)
> +{
> + struct ext4_proc_orphan *s = seq->private;
> + struct super_block *sb = pde_data(file_inode(seq->file));
> + struct ext4_sb_info *sbi = EXT4_SB(sb);
> + struct list_head *prev;
> +
> + mutex_lock(&sbi->s_orphan_lock);
> +
> + if (!*pos) {
> + prev = &sbi->s_orphan;
> + } else {
> + prev = &s->cursor.i_orphan;
> + if (list_empty(prev))
> + return NULL;
> + }
> +
> + return ext4_list_next(&sbi->s_orphan, prev);
> +}
> +
> static void *ext4_orphan_seq_next(struct seq_file *seq, void *v, loff_t *pos)
> {
> - return NULL;
> + struct super_block *sb = pde_data(file_inode(seq->file));
> + struct ext4_sb_info *sbi = EXT4_SB(sb);
> + struct inode *inode = v;
> +
> + ++*pos;
> +
> + return ext4_list_next(&sbi->s_orphan, &EXT4_I(inode)->i_orphan);
> +}
> +
> +static void ext4_show_filename(struct seq_file *seq, struct inode *inode)
> +{
> + struct dentry *dentry;
> +
> + dentry = d_find_alias(inode);
> + if (!dentry)
> + dentry = d_find_any_alias(inode);
> +
> + if (dentry)
> + seq_dentry(seq, dentry, "\t\n\\");
> + else
> + seq_puts(seq, "unknown");
> +
> + dput(dentry);
> + seq_putc(seq, '\n');
> }
>
> static int ext4_orphan_seq_show(struct seq_file *seq, void *v)
> {
> + struct inode *inode = v;
> + struct ext4_proc_orphan *s = seq->private;
> +
> + if (s->print_title) {
> + seq_puts(seq, "INO\tNLINK\tSIZE\tBLOCKS\tPROJID\tPATH\n");
> + s->print_title = false;
> + }
> +
> + seq_printf(seq, "%llu\t%u\t%llu\t%llu\t%u\t",
> + inode->i_ino, inode->i_nlink,
> + i_size_read(inode), inode->i_blocks,
> + __kprojid_val(EXT4_I(inode)->i_projid));
> +
> + ext4_show_filename(seq, inode);
> +
> return 0;
> }
>
> static void ext4_orphan_seq_stop(struct seq_file *seq, void *v)
> {
> + struct super_block *sb = pde_data(file_inode(seq->file));
> + struct ext4_sb_info *sbi = EXT4_SB(sb);
> + struct inode *inode = v;
> + struct ext4_proc_orphan *s = seq->private;
> +
> + if (inode)
> + list_move_tail(&s->cursor.i_orphan, &EXT4_I(inode)->i_orphan);
> + else
> + list_del_init(&s->cursor.i_orphan);
> +
> + mutex_unlock(&sbi->s_orphan_lock);
> }
>
> const struct seq_operations ext4_orphan_seq_ops = {
> @@ -703,6 +786,7 @@ static int ext4_seq_orphan_open(struct inode *inode, struct file *file)
> private = m->private;
> INIT_LIST_HEAD(&private->cursor.i_orphan);
> private->cursor.vfs_inode.i_ino = 0;
> + private->print_title = true;
> }
>
> return rc;
> --
> 2.34.1
>
Cheers, Andreas
^ permalink raw reply
* Re: [SECURITY] e2fsprogs v1.47.4 Vulnerabilities — Orphan File & Extent Handling
From: Andreas Dilger @ 2026-04-03 16:17 UTC (permalink / raw)
To: 4fqr; +Cc: linux-ext4@vger.kernel.org
In-Reply-To: <DJq7WqGAG9HBnpd-DVD5sVNGDUoSoP2sJ5RAXlucoYhtbqxJXFNUUfhFETwaDeDr6Q8a5xp0hVJb0yIt8EucI-F-PQ28zg8i0Y9HmF8qk5Q=@proton.me>
On Apr 3, 2026, at 05:29, 4fqr <4fqr@proton.me> wrote:
>
> linux-ext4@vger.kernel.org,
>
> I'm disclosing three security vulnerabilities in e2fsprogs v1.47.4 affecting orphan file inode processing and extent tree validation. This follows responsible disclosure notification to the maintainer (Theodore Ts'o).
>
> **Vulnerability Overview:**
>
> F1 (CRITICAL): process_orphan_block() lacks inode range validation before calling release_orphan_inode(), allowing arbitrary inode destruction via crafted orphan file blocks.
>
> F2 (HIGH): pass1 dispatch chain aliases s_orphan_file_inum with reserved inodes (5–10), bypassing reserved inode guards and enabling mode corruption on critical system inodes like the resize inode.
>
> F3 (MEDIUM): ext2fs_extent_fix_parents() contains an unsigned underflow where blk64_t subtraction truncates to __u32, corrupting parent extent length metadata.
I don't see how this exposes any kind of security vulnerability if it
requires that the image be specially modified in the first place?
At that point the "attacker" can directly modify the image in any way
they want, regardless of how e2fsprogs behaves.
It's like saying "the curatins on the inside of my windows do not
protect my privacy if I'm inside the house and I open them up...
Cheers, Andreas
>
> **Technical Details:**
>
> All three are exploitable via crafted .img files processed by e2fsck -y with no special privileges. Detailed technical report with code locations, attack scenarios, and exact fixes is attached.
>
> **Timeline:**
> - Primary maintainer contact: Theodore Ts'o (tytso@mit.edu)
> - 90-day embargo from maintainer acknowledgment
> - Kernel security team notified concurrently
>
> Patches and coordination discussion will follow once the maintainer has reviewed.
>
> Thanks,
> 4fqr
> 4fqr@proton.me
>
> Attachment: e2fsprogs_audit_4fqr.txt
> <e2fsprogs_audit_4fqr.txt>
Cheers, Andreas
^ permalink raw reply
* [PATCH] libsupport: change get_thread_id return type to unsigned long long
From: Darrick J. Wong @ 2026-04-03 15:19 UTC (permalink / raw)
To: Theodore Ts'o; +Cc: Ext4 Developers List
From: Darrick J. Wong <djwong@kernel.org>
With -Wformat enabled, the e2fsprogs build spews out a lot of warnings
for fuse2fs:
../../misc/fuse2fs.c: In function ‘__fuse2fs_finish’:
../../misc/fuse2fs.c:152:24: warning: format ‘%llu’ expects argument of type ‘long long unsigned int’, but argument 3 has type ‘uint64_t’ {aka ‘long unsigned int’} [-Wformat=]
152 | printf("FUSE2FS (%s): tid=%llu " format, (fuse2fs)->shortdev, get_thread_id(), ##__VA_ARGS__); \
| ^~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~
| |
| uint64_t {aka long unsigned int}
../../misc/fuse2fs.c:556:17: note: in expansion of macro ‘dbg_printf’
556 | dbg_printf(ff, "%s: libfuse ret=%d\n", func, ret);
| ^~~~~~~~~~
Because the linter doesn't like mixing %llu with a uint64_t type.
On some platforms, uint64_t is merely "unsigned long" and whiiiiine.
Since this is a new wrapper function, let's change the return type.
Signed-off-by: "Darrick J. Wong" <djwong@kernel.org>
---
lib/support/thread.h | 2 +-
lib/support/thread.c | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/lib/support/thread.h b/lib/support/thread.h
index 9a7f5c9db1b7a2..efba1c3b1eec0b 100644
--- a/lib/support/thread.h
+++ b/lib/support/thread.h
@@ -2,4 +2,4 @@
* thread.h -- header file for thread utilities
*/
-uint64_t get_thread_id(void);
+unsigned long long get_thread_id(void);
diff --git a/lib/support/thread.c b/lib/support/thread.c
index a9a10940c0fd3e..651be1cf833860 100644
--- a/lib/support/thread.c
+++ b/lib/support/thread.c
@@ -12,7 +12,7 @@
#include "support/thread.h"
-uint64_t get_thread_id(void)
+unsigned long long get_thread_id(void)
{
#if defined(HAVE_GETTID)
return gettid();
@@ -22,7 +22,7 @@ uint64_t get_thread_id(void)
if (pthread_threadid_np(NULL, &tid))
return tid;
#elif defined(HAVE_PTHREAD)
- return (__u64)(uintptr_t) pthread_self();
+ return (unsigned long long)(uintptr_t) pthread_self();
#endif
return getpid();
}
^ permalink raw reply related
* Re: [PATCH 1/3] libsupport: fix portability issues with the bthread.c
From: Darrick J. Wong @ 2026-04-03 15:15 UTC (permalink / raw)
To: Theodore Tso; +Cc: Ext4 Developers List
In-Reply-To: <20260403115313.GA12260@macsyma-wired.lan>
On Fri, Apr 03, 2026 at 07:53:13AM -0400, Theodore Tso wrote:
> On Thu, Apr 02, 2026 at 09:16:24PM -0700, Darrick J. Wong wrote:
> > > -/* Define to 1 if fuse supports cache_readdir */
> > > -#undef HAVE_FUSE_CACHE_READDIR
> >
> > Huh, there's a lot of churn in this file. Do you have a magic script
> > somewhere that regenerates config.h.in?
>
> It's "autoheader". Whenver you add or remove tests to configure.ac,
> you need to run "autoconf ; autoheader". If you don't run
> "autoheader", then even if the autoconf's feature test enables some
> new feature test, say HAVE_PR_SET_IO_FLUSHER, the feature won't
> actually be enabled in the #ifdef.
Aha! Thanks for that tip; I'll go fix my dev branch.
--D
> Cheers,
>
> - Ted
^ permalink raw reply
* Re: [SECURITY] e2fsprogs v1.47.4 Vulnerabilities — Orphan File & Extent Handling
From: Theodore Tso @ 2026-04-03 13:48 UTC (permalink / raw)
To: 4fqr; +Cc: linux-ext4@vger.kernel.org
In-Reply-To: <DJq7WqGAG9HBnpd-DVD5sVNGDUoSoP2sJ5RAXlucoYhtbqxJXFNUUfhFETwaDeDr6Q8a5xp0hVJb0yIt8EucI-F-PQ28zg8i0Y9HmF8qk5Q=@proton.me>
On Fri, Apr 03, 2026 at 11:29:55AM +0000, 4fqr wrote:
>
> I'm disclosing three security vulnerabilities in e2fsprogs v1.47.4
> affecting orphan file inode processing and extent tree
> validation. This follows responsible disclosure notification to the
> maintainer (Theodore Ts'o).
You notified me 45 seconds before sending this e-mail to linux-ext4,
which is a public mailing list. (For future reference, essentially
all lists @vger.kernel.org are public, with the contents available at
https://lore.kernel.org.)
> Patches and coordination discussion will follow once the maintainer
> has reviewed.
Coordination discussion is moot at this point, because you've already
made your findings public. I'll review them in detail in the next few
days, but it does appear that we are missing some checks in the
orphan_file handling, which whether or not they are exploitable by a
malicious attacker, are real bugs that should be fixed.
Cheers,
- Ted
^ permalink raw reply
* Re: [PATCH 0/3] show orphan file inode detail info
From: Theodore Tso @ 2026-04-03 13:03 UTC (permalink / raw)
To: Ye Bin; +Cc: adilger.kernel, linux-ext4, jack
In-Reply-To: <20260403082507.1882703-1-yebin@huaweicloud.com>
On Fri, Apr 03, 2026 at 04:25:04PM +0800, Ye Bin wrote:
> From: Ye Bin <yebin10@huawei.com>
>
> In actual production environments, the issue of inconsistency between
> df and du is frequently encountered. In many cases, the cause of the
> problem can be identified through the use of lsof. However, when
> overlayfs is combined with project quota configuration, the issue becomes
> more complex and troublesome to diagnose. First, to determine the project
> ID, one needs to obtain orphaned nodes using `fsck.ext4 -fn /dev/xx`, and
> then retrieve file information through `debugfs`. However, the file names
> cannot always be obtained, and it is often unclear which files they are.
> To identify which files these are, one would need to use crash for online
> debugging or use kprobe to gather information incrementally. However, some
> customers in production environments do not agree to upload any tools, and
> online debugging might impact the business. There are also scenarios where
> files are opened in kernel mode, which do not generate file descriptors(fds),
> making it impossible to identify which files were deleted but still have
> references through lsof. This patchset adds a procfs interface to query
> information about orphaned nodes, which can assist in the analysis and
> localization of such issues.
There are some concens which were noted by Sashiko review, including
races with unmountings, a potential deadlock, and some issues relating
to long pathnames, and a TOCTOU race that might lead to a file system
erroneously being declared corrupted. PTAL:
https://sashiko.dev/#/patchset/20260403082507.1882703-1-yebin%40huaweicloud.com
Thanks!
- Ted
^ permalink raw reply
* Re: [PATCH 1/3] ext4: register 'orphan_list' procfs
From: Theodore Tso @ 2026-04-03 12:55 UTC (permalink / raw)
To: Ye Bin; +Cc: adilger.kernel, linux-ext4, jack
In-Reply-To: <20260403082507.1882703-2-yebin@huaweicloud.com>
On Fri, Apr 03, 2026 at 04:25:05PM +0800, Ye Bin wrote:
> + proc_create_data("orphan_list", 0444, sbi->s_proc,
> + &ext4_orphan_proc_ops, sb);
This should really be mode 0400, especially once the file path is made
available, since otherwise the kernel might end up leaking private
user's information. Even in a data center use case, in a multi-user
container use case (Docker, Kubernetes, etc.) leaking information
about one user's file names could be a real problem.
- Ted
^ permalink raw reply
* Re: [PATCH 1/3] libsupport: fix portability issues with the bthread.c
From: Theodore Tso @ 2026-04-03 11:53 UTC (permalink / raw)
To: Darrick J. Wong; +Cc: Ext4 Developers List
In-Reply-To: <20260403041624.GD6254@frogsfrogsfrogs>
On Thu, Apr 02, 2026 at 09:16:24PM -0700, Darrick J. Wong wrote:
> > -/* Define to 1 if fuse supports cache_readdir */
> > -#undef HAVE_FUSE_CACHE_READDIR
>
> Huh, there's a lot of churn in this file. Do you have a magic script
> somewhere that regenerates config.h.in?
It's "autoheader". Whenver you add or remove tests to configure.ac,
you need to run "autoconf ; autoheader". If you don't run
"autoheader", then even if the autoconf's feature test enables some
new feature test, say HAVE_PR_SET_IO_FLUSHER, the feature won't
actually be enabled in the #ifdef.
Cheers,
- Ted
^ permalink raw reply
* [SECURITY] e2fsprogs v1.47.4 Vulnerabilities — Orphan File & Extent Handling
From: 4fqr @ 2026-04-03 11:29 UTC (permalink / raw)
To: linux-ext4@vger.kernel.org
[-- Attachment #1.1: Type: text/plain, Size: 1349 bytes --]
linux-ext4@vger.kernel.org,
I'm disclosing three security vulnerabilities in e2fsprogs v1.47.4 affecting orphan file inode processing and extent tree validation. This follows responsible disclosure notification to the maintainer (Theodore Ts'o).
**Vulnerability Overview:**
F1 (CRITICAL): process_orphan_block() lacks inode range validation before calling release_orphan_inode(), allowing arbitrary inode destruction via crafted orphan file blocks.
F2 (HIGH): pass1 dispatch chain aliases s_orphan_file_inum with reserved inodes (5–10), bypassing reserved inode guards and enabling mode corruption on critical system inodes like the resize inode.
F3 (MEDIUM): ext2fs_extent_fix_parents() contains an unsigned underflow where blk64_t subtraction truncates to __u32, corrupting parent extent length metadata.
**Technical Details:**
All three are exploitable via crafted .img files processed by e2fsck -y with no special privileges. Detailed technical report with code locations, attack scenarios, and exact fixes is attached.
**Timeline:**
- Primary maintainer contact: Theodore Ts'o (tytso@mit.edu)
- 90-day embargo from maintainer acknowledgment
- Kernel security team notified concurrently
Patches and coordination discussion will follow once the maintainer has reviewed.
Thanks,
4fqr
4fqr@proton.me
Attachment: e2fsprogs_audit_4fqr.txt
[-- Attachment #1.2: Type: text/html, Size: 2449 bytes --]
[-- Attachment #2: e2fsprogs_audit_4fqr.txt --]
[-- Type: text/plain, Size: 30030 bytes --]
================================================================================
e2fsprogs â SECURITY AUDIT REPORT
Target: v1.47.4 | Date: 2026-04-03
================================================================================
Auditor : 4fqr
Scope : Full source tree â emphasis on e2fsck, libext2fs, orphan handling,
extent tree code, and any path reachable by a malicious disk image.
Method : Static analysis + manual code review. No fuzzing performed.
Threat : Attacker supplies a crafted ext4 filesystem image.
Victim runs e2fsck -y on it (USB attach, cloud disk, VM image).
================================================================================
EXECUTIVE SUMMARY
================================================================================
Three confirmed vulnerabilities were found. Two are directly triggerable by
a crafted filesystem image processed by e2fsck. One creates a destructive
chain attack when combined with the other.
The root cause in each case is the same class of mistake: on-disk values are
trusted as valid indices or inode numbers without the same range-checks that
are applied in older, parallel code paths.
None of these require kernel exploitation, memory corruption primitives, or
special privileges. A crafted .img file + "e2fsck -y image.img" is enough.
Severity summary:
ââââââ¬ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ¬âââââââââââ
â # â Title â Severity â
ââââââ¼ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ¼âââââââââââ¤
â F1 â Orphan file blocks: no inode range check before â CRITICAL â
â â release_orphan_inode() â â
ââââââ¼ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ¼âââââââââââ¤
â F2 â s_orphan_file_inum aliases reserved inodes in pass1 â HIGH â
â â dispatch; triggers destructive chain via resize inode â â
ââââââ¼ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ¼âââââââââââ¤
â F3 â ext2fs_extent_fix_parents(): __u32 += blk64_t â MEDIUM â
â â unsigned underflow corrupts parent extent length â â
ââââââ´ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ´âââââââââââ
================================================================================
BACKGROUND â HOW ORPHAN PROCESSING WORKS
================================================================================
When ext4 needs to track inodes that must be cleaned up on next mount/fsck
(e.g. unlinked files still open), it uses one of two mechanisms:
LEGACY â a singly-linked list threaded through s_last_orphan â
inode.i_dtime â inode.i_dtime â ... â 0
NEW â an "orphan file": a dedicated hidden inode (s_orphan_file_inum)
whose data blocks each contain an array of u32 inode numbers.
Indicated by feature flags orphan_file + orphan_present.
e2fsck processes both at startup in release_orphan_inodes() (super.c:509),
before Pass 1 runs â meaning before any inode bitmap, block bitmap, or inode
table has been validated against the actual filesystem state.
The function that does the actual work for both paths is:
release_orphan_inode(ctx, &ino, block_buf) [super.c:317]
It reads the inode, frees its blocks, and â if i_links_count == 0 â marks
the inode itself as free in the inode allocation bitmap.
================================================================================
FINDING F1 â CRITICAL
Missing inode range validation in process_orphan_block()
================================================================================
File : e2fsck/super.c
Lines : 420 â 427 (vulnerable path)
548 â 552 (the guard that exists for the legacy path, but NOT here)
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â VULNERABLE CODE â
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
super.c:420
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
bdata = (__u32 *)pd->buf;
for (j = 0; j < inodes_per_ob; j++) {
if (!bdata[j])
continue;
ino = ext2fs_le32_to_cpu(bdata[j]); /* raw disk value */
if (release_orphan_inode(ctx, &ino, pd->block_buf)) /* NO CHECK */
goto return_abort;
bdata[j] = 0;
}
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â THE GUARD THAT PROTECTS THE LEGACY PATH (but is absent here) â
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
super.c:548
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
/* Traditional orphan list: head inode is validated */
if (ino && ((ino < EXT2_FIRST_INODE(fs->super)) ||
(ino > fs->super->s_inodes_count))) {
fix_problem(ctx, PR_0_ORPHAN_ILLEGAL_HEAD_INODE, &pctx);
goto err_qctx;
}
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â WHAT release_orphan_inode() DOES WITHOUT A VALID INODE â
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
super.c:317
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
static int release_orphan_inode(e2fsck_t ctx, ext2_ino_t *ino, ...)
{
e2fsck_read_inode_full(ctx, *ino, ...); /* reads inode from disk */
next_ino = inode.i_dtime;
if (next_ino &&
((next_ino < EXT2_FIRST_INODE(fs->super)) || ...)) /* NEXT is
{ return 1; } checked,
not *ino */
if (release_inode_blocks(ctx, *ino, &inode, ...)) /* frees */
return 1; /* blocks */
if (!inode.i_links_count) {
ext2fs_inode_alloc_stats2(fs, *ino, -1, ...); /* marks inode */
ctx->free_inodes++; /* as FREE in */
ext2fs_set_dtime(fs, ...); /* the bitmap */
}
e2fsck_write_inode_full(ctx, *ino, ...); /* writes back */
}
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
Note carefully: inside release_orphan_inode, the range check at lines
334â339 validates NEXT (i_dtime chain), not *ino itself. The current
inode is never validated.
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â ATTACK SCENARIO â
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
Craft an ext4 image with:
1. Feature flags: orphan_file + orphan_present set in the superblock.
Do NOT set metadata_csum â the block checksum is then skipped.
(If metadata_csum is desired, set s_checksum_seed to any known value
and compute the matching CRC32c â the attacker controls the seed.)
2. A valid inode for s_orphan_file_inum (any regular non-reserved inode).
Its data block(s) are attacker-controlled.
3. The data block(s) filled with target inode numbers as little-endian
u32 values. Useful targets with i_links_count == 0:
- inode 7 (EXT2_RESIZE_INO) â journal/block bitmaps freed
- inode 9 (exclude bitmap inode)
- inode 10 (journal backup inode)
Useful targets with i_links_count > 0 (blocks released, inode kept):
- Any inode whose blocks you want freed (data destruction)
4. Do NOT set EXT2_ERROR_FS in s_state â that flag causes
release_orphan_inodes() to short-circuit before reaching this code.
Result: e2fsck -y reads the image, enters release_orphan_inodes(), calls
process_orphan_file(), reaches process_orphan_block(), and invokes
release_inode_blocks() + ext2fs_inode_alloc_stats2() on each target inode
â all before Pass 1 has validated a single bitmap.
Impact: targeted inodes have their blocks freed and are marked available
for reuse. The filesystem is silently corrupted. With EXT2_RESIZE_INO as
a target, the block group descriptor tables are freed.
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â FIX â
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
Add the same guard that the legacy path has, immediately before the call
to release_orphan_inode() in process_orphan_block():
ino = ext2fs_le32_to_cpu(bdata[j]);
+ if (ino < EXT2_FIRST_INODE(fs->super) ||
+ ino > fs->super->s_inodes_count) {
+ fix_problem(ctx, PR_0_ORPHAN_ILLEGAL_INODE, &pctx);
+ goto return_abort;
+ }
if (release_orphan_inode(ctx, &ino, pd->block_buf))
================================================================================
FINDING F2 â HIGH
s_orphan_file_inum aliases reserved inodes in pass1 dispatch chain
================================================================================
File : e2fsck/pass1.c
Lines : 1725 â 1879 (the dispatch chain)
1853 â 1866 (the orphan-file branch â fires too early)
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â THE DISPATCH CHAIN IN PASS 1 â
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
pass1.c evaluates the following else-if ladder for every inode:
1725: if (ino == EXT2_BAD_INO) â inode 1 â protected
1773: elif (ino == EXT2_ROOT_INO) â inode 2 â protected
1800: elif (ino == EXT2_JOURNAL_INO) â inode 8 â protected
1826: elif (quota_inum_is_reserved(fs, ino)) â inodes 3, 4 â protected
1853: elif (ino == s_orphan_file_inum) â ORPHAN FILE BRANCH
1879: elif (ino < EXT2_FIRST_INODE(fs->super))â catches 5,6,7,9,10
If s_orphan_file_inum is set to 5, 6, 7, 9, or 10 in the superblock,
the orphan-file branch at line 1853 fires BEFORE the generic reserved-inode
guard at line 1879. Those reserved inodes are never properly handled.
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â VULNERABLE CODE â
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
pass1.c:1853
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
} else if (ino == fs->super->s_orphan_file_inum) {
ext2fs_mark_inode_bitmap2(ctx->inode_used_map, ino);
if (ext2fs_has_feature_orphan_file(fs->super)) {
if (!LINUX_S_ISREG(inode->i_mode) && /* mode check */
fix_problem(ctx, PR_1_ORPHAN_FILE_BAD_MODE, &pctx)) {
inode->i_mode = LINUX_S_IFREG; /* WRITES BACK */
e2fsck_write_inode(ctx, ino, inode, "pass1");
}
check_blocks(ctx, &pctx, block_buf, NULL);
FINISH_INODE_LOOP(ctx, ino, &pctx, failed_csum);
continue; /* skips all reserved-inode validation */
}
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
With -y, fix_problem() returns 1 unconditionally. For any reserved inode
that is not a regular file (resize inode has S_IFREG, others have mode=0),
e2fsck would write LINUX_S_IFREG into that inode's i_mode field on disk.
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â THE DESTRUCTIVE CHAIN: s_orphan_file_inum = 7 (EXT2_RESIZE_INO) â
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
The resize inode (inode 7) is used for online filesystem expansion. Its
data blocks are the per-group BLOCK BITMAP IMAGES â one block per group,
containing bit-for-bit maps of allocated blocks.
When process_orphan_file() runs on inode 7 (in release_orphan_inodes,
before Pass 1), it iterates every data block of inode 7 and calls
process_orphan_block() on each:
/* process_orphan_block reads the block as an array of u32 inode#s */
bdata = (__u32 *)pd->buf; /* resize inode block data */
for (j = 0; j < inodes_per_ob; j++) {
ino = ext2fs_le32_to_cpu(bdata[j]); /* bitmap words as inode#s */
release_orphan_inode(ctx, &ino, ...);
}
A per-group block bitmap for a dense filesystem is full of 0xFFFFFFFF
words â inode# 4294967295 > s_inodes_count, harmless. But for a filesystem
that is moderately allocated, the bitmaps contain words like 0x0000003F or
0x000003FF â small inode numbers that ARE valid inodes. Those inodes get
their blocks released and are marked free.
On a semi-sparse crafted filesystem, the attacker can arrange exactly which
words appear in the resize inode's blocks by controlling block allocation
density, giving precise control over which inodes get wiped.
This chain requires BOTH F1 and F2:
F2 â process_orphan_file() is called on the resize inode's blocks
F1 â each word in those blocks passes unchecked into release_orphan_inode
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â FIX â
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
Validate s_orphan_file_inum at superblock-check time and in the pass1
dispatch chain. Two changes:
(a) In e2fsck/super.c, when opening the orphan file, guard the inode number:
orphan_inum = fs->super->s_orphan_file_inum;
+ if (orphan_inum < EXT2_FIRST_INODE(fs->super) ||
+ orphan_inum > fs->super->s_inodes_count) {
+ /* fix_problem / clear orphan_file feature */
+ }
(b) In e2fsck/pass1.c, move the orphan-file else-if AFTER the
ino < EXT2_FIRST_INODE guard (line 1879), or add an explicit
lower-bound check:
} else if (ino == fs->super->s_orphan_file_inum
+ && ino >= EXT2_FIRST_INODE(fs->super)) {
================================================================================
FINDING F3 â MEDIUM
ext2fs_extent_fix_parents(): __u32 += blk64_t unsigned underflow
================================================================================
File : lib/ext2fs/extent.c
Line : 816
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â VULNERABLE CODE â
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
extent.c:800
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
/* modified node's start block */
start = extent.e_lblk; /* blk64_t: leaf's logical block */
...
while (handle->level > 0 &&
(path->left == path->entries - 1)) {
ext2fs_extent_get(handle, EXT2_EXTENT_UP, &extent); /* go to parent*/
if (extent.e_lblk == start)
break;
path = handle->path + handle->level;
extent.e_len += (extent.e_lblk - start); /* LINE 816 */
extent.e_lblk = start;
ext2fs_extent_replace(handle, 0, &extent);
}
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
Type breakdown:
extent.e_len is __u32 (32-bit unsigned)
extent.e_lblk is blk64_t (64-bit unsigned)
start is blk64_t (64-bit unsigned)
The function is designed for the case where a leaf's start block was moved
EARLIER (smaller) â the parent index entry must extend to cover it:
extent.e_lblk > start â subtraction positive â e_len grows. Correct.
But if a crafted extent tree has a parent index entry whose e_lblk is
LESS than the current leaf's e_lblk (a B-tree invariant violation that is
not rejected on read), then:
extent.e_lblk < start
â (extent.e_lblk - start) as blk64_t wraps to ~0ULL - delta + 1
â += on __u32 truncates that to a small garbage value
â extent_replace() writes the corrupted length back to disk
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â REACHABILITY â
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
ext2fs_extent_fix_parents() is called by:
- ext2fs_extent_insert() used throughout e2fsck and libext2fs
- ext2fs_extent_set_bmap() used during extent tree rebuild
- rewrite_extent_replay() e2fsck/extents.c â called in Pass 1E
on every inode flagged for rebuild
A crafted inode with an extent tree where a leaf's e_lblk < its parent
index's e_lblk will reach this path during Pass 1E. The attacker does not
need to corrupt the leaf-writing path â the malformed parent-child
relationship in the on-disk image is sufficient.
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â FIX â
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
Guard against the underflow with an explicit check before the arithmetic:
if (extent.e_lblk == start)
break;
path = handle->path + handle->level;
+ if (extent.e_lblk < start) {
+ /* parent starts after child â malformed tree; bail */
+ retval = EXT2_ET_EXTENT_INVALID_LENGTH;
+ goto done;
+ }
extent.e_len += (extent.e_lblk - start);
================================================================================
FINDINGS REVIEW
================================================================================
The preliminary scan raised several additional issues. After careful review
of the actual source, here is the verdict on each:
ââââââââââââââââââââââââââââââââââââ¬âââââââââââââââââââââââââââââââââââââââ
â Claim â Verdict â
ââââââââââââââââââââââââââââââââââââ¼âââââââââââââââââââââââââââââââââââââââ¤
â extents.c:96 â blk64_t overflow â NOT A BUG. Both __u32 values are â
â in extent merge accumulation â promoted to blk64_t before addition. â
â â The (1ULL<<32) check is correct. â
ââââââââââââââââââââââââââââââââââââ¼âââââââââââââââââââââââââââââââââââââââ¤
â icount.c:220 â sprintf overflow â NOT A BUG. Allocation at line 216 â
â with long tdb_dir path â is strlen(tdb_dir)+64; format string â
â â produces at most +53 bytes. Safe. â
ââââââââââââââââââââââââââââââââââââ¼âââââââââââââââââââââââââââââââââââââââ¤
â dir_iterate.c â infinite loop â NOT EXPLOITABLE. rec_len < 8 check â
â on rec_len = 0 or 1 â at line 84 returns 0 immediately. â
â â Zero-length entries abort cleanly. â
ââââââââââââââââââââââââââââââââââââ¼âââââââââââââââââââââââââââââââââââââââ¤
â extents.c:241 â loop re-entry â BENIGN by design. The ex--; i-- â
â use-after-free in UNINIT split â pattern re-processes the same array â
â â slot (e_len reduced each pass). The â
â â e_len==0 guard at line 232 exits. â
ââââââââââââââââââââââââââââââââââââ¼âââââââââââââââââââââââââââââââââââââââ¤
â extent.c:1607 â save_length â NEEDS FUZZING. Context unclear from â
â minus underflow in split_node â static analysis alone. Recommend â
â â targeted AFL++ run on this function. â
ââââââââââââââââââââââââââââââââââââ´âââââââââââââââââââââââââââââââââââââââ
================================================================================
ATTACK SURFACE NOTES
================================================================================
WHY THIS MATTERS MORE THAN TYPICAL PARSER BUGS
e2fsck is routinely run automatically:
- systemd-fsck triggers it on dirty ext4 partitions at boot
- Desktop OS automounters call it on USB drives
- Cloud providers run it during disk attach / snapshot restore
- CI pipelines often call "e2fsck -y" to clean up test images
In all of these contexts, the filesystem image is the attack input and
e2fsck runs as root. Filesystem-level bugs here can silently destroy
data, corrupt kernel metadata, or (with further chaining) achieve
privilege escalation via inode bitmap manipulation.
THE TIMING OF ORPHAN PROCESSING IS CRITICAL
release_orphan_inodes() is called at the very start of e2fsck, from
check_super_block() â before Pass 1, before bitmaps are validated,
before any consistency has been established. The attacker's inode
destructions happen against an unvalidated filesystem state.
CHECKSUMS DO NOT PROTECT YOU HERE
Finding F1 is exploitable both with and without metadata_csum:
- Without: checksum check is skipped entirely.
- With: attacker sets s_checksum_seed to any value, computes
CRC32c(seed || ino || gen || blk || buf) correctly.
The seed lives in the same superblock the attacker crafts.
Checksums here protect against accidental corruption, not adversarial input.
================================================================================
QUICK REFERENCE â EXACT DIFF LOCATIONS
================================================================================
F1 e2fsck/super.c line 424 add range check before
release_orphan_inode() call
F2a e2fsck/super.c ~line 446 validate s_orphan_file_inum
>= EXT2_FIRST_INODE on entry
to process_orphan_file()
F2b e2fsck/pass1.c line 1853 add lower-bound guard to the
s_orphan_file_inum elif branch
F3 lib/ext2fs/extent.c line 816 guard against extent.e_lblk < start
before the += arithmetic
================================================================================
END OF REPORT
================================================================================
^ permalink raw reply
page: next (older) | prev (newer) | latest
- recent:[subjects (threaded)|topics (new)|topics (active)]
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox