[PATCH] ntfs: fix out-of-bounds write in ntfs_rl_collapse_range() merge path

From: DaeMyung Kang

Date: Mon May 04 2026 - 11:50:53 EST


ntfs_rl_collapse_range() merges the run on the left of the collapsed
region with the run on its right when both are holes. The contiguous
check correctly clamps the index to 1 when @new_1st_cnt is 0:

i = new_1st_cnt == 0 ? 1 : new_1st_cnt;
if (ntfs_rle_lcn_contiguous(&new_rl[i - 1], &new_rl[i])) {

but the merge itself uses the unclamped value:

s_rl = &new_rl[new_1st_cnt - 1];
s_rl->length += s_rl[1].length;

When @new_1st_cnt is 0 this computes &new_rl[-1] and writes 8 bytes
before the kvcalloc'd runlist buffer. The path is reachable through
fallocate(FALLOC_FL_COLLAPSE_RANGE) starting at vcn 0 against an
attribute whose first run after the collapsed region is a hole. In that
case ntfs_rle_lcn_contiguous() returns true because both sides are
LCN_HOLE, so the merge path is entered with @new_1st_cnt still 0.

Use the same clamped index for the merge as for the contiguous check so
the write always lands inside the buffer.

The out-of-bounds write can corrupt an adjacent slab object. On a
non-KASAN kernel, it is reachable after a crafted NTFS volume has been
mounted read-write with the legacy fs/ntfs driver, by a local user that
has write access to the crafted file.

Fixes: 11ccc9107dc4 ("ntfs: update runlist handling and cluster allocator")
Signed-off-by: DaeMyung Kang <charsyam@xxxxxxxxx>
---
Trigger conditions / reproducer notes:

- subsystem: fs/ntfs (legacy NTFS runlist collapse path)
- introduced by: 11ccc9107dc4 ("ntfs: update runlist handling and
cluster allocator"), first released in v7.1-rc1
- affected kernels: v7.1-rc1 through current mainline; also present
in any branch that includes 11ccc9107dc4. Pre-v7.1-rc1 kernels do
not contain this collapse-range code path.
- local: yes
- remote: no direct remote trigger
- authentication required: no
- special privileges required: yes to mount the crafted NTFS volume
read-write in the normal case. After a privileged read-write mount,
no additional privilege is needed for the fallocate trigger if the
caller has write permission on the crafted file.
- filesystem requirement: the legacy fs/ntfs driver must be mounted
read-write against a crafted NTFS volume.
- repeatable: yes, the crafted runlist geometry and collapse range make
the sequence deterministic.

Reproducer summary used during verification:

1. Prepare an NTFS volume containing a file whose runlist makes
@new_1st_cnt equal to 0 and places a hole as the first run after
the collapsed region.
2. Mount the volume read-write with the legacy fs/ntfs driver.
3. Call fallocate(FALLOC_FL_COLLAPSE_RANGE) on the crafted file,
starting at file offset 0.
4. Repeat with different crafted runlist sizes to place the temporary
runlist allocation in different kmalloc caches.

Observed result without this fix:

- KASAN reports a slab out-of-bounds write in
ntfs_rl_collapse_range().
- The write updates the runlist element immediately before the
kvcalloc'd temporary runlist buffer.
- On a non-KASAN kernel, the same trigger corrupts adjacent kmalloc
objects.
- The crafted runlist geometry can steer the temporary runlist
allocation into different kmalloc caches.
- Impact: local kernel heap corruption, reachable only after a
privileged read-write mount of a crafted NTFS volume. Practical
impact in the field is bounded by that mount requirement.

Verification with the fix applied:

- "make M=fs/ntfs modules" succeeds.
- The KASAN QEMU reproducer no longer reports an out-of-bounds write.
- The non-KASAN QEMU observer object is no longer corrupted.

fs/ntfs/runlist.c | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)

diff --git a/fs/ntfs/runlist.c b/fs/ntfs/runlist.c
index da21dbeaaf66..e09bc86a7276 100644
--- a/fs/ntfs/runlist.c
+++ b/fs/ntfs/runlist.c
@@ -2058,8 +2058,12 @@ struct runlist_element *ntfs_rl_collapse_range(struct runlist_element *dst_rl, i
merge_cnt = 0;
i = new_1st_cnt == 0 ? 1 : new_1st_cnt;
if (ntfs_rle_lcn_contiguous(&new_rl[i - 1], &new_rl[i])) {
- /* Merge right and left */
- s_rl = &new_rl[new_1st_cnt - 1];
+ /* Merge right and left.
+ *
+ * Use the clamped @i; new_1st_cnt - 1 would index
+ * new_rl[-1] when @new_1st_cnt == 0.
+ */
+ s_rl = &new_rl[i - 1];
s_rl->length += s_rl[1].length;
merge_cnt = 1;
}
--
2.43.0