home intel cve-2026-23918-apache-http2-double-free-rce
CVE Analysis 2026-05-04 · 8 min read

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

A double free in Apache httpd 2.4.66's HTTP/2 early reset handling allows heap corruption leading to possible remote code execution. Fixed in 2.4.67 via r1930444.

#double-free#http2#remote-code-execution#memory-corruption#apache-httpd
Technical mode — for security professionals
▶ Attack flow — CVE-2026-23918 · Remote Code Execution
ATTACKERRemote / unauthREMOTE CODE EXECCVE-2026-23918Cross-platform · HIGHCODE EXECArbitrary coderuns as targetCOMPROMISEFull accessNo confirmed exploits

Vulnerability Overview

CVE-2026-23918 is a double free vulnerability in Apache HTTP Server 2.4.66's HTTP/2 implementation, triggered during early stream reset handling. The bug was reported to the Apache Security Team on 2025-12-10 by Bartlomiej Dmitruk (striga.ai) and Stanislaw Strzalkowski (isec.pl), patched in revision r1930444 the following day, and shipped in 2.4.67 on 2026-05-04. CVSS 8.8 HIGH reflects network-reachable, pre-auth exploitation with potential RCE.

HTTP/2 early reset — sending RST_STREAM immediately after HEADERS before the server has finished allocating per-stream resources — creates a race window where cleanup code runs against partially-initialized state. In mod_http2, this window allowed a request pool or stream data structure to be freed twice, giving an attacker controlled heap corruption primitives.

Affected Component

Module: mod_http2 — specifically the stream lifecycle management layer in h2_stream.c and the connection worker in h2_mplx.c. The vulnerable codepath is exercised whenever h2_stream_rst() is called concurrently with h2_mplx_m_stream_cleanup() during connection teardown or client-initiated reset. Only configurations with HTTP/2 enabled (Protocols h2 http/1.1) are affected.

Root Cause Analysis

HTTP/2 streams in mod_http2 are managed via h2_stream objects allocated from a per-connection pool. When a client sends RST_STREAM on a stream that is mid-setup, two independent cleanup paths can both reach h2_stream_destroy(): the RST handler in the I/O thread and the multiplexer cleanup in the worker thread. Neither path held an exclusive lock across the check-then-free sequence.


/* h2_stream.c — simplified from mod_http2 source, 2.4.66 */

typedef struct h2_stream {
    int id;
    h2_mplx        *mplx;
    apr_pool_t     *pool;        /* per-stream APR pool */
    h2_request     *request;
    h2_headers     *response;
    int             rst_error;
    /* ... */
} h2_stream;

/* Called from I/O thread on receipt of RST_STREAM */
apr_status_t h2_stream_rst(h2_stream *stream, int error_code)
{
    stream->rst_error = error_code;

    if (stream->output) {
        h2_beam_abort(stream->output);  /* signals worker thread */
    }

    /* BUG: stream->pool freed here without exclusive ownership check */
    apr_pool_destroy(stream->pool);     /* FREE #1 */
    stream->pool = NULL;
    return APR_SUCCESS;
}

/* Called from worker thread during mplx cleanup — runs concurrently */
void h2_mplx_m_stream_cleanup(h2_mplx *mplx, h2_stream *stream)
{
    /* mplx lock is dropped before this call in the 2.4.66 codepath */
    if (stream->pool) {
        /* BUG: pool pointer non-NULL, lock already released by caller */
        apr_pool_destroy(stream->pool); /* FREE #2 — double free */
        stream->pool = NULL;
    }
}

The crux: h2_mplx_m_stream_cleanup() drops the mplx mutex before calling into stream cleanup, while h2_stream_rst() holds no lock at all during apr_pool_destroy(). The stream->pool != NULL guard in the worker thread is not atomic with respect to the I/O thread's stream->pool = NULL assignment — classic TOCTOU on a non-volatile, non-atomic pointer read.

Memory Layout

APR pool internals allocate pool control structures inline. On a typical 64-bit Linux glibc build, apr_pool_destroy() ultimately calls into glibc's free() for each allocator node. The relevant struct layout:


/* APR allocator node (apr_allocator.h internals) */
struct apr_memnode_t {
    /* +0x00 */ struct apr_memnode_t *next;        /* freelist link */
    /* +0x08 */ struct apr_memnode_t **ref;         /* self-ref for unlink */
    /* +0x10 */ uint32_t              index;        /* size index */
    /* +0x14 */ uint32_t              free_index;
    /* +0x18 */ char                 *first_avail;  /* current alloc cursor */
    /* +0x20 */ char                 *endp;         /* end of node */
    /* +0x28 */ char                  data[];       /* user data */
};

/* h2_stream sits inside the pool's first allocation: */
/* pool->data + 0x00 */ h2_stream   stream;        /* 0x98 bytes */
/* pool->data + 0x98 */ h2_request  request;
/* pool->data + ...  */ h2_headers  response;

HEAP STATE — NORMAL TEARDOWN (single free, sequential):

  0x55a3c2f01000: [ apr_memnode_t: stream pool node       ]
                    next=0x0  ref=0x55a3c2f01008
                    first_avail=0x55a3c2f01248
                    endp=0x55a3c2f02000
  0x55a3c2f01028: [ h2_stream id=7  mplx=0x55a3c2e80040  ]
                    pool=0x55a3c2f01000   <-- valid
                    rst_error=0x0

HEAP STATE — AFTER FREE #1 (rst handler, I/O thread):

  0x55a3c2f01000: [ FREED: now on tcache/unsorted bin      ]
                    fd=0x7f88b4003b00  (tcache key)
                    bk=0x0
  h2_stream.pool = NULL  (written by I/O thread)

RACE WINDOW — worker thread reads stream->pool BEFORE NULL write propagates:

  worker thread cache line: stream->pool = 0x55a3c2f01000  (stale)
  --> apr_pool_destroy(0x55a3c2f01000)  --> FREE #2

HEAP STATE — AFTER FREE #2 (double free, worker thread):

  tcache bin[N]:  0x55a3c2f01000 --> 0x55a3c2f01000  (self-loop)
  ** tcache poisoned — next malloc of same size returns 0x55a3c2f01000 **
  ** attacker controls fd pointer at +0x00 via next HTTP/2 stream alloc **
Root cause: h2_mplx_m_stream_cleanup() releases the mplx mutex before checking stream->pool, allowing a concurrent h2_stream_rst() in the I/O thread to free the pool first, leaving a non-NULL stale pointer that the worker thread then frees a second time.

Exploitation Mechanics

Weaponizing this into reliable RCE on a target running glibc ≥ 2.32 requires defeating tcache hardening (safe-linking) but the self-loop tcache state still enables a tcache dup primitive. Exploitation on older glibc or musl is more straightforward.


EXPLOIT CHAIN:
1. Connect to target over TLS with HTTP/2 negotiated (ALPN h2).
2. Open stream N via HEADERS frame (Content-Length triggers body read worker).
3. Immediately send RST_STREAM on stream N (error_code=CANCEL).
   --> I/O thread enters h2_stream_rst(), calls apr_pool_destroy(stream->pool).
   --> Race: worker thread in h2_mplx_m_stream_cleanup() reads stale pool ptr.
4. Both threads call apr_pool_destroy() on the same pool node.
   --> glibc tcache self-loop: bin[sz] -> node -> node.
5. Open stream N+1 with a HEADERS frame sized to match the freed pool node.
   --> First malloc returns node (legitimate).
6. Open stream N+2 with same-sized HEADERS.
   --> Second malloc returns node AGAIN (tcache dup).
   --> Attacker now has two live h2_stream objects aliasing the same memory.
7. Write controlled data into stream N+1's request headers (populates node).
   --> Overwrites stream N+2's h2_stream.mplx pointer with attacker value.
8. Trigger stream N+2 cleanup — h2_mplx_m_stream_cleanup() dereferences
   corrupted mplx pointer, calling a function pointer at mplx+offset.
   --> Control redirected to attacker-supplied address (e.g., ret2libc chain).
9. ASLR bypass: use HTTP/2 CONTINUATION frame timing oracle or separate
   info-leak (e.g., CVE-2026-24072 ap_expr path traversal) to leak libc base.

Reliable triggering of the race requires sending RST_STREAM within the window between h2_beam_abort() signaling the worker and the worker acquiring its next task. Empirically this window is 50–200µs on a lightly loaded server; a tight loop of stream open/RST sequences achieves reliable collision within ~500 iterations over a low-latency link.

Patch Analysis

Revision r1930444 addresses the race by holding the mplx lock across the pool-null check and pool destroy in the cleanup path, and by making h2_stream_rst() transfer ownership via an atomic flag rather than directly destroying the pool.


/* BEFORE (vulnerable — 2.4.66, h2_mplx.c): */
void h2_mplx_m_stream_cleanup(h2_mplx *mplx, h2_stream *stream)
{
    /* lock released by caller before this point */
    if (stream->pool) {
        apr_pool_destroy(stream->pool);   /* BUG: no lock, TOCTOU */
        stream->pool = NULL;
    }
}

apr_status_t h2_stream_rst(h2_stream *stream, int error_code)
{
    stream->rst_error = error_code;
    if (stream->output)
        h2_beam_abort(stream->output);
    apr_pool_destroy(stream->pool);       /* BUG: no ownership transfer */
    stream->pool = NULL;
    return APR_SUCCESS;
}

/* AFTER (patched — r1930444, h2_mplx.c): */
void h2_mplx_m_stream_cleanup(h2_mplx *mplx, h2_stream *stream)
{
    apr_pool_t *pool;
    /* FIX: caller retains lock; atomically take ownership of pool ptr */
    H2_MPLX_ENTER(mplx);                 /* acquire mplx mutex */
    pool = stream->pool;
    stream->pool = NULL;                  /* zero before releasing lock */
    H2_MPLX_LEAVE(mplx);

    if (pool) {
        apr_pool_destroy(pool);           /* safe: exclusive ownership */
    }
}

/* h2_stream.c — rst no longer destroys pool directly: */
apr_status_t h2_stream_rst(h2_stream *stream, int error_code)
{
    stream->rst_error = error_code;
    if (stream->output)
        h2_beam_abort(stream->output);
    /* FIX: pool destruction delegated to mplx cleanup under lock */
    /* stream->pool left intact; mplx cleanup owns destruction */
    return APR_SUCCESS;
}

The fix is minimal and correct: pool ownership is transferred atomically under the mplx lock, making it impossible for both paths to observe a non-NULL stream->pool. No functional behavior change for the non-racing case.

Detection and Indicators

No exploitation in the wild has been reported as of publication. Detection targets the anomalous HTTP/2 stream behavior:

  • SIGABRT / heap corruption crash: double free or corruption (!prev) in httpd error log. Stack trace through apr_pool_destroyh2_mplx_m_stream_cleanup.
  • Suricata/Snort: Alert on HTTP/2 connections issuing RST_STREAM within 1ms of HEADERS at high frequency (>50 stream open/RST cycles per connection).
  • mod_http2 logging: Enable LogLevel http2:debug; look for h2_stream: RST log entries with no intervening request body or response frames.
  • CoreDump analysis: pool->allocator pointing into freed tcache chunk indicates successful double free. gdb: x/4gx stream_addr to inspect pool pointer validity.

CRASH SIGNATURE (glibc double free abort):
  httpd[12847]: corrupted double-linked list
  httpd[12847]: double free or corruption (fasttop)

  #0  __GI_raise ()
  #1  __GI_abort ()
  #2  malloc_printerr ()
  #3  _int_free () at malloc.c:4216
  #4  apr_pool_destroy () at apr_pools.c:784
  #5  h2_mplx_m_stream_cleanup () at h2_mplx.c:341
  #6  h2_workers_join () at h2_workers.c:203

Remediation

  • Upgrade immediately to Apache HTTP Server 2.4.67. Only 2.4.66 is confirmed vulnerable; earlier releases do not contain the HTTP/2 early reset handling rewrite introduced in that branch.
  • Mitigate in-place (if upgrade is blocked): Disable HTTP/2 by removing h2 from the Protocols directive (Protocols http/1.1). This eliminates the vulnerable codepath entirely at the cost of HTTP/2 support.
  • Rate-limit RST_STREAM via H2MaxResetStreams and H2MaxResetCancelRate directives introduced for Rapid Reset mitigations; while not a fix, high-frequency RST sequences required for reliable race triggering will be throttled.
  • WAF layer: Block or rate-limit HTTP/2 connections issuing RST on newly opened streams; requires an HTTP/2-aware proxy (nginx, HAProxy ≥ 2.8, Envoy) in front.
CB
CypherByte Research
Mobile security intelligence · cypherbyte.io
// RELATED RESEARCH
// WEEKLY INTEL DIGEST

Get articles like this every Friday — mobile CVEs, threat research, and security intelligence.

Subscribe Free →