mirror of https://lore.kernel.org/lkml/
 help / color / mirror / Atom feed
* [PATCH] ntfs: fail the mount when $MFT needs its own extent records
@ 2026-09-22 15:39 Matthias Goergens
  2026-09-23  1:16 ` Hyunchul Lee
  0 siblings, 1 reply; 9+ messages in thread
From: Matthias Goergens @ 2026-09-22 15:39 UTC (permalink / raw)
  To: Namjae Jeon, Hyunchul Lee; +Cc: ntfs, linux-kernel

Mounting a crafted image hangs the mount process forever: no output, no
progress, unkillable, 0% CPU.  Nothing is reported, because the task is
not blocked on any lock lockdep tracks.  With PROVE_LOCKING and
DEBUG_MUTEXES on it stays silent, and only the hung-task detector
notices:

  INFO: task mount:74 blocked in I/O wait for more than 30 seconds.
  locks held by mount/74: 1
    #0: (&type->s_umount_key#26/1) at sget_fc

It is a folio lock, which lockdep does not track:

  folio_wait_bit_common      <- waits forever
  filemap_read_folio
  map_mft_record_folio
  map_mft_record
  ntfs_map_runlist_nolock
  ntfs_attr_vcn_to_rl
  __ntfs_read_iomap_begin
  iomap_read_folio
  ntfs_read_folio            <- already holds that folio's lock
  ntfs_read_inode_mount
  ntfs_fill_super

ntfs_read_inode_mount() builds the first extent of $MFT/$DATA by hand
and then calls ntfs_read_locked_inode() to pick up $MFT's remaining
attributes.  The comment there says what that assumes:

	 * ... we would hope that we don't need
	 * further extents in order to find the other
	 * attributes belonging to $MFT. ... But lets
	 * hope this never happens...

An image whose $MFT attribute list puts those attributes in one of
$MFT's own extent records makes it false.  Mapping that extent record
reads $MFT at a vcn the half-built runlist does not cover, so the read
comes back into ntfs_map_runlist_nolock() for $MFT while it already
holds the lock on the folio it then waits for.

So check it.  Mark the volume while the bootstrap is assembling $MFT's
runlist, and refuse to map $MFT's runlist through the general path
while that mark is set.  The bootstrap builds the runlist itself with
ntfs_mapping_pairs_decompress() and never goes through the guarded
path, so only the re-entrant case is rejected.

The mount now fails instead of hanging:

  ntfs: (device vda): ntfs_map_runlist_nolock(): $MFT needs its own
    extent records to describe itself. $MFT is corrupt. Run chkdsk.
  ntfs: (device vda): ntfs_read_inode_mount(): ntfs_read_inode() of
    $MFT failed.

The guard bails before the function touches anything: no search
context, no mapped record, no lock taken.  -EIO is not a new return
value here either, and all six call sites already handle a negative
one.  ntfs_map_runlist() and ntfs_empty_logfile() propagate it,
ntfs_attr_vcn_to_lcn_nolock() maps it to LCN_EIO,
ntfs_attr_find_vcn_nolock() passes it through, and
ntfs_attr_vcn_to_rl() and ntfs_write_simple_iomap_begin_non_resident()
only act on success, falling through to the unmapped-runlist handling
they already have.

Found by fuzzing mountable images.  Checked under qemu with KASAN,
PROVE_LOCKING and the hung-task detector: two crafted images hang an
unpatched kernel and are rejected by a patched one, and eight
well-formed images mount on both.

Fixes: b041ca562526 ("ntfs: update iomap and address space operations")
Cc: stable@vger.kernel.org
Signed-off-by: Matthias Goergens <matthias.goergens@gmail.com>
---
The two crafted images are 28 KB each; happy to send them or put them
somewhere fetchable.

fs/ntfs3 rejects both while loading $Volume, so it is not affected by
these images, but I did not try fuzzing its parser.

 fs/ntfs/attrib.c | 11 +++++++++++
 fs/ntfs/inode.c  |  2 ++
 fs/ntfs/volume.h |  3 +++
 3 files changed, 16 insertions(+)

diff --git a/fs/ntfs/attrib.c b/fs/ntfs/attrib.c
index c949ff765075..f94e928f145f 100644
--- a/fs/ntfs/attrib.c
+++ b/fs/ntfs/attrib.c
@@ -103,6 +103,17 @@ int ntfs_map_runlist_nolock(struct ntfs_inode *ni, s64 vcn, struct ntfs_attr_sea
 		base_ni = ni;
 	else
 		base_ni = ni->ext.base_ntfs_ino;
+	/*
+	 * ntfs_read_inode_mount() builds $MFT's runlist itself, so nothing
+	 * should reach here for $MFT.  A crafted image can: the read that
+	 * gets here already holds the $MFT folio lock it would wait on.
+	 */
+	if (unlikely(NVolMftBootstrap(ni->vol) &&
+		     base_ni == NTFS_I(ni->vol->mft_ino))) {
+		ntfs_error(ni->vol->sb,
+			   "$MFT needs its own extent records to describe itself. $MFT is corrupt. Run chkdsk.");
+		return -EIO;
+	}
 	if (!ctx) {
 		ctx_is_temporary = ctx_needs_reset = true;
 		m = map_mft_record(base_ni);
diff --git a/fs/ntfs/inode.c b/fs/ntfs/inode.c
index a777de8a80c7..55f6d3334238 100644
--- a/fs/ntfs/inode.c
+++ b/fs/ntfs/inode.c
@@ -2150,7 +2150,9 @@ int ntfs_read_inode_mount(struct inode *vi)
 			 * ntfs_read_inode() on extents of $MFT/$DATA. But lets
 			 * hope this never happens...
 			 */
+			NVolSetMftBootstrap(vol);
 			err = ntfs_read_locked_inode(vi);
+			NVolClearMftBootstrap(vol);
 			if (err) {
 				ntfs_error(sb, "ntfs_read_inode() of $MFT failed.\n");
 				ntfs_attr_put_search_ctx(ctx);
diff --git a/fs/ntfs/volume.h b/fs/ntfs/volume.h
index bc85a9592245..f9ecbbe205ad 100644
--- a/fs/ntfs/volume.h
+++ b/fs/ntfs/volume.h
@@ -184,6 +184,7 @@ struct ntfs_volume {
  * NV_Discard			Issue discard/TRIM commands for freed clusters.
  * NV_DisableSparse		Disable creation of sparse regions.
  * NV_NativeSymlinkRel		Translate absolute Windows reparse targets (native_symlink=rel).
+ * NV_MftBootstrap		Mount is still assembling $MFT's own runlist.
  */
 enum {
 	NV_Errors,
@@ -203,6 +204,7 @@ enum {
 	NV_DisableSparse,
 	NV_NativeSymlinkRel,
 	NV_SymlinkNative,
+	NV_MftBootstrap,
 };
 
 /*
@@ -241,6 +243,7 @@ DEFINE_NVOL_BIT_OPS(Discard)
 DEFINE_NVOL_BIT_OPS(DisableSparse)
 DEFINE_NVOL_BIT_OPS(NativeSymlinkRel)
 DEFINE_NVOL_BIT_OPS(SymlinkNative)
+DEFINE_NVOL_BIT_OPS(MftBootstrap)
 
 static inline void ntfs_inc_free_clusters(struct ntfs_volume *vol, s64 nr)
 {

base-commit: 40288c9206c17eb66a603262e06a58d300d0f279
-- 
2.55.0


^ permalink raw reply	[flat|nested] 9+ messages in thread

* Re: [PATCH] ntfs: fail the mount when $MFT needs its own extent records
  2026-09-22 15:39 [PATCH] ntfs: fail the mount when $MFT needs its own extent records Matthias Goergens
@ 2026-09-23  1:16 ` Hyunchul Lee
  2026-09-27  5:08   ` [PATCH v2 0/6] ntfs: fix the $MFT bootstrap hang and reads of unmapped runlist ranges Matthias Goergens
  0 siblings, 1 reply; 9+ messages in thread
From: Hyunchul Lee @ 2026-09-23  1:16 UTC (permalink / raw)
  To: Matthias Goergens; +Cc: Namjae Jeon, ntfs, linux-kernel

Hi Matthias,

On Tue, Sep 22, 2026 at 11:39:31PM +0800, Matthias Goergens wrote:
> Mounting a crafted image hangs the mount process forever: no output, no
> progress, unkillable, 0% CPU.  Nothing is reported, because the task is
> not blocked on any lock lockdep tracks.  With PROVE_LOCKING and
> DEBUG_MUTEXES on it stays silent, and only the hung-task detector
> notices:
> 
>   INFO: task mount:74 blocked in I/O wait for more than 30 seconds.
>   locks held by mount/74: 1
>     #0: (&type->s_umount_key#26/1) at sget_fc
> 
> It is a folio lock, which lockdep does not track:
> 
>   folio_wait_bit_common      <- waits forever
>   filemap_read_folio
>   map_mft_record_folio
>   map_mft_record
>   ntfs_map_runlist_nolock
>   ntfs_attr_vcn_to_rl
>   __ntfs_read_iomap_begin
>   iomap_read_folio
>   ntfs_read_folio            <- already holds that folio's lock
>   ntfs_read_inode_mount
>   ntfs_fill_super
> 
> ntfs_read_inode_mount() builds the first extent of $MFT/$DATA by hand
> and then calls ntfs_read_locked_inode() to pick up $MFT's remaining
> attributes.  The comment there says what that assumes:
> 
> 	 * ... we would hope that we don't need
> 	 * further extents in order to find the other
> 	 * attributes belonging to $MFT. ... But lets
> 	 * hope this never happens...
> 
> An image whose $MFT attribute list puts those attributes in one of
> $MFT's own extent records makes it false.  Mapping that extent record
> reads $MFT at a vcn the half-built runlist does not cover, so the read
> comes back into ntfs_map_runlist_nolock() for $MFT while it already
> holds the lock on the folio it then waits for.
> 
> So check it.  Mark the volume while the bootstrap is assembling $MFT's
> runlist, and refuse to map $MFT's runlist through the general path
> while that mark is set.  The bootstrap builds the runlist itself with
> ntfs_mapping_pairs_decompress() and never goes through the guarded
> path, so only the re-entrant case is rejected.
> 
> The mount now fails instead of hanging:
> 
>   ntfs: (device vda): ntfs_map_runlist_nolock(): $MFT needs its own
>     extent records to describe itself. $MFT is corrupt. Run chkdsk.
>   ntfs: (device vda): ntfs_read_inode_mount(): ntfs_read_inode() of
>     $MFT failed.
> 
> The guard bails before the function touches anything: no search
> context, no mapped record, no lock taken.  -EIO is not a new return
> value here either, and all six call sites already handle a negative
> one.  ntfs_map_runlist() and ntfs_empty_logfile() propagate it,
> ntfs_attr_vcn_to_lcn_nolock() maps it to LCN_EIO,
> ntfs_attr_find_vcn_nolock() passes it through, and
> ntfs_attr_vcn_to_rl() and ntfs_write_simple_iomap_begin_non_resident()
> only act on success, falling through to the unmapped-runlist handling
> they already have.
> 
> Found by fuzzing mountable images.  Checked under qemu with KASAN,
> PROVE_LOCKING and the hung-task detector: two crafted images hang an
> unpatched kernel and are rejected by a patched one, and eight
> well-formed images mount on both.
> 
> Fixes: b041ca562526 ("ntfs: update iomap and address space operations")
> Cc: stable@vger.kernel.org
> Signed-off-by: Matthias Goergens <matthias.goergens@gmail.com>
> ---
> The two crafted images are 28 KB each; happy to send them or put them
> somewhere fetchable.
> 
> fs/ntfs3 rejects both while loading $Volume, so it is not affected by
> these images, but I did not try fuzzing its parser.
> 
>  fs/ntfs/attrib.c | 11 +++++++++++
>  fs/ntfs/inode.c  |  2 ++
>  fs/ntfs/volume.h |  3 +++
>  3 files changed, 16 insertions(+)
> 
> diff --git a/fs/ntfs/attrib.c b/fs/ntfs/attrib.c
> index c949ff765075..f94e928f145f 100644
> --- a/fs/ntfs/attrib.c
> +++ b/fs/ntfs/attrib.c
> @@ -103,6 +103,17 @@ int ntfs_map_runlist_nolock(struct ntfs_inode *ni, s64 vcn, struct ntfs_attr_sea
>  		base_ni = ni;
>  	else
>  		base_ni = ni->ext.base_ntfs_ino;
> +	/*
> +	 * ntfs_read_inode_mount() builds $MFT's runlist itself, so nothing
> +	 * should reach here for $MFT.  A crafted image can: the read that
> +	 * gets here already holds the $MFT folio lock it would wait on.
> +	 */
> +	if (unlikely(NVolMftBootstrap(ni->vol) &&
> +		     base_ni == NTFS_I(ni->vol->mft_ino))) {
> +		ntfs_error(ni->vol->sb,
> +			   "$MFT needs its own extent records to describe itself. $MFT is corrupt. Run chkdsk.");
> +		return -EIO;
> +	}
>  	if (!ctx) {
>  		ctx_is_temporary = ctx_needs_reset = true;
>  		m = map_mft_record(base_ni);
> diff --git a/fs/ntfs/inode.c b/fs/ntfs/inode.c
> index a777de8a80c7..55f6d3334238 100644
> --- a/fs/ntfs/inode.c
> +++ b/fs/ntfs/inode.c
> @@ -2150,7 +2150,9 @@ int ntfs_read_inode_mount(struct inode *vi)
>  			 * ntfs_read_inode() on extents of $MFT/$DATA. But lets
>  			 * hope this never happens...
>  			 */
> +			NVolSetMftBootstrap(vol);
>  			err = ntfs_read_locked_inode(vi);
> +			NVolClearMftBootstrap(vol);

The scope of the bootstrp flag seems to be too narrow.
The outer loop continues to call ntfs_attr_lookup() until all $DATA
extents are enumerated. ntfs_attr_lookup() can call map_mft_record()
also, when mapping pairs is stored in another MFT record. So the same
folio deadlock can occur.

I think that NVolMftBootstrap should remain set until all $DATA
extent enumeration has completed.

>  			if (err) {
>  				ntfs_error(sb, "ntfs_read_inode() of $MFT failed.\n");
>  				ntfs_attr_put_search_ctx(ctx);
> diff --git a/fs/ntfs/volume.h b/fs/ntfs/volume.h
> index bc85a9592245..f9ecbbe205ad 100644
> --- a/fs/ntfs/volume.h
> +++ b/fs/ntfs/volume.h
> @@ -184,6 +184,7 @@ struct ntfs_volume {
>   * NV_Discard			Issue discard/TRIM commands for freed clusters.
>   * NV_DisableSparse		Disable creation of sparse regions.
>   * NV_NativeSymlinkRel		Translate absolute Windows reparse targets (native_symlink=rel).
> + * NV_MftBootstrap		Mount is still assembling $MFT's own runlist.
>   */
>  enum {
>  	NV_Errors,
> @@ -203,6 +204,7 @@ enum {
>  	NV_DisableSparse,
>  	NV_NativeSymlinkRel,
>  	NV_SymlinkNative,
> +	NV_MftBootstrap,
>  };
>  
>  /*
> @@ -241,6 +243,7 @@ DEFINE_NVOL_BIT_OPS(Discard)
>  DEFINE_NVOL_BIT_OPS(DisableSparse)
>  DEFINE_NVOL_BIT_OPS(NativeSymlinkRel)
>  DEFINE_NVOL_BIT_OPS(SymlinkNative)
> +DEFINE_NVOL_BIT_OPS(MftBootstrap)
>  
>  static inline void ntfs_inc_free_clusters(struct ntfs_volume *vol, s64 nr)
>  {
> 
> base-commit: 40288c9206c17eb66a603262e06a58d300d0f279
> -- 
> 2.55.0
> 

-- 
Thanks,
Hyunchul

^ permalink raw reply	[flat|nested] 9+ messages in thread

* [PATCH v2 0/6] ntfs: fix the $MFT bootstrap hang and reads of unmapped runlist ranges
  2026-09-23  1:16 ` Hyunchul Lee
@ 2026-09-27  5:08   ` Matthias Goergens
  2026-09-27  5:08     ` [PATCH v2 1/6] ntfs: do not map an unmappable runlist fragment as a hole Matthias Goergens
                       ` (5 more replies)
  0 siblings, 6 replies; 9+ messages in thread
From: Matthias Goergens @ 2026-09-27  5:08 UTC (permalink / raw)
  To: Namjae Jeon, Hyunchul Lee; +Cc: ntfs, linux-kernel

Hi Hyunchul,

This is v2 of "ntfs: fail the mount when $MFT needs its own extent
records", now patch 3.  As you suggested, the mark now stays set for the
whole $DATA enumeration in ntfs_read_inode_mount().  A crafted image
that puts $MFT's second $DATA extent in an extent record the first
extent does not cover hung v1 in that loop; with v2 the mount fails.
One correction to v1: lockdep stayed silent not because of the folio
lock, but because ntfs_fill_super() turns it off for the whole mount.

Patch 3 needs patch 1.  With clusters smaller than a page, patch 3 can
refuse the tail of a folio holding an $MFT record, and without patch 1
the refused range is mapped as a hole and the mount carries on with an
all-zero mft record.

That hole is not specific to $MFT.  Patches 1 and 2 fix it for any file
whose runlist is partly in an extent record that cannot be read: reads
of that range return zeros with no error, and buffered writes into it
report success and are lost.  Patch 4 does the same for a runlist that
ends before allocated_size, which a crafted $MFT used to get past patch
3.  Patch 5 refuses a $MFT whose data_size exceeds its allocation, and
patch 6 requires initialized_size <= data_size <= allocated_size, with
allocated_size a multiple of the cluster size, for every non-resident
attribute, as fs/ntfs3 does.

Tested under qemu with KASAN and the hung-task detector on ntfs-next,
with and without the series.  Nine crafted images that hang or crash the
mount without it fail to mount with it, and six that read zeros that are
not on disk return errors instead.  Eighteen images that mount without
the series, among them eight public test volumes written by Windows or
mkntfs, read every file the same with it, and six small mkntfs images
still mount.  The images and the scripts that generate them are at

  https://github.com/matthiasgoergens/linux/tree/reproducer/2026-09-26-ntfs-mft-runlist

and, for the ordinary file of patch 4, the unaligned allocated_size of
patch 6 and three of the volumes that mount, at

  https://github.com/matthiasgoergens/linux/tree/reproducer/2026-09-26-ntfs-v2-extra

v1: https://lore.kernel.org/all/20260922153931.1976405-1-matthias.goergens@gmail.com/

Thanks,
Matthias

Matthias Goergens (6):
  ntfs: do not map an unmappable runlist fragment as a hole
  ntfs: do not turn an unmappable runlist fragment into delalloc on
    write
  ntfs: fail the mount when $MFT needs its own extent records
  ntfs: do not map a vcn as a hole when its runlist lookup failed
  ntfs: fail the mount when $MFT's data size exceeds its allocation
  ntfs: reject non-resident attributes whose sizes exceed their
    allocation

 fs/ntfs/attrib.c   | 54 +++++++++++++++++++++++++++++++++++++++----
 fs/ntfs/attrlist.c |  8 +++++--
 fs/ntfs/inode.c    | 57 ++++++++++++++++++++++++++++++++++++++++++++++
 fs/ntfs/iomap.c    | 16 +++++++++++--
 fs/ntfs/layout.h   | 13 +++++++----
 fs/ntfs/volume.h   |  3 +++
 6 files changed, 137 insertions(+), 14 deletions(-)


base-commit: 259abb551e2944998cad4214c201954ab1ac5c8d
-- 
2.55.0


^ permalink raw reply	[flat|nested] 9+ messages in thread

* [PATCH v2 1/6] ntfs: do not map an unmappable runlist fragment as a hole
  2026-09-27  5:08   ` [PATCH v2 0/6] ntfs: fix the $MFT bootstrap hang and reads of unmapped runlist ranges Matthias Goergens
@ 2026-09-27  5:08     ` Matthias Goergens
  2026-09-27  5:08     ` [PATCH v2 2/6] ntfs: do not turn an unmappable runlist fragment into delalloc on write Matthias Goergens
                       ` (4 subsequent siblings)
  5 siblings, 0 replies; 9+ messages in thread
From: Matthias Goergens @ 2026-09-27  5:08 UTC (permalink / raw)
  To: Namjae Jeon, Hyunchul Lee; +Cc: ntfs, linux-kernel

When the extent mft record holding part of a file's runlist cannot be
read, ntfs_attr_vcn_to_rl() ignores the failed ntfs_map_runlist_nolock()
retry and returns with *lcn == LCN_RL_NOT_MAPPED.  The iomap read path
only rejects lcn < LCN_ENOENT, so it maps the range as a hole and read()
returns zeros with no error.  The first read already does this:
ntfs_attr_map_whole_runlist() keeps the fragments it could read,
readahead drops its error, and the next lookup finds the unmapped tail.

On a file whose runlist is split between its base record (vcn 0-214) and
one extent record (vcn 215-1499), breaking only the extent record's FILE
magic makes the kernel log "Failed to map extent mft record", yet read()
returns 1285 clusters of zeros for vcn 215-1499.  A damaged attribute
list entry, which makes the retry fail with -ENOENT, gives the same
zeros.

Return -ENOMEM if the retry ran out of memory and -EIO otherwise.  Both
callers already handle an ERR_PTR; other negative lcns, including
LCN_ENOENT, are returned as before.  Reads of vcn 215-1499 now fail with
-EIO.  An intact volume exercised with buffered, mmap and O_DIRECT I/O,
fallocate, truncate, sparse and compressed files behaves as before.

Fixes: 495e90fa3348 ("ntfs: update attrib operations")
Cc: stable@vger.kernel.org
Signed-off-by: Matthias Goergens <matthias.goergens@gmail.com>
---
 fs/ntfs/attrib.c | 13 +++++++++++--
 1 file changed, 11 insertions(+), 2 deletions(-)

diff --git a/fs/ntfs/attrib.c b/fs/ntfs/attrib.c
index 333b3371acb47..a337a3429b401 100644
--- a/fs/ntfs/attrib.c
+++ b/fs/ntfs/attrib.c
@@ -317,7 +317,7 @@ int ntfs_map_runlist(struct ntfs_inode *ni, s64 vcn)
 struct runlist_element *ntfs_attr_vcn_to_rl(struct ntfs_inode *ni, s64 vcn, s64 *lcn)
 {
 	struct runlist_element *rl = ni->runlist.rl;
-	int err;
+	int err = 0;
 	bool is_retry = false;
 
 	if (!rl) {
@@ -335,12 +335,21 @@ struct runlist_element *ntfs_attr_vcn_to_rl(struct ntfs_inode *ni, s64 vcn, s64
 
 	if (*lcn <= LCN_RL_NOT_MAPPED && is_retry == false) {
 		is_retry = true;
-		if (!ntfs_map_runlist_nolock(ni, vcn, NULL)) {
+		err = ntfs_map_runlist_nolock(ni, vcn, NULL);
+		if (!err) {
 			rl = ni->runlist.rl;
 			goto remap_rl;
 		}
 	}
 
+	/*
+	 * The runlist fragment containing @vcn could not be mapped, e.g.
+	 * because the extent mft record holding it is corrupt.  Do not hand
+	 * LCN_RL_NOT_MAPPED back to callers, which would treat it as a hole.
+	 */
+	if (*lcn == LCN_RL_NOT_MAPPED)
+		return ERR_PTR(err == -ENOMEM ? -ENOMEM : -EIO);
+
 	return rl;
 }
 
-- 
2.55.0


^ permalink raw reply	[flat|nested] 9+ messages in thread

* [PATCH v2 2/6] ntfs: do not turn an unmappable runlist fragment into delalloc on write
  2026-09-27  5:08   ` [PATCH v2 0/6] ntfs: fix the $MFT bootstrap hang and reads of unmapped runlist ranges Matthias Goergens
  2026-09-27  5:08     ` [PATCH v2 1/6] ntfs: do not map an unmappable runlist fragment as a hole Matthias Goergens
@ 2026-09-27  5:08     ` Matthias Goergens
  2026-09-27  5:08     ` [PATCH v2 3/6] ntfs: fail the mount when $MFT needs its own extent records Matthias Goergens
                       ` (3 subsequent siblings)
  5 siblings, 0 replies; 9+ messages in thread
From: Matthias Goergens @ 2026-09-27  5:08 UTC (permalink / raw)
  To: Namjae Jeon, Hyunchul Lee; +Cc: ntfs, linux-kernel

ntfs_write_simple_iomap_begin_non_resident() has the same unchecked
retry.  LCN_RL_NOT_MAPPED passes its "lcn <= LCN_HOLE" test, so a
buffered write into a range whose extent record cannot be read is
merged into the runlist as LCN_DELALLOC over clusters that are already
allocated on disk, and write() succeeds.  If the record stays
unreadable, writeback fails too and the data never reaches the disk;
the error only shows up at a later fsync() or a synchronous write.

On the volume from the previous patch, after a read of the file, an
8 KiB pwrite() at vcn 1000 returns 8192, fsync() returns -EIO, and
after remount the range is unchanged.

Fail the lookup here too, with -ENOMEM or -EIO.  That pwrite() now
fails with -EIO.  Writes whose range can be mapped are unaffected.

Fixes: b041ca562526 ("ntfs: update iomap and address space operations")
Cc: stable@vger.kernel.org
Signed-off-by: Matthias Goergens <matthias.goergens@gmail.com>
---
 fs/ntfs/iomap.c | 16 ++++++++++++++--
 1 file changed, 14 insertions(+), 2 deletions(-)

diff --git a/fs/ntfs/iomap.c b/fs/ntfs/iomap.c
index c812d7f19b360..b4475963e57a7 100644
--- a/fs/ntfs/iomap.c
+++ b/fs/ntfs/iomap.c
@@ -394,7 +394,7 @@ static int ntfs_write_simple_iomap_begin_non_resident(struct inode *inode, loff_
 	loff_t vcn_ofs, rl_length;
 	struct runlist_element *rl, *rlc;
 	bool is_retry = false;
-	int err = 0;
+	int err = 0, map_err = 0;
 	s64 vcn, lcn;
 	s64 max_clu_count =
 		ntfs_bytes_to_cluster(vol, round_up(length, vol->cluster_size));
@@ -429,12 +429,24 @@ static int ntfs_write_simple_iomap_begin_non_resident(struct inode *inode, loff_
 
 	if (lcn <= LCN_RL_NOT_MAPPED && is_retry == false) {
 		is_retry = true;
-		if (!ntfs_map_runlist_nolock(ni, vcn, NULL)) {
+		map_err = ntfs_map_runlist_nolock(ni, vcn, NULL);
+		if (!map_err) {
 			rl = ni->runlist.rl;
 			goto remap_rl;
 		}
 	}
 
+	/*
+	 * As in ntfs_attr_vcn_to_rl(): a runlist fragment that could not be
+	 * mapped is not a hole.  Treating it as one would put a delalloc
+	 * extent over clusters that are allocated on disk but unknown to us.
+	 */
+	if (lcn == LCN_RL_NOT_MAPPED) {
+		up_write(&ni->runlist.lock);
+		mutex_unlock(&ni->mrec_lock);
+		return map_err == -ENOMEM ? -ENOMEM : -EIO;
+	}
+
 	max_clu_count = min(max_clu_count, rl->length - (vcn - rl->vcn));
 	if (max_clu_count == 0) {
 		ntfs_error(inode->i_sb,
-- 
2.55.0


^ permalink raw reply	[flat|nested] 9+ messages in thread

* [PATCH v2 3/6] ntfs: fail the mount when $MFT needs its own extent records
  2026-09-27  5:08   ` [PATCH v2 0/6] ntfs: fix the $MFT bootstrap hang and reads of unmapped runlist ranges Matthias Goergens
  2026-09-27  5:08     ` [PATCH v2 1/6] ntfs: do not map an unmappable runlist fragment as a hole Matthias Goergens
  2026-09-27  5:08     ` [PATCH v2 2/6] ntfs: do not turn an unmappable runlist fragment into delalloc on write Matthias Goergens
@ 2026-09-27  5:08     ` Matthias Goergens
  2026-09-27  5:08     ` [PATCH v2 4/6] ntfs: do not map a vcn as a hole when its runlist lookup failed Matthias Goergens
                       ` (2 subsequent siblings)
  5 siblings, 0 replies; 9+ messages in thread
From: Matthias Goergens @ 2026-09-27  5:08 UTC (permalink / raw)
  To: Namjae Jeon, Hyunchul Lee; +Cc: ntfs, linux-kernel

Mounting a crafted image hangs the mount process forever, unkillable at
0% CPU.  Only the hung-task detector reports it:

  INFO: task mount:74 blocked in I/O wait for more than 30 seconds.

  folio_wait_bit_common      <- waits forever
  filemap_read_folio
  map_mft_record_folio
  map_mft_record
  ntfs_map_runlist_nolock
  ntfs_attr_vcn_to_rl
  __ntfs_read_iomap_begin
  iomap_read_folio
  ntfs_read_folio            <- already holds that folio's lock
  ntfs_read_inode_mount
  ntfs_fill_super

ntfs_read_inode_mount() assembles $MFT's runlist one $DATA extent at a
time, hoping, as its comment says, that it never needs a part of $MFT it
has not decoded yet.  If $MFT's attribute list puts one of its
attributes or later $DATA extents in an extent record outside the
runlist decoded so far, reading that record re-enters
ntfs_map_runlist_nolock() for $MFT.  Depending on the layout, that waits
on the folio lock it already holds, as above, blocks on $MFT's runlist
lock, or dereferences NULL in map_extent_mft_record().

Mark the volume for the whole $DATA enumeration and have
ntfs_map_runlist_nolock() refuse $MFT with -EIO while the mark is set.
The enumeration decodes each extent itself with
ntfs_mapping_pairs_decompress(), so it does not need that path.

A validly placed record can trip the check too.  The driver puts an
extent record for $MFT's own mapping pairs before the first vcn it
describes, but with clusters smaller than a page, the folio holding it
can still run past the decoded runlist.  An unpatched kernel hangs on
that layout as well; with the check the mount fails.  The check then
refuses only the folio's tail, so this patch needs "ntfs: do not map an
unmappable runlist fragment as a hole": without it the tail is
zero-filled and the mount serves an all-zero mft record.  The mount now
fails instead of hanging:

  ntfs: (device vda): ntfs_map_runlist_nolock(): $MFT needs its own
    extent records to describe itself; cannot mount.

Tested under qemu with KASAN, PROVE_LOCKING and the hung-task detector
on ntfs-next, with and without the whole series: six crafted images
that hang or crash an unpatched kernel fail to mount with the series,
and nine images that mount without it still mount, three of them with
the same file listing and contents, among them a volume written by
ntfs-3g whose $MFT has its $DATA in three extent records.

Fixes: b041ca562526 ("ntfs: update iomap and address space operations")
Suggested-by: Hyunchul Lee <hyc.lee@gmail.com>
Cc: stable@vger.kernel.org
Signed-off-by: Matthias Goergens <matthias.goergens@gmail.com>
---
 fs/ntfs/attrib.c | 11 +++++++++++
 fs/ntfs/inode.c  |  9 +++++++++
 fs/ntfs/volume.h |  3 +++
 3 files changed, 23 insertions(+)

diff --git a/fs/ntfs/attrib.c b/fs/ntfs/attrib.c
index a337a3429b401..eab4d8d32132f 100644
--- a/fs/ntfs/attrib.c
+++ b/fs/ntfs/attrib.c
@@ -103,6 +103,17 @@ int ntfs_map_runlist_nolock(struct ntfs_inode *ni, s64 vcn, struct ntfs_attr_sea
 		base_ni = ni;
 	else
 		base_ni = ni->ext.base_ntfs_ino;
+	/*
+	 * ntfs_read_inode_mount() builds $MFT's runlist itself, so nothing
+	 * should reach here for $MFT.  A crafted image can: the read that
+	 * gets here already holds the $MFT folio lock it would wait on.
+	 */
+	if (unlikely(NVolMftBootstrap(ni->vol) &&
+		     base_ni == NTFS_I(ni->vol->mft_ino))) {
+		ntfs_error(ni->vol->sb,
+			   "$MFT needs its own extent records to describe itself; cannot mount.");
+		return -EIO;
+	}
 	if (!ctx) {
 		ctx_is_temporary = ctx_needs_reset = true;
 		m = map_mft_record(base_ni);
diff --git a/fs/ntfs/inode.c b/fs/ntfs/inode.c
index 9583b2c6c7a26..a61f1519549cb 100644
--- a/fs/ntfs/inode.c
+++ b/fs/ntfs/inode.c
@@ -2080,6 +2080,11 @@ int ntfs_read_inode_mount(struct inode *vi)
 	/* Now load all attribute extents. */
 	a = NULL;
 	next_vcn = last_vcn = highest_vcn = 0;
+	/*
+	 * Reading one of $MFT's own extent records in this loop can re-enter
+	 * ntfs_map_runlist_nolock() for $MFT; see the check there.
+	 */
+	NVolSetMftBootstrap(vol);
 	while (!(err = ntfs_attr_lookup(AT_DATA, NULL, 0, 0, next_vcn, NULL, 0,
 			ctx))) {
 		struct runlist_element *nrl;
@@ -2162,6 +2167,7 @@ int ntfs_read_inode_mount(struct inode *vi)
 			err = ntfs_read_locked_inode(vi);
 			if (err) {
 				ntfs_error(sb, "ntfs_read_inode() of $MFT failed.\n");
+				NVolClearMftBootstrap(vol);
 				ntfs_attr_put_search_ctx(ctx);
 				/* Revert to the safe super operations. */
 				kfree(m);
@@ -2195,6 +2201,7 @@ int ntfs_read_inode_mount(struct inode *vi)
 			goto put_err_out;
 		}
 	}
+	NVolClearMftBootstrap(vol);
 	if (err != -ENOENT) {
 		ntfs_error(sb, "Failed to lookup $MFT/$DATA attribute extent. Run chkdsk.\n");
 		goto put_err_out;
@@ -2229,6 +2236,8 @@ int ntfs_read_inode_mount(struct inode *vi)
 put_err_out:
 	ntfs_attr_put_search_ctx(ctx);
 err_out:
+	/* Also reached from inside the $DATA loop. */
+	NVolClearMftBootstrap(vol);
 	ntfs_error(sb, "Failed. Marking inode as bad.");
 	kfree(m);
 	return -1;
diff --git a/fs/ntfs/volume.h b/fs/ntfs/volume.h
index fdb57279de84c..0473b602084c5 100644
--- a/fs/ntfs/volume.h
+++ b/fs/ntfs/volume.h
@@ -194,6 +194,7 @@ struct ntfs_volume {
  * NV_Discard			Issue discard/TRIM commands for freed clusters.
  * NV_DisableSparse		Disable creation of sparse regions.
  * NV_NativeSymlinkRel		Translate absolute Windows reparse targets (native_symlink=rel).
+ * NV_MftBootstrap		Mount is still assembling $MFT's own runlist.
  */
 enum {
 	NV_Errors,
@@ -214,6 +215,7 @@ enum {
 	NV_DisableSparse,
 	NV_NativeSymlinkRel,
 	NV_SymlinkNative,
+	NV_MftBootstrap,
 };
 
 /*
@@ -253,6 +255,7 @@ DEFINE_NVOL_BIT_OPS(Discard)
 DEFINE_NVOL_BIT_OPS(DisableSparse)
 DEFINE_NVOL_BIT_OPS(NativeSymlinkRel)
 DEFINE_NVOL_BIT_OPS(SymlinkNative)
+DEFINE_NVOL_BIT_OPS(MftBootstrap)
 
 static inline void ntfs_inc_free_clusters(struct ntfs_volume *vol, s64 nr)
 {
-- 
2.55.0


^ permalink raw reply	[flat|nested] 9+ messages in thread

* [PATCH v2 4/6] ntfs: do not map a vcn as a hole when its runlist lookup failed
  2026-09-27  5:08   ` [PATCH v2 0/6] ntfs: fix the $MFT bootstrap hang and reads of unmapped runlist ranges Matthias Goergens
                       ` (2 preceding siblings ...)
  2026-09-27  5:08     ` [PATCH v2 3/6] ntfs: fail the mount when $MFT needs its own extent records Matthias Goergens
@ 2026-09-27  5:08     ` Matthias Goergens
  2026-09-27  5:08     ` [PATCH v2 5/6] ntfs: fail the mount when $MFT's data size exceeds its allocation Matthias Goergens
  2026-09-27  5:08     ` [PATCH v2 6/6] ntfs: reject non-resident attributes whose sizes exceed their allocation Matthias Goergens
  5 siblings, 0 replies; 9+ messages in thread
From: Matthias Goergens @ 2026-09-27  5:08 UTC (permalink / raw)
  To: Namjae Jeon, Hyunchul Lee; +Cc: ntfs, linux-kernel

ntfs_attr_vcn_to_rl() retries ntfs_map_runlist_nolock() for any lcn up
to LCN_RL_NOT_MAPPED, which includes LCN_ENOENT, but turns a failed
retry into an error only for LCN_RL_NOT_MAPPED.  For LCN_ENOENT the
error is dropped and the read path maps the range as a hole.

An LCN_ENOENT below allocated_size comes from a base extent with a
highest_vcn of 0, which ntfs_mapping_pairs_decompress() takes to map the
whole attribute, so the runlist ends after its last mapping pair.  If
the pairs end early, the retry finds the same extent and fails with
-ENOENT.  On a crafted volume with 4 KiB clusters, a 64-cluster file
whose mapping pairs stop after 16 clusters reads 48 clusters of zeros,
with no error.

The same layout gets a crafted $MFT past the check from "ntfs: fail the
mount when $MFT needs its own extent records".  With 512-byte clusters
and $MFT's mapping pairs ending at vcn 4, an unpatched kernel hangs on
the folio lock reading records 0-3.  With the check alone, the -EIO is
dropped, records 2 and 3 read as zeros and the mount carries on until
check_mft_mirror() finds the zeroed record 2.

Fail the lookup whenever the retry leaves @vcn unmapped, -ENOENT
included.  At or beyond allocated_size nothing is mapped, so do not
retry there: the runlist ends with LCN_ENOENT, or with LCN_RL_NOT_MAPPED
when only the last extent is mapped, as after a write into it, and with
clusters smaller than a page every read of a file's last folio looks up
such vcns.

A failed expansion in ntfs_non_resident_attr_expand() or
ntfs_attrlist_repack() truncates the runlist under the runlist lock but
restores allocated_size only after dropping it.  A lookup in between
would now fail, so restore allocated_size under the lock in both.

The crafted file now fails from vcn 16 on with -EIO, and the crafted
volume fails to mount with the check's message.

Fixes: 495e90fa3348 ("ntfs: update attrib operations")
Cc: stable@vger.kernel.org
Signed-off-by: Matthias Goergens <matthias.goergens@gmail.com>
---
 fs/ntfs/attrib.c   | 38 +++++++++++++++++++++++++++++++-------
 fs/ntfs/attrlist.c |  8 ++++++--
 2 files changed, 37 insertions(+), 9 deletions(-)

diff --git a/fs/ntfs/attrib.c b/fs/ntfs/attrib.c
index eab4d8d32132f..30d3d2eb5ef3c 100644
--- a/fs/ntfs/attrib.c
+++ b/fs/ntfs/attrib.c
@@ -344,6 +344,23 @@ struct runlist_element *ntfs_attr_vcn_to_rl(struct ntfs_inode *ni, s64 vcn, s64
 		rl++;
 	*lcn = ntfs_rl_vcn_to_lcn(rl, vcn);
 
+	/*
+	 * Nothing is mapped at or beyond the allocated size: the runlist ends
+	 * there with LCN_ENOENT, or with LCN_RL_NOT_MAPPED if only a later
+	 * extent has been mapped.  Return that end as it is.  Below the
+	 * allocated size, an unmapped vcn is worth a retry.
+	 */
+	if (*lcn <= LCN_RL_NOT_MAPPED && !is_retry) {
+		unsigned long flags;
+		s64 allocated_vcn;
+
+		read_lock_irqsave(&ni->size_lock, flags);
+		allocated_vcn = ntfs_bytes_to_cluster(ni->vol, ni->allocated_size);
+		read_unlock_irqrestore(&ni->size_lock, flags);
+		if (vcn >= allocated_vcn)
+			return rl;
+	}
+
 	if (*lcn <= LCN_RL_NOT_MAPPED && is_retry == false) {
 		is_retry = true;
 		err = ntfs_map_runlist_nolock(ni, vcn, NULL);
@@ -354,11 +371,14 @@ struct runlist_element *ntfs_attr_vcn_to_rl(struct ntfs_inode *ni, s64 vcn, s64
 	}
 
 	/*
-	 * The runlist fragment containing @vcn could not be mapped, e.g.
-	 * because the extent mft record holding it is corrupt.  Do not hand
-	 * LCN_RL_NOT_MAPPED back to callers, which would treat it as a hole.
+	 * Neither the runlist nor the retry mapped @vcn, which lies below the
+	 * allocated size, e.g. because the extent mft record holding it is
+	 * corrupt or because the mapping pairs end too soon.
+	 * ntfs_map_runlist_nolock() reports the latter as -ENOENT, as @vcn
+	 * lies past the extent it found.  Callers would treat
+	 * LCN_RL_NOT_MAPPED or LCN_ENOENT here as a hole, so fail instead.
 	 */
-	if (*lcn == LCN_RL_NOT_MAPPED)
+	if (*lcn <= LCN_RL_NOT_MAPPED)
 		return ERR_PTR(err == -ENOMEM ? -ENOMEM : -EIO);
 
 	return rl;
@@ -4703,11 +4723,17 @@ static int ntfs_non_resident_attr_expand(struct ntfs_inode *ni, const s64 newsiz
 	if (err2)
 		ntfs_debug("Leaking clusters");
 
-	/* Now, truncate the runlist itself. */
+	/*
+	 * Now, truncate the runlist itself.  Restore allocated_size before
+	 * dropping the lock: ntfs_attr_vcn_to_rl() fails a lookup below the
+	 * allocated size that falls past the end of the runlist.
+	 */
 	if (ni != locked_ni)
 		down_write(&ni->runlist.lock);
 	err2 = ntfs_rl_truncate_nolock(vol, &ni->runlist,
 			ntfs_bytes_to_cluster(vol, org_alloc_size));
+	if (!err2)
+		ni->allocated_size = org_alloc_size;
 	if (ni != locked_ni)
 		up_write(&ni->runlist.lock);
 	if (err2) {
@@ -4719,8 +4745,6 @@ static int ntfs_non_resident_attr_expand(struct ntfs_inode *ni, const s64 newsiz
 		ni->runlist.rl = NULL;
 		ntfs_error(sb, "Couldn't truncate runlist. Rollback failed");
 	} else {
-		/* Prepare to mapping pairs update. */
-		ni->allocated_size = org_alloc_size;
 		/* Restore mapping pairs. */
 		if (ni != locked_ni)
 			down_read(&ni->runlist.lock);
diff --git a/fs/ntfs/attrlist.c b/fs/ntfs/attrlist.c
index 1bbd2bc62c582..3660e7fd24b13 100644
--- a/fs/ntfs/attrlist.c
+++ b/fs/ntfs/attrlist.c
@@ -168,14 +168,18 @@ static int ntfs_attrlist_repack(struct inode *attr_vi,
 	return 0;
 
 restore_old_runlist:
+	/*
+	 * Restore allocated_size before dropping the runlist lock:
+	 * ntfs_attr_vcn_to_rl() fails a lookup below the allocated size that
+	 * falls past the end of the runlist.
+	 */
 	down_write(&attr_ni->runlist.lock);
 	attr_ni->runlist.rl = old_rl;
 	attr_ni->runlist.count = old_rl_count;
-	up_write(&attr_ni->runlist.lock);
-
 	write_lock_irqsave(&attr_ni->size_lock, flags);
 	attr_ni->allocated_size = old_alloc_size;
 	write_unlock_irqrestore(&attr_ni->size_lock, flags);
+	up_write(&attr_ni->runlist.lock);
 
 	restore_err = ntfs_attr_update_mapping_pairs_locked(
 			attr_ni, 0, locked_ni);
-- 
2.55.0


^ permalink raw reply	[flat|nested] 9+ messages in thread

* [PATCH v2 5/6] ntfs: fail the mount when $MFT's data size exceeds its allocation
  2026-09-27  5:08   ` [PATCH v2 0/6] ntfs: fix the $MFT bootstrap hang and reads of unmapped runlist ranges Matthias Goergens
                       ` (3 preceding siblings ...)
  2026-09-27  5:08     ` [PATCH v2 4/6] ntfs: do not map a vcn as a hole when its runlist lookup failed Matthias Goergens
@ 2026-09-27  5:08     ` Matthias Goergens
  2026-09-27  5:08     ` [PATCH v2 6/6] ntfs: reject non-resident attributes whose sizes exceed their allocation Matthias Goergens
  5 siblings, 0 replies; 9+ messages in thread
From: Matthias Goergens @ 2026-09-27  5:08 UTC (permalink / raw)
  To: Namjae Jeon, Hyunchul Lee; +Cc: ntfs, linux-kernel

ntfs_read_inode_mount() takes $MFT's i_size from data_size without
checking it against allocated_size, unlike the resident case in
ntfs_read_locked_inode().  mft records between the two are not on disk.

On a crafted volume with 512-byte clusters whose $MFT is cut to 4
clusters while data_size still says 27 records, reading the folio
holding records 0-3 finds the end of the runlist at vcn 4.  An unpatched
kernel hangs there on the folio lock, as in "ntfs: fail the mount when
$MFT needs its own extent records".  With the previous patch vcn 4-7 are
past the allocation and so read as a hole: records 2 and 3 come back as
zeros and the mount carries on until check_mft_mirror() finds the zeroed
record 2.

Refuse such a $MFT before anything is read through it.  The mount now
fails with:

  ntfs: (device vda): ntfs_read_inode_mount(): $MFT data size 27648
    exceeds its allocated size 2048. $MFT is corrupt. Run chkdsk.

fs/ntfs3 rejects any non-resident attribute whose data_size exceeds its
allocated size.  This patch checks only $MFT; the next one covers the
other non-resident attributes.

Fixes: b041ca562526 ("ntfs: update iomap and address space operations")
Cc: stable@vger.kernel.org
Signed-off-by: Matthias Goergens <matthias.goergens@gmail.com>
---
 fs/ntfs/inode.c | 10 ++++++++++
 1 file changed, 10 insertions(+)

diff --git a/fs/ntfs/inode.c b/fs/ntfs/inode.c
index a61f1519549cb..9c97fc3f9e5ff 100644
--- a/fs/ntfs/inode.c
+++ b/fs/ntfs/inode.c
@@ -2136,6 +2136,16 @@ int ntfs_read_inode_mount(struct inode *vi)
 			vi->i_size = le64_to_cpu(a->data.non_resident.data_size);
 			ni->initialized_size = le64_to_cpu(a->data.non_resident.initialized_size);
 			ni->allocated_size = le64_to_cpu(a->data.non_resident.allocated_size);
+			/*
+			 * Records between allocated_size and data_size are not
+			 * on disk, and would be read as zeros.
+			 */
+			if (vi->i_size > ni->allocated_size) {
+				ntfs_error(sb,
+					   "$MFT data size %lld exceeds its allocated size %lld. $MFT is corrupt. Run chkdsk.",
+					   vi->i_size, ni->allocated_size);
+				goto put_err_out;
+			}
 			/*
 			 * Verify the number of mft records does not exceed
 			 * 2^32 - 1.
-- 
2.55.0


^ permalink raw reply	[flat|nested] 9+ messages in thread

* [PATCH v2 6/6] ntfs: reject non-resident attributes whose sizes exceed their allocation
  2026-09-27  5:08   ` [PATCH v2 0/6] ntfs: fix the $MFT bootstrap hang and reads of unmapped runlist ranges Matthias Goergens
                       ` (4 preceding siblings ...)
  2026-09-27  5:08     ` [PATCH v2 5/6] ntfs: fail the mount when $MFT's data size exceeds its allocation Matthias Goergens
@ 2026-09-27  5:08     ` Matthias Goergens
  5 siblings, 0 replies; 9+ messages in thread
From: Matthias Goergens @ 2026-09-27  5:08 UTC (permalink / raw)
  To: Namjae Jeon, Hyunchul Lee; +Cc: ntfs, linux-kernel

ntfs_read_locked_inode(), ntfs_read_locked_attr_inode() and
ntfs_read_locked_index_inode() take a non-resident attribute's data_size
and initialized_size from disk without checking them against its
allocated_size.  The runlist ends at allocated_size and the read path
maps what lies beyond it as a hole, so a data_size larger than the
allocation makes reads return zeros that are not on disk, with no error.

On a crafted volume with 4 KiB clusters where a 6144000-byte file claims
a data_size and initialized_size 64 KiB beyond its allocation, reading
the file returns 16 clusters of such zeros.  A sparse file behaves the
same.  A compressed file instead fails with -EIO after a burst of "Still
have pages left!" errors from ntfs_read_compressed_block().

Require 0 <= initialized_size <= data_size <= allocated_size, with
allocated_size a multiple of the cluster size, when the inode is read,
as fs/ntfs3 does in mi_enum_attr(), and fail with -EIO otherwise, which
marks the inode bad and the volume as having errors.  initialized_size
above data_size is rejected too because an extending write zeroes the
gap it opens only from initialized_size onwards.  An unaligned
allocated_size ends inside a cluster that the read path treats as past
the end: a crafted 66440-byte file with 4 KiB clusters reads its last
904 bytes as zeros.

Sparse and compressed attributes need no exception.  Every non-resident
attribute has a cluster-aligned allocated_size >= data_size, holes
included, on eight public test volumes (seven written by Windows, with
LZNT1-compressed files, a sparse $UsnJrnl:$J and a OneDrive placeholder
whose unnamed $DATA is one hole, and one by mkntfs), on a volume written
by ntfs-3g and on one written by this driver, and the patched driver
reads every file on them as before.  fs/ntfs3 has enforced the same
checks since v6.6, also without exceptions.  The layout.h comment saying
that data_size can exceed allocated_size for compressed and sparse
attributes is corrected.

This also covers a non-resident attribute list, which
load_attribute_list() reads through ntfs_attr_iget(), and $MFT, whose
inode goes through ntfs_read_locked_inode() after the previous patch's
check.  The check was already missing in the classic driver; the Fixes
tag names the commit that brought that code back.

Fixes: 1e9ea7e04472 ("Revert "fs: Remove NTFS classic"")
Signed-off-by: Matthias Goergens <matthias.goergens@gmail.com>
---
 fs/ntfs/inode.c  | 38 ++++++++++++++++++++++++++++++++++++++
 fs/ntfs/layout.h | 13 ++++++++-----
 2 files changed, 46 insertions(+), 5 deletions(-)

diff --git a/fs/ntfs/inode.c b/fs/ntfs/inode.c
index 9c97fc3f9e5ff..126c50b5a349e 100644
--- a/fs/ntfs/inode.c
+++ b/fs/ntfs/inode.c
@@ -651,6 +651,38 @@ void ntfs_set_vfs_operations(struct inode *inode, mode_t mode, dev_t dev)
 	}
 }
 
+/*
+ * The clusters of a non-resident attribute end at its allocated size.  Past
+ * that there is nothing on disk to read, and past the initialized size reads
+ * return zeros and writes zero the gap they open, so the sizes must satisfy
+ * 0 <= initialized_size <= data_size <= allocated_size, with allocated_size
+ * a multiple of the cluster size.
+ *
+ * Sparse and compressed attributes get no exception.  Windows, ntfs-3g and
+ * this driver all count holes in allocated_size (the clusters actually in use
+ * are in compressed_size), including for streams that are entirely a hole,
+ * and fs/ntfs3 has applied the same check to every non-resident attribute in
+ * mi_enum_attr() since v6.6.
+ */
+static bool ntfs_non_resident_sizes_inconsistent(struct inode *vi,
+						 const struct attr_record *a)
+{
+	s64 allocated_size = le64_to_cpu(a->data.non_resident.allocated_size);
+	s64 data_size = le64_to_cpu(a->data.non_resident.data_size);
+	s64 initialized_size = le64_to_cpu(a->data.non_resident.initialized_size);
+
+	if (initialized_size >= 0 && initialized_size <= data_size &&
+	    data_size <= allocated_size &&
+	    !ntfs_bytes_to_cluster_off(NTFS_I(vi)->vol, allocated_size))
+		return false;
+
+	ntfs_error(vi->i_sb,
+		   "Attribute 0x%x of inode 0x%llx is corrupt (initialized size %lld, data size %lld, allocated size %lld).",
+		   le32_to_cpu(a->type), NTFS_I(vi)->mft_no, initialized_size,
+		   data_size, allocated_size);
+	return true;
+}
+
 /*
  * ntfs_read_locked_inode - read an inode from its device
  * @vi:		inode to read
@@ -1184,6 +1216,8 @@ static int ntfs_read_locked_inode(struct inode *vi)
 					"First extent of $DATA attribute has non zero lowest_vcn.");
 				goto unm_err_out;
 			}
+			if (ntfs_non_resident_sizes_inconsistent(vi, a))
+				goto unm_err_out;
 			vi->i_size = ni->data_size = le64_to_cpu(a->data.non_resident.data_size);
 			ni->initialized_size = le64_to_cpu(a->data.non_resident.initialized_size);
 			ni->allocated_size = le64_to_cpu(a->data.non_resident.allocated_size);
@@ -1446,6 +1480,8 @@ static int ntfs_read_locked_attr_inode(struct inode *base_vi, struct inode *vi)
 			ntfs_error(vi->i_sb, "First extent of attribute has non-zero lowest_vcn.");
 			goto unm_err_out;
 		}
+		if (ntfs_non_resident_sizes_inconsistent(vi, a))
+			goto unm_err_out;
 		vi->i_size = ni->data_size = le64_to_cpu(a->data.non_resident.data_size);
 		ni->initialized_size = le64_to_cpu(a->data.non_resident.initialized_size);
 		ni->allocated_size = le64_to_cpu(a->data.non_resident.allocated_size);
@@ -1675,6 +1711,8 @@ static int ntfs_read_locked_index_inode(struct inode *base_vi, struct inode *vi)
 			"First extent of $INDEX_ALLOCATION attribute has non zero lowest_vcn.");
 		goto unm_err_out;
 	}
+	if (ntfs_non_resident_sizes_inconsistent(vi, a))
+		goto unm_err_out;
 	vi->i_size = ni->data_size = le64_to_cpu(a->data.non_resident.data_size);
 	ni->initialized_size = le64_to_cpu(a->data.non_resident.initialized_size);
 	ni->allocated_size = le64_to_cpu(a->data.non_resident.allocated_size);
diff --git a/fs/ntfs/layout.h b/fs/ntfs/layout.h
index 8f5792139d719..2de83d4ca40b9 100644
--- a/fs/ntfs/layout.h
+++ b/fs/ntfs/layout.h
@@ -811,14 +811,17 @@ enum {
  *                                  on XP SP2+.
  * @data.non_resident.reserved:     5 bytes for 8-byte alignment.
  * @data.non_resident.allocated_size:
- *                                  Allocated disk space in bytes.
- *                                  For compressed: logical allocated size.
+ *                                  Allocated size in bytes, a multiple of
+ *                                  the cluster size.  For compressed and
+ *                                  sparse attributes holes count as
+ *                                  allocated; the clusters actually in use
+ *                                  are in compressed_size.
  * @data.non_resident.data_size:    Logical attribute value size in bytes.
- *                                  Can be larger than allocated_size if
- *                                  compressed/sparse.
+ *                                  Never larger than allocated_size, also
+ *                                  when compressed/sparse.
  * @data.non_resident.initialized_size:
  *                                  Initialized portion size in bytes.
- *                                  Usually equals data_size.
+ *                                  Usually equals data_size, never larger.
  * @data.non_resident.compressed_size:
  *                                  Compressed on-disk size in bytes.
  *                                  Only present when compressed or sparse.
-- 
2.55.0


^ permalink raw reply	[flat|nested] 9+ messages in thread

end of thread, other threads:[~2026-09-27  5:08 UTC | newest]

Thread overview: 9+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2026-09-22 15:39 [PATCH] ntfs: fail the mount when $MFT needs its own extent records Matthias Goergens
2026-09-23  1:16 ` Hyunchul Lee
2026-09-27  5:08   ` [PATCH v2 0/6] ntfs: fix the $MFT bootstrap hang and reads of unmapped runlist ranges Matthias Goergens
2026-09-27  5:08     ` [PATCH v2 1/6] ntfs: do not map an unmappable runlist fragment as a hole Matthias Goergens
2026-09-27  5:08     ` [PATCH v2 2/6] ntfs: do not turn an unmappable runlist fragment into delalloc on write Matthias Goergens
2026-09-27  5:08     ` [PATCH v2 3/6] ntfs: fail the mount when $MFT needs its own extent records Matthias Goergens
2026-09-27  5:08     ` [PATCH v2 4/6] ntfs: do not map a vcn as a hole when its runlist lookup failed Matthias Goergens
2026-09-27  5:08     ` [PATCH v2 5/6] ntfs: fail the mount when $MFT's data size exceeds its allocation Matthias Goergens
2026-09-27  5:08     ` [PATCH v2 6/6] ntfs: reject non-resident attributes whose sizes exceed their allocation Matthias Goergens

This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox

all inboxes | Powered by JetHome®