[Bug report] qnx6: unvalidated di_filelevels in an inode drives a shift-out-of-bounds in qnx6_block_map() - UBSAN panic at first read
From: Guikun Yang
Date: Sun Nov 01 2026 - 22:19:59 EST
To: linux-kernel@xxxxxxxxxxxxxxx
Cc: linux-fsdevel@xxxxxxxxxxxxxxx, Christian Brauner
Subject: [Bug report] qnx6: unvalidated di_filelevels in an inode drives a
shift-out-of-bounds in qnx6_block_map() - UBSAN panic at first read
Hi,
While testing on-disk field corruption in the QNX6 filesystem driver we
found that the per-inode di_filelevels byte is copied from disk and used
as a shift exponent without any bounds check, while the same field is
bounds-checked for the superblock's two root nodes. A single crafted inode
therefore panics the kernel (UBSAN shift-out-of-bounds) the first time its
data is read.
The code:
fs/qnx6/inode.c, qnx6_iget():
ei->di_filelevels = raw_inode->di_filelevels; /* disk u8, 0..255 */
fs/qnx6/inode.c, qnx6_block_map():
int ptrbits = sbi->s_ptrbits; /* ilog2(s_blocksize / 4) */
int depth = ei->di_filelevels;
...
bitdelta = ptrbits * depth;
levelptr = no >> bitdelta; /* shift >= 32 is undefined */
sbi->s_ptrbits is set from ilog2(s_blocksize / 4) when the superblock is
read, so with 512-byte blocks ptrbits is 7. Any di_filelevels above 4
already gives a shift exponent of 35 or more, and 200 gives 1400.
The asymmetry: qnx6_fill_super() does check the same field for the
superblock's two root nodes:
if (sb1->Inode.levels > QNX6_PTR_MAX_LEVELS) goto out;
if (sb1->Longfile.levels > QNX6_PTR_MAX_LEVELS) goto out;
with QNX6_PTR_MAX_LEVELS = 5 (include/linux/qnx6_fs.h), but ordinary
inodes get no check at all. Note that even the superblock bound is one
level too loose for a 512-byte block size: ptrbits * 5 = 35 >= 32.
Trigger: a 30KB hand-built QNX6 image (little endian, 512-byte blocks),
a root directory with three entries, one of them pointing at an inode
whose di_filelevels is 200. The mount succeeds and the first read of that
file panics:
UBSAN: shift-out-of-bounds in fs/qnx6/inode.c:128:16
shift exponent 1400 is too large for 32-bit type 'unsigned int'
Call Trace:
__ubsan_handle_shift_out_of_bounds
qnx6_get_block.cold
do_mpage_readpage
mpage_readahead
...
Kernel panic - not syncing: UBSAN: panic_on_warn set ...
Reproduced 3/3 on a self-built 7.3.0 kernel (CONFIG_QNX6FS_FS=y,
CONFIG_UBSAN=y, CONFIG_UBSAN_SHIFT=y) under QEMU/KVM. A control image with
di_filelevels = 4 reads the same file normally. The field and the missing
check are unchanged in current mainline.
A natural fix is to reject the inode in qnx6_iget() when di_filelevels
exceeds what ptrbits allows, and to tighten the superblock check to the
same bound. QNX6_PTR_MAX_LEVELS alone is not sufficient for small block
sizes: with ptrbits = 7 the usable maximum is 31 / 7 = 4. Happy to test a
patch.
We report this as a crafted-image robustness issue; the crash is a plain
UBSAN violation reachable from a normal mount and read.
The reproducer (the crafted image, a control image, the run script and
guest init that mount, open and read, and the image builder with the
exact field offsets) is available on request.
Kernel: self-built 7.3.0, CONFIG_QNX6FS_FS=y, CONFIG_UBSAN=y,
CONFIG_KASAN_GENERIC=y; QEMU/KVM x86_64, 1 vCPU.
Thanks,
Guikun Yang