CVE-2026-23918: Apache HTTP/2 Double Free RCE

CVE-2026-23918 is a double free in Apache 2.4.66 mod_http2. An HTTP/2 early reset can trigger remote code execution. Apache 2.4.67 ships the fix.

cveapachercethreat-intel

Apache shipped HTTP Server 2.4.66 in April 2025 and called it stable. Yesterday they shipped 2.4.67, and the release notes carry CVE-2026-23918, a double free in mod_http2 that affects exactly one Apache version. The version that has been the production stable release for the past 13 months.

The fix is small. The exposure is the second most deployed web server on the public internet, running on every distribution that has shipped the 2.4.66 line since spring 2025.

The basics

FieldValue
CVECVE-2026-23918
CVSS8.8 (HIGH)
VectorAV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H
CWECWE-415 (Double Free)
AffectedApache HTTP Server 2.4.66 (mod_http2 only)
PatchedApache HTTP Server 2.4.67
Reported2025-12-10 by Bartlomiej Dmitruk (striga.ai) and Stanislaw Strzalkowski (isec.pl)
Disclosed2026-05-04

HTTP/2 reset bugs keep finding new ways to hurt people

In October 2023 the entire CDN industry got dragged into emergency rotations by CVE-2023-44487, the Rapid Reset attack. The primitive was simple. An attacker opens an HTTP/2 stream, immediately sends a RST_STREAM frame, and repeats. Servers happily accepted millions of streams per second because the protocol said they had to. Cloudflare measured peaks above 200 million requests per second.

Rapid Reset was a denial of service amplification. CVE-2026-23918 takes the same primitive, the early reset, and turns it into something worse. Where Rapid Reset eats CPU, this one eats the heap.

The pattern shows up because the HTTP/2 stream lifecycle is busy. A stream can be created, cancelled, paused, drained, joined to a worker thread, and reset, in any order, sometimes within milliseconds of each other. Race conditions hide in those transitions. The mod_http2 code that handles them has been refactored more than once to fix issues that turned out to not be fully fixed.

What the bug actually does

Apache hands HTTP/2 connection state to the mod_http2 multiplexer, which tracks every active stream on a connection. When a stream finishes, normally or through reset, mod_http2 puts the stream pointer on a purge list called m->spurge. The list is processed shortly after, and each entry frees the underlying memory.

The bug is that more than one code path can add the same stream pointer to m->spurge. If a stream is reset early, the cleanup function m_stream_cleanup() adds it to the purge list. A second code path, typically the worker thread joining back to the connection in c1c2_stream_joined(), can add the same pointer again before the first purge runs. When purge processing reaches both entries, the same memory block is freed twice.

A double free is heap allocator state corruption. The first free returns the block to the allocator’s free list. The second free, depending on the allocator, can either crash the process (best case for the defender) or corrupt the free list metadata (worst case). On modern glibc, malloc detects most simple double frees and aborts. On the apr_pool allocator that Apache uses internally, the abort is less reliable, and the corrupted state can be groomed by an attacker who controls subsequent allocations on the same connection. That is the path from crash to remote code execution.

The patch in 2.4.67 adds a deduplication check. The new helper add_for_purge() walks the array first and refuses to add a pointer that is already there. The relevant call sites in c1c2_stream_joined() and m_stream_cleanup() were updated to go through it.

// h2_mplx.c, after the patch
static int add_for_purge(h2_mplx *m, h2_stream *stream) {
    int i;
    for (i = 0; i < m->spurge->nelts; i++) {
        if (APR_ARRAY_IDX(m->spurge, i, h2_stream*) == stream) {
            return FALSE;  // already scheduled, skip
        }
    }
    APR_ARRAY_PUSH(m->spurge, h2_stream*) = stream;
    return TRUE;
}

That is the entire bug. One missing dedup check across two call sites, with stream lifecycle racing fast enough that both paths can fire on the same pointer.

About that CVSS PR:L

The CISA-ADP scoring sets PR:L, meaning low privileges are required to exploit. That is generous. In practice, anyone who can open an HTTP/2 connection to your server can trigger this. For a public Apache instance, that is everyone on the internet. The PR:L tag likely accounts for needing to negotiate HTTP/2 itself, which requires a TLS ALPN exchange and an HTTP/2 settings frame. Every modern browser, curl, and reqwest does that automatically. If you are scoring this for your own risk register, treat it as unauthenticated for any internet facing web server.

Who is actually exposed

You are vulnerable if all three of these are true. You run Apache 2.4.66 specifically. The mod_http2 module is loaded. The Protocols directive includes h2 (HTTPS) or h2c (cleartext HTTP/2).

Earlier 2.4.x releases use an older mod_http2 codebase without the racing call sites and are not affected. Apache 2.4.67 fixes it.

Most enterprise distributions will backport the patch into their own 2.4.66 packages rather than ship 2.4.67 directly. AlmaLinux already pushed a backported patch on the day of disclosure, and RHEL and Debian usually follow within hours to days. If you run a distribution maintained Apache, your update path is the distribution’s security advisory feed, not the upstream Apache release tarball.

The exposure window matters. Apache 2.4.66 has been the recommended stable release since April 2025. Anyone who runs apt upgrade or dnf upgrade between then and yesterday is on the affected version. That is 13 months of default exposure on a server family that the 2025 W3Techs survey put at roughly a quarter of all web servers globally.

Quick check on your boxes:

# What version do I run?
apachectl -v
# Server version: Apache/2.4.66 (Unix)

# Is mod_http2 loaded?
apachectl -M 2>/dev/null | grep http2

# Is HTTP/2 actually enabled?
grep -RiE "^[^#]*Protocols.*h2c?" /etc/apache2 /etc/httpd

A Python self-check for fleets

If you run one Apache box, the shell one-liners above are enough. If you run enough Apache instances that apachectl across them is a project of its own, the same logic from the outside in stdlib Python. It does two TLS handshakes against the target. One forces HTTP/1.1 to read the Server header. The other offers h2 first to see whether the server selects HTTP/2. A target is flagged when both Apache 2.4.66 and HTTP/2 are present.

The script is a fingerprint, not an exploit. It does not attempt the double free, does not allocate streams, does not send RST_STREAM. Run it only against systems you own or have written authorization to test.

#!/usr/bin/env python3
"""
CVE-2026-23918 detection helper.

Flags a target as VULNERABLE when both are true:
  - Server header reports Apache HTTP Server 2.4.66
  - TLS ALPN negotiates h2 (HTTP/2 enabled)

The script only fingerprints. It does not attempt the double free.
"""
import argparse
import re
import socket
import ssl
import sys

VULN_VERSION = "2.4.66"
SERVER_RE = re.compile(rb"^Server:\s*(.+)$", re.IGNORECASE | re.MULTILINE)
VERSION_RE = re.compile(r"Apache/(\d+\.\d+\.\d+)")


def tls_connect(host, port, alpn, timeout):
    ctx = ssl.create_default_context()
    ctx.set_alpn_protocols(alpn)
    ctx.check_hostname = False
    ctx.verify_mode = ssl.CERT_NONE
    raw = socket.create_connection((host, port), timeout=timeout)
    return ctx.wrap_socket(raw, server_hostname=host)


def get_server_header(host, port, timeout):
    with tls_connect(host, port, ["http/1.1"], timeout) as tls:
        req = (
            f"HEAD / HTTP/1.1\r\n"
            f"Host: {host}\r\n"
            "User-Agent: cve-2026-23918-check/1.0\r\n"
            "Connection: close\r\n\r\n"
        ).encode("ascii")
        tls.sendall(req)
        buf = b""
        while True:
            try:
                chunk = tls.recv(4096)
            except socket.timeout:
                break
            if not chunk:
                break
            buf += chunk
            if b"\r\n\r\n" in buf:
                break
    match = SERVER_RE.search(buf)
    return match.group(1).strip().decode("ascii", "replace") if match else None


def http2_enabled(host, port, timeout):
    with tls_connect(host, port, ["h2", "http/1.1"], timeout) as tls:
        return tls.selected_alpn_protocol() == "h2"


def assess(server, http2_on):
    if not server:
        return "UNKNOWN", "No Server header. Target may not be Apache or strips banners."
    match = VERSION_RE.search(server)
    if not match:
        return "NOT_APACHE", f"Server header is not Apache: {server!r}"
    version = match.group(1)
    if version == VULN_VERSION and http2_on:
        return "VULNERABLE", (
            f"Apache/{version} with HTTP/2 enabled. "
            "Patch to 2.4.67 or set Protocols http/1.1."
        )
    if version == VULN_VERSION:
        return "PARTIAL", (
            f"Apache/{version} but HTTP/2 was not negotiated. "
            "Confirm mod_http2 is disabled or removed."
        )
    return "OK", f"Apache/{version} is not the affected version. HTTP/2: {http2_on}."


def main():
    parser = argparse.ArgumentParser(
        description="CVE-2026-23918 Apache mod_http2 double free detection."
    )
    parser.add_argument(
        "host",
        help="Target hostname. You must own this server or have written authorization.",
    )
    parser.add_argument("--port", type=int, default=443)
    parser.add_argument("--timeout", type=float, default=8.0)
    args = parser.parse_args()

    print(f"[*] Target: {args.host}:{args.port}")
    try:
        server = get_server_header(args.host, args.port, args.timeout)
        h2 = http2_enabled(args.host, args.port, args.timeout)
    except (socket.gaierror, socket.timeout, ConnectionError, ssl.SSLError, OSError) as exc:
        print(f"[!] Connection failed: {exc}", file=sys.stderr)
        return 2

    print(f"[*] Server header: {server!r}")
    print(f"[*] HTTP/2 enabled: {h2}")
    state, msg = assess(server, h2)
    print(f"[{state}] {msg}")
    return 1 if state == "VULNERABLE" else 0


if __name__ == "__main__":
    sys.exit(main())

Save it as cve_2026_23918_check.py and run it against one host:

python3 cve_2026_23918_check.py www.example.com
# [*] Target: www.example.com:443
# [*] Server header: 'Apache/2.4.66 (Debian)'
# [*] HTTP/2 enabled: True
# [VULNERABLE] Apache/2.4.66 with HTTP/2 enabled. Patch to 2.4.67 or set Protocols http/1.1.

Or run it across an inventory file with one host per line:

while read -r host; do
    python3 cve_2026_23918_check.py "$host"
done < apache_hosts.txt | tee scan-$(date +%F).log

The script returns exit code 1 when a host is VULNERABLE, 0 when it is OK or PARTIAL, and 2 when the connection fails. That makes it easy to wire into a Nagios check, a CI step, or a one-off pipeline that opens a Jira ticket per finding.

Detection

If you cannot patch immediately, you can at least watch for exploitation attempts. The signature of an early reset attack is a burst of HTTP/2 streams from one source where each stream is cancelled before its headers complete.

A starter Sigma rule, adapt the field names to your log shipper:

title: HTTP/2 Early Reset Burst (CVE-2026-23918 indicator)
id: 8a3b1c64-d51e-4a18-8d4f-3e90b29c2d10
status: experimental
description: >
  Detects high frequency HTTP/2 RST_STREAM frames issued before stream
  headers complete. This is the exploitation primitive for CVE-2026-23918
  and the broader HTTP/2 reset class.
references:
  - https://httpd.apache.org/security/vulnerabilities_24.html
  - https://nvd.nist.gov/vuln/detail/CVE-2026-23918
date: 2026/05/05
logsource:
  product: apache
  service: access
detection:
  selection:
    protocol: 'HTTP/2'
    response_status: 0
    request_bytes_in: 0
  timeframe: 30s
  condition: selection | count() by client_ip > 100
falsepositives:
  - Aggressive client side fetchers that cancel speculative requests
  - Browser navigations that drop in flight prefetches
level: high

If you run mod_http2 with LogLevel http2:debug, the error log gets verbose enough to show stream lifecycle events. Grepping for cleanup and reset together in tight time windows from the same client will surface the same pattern in plain text. The logging is expensive, so turn it on only while you investigate, then turn it off again.

For network captures, a tcpdump targeting the HTTP/2 frame type 3 (RST_STREAM) on TLS port 443 catches the abuse pattern at the wire. Most operators will not run that in production. It is useful for reproducing on a staging box.

Mitigation playbook for a small Swiss IT team

If you operate Apache for an SMB, an MSP fleet, or a Treuhander office, here is the order of operations that takes about 30 minutes.

Identify every Apache instance you run. Public web servers, Confluence and Jira behind reverse proxies, Bitnami stacks, vendor appliances that ship Apache internally. Anything where apachectl -v returns 2.4.66 is in scope.

Check whether HTTP/2 is enabled on each one. If Protocols is unset or only contains http/1.1, you are not exposed even on 2.4.66. Move on. If h2 or h2c is present, continue.

For internet facing servers, deploy the upgrade or the workaround today. The upgrade is apt install apache2 or dnf upgrade httpd once your distribution publishes the patched package. The workaround is one line in your main Apache config or your virtual host:

# Disable HTTP/2 entirely until the patch is deployed
Protocols http/1.1

Restart Apache with systemctl restart apache2 or systemctl restart httpd. Verify with curl --http2 -I https://your-domain that negotiation falls back to HTTP/1.1.

For Apache instances that sit behind a reverse proxy already terminating HTTP/2 (nginx, HAProxy, Cloudflare, Traefik), the upstream connection from proxy to Apache is usually HTTP/1.1 anyway. Verify that, and you are safe even if Apache itself is unpatched, as long as nothing else can reach Apache on its HTTP/2 port directly.

Document the patch level and the workaround, even if the workaround is temporary. Compliance audits under nDSG ask for “appropriate technical and organizational measures”. A pinned Apache version, a known patch date, and a written rollback plan is exactly that.

The pattern

Web management and serving software keeps shipping with auth gaps, race conditions, and protocol parser bugs that turn into remote code execution. Last month it was nginx-ui shipping a public MCP endpoint with no authentication. This month it is mod_http2 freeing the same memory twice. Last summer it was SharePoint sitting unpatched while exploitation curves climbed. The patches always land. Knowing which servers in your fleet need them is the part most teams get wrong.

Where Sentinel fits

The boring half of this whole exercise is identifying every Apache version you actually run. Vendor appliances embed Apache. Old VMs lurk under firewalls that have been opened “temporarily” since 2022. Sentinel scans your public IP space, fingerprints the Apache version from the Server header and TLS handshake, and tells you whether you are on 2.4.66 with HTTP/2 enabled. The free scan finishes in 30 to 60 minutes and is delivered as a written report a manager without a security background can read without an interpreter. Run it before the proof of concept hits Twitter.

Patches always land. Knowing where to apply them is the part defenders keep losing on.