# 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:

```c
/* 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:

```c
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                                |
