When the critical Apache mod_http2 vulnerability (CVE-2026-119618) surfaced — a memory corruption in assign_headers() leading to potential RCE with a CVSS of 9.8 — the first reflex of most teams was to grep banners for "Apache/2.4.x" and call it a day. That approach has two fatal flaws: it fires on every Apache 2.4 build regardless of whether HTTP/2 is even enabled, and it collapses into silence the moment the target sets ServerTokens Prod.
I built apache-mod-http2-scanner.nse to solve exactly that: a non-intrusive Nmap Scripting Engine module that identifies Apache HTTP Server instances potentially affected by CVE-2026-119618, by combining version detection with actual HTTP/2 capability probing — h2 over TLS and h2c over plaintext — instead of trusting a header.
⚠️ Honesty first: the version ranges and detection criteria come from an internal bulletin (published 2026-11-11). The CVE could not be independently verified at the time of writing. Always confirm against Apache's official advisory (https://httpd.apache.org/security/vulnerabilities_24.html) before acting on results in production. The script itself is safe — it sends no exploit payloads.
🛑 The 3 Technical Challenges We Had to Solve
1. The Dual-Protocol Problem (h2 vs h2c)
The vulnerability only matters if HTTP/2 is actually active on the target. But HTTP/2 can be enabled in two fundamentally different ways, and each requires a different probe:
- h2 over TLS — negotiated via ALPN during the TLS handshake. The only correct way to detect it is to send a ClientHello advertising
h2and inspect the ServerHello. A plain HTTP request tells you nothing, because the negotiation already happened at the TLS layer. - h2c over plaintext — negotiated via an HTTP/1.1
Upgrade: h2crequest, which expects a101 Switching Protocolsresponse.
A scanner that only does one of these gives false negatives by design. The script inspects the port's known state first: if Nmap knows the port is TLS, it uses ALPN; if it's plaintext, it uses h2c; if it doesn't know, it tries h2c and, if inconclusive, falls back to ALPN.
2. The "Silent" Server (ServerTokens Prod)
The moment an admin sets ServerTokens Prod, the Server header becomes Apache with no version. Header grabbing returns nothing useful. But here's the nuance that most scanners miss: HTTP/2 being active is still a meaningful signal even when the version is hidden.
The script handles this by not lying to you. If it detects Apache + HTTP/2 active + hidden version, it returns LIKELY VULNERABLE — not VULNERABLE (we don't know the version) and not NOT VULNERABLE (we know HTTP/2 is on, and most deployed 2.4.x builds fall in the affected window). If you prefer the stricter stance, set hidden_version=unknown and it will report NO CONCLUYENTE instead.
3. The Binary Verdict Trap
Most NSE vuln scripts output one of two states: VULNERABLE or nothing. That's dangerous for a CVE whose affected range is a specific version window and depends on a runtime feature flag. "Not vulnerable" and "couldn't tell" are not the same thing, especially when NOT VULNERABLE will be silently skipped in a report.
The script distinguishes four states:
| State | Meaning |
|---|---|
VULNERABLE | Version in range and HTTP/2 active |
LIKELY VULNERABLE | Apache + HTTP/2 active, version hidden |
NO CONCLUYENTE | Couldn't complete a probe (TLS 1.3-only, timeout, filtered, hidden_version=unknown) |
NOT VULNERABLE | Version out of range, or HTTP/2 not detected conclusively |
NO CONCLUYENTE is deliberately shown apart from the VULNERABLE: block, so it can't be mistaken for a positive. It tells you to retry with -sV, bump the timeout, or verify locally — instead of silently dropping the host from your report.
🔍 How the Detection Engine Works
The script runs a short cascade — no exploits, no fuzzing:
- Identify the server via
nmap -sVor theServerheader. If it's not Apache httpd (Tomcat, nginx, Traffic Server), it exits without further probes. - Probe HTTP/2 with the appropriate mechanism for the port (ALPN or h2c Upgrade).
- Compare the version against the affected range and emit the verdict.
Two practical notes:
-
-sVis strongly recommended. Without it, the script relies solely on theServerheader, and non-standard TLS ports (9443, 10443…) may fall back to h2c and miss an h2-only server. You can force the probe withapache-mod-http2-scanner.ssl=true. - The ALPN probe uses TLS 1.2. A server that only accepts TLS 1.3 will produce
NO CONCLUYENTE— by design, not as a crash.
💻 The Result in Terminal (Two Real Scenarios)
Scenario 1: Fully Exposed Environment (h2 over TLS)
In this scenario the port is recognized as TLS, ALPN negotiates h2, and the version is visible via -sV. The script confirms both the affected version and the active HTTP/2 capability — a true positive.
PORT STATE SERVICE VERSION 8443/tcp open ssl/http Apache httpd 2.4.70 ((Unix) OpenSSL/3.5.7) | apache-mod-http2-scanner: | VULNERABLE: | Apache HTTP Server mod_http2 - corrupcion de memoria / RCE (severidad Critica) | State: VULNERABLE | IDs: CVE:CVE-2026-119618 | Risk factor: High CVSSv3: 9.8 | Check results: | Banner/Version: Apache/2.4.70 (nmap -sV) | HTTP/2 habilitado: SI - h2 (ALPN) | Rango afectado: 2.4.55 - 2.4.70 (corregido en 2.4.71) |_ Script: apache-mod-http2-scanner v1.0.1
Risk factor: Highis a limitation of Nmap'svulnslibrary, which only accepts High/Medium/Low. The real severity (Critical, CVSS 9.8) is shown in the title and theCVSSv3field.
Scenario 2: Hidden Version (ServerTokens Prod)
Here the Server header leaks only Apache. The script still detects Apache and still confirms HTTP/2 is active, but because the version is unknown, it reports LIKELY VULNERABLE — and tells you exactly what to do next.
| apache-mod-http2-scanner: | VULNERABLE: | Apache HTTP Server mod_http2 - corrupcion de memoria / RCE (severidad Critica) | State: LIKELY VULNERABLE | IDs: CVE:CVE-2026-119618 | Risk factor: High CVSSv3: 9.8 | Check results: | Banner/Version: Apache (nmap -sV) | HTTP/2 habilitado: SI - h2c (Upgrade) | Rango afectado: 2.4.55 - 2.4.70 (corregido en 2.4.71) |_ Version oculta: verifique localmente (httpd -v). If you want this to read as NO CONCLUYENTE instead — for a change-ticket process that won't accept "likely" — add --script-args apache-mod-http2-scanner.hidden_version=unknown.
🧪 Field-Tested on Debian 13 (trixie)
Documentation and screenshots are one thing; reproducibility is another. The script was validated end-to-end on a clean Debian 13 (trixie) VM with Nmap 7.95+dfsg-3 (from Debian repos), using the official httpd:2.4.70 Docker image — a real build inside the affected range. Three containers were spun up with different Protocols configurations:
| Container | Port | Configuration | Expected |
|---|---|---|---|
h2c-lab | 8081 | Protocols h2c http/1.1 | VULNERABLE (h2c) |
h2-lab | 8443 | TLS + Protocols h2 http/1.1 | VULNERABLE (h2/ALPN) |
h1-lab | 8082 | default http/1.1 only | NOT VULNERABLE |
The baseline: default httpd:2.4.70 is not vulnerable
Before touching anything, the first scan against a stock httpd:2.4.70 container (no Protocols override, no SSL) is instructive. The script does not panic, does not crash, and does not guess. It correctly reports UNKNOWN (unable to test) with an explicit reason:
| apache-mod-http2-scanner: | VULNERABLE: | Apache HTTP Server mod_http2 - corrupcion de memoria / RCE (severidad Critica) | State: UNKNOWN (unable to test) | IDs: CVE:CVE-2026-119618 | Check results: | Banner/Version: no disponible | HTTP/2 habilitado: NO detectado | Rango afectado: 2.4.55 - 2.4.70 (corregido en 2.4.71) | Script: apache-mod-http2-scanner v1.0.1 | No se pudo determinar la version de Apache. This is the correct behavior for a container that doesn't expose its version and doesn't enable HTTP/2: it isn't a false negative, it's an honest "I can't tell". That's exactly the distinction between NOT VULNERABLE and NO CONCLUYENTE the script was designed around.
The full three-container sweep
After enabling h2c on 8081, h2 + TLS on 8443, and leaving 8082 at HTTP/1.1-only, a single Nmap invocation against all three ports returns the three expected states side-by-side. -sV is enabled so version detection feeds the script directly:
nmap -Pn -sV -p 8081,8082,8443 --script apache-mod-http2-scanner localhost PORT STATE SERVICE VERSION 8081/tcp open http Apache httpd 2.4.70 ((Unix)) | apache-mod-http2-scanner: | VULNERABLE: | State: VULNERABLE | Check results: | Banner/Version: Apache/2.4.70 (nmap -sV) | HTTP/2 habilitado: SI - h2c (Upgrade) | Rango afectado: 2.4.55 - 2.4.70 (corregido en 2.4.71) |_ Script: apache-mod-http2-scanner v1.0.1 8082/tcp open http Apache httpd 2.4.70 ((Unix)) | apache-mod-http2-scanner: | NOT VULNERABLE: | State: NOT VULNERABLE | IDs: CVE:CVE-2026-119618 8443/tcp open ssl/http Apache httpd 2.4.70 ((Unix) OpenSSL/3.5.7) | apache-mod-http2-scanner: | VULNERABLE: | State: VULNERABLE | Check results: | Banner/Version: Apache/2.4.70 (nmap -sV) | HTTP/2 habilitado: SI - h2 (ALPN) | Rango afectado: 2.4.55 - 2.4.70 (corregido en 2.4.71) |_ Script: apache-mod-http2-scanner v1.0.1 Three different ports, same binary version, three different verdicts — all correct. That is the core value of not stopping at the banner.
What the script is doing internally (nmap -d)
The -d flag on the same scan reveals exactly why 8443 is flagged and 8082 is not. The debug trace walks through the cascade in order:
NSE: Starting apache-mod-http2-scanner against localhost (127.0.0.1:8443). NSE: [apache-mod-http2-scanner 127.0.0.1:8443] identify: producto de -sV = Apache/2.4.70 (nmap -sV) NSE: [apache-mod-http2-scanner 127.0.0.1:8443] modo de sondeo: tls NSE: [apache-mod-http2-scanner 127.0.0.1:8443] ALPN: protocolo elegido = h2 NSE: Finished apache-mod-http2-scanner against localhost (127.0.0.1:8443). Three lines that tell the whole story:
-
identify: producto de -sV = Apache/2.4.70— version detection from-sVis the first source of truth; theServerheader is only a fallback. -
modo de sondeo: tls— the script recognized the port as TLS and chose ALPN, not h2c. That's the "port-state-aware" branch from Challenge #1. -
ALPN: protocolo elegido = h2— the ServerHello genuinely negotiatedh2. Not "Apache is present", not "HTTP/2 is suggested by a header", but "the TLS layer confirmedh2".
Independent confirmation with a plain curl (the same probe the script performs, but from a different tool) agrees:
curl -skI --http2 https://localhost:8443/ | head -1 # HTTP/2 200 curl -sI --http2-prior-knowledge http://localhost:8081/ | head -1 # HTTP/2 200 The script's verdict is not based on a heuristic — it's based on the same wire-level signal curl uses, parsed via Nmap's own tls.record_read.
Why the "NOT VULNERABLE" case matters as much as the vulnerable one
The 8082 result (NOT VULNERABLE) is the most commonly overlooked piece of evidence. The container runs the same httpd:2.4.70 binary as the other two. If the script were a naive version-range checker, it would have flagged 8082 as vulnerable along with the rest — a false positive that would waste remediation cycles on a box with HTTP/2 disabled.
It didn't, because HTTP/2 was conclusively not detected. That's the point of separating "version in range" from "feature active": both must be true for a positive, and the script enforces it.
🚀 Try it in your lab
The script is free, open-source, Apache-2.0 licensed, and auditable. The Debian 13 lab above is reproducible with a single shell script. The relevant container invocations:
# h2c (plaintext) — vulnerable path docker run -d --name h2c-lab -p 8081:80 httpd:2.4.70 sh -c \ "sed -i 's/^#LoadModule http2_module/LoadModule http2_module/' conf/httpd.conf \ && echo 'Protocols h2c http/1.1' >> conf/httpd.conf && httpd-foreground" # h2 over TLS — vulnerable path docker run -d --name h2-lab -p 8443:443 httpd:2.4.70 sh -c " sed -i -e 's/^#LoadModule http2_module/LoadModule http2_module/' \ -e 's/^#LoadModule ssl_module/LoadModule ssl_module/' \ -e 's/^#LoadModule socache_shmcb_module/LoadModule socache_shmcb_module/' conf/httpd.conf && openssl req -x509 -newkey rsa:2048 -nodes -keyout conf/server.key -out conf/server.crt -days 2 -subj /CN=localhost && printf 'Listen 443\nSSLSessionCache shmcb:logs/ssl_scache(512000)\n<VirtualHost *:443>\nSSLEngine on\nSSLCertificateFile conf/server.crt\nSSLCertificateKeyFile conf/server.key\nProtocols h2 http/1.1\n</VirtualHost>\n' >> conf/httpd.conf && httpd-foreground" # HTTP/1.1-only baseline — not vulnerable path docker run -d --name h1-lab -p 8082:80 httpd:2.4.70 # Scan all three nmap -Pn -sV -p 8081,8082,8443 --script apache-mod-http2-scanner localhost The tests/lab.sh in the repository wraps the whole up / scan / down cycle. To see NOT VULNERABLE results too (which are silent by default), add --script-args vulns.showall.
A note on scope: the script is non-intrusive, but scanning systems you don't own or have written authorization for is still illegal in most jurisdictions. Treat the USO RESPONSABLE section of the README as the contract, not as boilerplate.
🔗 Repository & Documentation: https://github.com/matarturo/apache-mod-http2-scanner
💬 Let's Discuss: What Does "Not Vulnerable" Actually Mean?
To my fellow AppSec engineers and vulnerability-management folks here on dev.to:
When your scanner returns NOT VULNERABLE, how often is it actually "we couldn't tell"? The NO CONCLUYENTE state in this script exists because I kept seeing hosts silently dropped from reports simply because a probe timed out or a server only spoke TLS 1.3. I'd love to hear how your teams handle the unknown-vs-negative distinction — do you quarantine them, re-scan them, or let them slip?
Also: if you're running Apache 2.4.55–2.4.70 with HTTP/2 enabled, the mitigation is simple — either upgrade to 2.4.71+, or strip h2 and h2c from the Protocols directive and run apachectl configtest && apachectl graceful. Either way, please re-scan afterward to confirm the change.
🔗 Useful Links & Documentation
The script, the Docker lab, and the full argument reference live in the dedicated repository:
- GitHub Repository: https://github.com/matarturo/apache-mod-http2-scanner
- Direct script download (
.nse): https://github.com/matarturo/apache-mod-http2-scanner/blob/main/apache-mod-http2-scanner.nse
📦 Installation (Linux, macOS, Windows)
The installation is the standard Nmap NSE flow — copy the script into the scripts directory, rebuild the database, and verify it is recognised:
sudo cp apache-mod-http2-scanner.nse /usr/share/nmap/scripts/ sudo nmap --script-updatedb nmap --script-help apache-mod-http2-scanner # verify The --script-updatedb step is mandatory: Nmap will not resolve the script by name until the script database is rebuilt. If you prefer to run it without installing, point Nmap at the file directly:
nmap -Pn --script apache-mod-http2-scanner.nse -p 443 <target> Platform-specific paths (macOS Homebrew, Windows) and the full --script-args reference (path, min, max, timeout, ssl, hidden_version) are documented in the README.
⚠️ noexec mount workaround
On systems where the Nmap scripts directory or the download location is mounted with noexec (common on hardened /usr/share, containers, and CI runners), copying the file is enough — Nmap loads .nse files as Lua sources, it does not execute them as binaries. No chmod +x and no interpreter workaround are needed for the script itself. If you download the .nse to a noexec location first, simply cp it to /usr/share/nmap/scripts/ as shown above.
📚 What's in the repository
-
apache-mod-http2-scanner.nse— the non-intrusive detector for CVE-2026-119618 described in this article. - README — decision table, argument reference, honest limitations (backports,
ServerTokens Prod, TLS 1.3-only servers, CDN fronting), and the mitigation steps. - Issue tracker — if you hit a false positive or an unexpected
NO CONCLUYENTE or INCONCLUSIVE, attach thenmap -dtrace; it is the fastest way to get it triaged.
🛡️ A final word on scope
The script is non-intrusive: it does not exploit CVE-2026-119618, does not send crafted payloads, and does not attempt to trigger the memory corruption. But non-intrusive is not the same as authorized. Scanning systems you do not own or do not have written permission to test is illegal in most jurisdictions, regardless of how gentle the probes are. Run it against your own infrastructure, your lab, or a client engagement with a signed scope. The USO RESPONSABLE or RESPONSIBLE USE section of the README is the contract, not boilerplate.
If you deploy it internally, I would love to hear what your vulnerability-management workflow does with the INCONCLUSIVE bucket — that is the open question the script was built to surface, and the answer probably differs across every org reading this. 👇