Skip to content

CVE-2026-42945 — NGINX Rift: Heap Buffer Overflow to Remote Code Execution

Published 2026-06-04

ASLR-disabled precondition

The remote code execution demonstrated on this page requires the target NGINX worker to run with address-space layout randomisation (ASLR) disabled. Under default Linux settings (ASLR enabled) the same bug causes a worker crash and restart (DoS). No public RCE technique for ASLR-enabled targets has been disclosed. See Security advice for mitigations regardless of ASLR state.

Field Detail
Vulnerability CVE-2026-42945 / GHSA-gcgv-v5gf-c543
Project NGINX
Affected component src/http/ngx_http_script.c (rewrite script engine); Open Source and NGINX Plus
Severity HIGH
CVSS v4.0 9.2 CRITICAL (AV:N/AC:H/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N)
CVSS v3.1 8.1 HIGH (AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H)
CWE CWE-122 Heap-based Buffer Overflow
Impact Unauthenticated RCE (ASLR off) · Worker crash / DoS (ASLR on)
Affected NGINX OSS 0.6.27–1.30.0 · NGINX Plus R32–R36
Fixed NGINX OSS 1.30.1 (stable) / 1.31.0 (mainline) · NGINX Plus R36 P4, R35 P2, R32 P6

1. Vulnerability overview

NGINX is the world's most-deployed HTTP server and reverse proxy. CVE-2026-42945 ("NGINX Rift") is an 18-year-old heap-based buffer overflow in its rewrite script engine, a code path that has existed since NGINX 0.6.27 (2008) and is present in every NGINX release through 1.30.0.

The vulnerability is triggered when three configuration ingredients appear together in a single location block:

  1. A rewrite directive whose source pattern contains an unnamed PCRE capture group (e.g., ^/api/(.*)).
  2. A literal ? in the rewrite replacement string (e.g., /internal?migrated=true), which activates URI argument encoding.
  3. A subsequent set, if, or rewrite directive that references the captured group as $1, $2, etc.

An unauthenticated attacker sends a single crafted HTTP request to any such location. The overflow corrupts the adjacent heap, typically crashing the NGINX worker process. With ASLR disabled, the deterministic heap layout allows arbitrary command execution inside the worker, requiring no authentication and no prior knowledge of the target's configuration beyond the presence of the trigger pattern.

Root cause

File: src/http/ngx_http_script.c Function: ngx_http_script_regex_end_code()

NGINX's rewrite script engine processes directives in two passes: a length-calculation pass first measures the buffer space required, then an allocation-and-copy pass fills the allocated buffer. The bug arises from a state flag, is_args, that is set to 1 when a ? is seen in the replacement string (indicating that URI argument encoding should be applied) but is never reset between the two passes.

The two passes use different engine instances. The length pass operates on a fresh sub-engine le where le.is_args == 0, so it returns the raw, unescaped byte count of the captured string. The copy pass operates on the main engine e where e.is_args == 1, so it calls ngx_escape_uri(buf, capture, len, NGX_ESCAPE_ARGS), which expands every URI-special byte (e.g., +, %, &) from 1 byte to 3 bytes in percent-encoded form. The buffer is too small for the escaped output, and attacker-controlled URI bytes overflow it.

The fix is a single line added by commit 2046b45aa0c6e712c216b9075886f3f26e9b4ca9:

// src/http/ngx_http_script.c  —  ngx_http_script_regex_end_code()

     r = e->request;

+    e->is_args = 0;      /* ← the one-line fix */
     e->quote = 0;

     ngx_log_debug0(NGX_LOG_DEBUG_HTTP, r->connection->log, 0,

Resetting e->is_args to 0 immediately after the regex-end handler completes makes both passes see the same flag value and agree on buffer sizing.

The minimum nginx.conf pattern that triggers the bug:

location ~ ^/api/(.*)$ {
    rewrite ^/api/(.*)$ /internal?migrated=true;  # sets is_args=1
    set $original_endpoint $1;                    # copy pass expands $1
}

2. Vulnerable environment

The reproduction runs inside a single Docker container. The container builds NGINX from the upstream git tree pinned at the pre-fix commit 98fc3bb78e8daef25c3d850c9cba8c2f787fb99e (the authoritative PoC's own pin), leaving the source entirely unmodified. NGINX self-reports version 1.31.0 at this commit because the dev version string was bumped before the fix landed; the correct reference is the git commit hash, which matches the PoC's pin character-for-character.

The container runs as linux/amd64 (via Docker Desktop QEMU emulation on ARM hosts) because the PoC hardcodes x86-64 addresses. A single NGINX worker (worker_processes 1) is launched with ASLR disabled via setarch x86_64 -R in the entrypoint, producing a deterministic heap and system() address across restarts. A small Python backend (backend.py) runs on the container's loopback, holding upstream connections open so that spray POST bodies linger in the request pool; it is not reachable from the host.

Topology:

Service Role Accessible from host
NGINX worker (commit 98fc3bb78) Vulnerable HTTP listener (trigger and spray endpoints) http://127.0.0.1:19321/
backend.py Proxy backend for /internal and /spray No (container-internal only)

Download the environment files (env.zip), which include env/Dockerfile, env/docker-compose.yml, env/nginx.conf, env/entrypoint.sh, and env/backend.py.

Bringing it up:

docker compose -f env/docker-compose.yml up -d --build

Confirming the environment is the vulnerable one:

Once the container is running, three checks establish that this is the correct vulnerable substrate:

# 1. HTTP server is live and both attack-surface locations respond:
curl -s http://127.0.0.1:19321/ | grep -q ok && echo "nginx up"
curl -s -o /dev/null -w "api=%{http_code}\n"  "http://127.0.0.1:19321/api/x" -H 'X-Delay: 0'
curl -s -o /dev/null -w "spray=%{http_code}\n" -X POST --data x http://127.0.0.1:19321/spray -H 'X-Delay: 0'

# 2. Pinned pre-fix commit and ASLR disabled:
docker exec cve-2026-42945-nginx git -C /nginx-src rev-parse HEAD
# expected: 98fc3bb78e8daef25c3d850c9cba8c2f787fb99e

docker exec cve-2026-42945-nginx bash -c 'cat /proc/$(pgrep nginx|sort -n|tail -1)/personality'
# expected: 00040000  (ADDR_NO_RANDOMIZE)

# 3. Marker directory is empty — nothing pre-planted:
docker exec cve-2026-42945-nginx ls -A /app/marker/
# expected: (empty)

Expected output: nginx up, api=200, spray=200, the commit hash above, personality flag 00040000, and an empty /app/marker/ directory.

The is_args reset is verifiably absent: searching for the fix inside the running source tree returns nothing, so the pre-fix code path is confirmed present.


3. How to exploit

Overview of the technique

The exploit chains two HTTP-only primitives:

  1. Heap spray (POST /spray): binary payloads containing fake ngx_pool_cleanup_s structs are POST-ed into the pool before the overflow. Each fake struct contains handler = system() (the fixed libc address, known because ASLR is off), data = <address of the command string>, and next = 0. The command string (echo <nonce> > /app/marker/<nonce>) follows immediately in the same body. Because URI bytes cannot carry null bytes, the binary payload (including the null terminator on the command string) must arrive via POST body.

  2. Overflow (GET /api/...): a single crafted GET request triggers the length/copy mismatch. The URI encodes 349 padding bytes (A), then 969 URI-escapable bytes (+), then a 6-byte target address. During the copy pass, each + expands to %2B (1 → 3 bytes), producing a ~2 000-byte overflow into the adjacent ngx_pool_t. The overflow overwrites only the low 6 bytes of the adjacent pool's cleanup pointer, redirecting it to one of the sprayed fake structs. When the pool is torn down (ngx_destroy_pool), NGINX iterates the cleanup list and calls handler(data), i.e., system("echo <nonce> > /app/marker/<nonce>"), inside the worker process.

The exploit iterates 20 pre-computed heap offset candidates × up to 10 attempts each (8 sweeps), crash-detecting between attempts via a concurrent keep-alive connection. The probabilistic nature comes from heap feng shui: the fake struct must land at exactly the offset the overwritten pointer targets.

Step-by-step

Download the exploit files (exploit.zip), which include exploit/run.sh and exploit/poc.py.

Step 1. Bring the environment up (see above) and verify the smoke test passes.

Step 2. Choose a per-run nonce. The nonce is the only run-specific value; it must not exist on the server before the exploit runs, and its presence after the exploit proves real command execution.

NONCE="rift_$(openssl rand -hex 6)"

Step 3. Confirm the nonce is absent on the server (baseline check):

docker exec cve-2026-42945-nginx ls /app/marker/"$NONCE"
# expected: No such file or directory

Step 4. Run the exploit. It is entirely HTTP-only: it opens plain TCP connections to the nginx listener and sends spray POST bodies followed by the overflow GET request. No shell, credentials, or direct filesystem access is used.

bash exploit/run.sh "$NONCE" 127.0.0.1 19321

run.sh builds the command string echo $NONCE > /app/marker/$NONCE, passes it to exploit/poc.py, and lets poc.py iterate through its offset candidates and attempt budget (--max-rounds 8). A successful attempt typically completes within 1–2 minutes. Do not wrap the command in timeout (not present on macOS runners); the script self-bounds.

Step 5 (teardown). When finished:

docker compose -f env/docker-compose.yml down -v

What proves it worked

Success is verified through an independent, privileged channel: a docker exec shell into the running container that the exploit itself never touches. This channel reads the server's filesystem directly; the exploit's own stdout is not the signal.

Three sub-checks must all pass:

# (a) The marker file now exists (was absent at baseline):
docker exec cve-2026-42945-nginx ls -A /app/marker/
# -> rift_33a425b86a2d

# (b) Its contents are exactly the nonce — byte-exact:
docker exec cve-2026-42945-nginx cat /app/marker/rift_33a425b86a2d
# -> rift_33a425b86a2d

# (c) It is owned by uid 65534 (nobody) — the NGINX worker's process context:
docker exec cve-2026-42945-nginx stat -c 'uid=%u gid=%g %n' /app/marker/rift_33a425b86a2d
# -> uid=65534 gid=65534 /app/marker/rift_33a425b86a2d

In this run all three checks passed with nonce rift_33a425b86a2d. The marker was absent at baseline and appeared only after exploit/run.sh completed. The worker uid (65534, nobody) is the process identity that executed system(), not a host-side or root artifact. The nonce was generated by the verifier and could only have been placed on the server by the worker executing the command string, confirming server-side state change at the correct privilege level.

The NGINX error log also shows the overflow firing independently of the marker:

[alert] 7#0: worker process 661 exited on signal 11 (core dumped)
[notice] 7#0: start worker process 663
[alert] 7#0: worker process 663 exited on signal 11 (core dumped)
[notice] 7#0: start worker process 665

The repeated SIGSEGV, core dump, and master respawn are the expected signature of the overflow reaching and redirecting the cleanup pointer. One of those redirected attempts landed on a sprayed struct and called system(), producing the marker. The worker pid advanced from baseline 11 to 665 across the attempt sequence.

The effect traces directly to the vulnerable code path: the is_args reset fix is absent from the running build (confirmed at environment setup), and the marker can only have been written via the ngx_destroy_pool → cleanup handler → system() chain triggered by that missing reset.


4. Security advice

Remediation

Upgrade to a fixed release. The one-line fix (e->is_args = 0 in ngx_http_script_regex_end_code()) is in:

  • NGINX Open Source: 1.30.1 (stable branch) or 1.31.0 (mainline)
  • NGINX Plus: R36 P4, R35 P2, or R32 P6

Any NGINX OSS release from 0.6.27 through 1.30.0, and NGINX Plus R32 through R36 without the relevant patch, is vulnerable to at least a worker-crash DoS from an unauthenticated HTTP request. The DoS requires no special conditions; the RCE requires ASLR to be disabled (or an independent ASLR bypass).

Workaround (if immediate upgrade is not possible)

Replace all unnamed PCRE captures in rewrite directives with named captures. This prevents the three-ingredient trigger from being satisfied:

# Vulnerable pattern:
rewrite ^/api/(.*)$ /internal?migrated=true;
set $original_endpoint $1;

# Safe equivalent using a named capture:
rewrite ^/api/(?P<ep>.*)$ /internal?migrated=true;
set $original_endpoint $ep;

Named captures are referenced by $ep (the capture name), not by $1/$2, so the copy pass does not look up a positional capture and cannot trigger the overflow. This workaround is effective but requires auditing every location block that combines an unnamed-capture rewrite with a ? in the replacement and a later set/if referencing $N.

Additional mitigations

Enabling ASLR on the host (the Linux default) limits the practical impact to a worker crash (DoS) under currently known public techniques. The NGINX master automatically restarts the worker, so the DoS is a transient availability disruption rather than a persistent outage. That said, DoS via a single unauthenticated request is still a meaningful risk for production systems; only the patch fully addresses it.

There is no WAF-layer rule that reliably suppresses the trigger: the vulnerable URI can consist entirely of printable ASCII characters, and the three trigger ingredients are in the server configuration, not the request.

References