ntfs3: unvalidated c_unit in sparse/compressed attribute header triggers UBSAN shift-out-of-bounds in attr_set_size_ex()

From: Guikun Yang

Date: Tue Nov 03 2026 - 22:39:02 EST


Hi,

Truncating a file whose non-resident attribute carries the sparse or
compressed flag ends in a UBSAN shift-out-of-bounds. The shift amount
is the raw on-disk c_unit byte, which is never bounds-checked on the
ext attribute path.

UBSAN: shift-out-of-bounds in fs/ntfs3/attrib.c:485:9
shift exponent 32 is too large for 32-bit type 'unsigned int'
Call Trace:
__ubsan_handle_shift_out_of_bounds
attr_set_size_ex.cold+0x152/0x1bf [ntfs3]
ntfs_set_size+0x202/0x360 [ntfs3]
ntfs_setattr+0x363/0x6d0 [ntfs3]

## The gap

mi_enum_attr() (fs/ntfs3/record.c) validates each attribute it walks.
For a plain non-resident attribute it rejects any nonzero c_unit:

} else {
if (attr->nres.c_unit)
goto out;
...
}

but the ext branch - first segment of an attribute with
ATTR_FLAG_SPARSED or ATTR_FLAG_COMPRESSED set - checks asize,
total_size alignment and total_size <= alloc_size only. c_unit is
accepted unbounded, and it is a raw u8 at attribute offset 0x22.

The first consumer is attr_set_size_ex() (fs/ntfs3/attrib.c:501):

align = sbi->cluster_size;
if (is_ext) {
align <<= attr_b->nres.c_unit;
...
}

and there are four more shift sites using the same field
(attrib.c:1168, 1732, 2089, 2401).

The kernel itself only ever writes 0 or NTFS_LZNT_CUNIT (4) into
c_unit (fs/ntfs3/frecord.c), so anything above 4 is a state the
filesystem never produces legitimately.

## Observed

The reported exponent tracks the on-disk byte exactly - one run per
forged value, each deterministic:

c_unit = 32 -> "shift exponent 32"
c_unit = 64 -> "shift exponent 64"
c_unit = 255 -> "shift exponent 255"

Trigger is open + ftruncate after mount; no privileges needed.

## Notes on reproduction

Two details make this awkward to reach by blind mutation and worth
spelling out:

- MFT records are covered by the update sequence array. Any field
edit has to undo the USA fixup first and recompute it afterwards,
or the record is rejected as corrupt.
- Converting the attribute to ext form makes total_size land at
offset 0x40, where the runlist used to be. The attribute has to
grow from 0x48 to 0x50, the runlist moves to 0x48, ATTR_END is
re-appended and used is updated.

The reproducer (image builder with USA handling, ready-made image,
crash logs for all three c_unit values) is available on request.

## Suggested fix

Symmetric with the plain-attribute branch in mi_enum_attr():

if (!attr->nres.svcn && is_attr_ext(attr)) {
...
if (tot_size > alloc_size)
goto out;
+ if (attr->nres.c_unit > NTFS_LZNT_CUNIT)
+ goto out;
} else {

Tested on 7.3.0 (ntfs3 as a module), deterministic; the ext branch
and the unbounded shift are unchanged in current mainline.

Thanks,
Guikun Yang