Compressed NTFS block leaves output tail uninitialized - stale kernel-heap info leak
| Field | Value |
|---|---|
| ID | DF-0933 |
| Status | new |
| Severity | Medium |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N |
| CWE | CWE-908 Use of Uninitialized Resource |
| File | sys/vfs/ntfs/ntfs_compr.c |
| Lines | 70-92 |
| Area | vfs |
| Confidence | certain |
| Discovered | 2026-07-05 |
| Reported | pending |
| Known CVE | none |
| CVE match | dfly_specific |
Summary
When a compressed NTFS block's stream terminates early (cpos reaches
len+3 before pos reaches NTFS_COMPBLOCK_SIZE),
ntfs_uncompblock returns without writing buf[pos..4095]. Unlike the
uncompressed path which explicitly bzeros the tail (line 65), the
compressed path never clears it. Because the caller's output buffer
uup is allocated with kmalloc(M_WAITOK) (no M_ZERO) and is reused
across compression units, the unwritten tail contains stale kernel heap
data which is then copied to the reader.
Root cause
ntfs_compr.c:70:
while ((cpos < len + 3) && (pos < NTFS_COMPBLOCK_SIZE)) {
...
}
exits as soon as the attacker-controlled compressed payload is consumed,
regardless of how many output bytes were produced. The loop body
(:71-90) only writes buf entries as it advances pos; there is no
zeroing of buf[pos..NTFS_COMPBLOCK_SIZE-1] before the
return len + 3; at :92.
Contrast the uncompressed branch at ntfs_compr.c:65:
bzero(buf + len + 1, NTFS_COMPBLOCK_SIZE - 1 - len);
β that asymmetry is direct evidence that the tail is expected to be defined.
ntfs_subr.c:1689-1690 allocates uup with
kmalloc(..., M_NTFSDECOMP, M_WAITOK) (no M_ZERO; confirmed at
ntfs_subr.c:1687-1690) and reuses the same buffer for every
compression unit in the read loop (ntfs_subr.c:1695-1732). A read of
the unwritten region (ntfs_subr.c:1722-1725 uiomove/memcpy of
uup+off, tocopy) therefore returns whatever the slab previously
held.
Threat model & preconditions
- Attacker position: Anyone who can deliver a crafted NTFS image.
- Privileges gained or impact: Leaks up to 4095 bytes of stale
kernel heap per block. On first use of
uupthis is uninitialized slab content (previous freed object); on subsequent units it is data from a previously-decompressed (possibly different) file, i.e. a cross-file information disclosure. No panic; pure confidentiality impact. - Required config or capabilities: Same mounting precondition as DF-0932.
- Reachability:
mount -t ntfs+read()of the compressed file with a malformed/short compressed block.
Proof of concept
PoC source: findings/poc/DF-0933/
Build & run
# 1. Build an NTFS image with a compressed file; patch one compression # unit's block-0 header to claim a small compressed payload: # header 0x8001: bit15 set, len=1 # followed by a single ctag byte 0x00 (one literal) # Total payload 2 bytes, pos ends at 1, buf[1..4095] unwritten. python3 patch_img.py base.ntfs evil.ntfs # 2. Mount and read: mount -t ntfs -o ro evil.ntfs /mnt dd if=/mnt/file bs=4096 skip=<unit> count=1 of=leak.bin # any reader # 3. Inspect leak.bin bytes [1..4095] β they are stale uup slab # content, not the file's real data. hexdump -C leak.bin | head
Expected output
leak.bin contains recognizable kernel pointers / data not present
anywhere in the mounted image. Run a workload that causes the kernel to
allocate/free sensitive objects into the M_NTFSDECOMP slab just
before the read to maximize leak value; repeat reads across compression
units to show cross-file content correlation.
Impact
Local kernel heap information disclosure (up to 4095 bytes per block). Pure confidentiality impact (no panic, no corruption).
Recommended fix
Zero the unwritten tail of the output block before returning from the compressed path, mirroring the uncompressed branch:
--- a/sys/vfs/ntfs/ntfs_compr.c
+++ b/sys/vfs/ntfs/ntfs_compr.c
@@ -88,6 +88,9 @@ ntfs_uncompblock(u_int8_t * buf, u_int8_t * cbuf)
ctag >>= 1;
}
}
+ if (pos < NTFS_COMPBLOCK_SIZE)
+ bzero(buf + pos, NTFS_COMPBLOCK_SIZE - pos);
return len + 3;
}
(Alternatively, and more strictly, reject blocks that terminate with
pos < NTFS_COMPBLOCK_SIZE by returning 0, since a well-formed
non-final compressed LZNT1 block must decompress to exactly 4096 bytes.)
References
sys/vfs/ntfs/ntfs_subr.c:1687-1690βuupallocated withkmalloc(M_WAITOK), noM_ZERO.sys/vfs/ntfs/ntfs_compr.c:65β the uncompressed branch that correctlybzeros the tail (asymmetry evidence).sys/vfs/ntfs/ntfs_subr.c:1695-1732βuupreused across compression units.
Timeline
- 2026-07-05 Discovered during automated audit.
- pending Reported to DragonFlyBSD security contact.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0933 Β· 17 files| File | Type | Description | Size | |
|---|---|---|---|---|
| harness.c | trigger-source | deterministic line-for-line transcription of ntfs_uncompblock; poisoned-buffer proof the compressed branch never zeroes buf[pos..4095] | 6.7 KB | view raw |
| craft_img.py | trigger-source | builds a mountable NTFS image whose compressed file F starts with the 3-byte DF-0933 trigger (01 80 00) | 12.2 KB | view raw |
| build.sh | build-script | crafts the NTFS image on the host | 357 B | view raw |
| run.sh | run-script | harness + mount + read sequence (root mounts, maxx reads) | 1.5 KB | view raw |
| fix.diff | suggested-fix | git-apply-able: bzero(buf+pos, NTFS_COMPBLOCK_SIZE-pos) before compressed-branch return | 278 B | view raw |
| build.log | build-log | harness compile output (cc -O2), BUILD_EXIT=0 | 13 B | view raw |
| run.log | run-log | decisive run: harness LEAK CONFIRMED + live 1358 nonzero bytes with kvaddr pointers, root+maxx identical | 2.5 KB | view raw |
| leak_sample.txt | leak-sample | full 4096-byte live leak as maxx; 177 kvaddr-shaped qwords (high32==0x0008) | 17.3 KB | view raw |
| env.txt | environment | uname, cc version, vfs.usermount | 279 B | view raw |
| fix_baseline.log | fix-baseline | #0 unpatched: 1358 nonzero bytes incl 0x0008006934c0 | 205 B | view raw |
| fix_build.log | fix-build-log | make -j6 nativekernel full output, NK_DONE rc=0 | 5.6 MB | β download |
| fix_run.log | fix-run-log | #1 patched: 0 nonzero bytes across 3 reads | 339 B | view raw |
| VERDICT.md | verdict | full narrative: mechanism, evidence, fix validation | 6.1 KB | β raw |
| manifest.json | manifest | this catalog | 3.5 KB | view raw |
| README.md | readme | human reproduce doc | 3.8 KB | β raw |
| ../fix_build_combined.log | build-log | Combined 41-finding kernel build (rc=0, -Werror clean) | 5.6 MB | β download |
| ../fix_build_summary.txt | build-summary | Summary of the combined 41-finding kernel build | 826 B | view raw |
DF-0932 / DF-0933 / DF-0934 β shared PoC scaffolding
Goal
Reproduce three distinct NTFS LZNT1 decompression bugs reachable by any local user reading a crafted compressed file:
- DF-0932 (High): LZ77 back-reference offset not bounded to current
output position β
buf[pos+boff]underflowsbufand reads up to ~2 KB of preceding kernel heap (ntfs_compr.c:79,82). - DF-0933 (Medium): Compressed branch leaves output tail
uninitialized;
uupallocated withM_WAITOK(noM_ZERO) and reused across compression units, so stale slab content is shipped to the reader (ntfs_compr.c:70-92). - DF-0934 (Medium):
ntfs_uncompunitaccumulatesoff += new(new = len+3, attacker-controlled3..4098) with no bound againstcupsize; on the last block of a unit theGET_UINT16/cbuf[]reads overshootcup(ntfs_compr.c:55,71,79-80,109).
Files (shared)
patch_img.pyβ locates the first non-resident compressed$DATAattribute of a file in the MFT ofbase.ntfsand overwrites the first bytes of its on-disk compression unit with one of the three trigger payloads below. Run with--variant oob|tail|input.
Trigger payloads
DF-0932 (oob) 0x02 0x80 0x01 0x00 0xF0
block header 0x8002 (compressed, len=2), ctag=0x01
(token is back-ref), GET_UINT16=0xF000 at pos=0 ->
boff=-1-(0xF000>>12)=-1-15=-16, blen=3 -> reads
buf[-16..-14] into buf[0..2].
DF-0933 (tail) 0x01 0x80 0x00
block header 0x8001 (compressed, len=1), ctag=0x00
(one literal). Payload 2 bytes, pos ends at 1,
buf[1..4095] left uninitialized.
DF-0934 (input) set every 4096-byte block header in the compression
unit to 0xFF 0x8F (GET_UINT16=0x8FFF: compressed,
len=0xFFF=4095, new=4098). After 16 blocks off
reaches 16*4098=65568 > 65536; the last block's
GET_UINT16/cbuf[] reads overrun cup.
Build & run (DragonFlyBSD guest)
# 1. Build a base NTFS image (mkntfs from ntfs-3g), large enough to # hold a 16-cluster (64 KB) compressed file. Create the file, mark # it compressed, populate with a known pattern, and let ntfs-3g # write the compressed representation to disk. # 2. Patch: python3 patch_img.py --variant oob base.ntfs evil.ntfs # DF-0932 python3 patch_img.py --variant tail base.ntfs evil.ntfs # DF-0933 python3 patch_img.py --variant input base.ntfs evil.ntfs # DF-0934 # 3. Mount and read (root mounts; any reader triggers): mount -t ntfs -o ro evil.ntfs /mnt dd if=/mnt/secret.bin bs=4096 count=1 | hexdump -C | head
Expected output
DF-0932 (oob)
Either a kernel panic on the underread page boundary, OR a read buffer whose first 16 bytes are kernel heap pointers/data not present anywhere in the mounted image (info leak). On INVARIANTS kernels the panic is near-immediate; on production kernels the leak is silent.
DF-0933 (tail)
No panic; leak.bin bytes [1..4095] are stale uup slab content
(recognizable kernel pointers / data not present in the file). Repeat
reads across compression units shows cross-file content correlation.
DF-0934 (input)
Either a kernel panic in ntfs_uncompblock (page-fault-on-read at
cup_size + small delta), or β if the adjacent slab is mapped β
subtly wrong decompression output whose tail bytes are heap content
from the slab neighbor of cup.
Notes
- DragonFly NTFS is read-only; the malicious bytes must be written into the image offline (Python/script), not via the kernel.
- The three findings share the same mount-and-read trigger; they differ only in the patched bytes.
- All three are fixed by validating the LZ77 displacement
(
pos + boff >= 0), zeroing the output tail, and bounding the input offset against the compression-unit size.
DF-0933 β Compressed NTFS block leaves output tail uninitialized β stale kernel-heap info leak
Verdict
REPRODUCED β confirmed two ways: (1) deterministic userspace harness
transcribing ntfs_uncompblock line-for-line shows the output tail retains a
pre-poisoned sentinel; (2) live on the default #0 GENERIC kernel, a crafted
NTFS image with a short compressed block leaks 1358 non-zero bytes of stale
kernel heap (kernel-virtual pointers in the 0x0008_0073_xxxx range) to any
reader of the mounted file β root or unprivileged maxx (uid 1001), identical.
Impact: leak:4095 (up to 4088 bytes/block on this trigger; the finding's
theoretical ceiling is 4095). Pure confidentiality (CWE-908); no write, no
corruption, no escalation chain.
Fix: VALIDATED β a one-line bzero(buf+pos, NTFS_COMPBLOCK_SIZE-pos)
before the compressed-branch return drops the leak from 1358 β 0 non-zero
bytes on a built-and-booted single-fix #1 kernel.
Mechanism (trigger β primitive β effect)
ntfs_uncompblock() (sys/vfs/ntfs/ntfs_compr.c) decompresses one 4096-byte
LZNT1 block into buf:
- Uncompressed branch (
ntfs_compr.c:59-67): if the block header's bit15 is clear, the data is stored raw. Itmemcpys then explicitly zeroes the tail at:65:c bzero(buf + len + 1, NTFS_COMPBLOCK_SIZE - 1 - len); - Compressed branch (
ntfs_compr.c:68-92): if bit15 is set, the LZ77 decode loop advancesposas it writes output bytes. The loop exits as soon as the compressed payload is consumed (cpos >= len+3) regardless of how many output bytes were produced. At:92it returnslen+3with no zeroing ofbuf[pos..NTFS_COMPBLOCK_SIZE-1].
That asymmetry (the uncompressed branch zeroes, the compressed branch does not) is direct evidence the tail is expected to be defined.
Caller (sys/vfs/ntfs/ntfs_subr.c:1687-1690): the output buffer uup is
kmalloc(65536, M_NTFSDECOMP, M_WAITOK) β no M_ZERO β and is reused for
every compression unit in the read loop (:1695-1732). The decompressed bytes
are shipped to the reader via uiomove(uup+off, tocopy, uio) at
:1722-1725. Whatever the slab previously held in the unwritten tail
uup[pos..] reaches userspace.
Trigger block (01 80 00)
header 0x8001 (LE): bit15=1 COMPRESSED, len = 1 ctag 0x00: all 8 sub-tokens are LITERALS
Trace of ntfs_uncompblock on this block:
- len=1; cpos=2; pos=0
- outer while cpos(2) < len+3(4) β true; ctag=cbuf[2]=0x00; cpos=3
- inner for i=0..7 (ctag==0 β all literals): writes buf[0..7]=cbuf[3..10] (zeros); pos=8; cpos=11
- outer while cpos(11) < 4 β false β EXIT
- return len+3 = 4
- β buf[8..4095] never written, never zeroed β 4088 bytes of stale heap
Evidence
Deterministic harness (harness.c, transcribes ntfs_compr.c:46-93):
pre-poisons the output buffer with 0xDE + an ASCII marker, runs the trigger,
then inspects: 4072/4088 tail bytes still hold the sentinel (the other 16
are the ASCII marker, also intact). LEAK CONFIRMED.
Live on #0 GENERIC (after heap warming): reading /mnt/evil/F as maxx:
00 00 00 00 00 00 00 00 d8 33 73 00 08 00 00 00 <- 0x0008007333d8 (kvaddr) e0 33 73 00 08 00 00 00 88 30 73 00 08 00 00 00 <- more kernel pointers 90 30 73 00 08 00 00 00 98 30 73 00 08 00 00 00
- 1358 non-zero bytes in the first 4096 (stable across 3 reads).
- 177 kernel-virtual-address-shaped qwords (high-32 ==
0x0008). - Identical for root and unprivileged maxx (uid 1001).
- First 8 bytes are
0x00(the 8 literals from the zero-padded trigger) β exactly as the trace predicts β confirming this is block-0's tail, not some other data.
Exploit chain
none β pure info-leak / CWE-908 read primitive. No write, no corruption.
The realistic impact ceiling is disclosure of kernel heap memory (useful for
KASLR defeat β though KASLR is OFF on this guest β or for grooming/confirming a
separate write primitive). Realistic preconditions: an admin has mounted (or
made mountable via vfs.usermount + a root-created image chowned to the user)
an attacker-crafted NTFS image; the trigger is a normal read(2) the
unprivileged user issues. Same threat model as DF-0871/0873/0878/0932
(admin-mount of attacker FS β acceptable per the realistic-threat-model
table).
PoC changes
Created findings/poc/DF-0933/ from scratch (the folder previously held only a
shared README.md). Added:
- harness.c β deterministic line-for-line transcription of
ntfs_uncompblock with a poisoned output buffer; proves the tail is
untouched. (Independent of mount/heap state.)
- craft_img.py β builds a complete mountable NTFS image (boot sector, $MFT
with records 0/4/5/6/10/32, $AttrDef, $INDEX_ROOT, $Bitmap, $UpCase) whose
record-32 file "F" is a compressed file whose 16-cluster unit starts with the
3-byte DF-0933 trigger (01 80 00) zero-padded to 4096. Adapted from
DF-0932's crafter; only the trigger payload differs.
- build.sh / run.sh β exact build + run (harness + image + mount + read).
- fix.diff β the verified one-line fix.
- Full logs: build.log, run.log, leak_sample.txt, fix_baseline.log,
fix_build.log, fix_run.log, env.txt.
Fix (validated)
fix.diff adds the missing tail-zeroing to the compressed branch, mirroring
the uncompressed branch at :65:
--- a/sys/vfs/ntfs/ntfs_compr.c
+++ b/sys/vfs/ntfs/ntfs_compr.c
@@ -89,6 +89,8 @@
ctag >>= 1;
}
}
+ if (pos < NTFS_COMPBLOCK_SIZE)
+ bzero(buf + pos, NTFS_COMPBLOCK_SIZE - pos);
return len + 3;
}
Validation (Phase 8):
- Baseline #0 (unpatched, with-src): same PoC + same heap warming β 1358
non-zero bytes, kernel pointers 0x0008006934c0 etc. in the tail.
- Applied fix.diff β make -j6 nativekernel β rc=0.
- Installed kernel.stripped β rebooted β #1: Sun Jul 12 11:40:33 UTC 2026.
- Same PoC + same warming on #1: 0 non-zero bytes across 3 reads.
Matches the finding markdown's ## Recommended fix proposal (same
bzero(buf+pos, ...) insertion at the same site).
Fix verification
fixedVALIDATED the fix: same PoC leaks 1358 non-zero bytes including kernel kvaddr pointers on unpatched #0 baseline and ZERO non-zero bytes on single-fix #1 kernel across 3 reads.
baseline #0: nonzero=1358, kvaddr 0x0008006934c0. patched #1: nonzero=0, all reads 0.
Confirmed kernel references
Detail
Exploit chain
none (pure info-leak / CWE-908 read primitive; no write, no corruption, no chain derivable). The bug leaks up to 4088 bytes/block of stale M_NTFSDECOMP slab content to any reader of a mounted attacker-crafted NTFS compressed file.
Evidence (decisive lines)
harness: 'LEAK CONFIRMED: buf[8..4095] retains the sentinel' (4072/4088 0xDE bytes). live #0 GENERIC: 1358 nonzero bytes including kvaddr 0x0008007333d8, 177 kvaddr-shaped qwords, stable across 3 reads.
PoC changes
Created findings/poc/DF-0933/ from scratch. Added harness.c, craft_img.py (builds NTFS image with compressed file starting with 3-byte trigger 01 80 00), build.sh/run.sh, fix.diff, and full evidence logs.
Verified recommended fix
Zero the unwritten tail before returning from the compressed branch, mirroring the uncompressed branch at ntfs_compr.c:65: insert 'if (pos < NTFS_COMPBLOCK_SIZE) bzero(buf + pos, NTFS_COMPBLOCK_SIZE - pos);' immediately before 'return len + 3;' at sys/vfs/ntfs/ntfs_compr.c:92. Matches finding markdown's proposal. Full git-apply-able diff in findings/poc/DF-0933/fix.diff.
Verdict
REPRODUCED. The bug is real: ntfs_uncompblock's COMPRESSED branch (sys/vfs/ntfs/ntfs_compr.c:68-92) returns at :92 without zeroing buf[pos..NTFS_COMPBLOCK_SIZE-1], unlike the UNCOMPRESSED branch which explicitly bzeros the tail at :65. The caller's uup buffer is kmalloc'd with M_WAITOK (no M_ZERO) at ntfs_subr.c:1687-1690 and reused across compression units, so the unwritten tail holds stale slab content shipped to the reader via uiomove at ntfs_subr.c:1722-1725. Confirmed two ways: (1) deterministic harness on a 0xDE-poisoned buffer shows 4072/4088 tail bytes retain the sentinel; (2) live on #0 GENERIC a crafted NTFS image with a short compressed block leaks 1358 non-zero bytes including kernel-virtual pointers (0x0008007333d8) to any reader.
No comments yet.