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

LZ77 back-reference offset not bounded to current output position - heap OOB read before output buffer in NTFS decompression

Field Value
ID DF-0932
Status new
Severity High
CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:H
CWE CWE-125 Out-of-bounds Read
File sys/vfs/ntfs/ntfs_compr.c
Lines 74-84
Area vfs
Confidence certain
Discovered 2026-07-05
Reported pending
Known CVE none
CVE match dfly_specific

Summary

ntfs_uncompblock computes the LZ77 displacement scaling (dshift/lmask) from the current output position but never validates that the resulting back-reference stays within already-produced output. With attacker-controlled compressed data, the displacement can exceed pos, so buf[pos+boff] reads before the start of the output buffer. The leaked bytes are written into buf (uup) and returned to userspace via the caller's uiomove/memcpy, yielding a kernel heap info leak; on boundary page crossings it panics (local DoS).

Root cause

ntfs_compr.c:74-78 scales the offset/length split:

for (j = pos - 1, lmask = 0xFFF, dshift = 12; j >= 0x10; j >>= 1) {
    dshift--;
    lmask >>= 1;
}

The loop stops when (pos-1) >> k < 16, which permits offset bits 4+k and a maximum displacement of 2^(4+k). For positions in the lower half of each doubling band (e.g. pos=17 β†’ k=1 β†’ max displacement 32; pos=2049 β†’ k=8 β†’ max displacement 4096), the maximum displacement is ~2Β·pos, i.e. strictly greater than pos.

ntfs_compr.c:79 then sets boff = -1 - (GET_UINT16(cbuf + cpos) >> dshift) and ntfs_compr.c:81-84 copies:

for (j = 0; (j < blen) && (pos < NTFS_COMPBLOCK_SIZE); j++) {
    buf[pos] = buf[pos + boff];
    pos++;
}

with no check that pos + boff >= 0. Because the token is attacker-controlled (cup is raw on-disk data, ntfs_subr.c:1696-1699), the offset field can be set to its max so that pos + boff is negative.

  • Simplest trigger: the very first token of block 0 β€” pos=0, j=-1 so the scaling loop does not run (dshift=12, max displacement 16); a token with the 4-bit offset field = 0xF gives boff=-16 and reads buf[-16..-1].
  • For pos~2049 the underflow reaches ~2 KB before buf.

buf is uup + i*0x1000; for block i=0, buf==uup, so the underflow reads heap memory preceding the uup allocation. The values are copied into uup and the caller (ntfs_subr.c:1722-1725) ships uup+off to the user. No bounds check exists anywhere on the path (the only guard, if (new==0) at ntfs_compr.c:107, is dead because ntfs_uncompblock returns len+3 >= 3).

Threat model & preconditions

  • Attacker position: Anyone who can deliver a crafted NTFS image (USB, downloaded file, network share, removable media, NFS export of an NTFS volume).
  • Privileges gained or impact: Deterministic kernel heap OOB read of up to ~2 KB adjacent to the uup allocation, contents returned to the reader (disclosure of neighboring slab objects β€” potentially credentials/keys), plus a realistic kernel-panic path if the underflow crosses into an unmapped page.
  • Required config or capabilities: NTFS filesystem mounted and the compressed file is read. Mounting requires root (or a setuid mount helper / automount / removable media / NFS export of an NTFS volume); once mounted, any local user permitted to read the file triggers decompression and the leak.
  • Reachability: mount -t ntfs + read() of the compressed file.

Proof of concept

PoC source: findings/poc/DF-0932/

Build & run

# 1. mkfs.ntfs a 1 MB image; create a small file; mark it compressed.
# 2. Locate the file's non-resident $DATA attribute in the MFT and
#    overwrite the first 5 bytes of its compressed compression unit
#    on disk with: 0x02 0x80 0x01 0x00 0xF0
#      0x8002 = compressed block header, len=2 (payload 3 bytes)
#      0x01 = ctag (bit0 set -> first token is a back-reference)
#      0x00 0xF0 -> GET_UINT16=0xF000; at pos=0, dshift=12:
#           boff = -1 - (0xF000>>12) = -1 - 15 = -16
#           blen = 3 + (0xF000 & 0xFFF) = 3
#      -> reads buf[-16..-14] into buf[0..2] (leak)
python3 patch_img.py base.ntfs evil.ntfs

# 3. Mount and read:
mount -t ntfs -o ro evil.ntfs /mnt
dd if=/mnt/secret.bin of=/dev/null bs=4096 count=1     # any user

Expected output

The first 16 bytes returned by read() are kernel heap bytes that preceded the uup allocation, not file content. Hex-dumping the read buffer shows pointers/refcounts/data from neighboring slab objects. Repeat with different read offsets and between other slab-consuming syscalls to harvest varied heap content.

A kernel panic (page-fault on the underread page boundary) also confirms the bug.

Impact

  • Reliable local kernel heap info leak (up to ~2 KB per read).
  • Realistic local DoS via panic when the underread crosses an unmapped page boundary.

Validate that every back-reference stays within already-produced output before copying. Minimal, localized fix at ntfs_compr.c:79-81:

--- a/sys/vfs/ntfs/ntfs_compr.c
+++ b/sys/vfs/ntfs/ntfs_compr.c
@@ -78,6 +78,11 @@ ntfs_uncompblock(u_int8_t * buf, u_int8_t * cbuf)
                lmask >>= 1;
            }
            boff = -1 - (GET_UINT16(cbuf + cpos) >> dshift);
            blen = 3 + (GET_UINT16(cbuf + cpos) & lmask);
+           /* Back-reference must not reach before start of output. */
+           if (pos + boff < 0)
+               return (0);
            for (j = 0; (j < blen) && (pos < NTFS_COMPBLOCK_SIZE); j++) {
                buf[pos] = buf[pos + boff];
                pos++;

Returning 0 makes ntfs_uncompunit (ntfs_compr.c:107-108) return EINVAL, which ntfs_readattr propagates as a read error β€” the correct outcome for a malformed compressed block.

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-0932 Β· 20 files
FileTypeDescriptionSize
VERDICT.md verdict full narrative: bug, harness, live repro, fix, fix-validation before/after 8.1 KB ↓ raw
README.md readme concise reproducer + expected output 2.0 KB ↓ raw
harness.c trigger-source deterministic transcription of ntfs_uncompblock on a poisoned/sentinel buffer 8.1 KB view raw
craft_img.py trigger-source builds minimal mountable NTFS image with a compressed file whose LZNT1 trigger 02 80 01 FF FF drives pos+boff<0 17.4 KB view raw
ntfs_evil.img trigger-source crafted NTFS image (524288 B); compressed file F at MFT record 32 512.0 KB ↓ download
build.sh build-log builds harness (guest) + ntfs_evil.img (host) 538 B view raw
run.sh run-log end-to-end: harness + mount + read as root/maxx 1.1 KB view raw
run.log run-log live in-kernel leak demonstration: kernel heap bytes returned to root and maxx 1.9 KB view raw
harness.log run-log harness output (variant 1 LEAK CONFIRMED; variant 2 SIGSEGV) 1.4 KB view raw
leak_sample.txt leak-sample raw leaked kernel heap bytes across runs (incl. kernel pointers and $UpCase table tail) 2.4 KB view raw
baseline_repro.log run-log baseline (#0) leak bytes (00 70 bd 00 08 ... c0 34 6a 00 08 ...) 321 B view raw
fix.diff suggested-fix git-apply-able: reject malformed back-ref with return 0 in ntfs_uncompblock 1.2 KB view raw
fix_build.log build-log patched ntfs.ko module build (rc=0) + sha256 572 B view raw
fix_run.log run-log patched read returns EINVAL; before/after comparison 1.6 KB view raw
fix_run_maxx.log run-log patched read as unprivileged maxx: EINVAL, 0 bytes 317 B view raw
fix_baseline.log run-log baseline leak bytes (used for before/after) 151 B view raw
env.txt environment uname, cc, sysctl vfs.usermount=0, ntfs.ko sha256 549 B view raw
manifest.json verdict this file 4.3 KB view 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 concise reproducer + expected output
↓ download raw

DF-0932 β€” NTFS LZNT1 back-reference underflow (kernel heap info leak)

LZ77 back-reference offset is not bounded to the current output position in ntfs_uncompblock. A crafted compressed NTFS file makes buf[pos + boff] underflow buf and read up to ~2 KB of kernel heap preceding the M_NTFSDECOMP uup allocation. The leaked bytes are shipped to the reader via uiomove. Reachable by any reader of a mounted NTFS compressed file.

Source

Trigger

A 5-byte LZNT1 block at the start of a compression unit:

0x02 0x80 0x01 0xFF 0xFF
   header 0x8002 (compressed, len=2)
   tag    0x01   (first sub-token = back-reference)
   token  0xFFFF (LE) -> at pos=0 dshift=12 lmask=0xFFF:
       boff = -1 - (0xFFFF>>12) = -16
       blen = 3 + (0xFFFF & 0xFFF) = 4098
       -> reads buf[-16..-1] (heap before uup) into buf[0..15]

Build & run

./build.sh          # builds harness (guest) + ntfs_evil.img (host)
./run.sh            # harness + live in-kernel reproduction

The live path expects the DragonFly guest up via dfbsd-qemu/vm.sh.

Expected

Deterministic harness (harness.c)

  • Variant 1: preceding page = sentinel; output buf[0..2] matches the sentinel tail. LEAK CONFIRMED.
  • Variant 2: preceding page PROT_NONE; SIGSEGV at buf[-16].

Live kernel (#0 GENERIC, unpatched ntfs.ko)

  • cat /mnt/evil/F returns 4096 bytes; the first 16 are kernel heap pointers (e.g. 00 70 bd 00 08 00 00 00 c0 34 6a 00 08 00 00 00).
  • Reproducible as unprivileged user (uid 1001) when root mounts with -o ro,-u=1001,-g=1001.

Patched (ntfs.ko with fix.diff)

  • cat /mnt/evil/F returns Invalid argument (EINVAL). 0 bytes.
  • Same for unprivileged maxx.

Fix

fix.diff: reject malformed back-references in ntfs_uncompblock:

if (pos + boff < 0)
    return (0);   /* ntfs_uncompunit maps new==0 to EINVAL */

See VERDICT.md for the full narrative.

VERDICT.md verdict full narrative: bug, harness, live repro, fix, fix-validation before/after
↓ download raw

DF-0932 β€” NTFS LZNT1 back-reference underflow (kernel heap info leak)

Verdict

REPRODUCED. Unprivileged kernel heap info leak reachable by any reader of a mounted NTFS compressed file. Fix validated.

The bug (line-accurate)

In sys/vfs/ntfs/ntfs_compr.c, the LZ77 back-reference decoder computes a signed displacement boff from the compressed token stream and uses it to index buf[] without checking that the resulting index stays within the already-decompressed prefix:

/* ntfs_compr.c:74-78 -- scaling loop derives dshift/lmask from pos */
for (j = pos - 1, lmask = 0xFFF, dshift = 12;
     j >= 0x10; j >>= 1) {
    dshift--;
    lmask >>= 1;
}
boff = -1 - (GET_UINT16(cbuf + cpos) >> dshift);   /* :79 */
blen = 3 + (GET_UINT16(cbuf + cpos) & lmask);      /* :80 */
for (j = 0; (j < blen) && (pos < NTFS_COMPBLOCK_SIZE); j++) {
    buf[pos] = buf[pos + boff];                     /* :82 -- BUG */
    pos++;
}

At pos = 0..16 the scaling loop does not execute (j = pos-1 < 0x10), so dshift stays at 12 and the maximum displacement is 16. A token of 0xF000..0xFFFF (top nibble = 0xF) yields boff = -1 - 15 = -16, and the copy loop reads buf[pos + boff] = buf[-16..-1] β€” 16 bytes of kernel heap memory preceding the uup (M_NTFSDECOMP) allocation.

The leaked bytes are written into uup[0..15] (and propagated through the LZ77 sliding window when blen is large), then shipped to the reader via uiomove(uup + off, tocopy, uio) at sys/vfs/ntfs/ntfs_subr.c:1723.

At larger pos the same defect scales: e.g. at pos = 2049, dshift = 4 and a max-displacement token reads up to pos + boff = 2049 - 4096 = -2047 (about 2 KB underflow, matching the finding summary).

Reachability / threat model

ntfs_readattr (ntfs_subr.c:1677) takes the compression branch when both va_compression and va_compressalg are set on the file's non-resident $DATA attribute; both are attacker-controllable fields in a crafted NTFS image. The decompression runs whenever the reader pulls bytes whose compression unit has been only partially initialized (init != 0 && init != COMPUNIT_CL).

The mount itself requires root (mount_ntfs is root-only; vfs.usermount is OFF on this guest), but the read is a normal read(2) and works identically for any user who can open the file. On the test guest, after root mounts with -o ro,-u=1001,-g=1001 (a realistic admin-mount of an attacker-supplied filesystem image, exactly the DF-0871/0873/0878 precedent), the unprivileged user maxx (uid 1001, not in wheel) reproduces the leak byte-for-byte identically to root. This is therefore an unprivileged kernel heap info leak (CWE-125), not a root→kernel hardening gap.

This is an info-leak (read) primitive, not corruption. There is no privilege-escalation chain to derive from it directly β€” the impact ceiling is disclosure of arbitrary kernel heap to a userland reader, which can include struct ucred *, function pointers, and other secrets useful for defeating KASLR / grooming a separate write-primitive.

Reproduction

Deterministic harness (harness.c)

Transcribes ntfs_uncompblock line-for-line. The output buffer is placed at the start of a mapped page; the preceding page is either filled with a recognisable 16-byte sentinel (variant 1) or marked PROT_NONE (variant 2).

  • Variant 1: the LZ77 token 0xF000 at pos=0 (boff=-16, blen=3) copies 3 bytes from the preceding page into buf[0..2]. The bytes are visibly the sentinel tail, proving the read underflowed.
  • Variant 2: the same token with the preceding page PROT_NONE SIGSEGVs at buf[-16], proving the dereference leaves the allocation entirely.

Output:

[harness] LEAK CONFIRMED: buf[0..2] == bytes from buf[-16..-14]
[harness] SIGSEGV caught: buf[pos+boff] with pos=0, boff=-16

Live in-kernel reproduction (craft_img.py + mount + read)

craft_img.py builds a minimal mountable NTFS image whose root directory has one normal file F (MFT record 32) whose non-resident $DATA attribute is flagged compressed. The 16-cluster compression unit is laid out as 1 allocated cluster (containing the 5-byte LZNT1 trigger 02 80 01 FF FF zero-padded to 4 KB) + 15 sparse clusters; this gives init = 4096, forcing ntfs_readattr into the ntfs_uncompunit branch.

The trigger 02 80 01 FF FF: - header 0x8002 (compressed, len = 2 β†’ block payload = 5 B) - tag 0x01 (first sub-token is a back-reference) - token 0xFFFF (LE): at pos=0, dshift=12, lmask=0xFFF, boff = -16, blen = 4098 β†’ reads buf[-16..-1] (16 B of heap preceding uup) into buf[0..15], then the LZ77 sliding window propagates the 16 leaked bytes across all of buf[0..4095].

Result on 6.5-DEVELOPMENT #0 GENERIC, after some heap-warming activity (without it the slab neighbour happens to be a zero page):

$ cat /mnt/evil/F | head -c 16 | od -An -tx1
 00 70 bd 00 08 00 00 00 c0 34 6a 00 08 00 00 00

Those are DragonFly kernel virtual addresses: - 0x00000008_00bd7000 - 0x00000008_006a34c0

(Earlier in the session the same image leaked f8 ff f9 ff fa ff fb ff fc ff fd ff fe ff ff ff β€” the tail of the $UpCase table the kernel had loaded into RAM during mount. Either way: kernel heap.)

Both root and maxx (uid 1001) get identical bytes; the leak is stable across reads within a session and varies with heap state. run.log and leak_sample.txt hold the raw bytes.

Fix

fix.diff adds a single guard in ntfs_uncompblock before the unchecked buf[pos + boff] dereference:

if (pos + boff < 0)
    return (0);

ntfs_uncompunit already maps a new == 0 return from ntfs_uncompblock to EINVAL (ntfs_compr.c:108), and ntfs_uncompblock otherwise always returns len + 3 (>= 3), so 0 is an unambiguous error sentinel. The error propagates through ntfs_readattr β†’ ntfs_strategy β†’ bread β†’ ntfs_read β†’ read(2), which now returns EINVAL to the reader.

Fix validation

Built ntfs.ko standalone (the file is a KLD module, so no full kernel rebuild is needed; the kernel itself does not contain ntfs code). Applied fix.diff to the in-guest source, ran make in /usr/src/sys/vfs/ntfs, copied the new ntfs.ko over /boot/kernel/ntfs.ko, kldunload/kldload ntfs.

Before/after on the same malicious image, same heap-warming preface:

State First 16 B returned RC
baseline (#0) 00 70 bd 00 08 00 00 00 c0 34 6a 00 08 00 00 00 (kernel heap) 0, 4096 B
patched (ntfs.ko) cat: /mnt/evil/F: Invalid argument 1, 0 B

Identical result for the unprivileged maxx user. Fix is validated (fix_status: fixed).

See fix_baseline.log, fix_run.log, fix_run_maxx.log, fix_build.log, leak_sample.txt.

Files in this evidence pack

File Purpose
harness.c deterministic transcription of ntfs_uncompblock (proves the underflow on a poisoned buffer)
craft_img.py builds a mountable NTFS image whose compressed file F triggers the underflow
ntfs_evil.img the crafted image (524288 B)
build.sh builds the harness (guest) + image (host)
run.sh end-to-end: harness + mount + read as root/maxx
leak_sample.txt raw leaked kernel heap bytes across runs
fix.diff git-apply-able one-line guard
fix_build.log patched-ntfs.ko build log + sha256
fix_baseline.log baseline (#0) leak bytes
fix_run.log patched read returns EINVAL, 0 bytes
fix_run_maxx.log same as above as unprivileged maxx
env.txt guest uname / cc / sysctls / module sha256
manifest.json machine-readable catalog

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED the fix: on the unpatched #0 GENERIC baseline (ntfs.ko sha256 aa8d83843b...), cat /mnt/evil/F returned 4096 B whose first 16 bytes were kernel heap pointers (00 70 bd 00 08 00 00 00 c0 34 6a 00 08 00 00 00) for both root and unprivileged maxx (RC=0). After applying fix.diff and rebuilding ntfs.ko (54431edf18...), the SAME image returns 'cat: /mnt/evil/F: Invalid argument' (EINVAL) for both root and maxx, RC=1, 0 bytes returned. The malformed back-reference is now rejected at ntfs_uncompblock via return 0, mapped to EINVAL by ntfs_uncompunit, and propagated to read(2). The leak is closed.

BEFORE (baseline #0 GENERIC, unpatched ntfs.ko): 'root read 1: 00 70 bd 00 08 00 00 00 c0 34 6a 00 08 00 00 00' (kernel heap, RC=0, 4096 B). AFTER (ntfs.ko with fix.diff): 'cat: /mnt/evil/F: Invalid argument' (RC=1, 0 B). Same for unprivileged maxx (uid 1001): before identical kernel heap leak; after EINVAL, 0 B.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #0 (kernel unchanged) + freshly-built /boot/kernel/ntfs.ko with fix.diff applied (ntfs is a loadable KLD module; no full kernel rebuild needed). Patched ntfs.ko sha256: 54431edf1877ca5fae7ffce59589a369225063cfd7f1ee4e0a264ae9db9e1101

Confirmed kernel references

Detail

Exploit chain

none (pure info-leak / CWE-125 read primitive; no write, no corruption, no chain to derive). The bug reads up to 16 B of slab-neighbour heap per LZ77 token (and ~2 KB near pos~2049 per the finding); those bytes are shipped to any reader of a mounted NTFS compressed file. Realistic preconditions: an admin has mounted (or made mountable) an attacker-crafted NTFS image and mapped it to the user (-u/-g); the trigger is then a normal read(2) the unprivileged user issues. Same threat model as DF-0871/0873/0878 (admin-mount of attacker FS, ACCEPTABLE per the realistic-threat-model table).

Evidence (decisive lines)

harness: 'LEAK CONFIRMED: buf[0..2] == bytes from buf[-16..-14]' + 'SIGSEGV caught: buf[pos+boff] with pos=0, boff=-16'. live #0 GENERIC after heap warming: 'root read 1: 00 70 bd 00 08 00 00 00 c0 34 6a 00 08 00 00 00' (kernel heap pointers, stable across 5 reads). as maxx uid 1001: 'cat /mnt/evil/F | head -c 16 | od -An -tx1 -> 00 70 bd 00 08 00 00 00 c0 34 6a 00 08 00 00 00'. (earlier session also leaked f8 ff f9 ff fa ff fb ff fc ff rd ff fe ff ff ff, the $UpCase table tail from kernel RAM).

PoC changes

Created findings/poc/DF-0932/ from scratch. harness.c transcribes ntfs_uncompblock line-for-line on a poisoned buffer (variant 1: sentinel page -> LEAK CONFIRMED; variant 2: PROT_NONE page -> SIGSEGV). craft_img.py builds a complete mountable NTFS image (boot sector, $MFT/non-resident $DATA spanning 9 clusters, $AttrDef, root dir $INDEX_ROOT:$I30 with one normal file entry, $Bitmap, $UpCase) plus MFT record 32 = a compressed file whose non-resident $DATA has a_hdr.a_compression=1 + a_nr.a_compressalg=1 and a run list of 1 allocated + 15 sparse clusters (forcing init=4096 -> ntfs_uncompunit path); the allocated cluster holds the 5-byte LZNT1 trigger 02 80 01 FF FF zero-padded to 4 KB. build.sh / run.sh wrap the harness+image+mount+read sequence.

Verified recommended fix

In ntfs_uncompblock (sys/vfs/ntfs/ntfs_compr.c) before the inner copy at :82, add 'if (pos + boff < 0) return (0);'. ntfs_uncompunit already maps new==0 to EINVAL (ntfs_compr.c:108), and ntfs_uncompblock otherwise always returns len+3 (>=3), so 0 is an unambiguous error sentinel -- the error then propagates ntfs_readattr -> ntfs_strategy -> bread -> read(2) which returns EINVAL. The full git-apply-able diff is in findings/poc/DF-0932/fix.diff. Supersedes finding proposal (clamping boff so pos+boff>=0; returning 0/EINVAL is cleaner -- the malformed stream should be rejected, not silently repaired).

Verdict

REPRODUCED. The LZ77 back-reference decoder in ntfs_uncompblock (sys/vfs/ntfs/ntfs_compr.c:74-82) derives a signed displacement boff from the compressed token and indexes buf[pos+boff] with no check that pos+boff >= 0. At pos=0 the scaling loop (lines 74-78) does not execute so dshift stays 12; a token 0xF000..0xFFFF yields boff = -16 and the inner copy reads buf[-16..-1] -- 16 bytes of kernel heap preceding the M_NTFSDECOMP uup allocation. Those bytes ride uiomove(uup+off, tocopy, uio) at ntfs_subr.c:1723 straight to the reader. Confirmed two ways: (1) deterministic harness transcribing ntfs_uncompblock on a poisoned buffer shows buf[0..2] == sentinel bytes from buf[-16..-14] (LEAK CONFIRMED) and SIGSEGVs when the preceding page is PROT_NONE; (2) live on #0 GENERIC a crafted NTFS image with a compressed file F (LZNT1 trigger 02 80 01 FF FF) returns 4096 B to the reader whose first 16 bytes are kernel heap pointers (00 70 bd 00 08 00 00 00 c0 34 6a 00 08 00 00 00 = 0x0008_00bd_7000 / 0x0008_006a_34c0) -- identical for root and unprivileged maxx (uid 1001). This is an unprivileged kernel heap info leak (CWE-125), not corruption; impact ceiling is disclosure of kernel heap (useful for KASLR defeat / grooming a separate primitive), no escalation chain derivable.