NTFS3 ntfs_dir_emit OOB-read: CVE-2023-45896, still alive
Greg KH disputed CVE-2023-45896, CISA reasserted it, and nothing landed
upstream. i pulled up fs/ntfs3/dir.c expecting stale code and
found the same unit confusion still sitting there.
ntfs_dir_emit() checks an attacker-controlled directory entry's
fname->name_len against the entry's e->size
and treats both as bytes. they aren't the same unit.
fname->name_len counts UTF-16 code units
(fs/ntfs3/ntfs.h:575), so the check passes on entries it
shouldn't. then ntfs_utf16_to_nls() at :311 reads
name_len × 2 bytes from e + 0x52 - up to ~510
of them - and runs straight off the entry into adjacent slab memory.
the leak path is annoyingly clean. those bytes leave the kernel
and come back to userspace as the directory entry's name through
dir_emit(ctx, name, name_len, ino, dt_type), which any
unprivileged process can read with getdents64 on the mounted
directory. the slab neighbour is a kmalloc-1024 object, so what you get is a
controlled-length read out of adjacent kernel memory.
prior art. this is not novel
dfir.ru disclosed this on 2023-08-13 as CVE-2023-45896. Greg KH disputed the
CVE, CISA reasserted it at CVSS 7.1 HIGH, and nothing landed upstream.
mainline still carries the unit-confused bound check at
fs/ntfs3/dir.c:308 today, and the same primitive still fires.
i'm not claiming the bug. i verified it's still alive on current Resolute
26.04 LTS production kernels and on mainline 7.1-rc1, found the sibling in
cmp_fnames, and built a PoC image and harness. cite dfir.ru first.
root cause
/* fs/ntfs3/dir.c:308 */
if (fname->name_len + sizeof(struct NTFS_DE) > le16_to_cpu(e->size))
return true; /* "valid" -- skip the entry */
/* fs/ntfs3/dir.c:311 */
name_len = ntfs_utf16_to_nls(sbi, fname->name, fname->name_len, name, PATH_MAX);
fname->name_len is in UTF-16 code units
(fs/ntfs3/ntfs.h:575). the byte size of the name field is what
fname_full_size(fname) returns (ntfs.h:590). the
check at :308 compares the code-unit count against a byte size, so an entry
with e->size=0x111, key_size=0x44,
name_len=0xFF sails through. then
ntfs_utf16_to_nls() reads 510 bytes - 255 UTF-16 chars times 2 -
starting at e + 0x52, well past the entry boundary and into
whatever sits next in the slab.
there's a sibling in the same family. cmp_fnames() at
fs/ntfs3/index.c:34 takes e->key_size as its
l2 without checking it against
e->size - sizeof(NTFS_DE) first. it's reachable through
hdr_find_e() (index.c:715-793) on the
lookup() path. same unit confusion, different entry point.
verification (KASAN, Resolute lab)
i built a KASAN lab kernel at 7.0.0-kasan-kcov #15.15.kasan. one
ls /mnt/poc as uid 1000 against the included poc.img
trips it:
BUG: KASAN: slab-out-of-bounds in ntfs_utf16_to_nls+0x24e/0x380 [ntfs3]
Read of size 2 at addr ffff88810d484400 by task ls/5057
CPU: 3 UID: 1000 PID: 5057 Comm: ls Not tainted 7.0.0-kasan-kcov #15.15.kasan PREEMPT(lazy)
Call Trace:
ntfs_utf16_to_nls+0x24e/0x380 [ntfs3]
ntfs_read_hdr+0x43f/0x670 [ntfs3]
ntfs_readdir+0x875/0xab0 [ntfs3]
iterate_dir
__x64_sys_getdents64
The buggy address is located 0 bytes to the right of
allocated 1024-byte region [...]
Allocated by task ...:
mi_init+0x4b/0x80 [ntfs3] /* kmalloc(record_size, GFP_NOFS) */
the read lands in adjacent kmalloc-1024 slab memory. groom the heap and that neighbour slot holds what you want it to - attacker-chosen objects, or something sensitive you've steered into place. either way the bytes return to userspace as the directory entry's name. i didn't finish a full weaponization chain here; the read primitive is what i can stand behind.
reproducer
# image is pre-built and included as poc.img (64 MB NTFS).
# crafter source: craft_poc.py (re-buildable from base.img if needed).
sudo modprobe ntfs3
sudo mkdir -p /mnt/poc
sudo losetup -fP poc.img
sudo mount -t ntfs3 -o ro,iocharset=iso8859-1 /dev/loopN /mnt/poc
ls /mnt/poc # KASAN trip on KASAN kernel
gcc -O2 -o /tmp/harness harness.c
/tmp/harness /mnt/poc # dumps OOB bytes to stdout
the iocharset=iso8859-1 mount option is Resolute's default for
ntfs3. nothing here is a contrived flag. it's how the filesystem comes up when
udisks2 attaches a real Windows NTFS partition on a dual-boot box.
reach
fs/ntfs3/super.c doesn't set FS_USERNS_MOUNT, so
mounting a hostile NTFS3 image wants init_user_ns CAP_SYS_ADMIN. that gate
sounds like it closes the door. it doesn't. here's where the mount happens
anyway, with no sudo on the attacker's side:
- cloud / multi-tenant hosts loop-mounting customer NTFS images: EBS volume snapshots, container image extraction, file-scanning pipelines. the host mounts on behalf of an image producer it doesn't trust.
- container build hosts chewing through attacker virtio-blk or dm-verity images.
- PXE / imaging rigs running post-install diagnostics on whatever drive is attached.
- Windows-dual-boot Linux desktops where udisks2 auto-mounts the Windows partition on boot. Windows wrote that partition. if a prior Windows compromise rewrote a directory's metadata, the next Linux mount-and-list does the OOB read for you.
- USB auto-mount via udisks2: hand an unprivileged user a USB stick, the desktop session auto-mounts it, the user lists the directory, done.
suggested fix
/* fs/ntfs3/dir.c:308 -- use the correct byte-size accessor */
if (fname_full_size(fname) + sizeof(struct NTFS_DE) > le16_to_cpu(e->size))
return true;
and while you're in there, tighten hdr_find_e at
index.c:741-744 so it checks
e_key_len + sizeof(NTFS_DE) <= e_size before it calls
cmp_fnames.
cross-distro applicability
| distro / kernel | status |
|---|---|
| Ubuntu Resolute 7.0.0-15 | vulnerable (live verified, KASAN trace above) |
| Arch Linux 7.0.3.arch1 | vulnerable (source verified) |
| Mainline 7.1-rc1 | vulnerable (source verified) |
downloads
- poc.img.gz: pre-built 64 MB NTFS PoC image, gzipped (~190 KB on the wire).
gunzip poc.img.gzto extract. - craft_poc.py: image patcher; rebuild the PoC from a clean baseline if you don't trust the prebuilt.
- harness.c: userspace
getdents64dumper that prints the leaked OOB bytes to stdout. - repro.sh: orchestrator: losetup + mount + harness invocation.
- kasan-output.txt: full KASAN slab-out-of-bounds report.
- evidence.txt: pre-KASAN run output showing in-record OOB.
SHA-256:
d3bf890db593e0a27064c3b3b8c2b102082e0660d69bdd1ccf60fdab90616d43 poc.img.gz
7deba7ab5df37d82b8ce6da4939f5f7ccacf0da1b32b90c257b2dd65b346d7e0 craft_poc.py
42a7833e04a04f1e8b873aeddd4037693c33e5434a7a851cfa6d466e79bad846 harness.c
54a3926fe61773b2ec7952e02d1978603ecdfb7d7b30cdc7ada642ebf4680ca9 repro.sh
fbd84f8889f9d5ff52a46a72f46d41b362bd1e893dacde4fca5bba55d3516af1 kasan-output.txt
3ac909a283ec43172f68d90a5c0b5e4299e330d6f351347b77e6ba3c6e264c01 evidence.txt
credit
dfir.ru. original 2023-08-13 disclosure (CVE-2023-45896).
CISA. CVSS 7.1 HIGH assignment.
_SiCk <sick@afflicted.sh> (https://afflicted.sh). verification still alive on current kernels, sibling-bug identification (cmp_fnames), pre-built PoC image and dumper harness.
no warranty. no support. no apologies.
. _SiCk · afflicted.sh