β¬’ DragonFlyBSD Kernel Audit
← triage Β· dashboard
DF-0933

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 uup this 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).

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

Timeline

  • 2026-07-05 Discovered during automated audit.
  • pending Reported to DragonFlyBSD security contact.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0933 Β· 17 files
FileTypeDescriptionSize
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
README.md readme human reproduce doc
↓ download 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] underflows buf and reads up to ~2 KB of preceding kernel heap (ntfs_compr.c:79,82).
  • DF-0933 (Medium): Compressed branch leaves output tail uninitialized; uup allocated with M_WAITOK (no M_ZERO) and reused across compression units, so stale slab content is shipped to the reader (ntfs_compr.c:70-92).
  • DF-0934 (Medium): ntfs_uncompunit accumulates off += new (new = len+3, attacker-controlled 3..4098) with no bound against cup size; on the last block of a unit the GET_UINT16/cbuf[] reads overshoot cup (ntfs_compr.c:55,71,79-80,109).

Files (shared)

  • patch_img.py β€” locates the first non-resident compressed $DATA attribute of a file in the MFT of base.ntfs and 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.
VERDICT.md verdict full narrative: mechanism, evidence, fix validation
↓ download raw

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:

  1. Uncompressed branch (ntfs_compr.c:59-67): if the block header's bit15 is clear, the data is stored raw. It memcpys then explicitly zeroes the tail at :65: c bzero(buf + len + 1, NTFS_COMPBLOCK_SIZE - 1 - len);
  2. Compressed branch (ntfs_compr.c:68-92): if bit15 is set, the LZ77 decode loop advances pos as 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 :92 it returns len+3 with no zeroing of buf[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

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED 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.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Sun Jul 12 11:40:33 UTC 2026

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.