Striga
← Back to researchFor a Fistful of Dollars: Less than $100 of Compute Surfaces Pre-auth RCE in Apache httpd

A double-free in Apache httpd's mod_http2 stream cleanup, surfaced by Striga, turns a two-frame HTTP/2 sequence into pre-auth Remote Code Execution.

Bartłomiej Dmitruk

Overview

Apache httpd 2.4.66 ships with mod_http2 compiled in. The module is not enabled by default in most Linux distribution packages, but it is widely enabled in production HTTP/2 deployments. Two HTTP/2 frames sent on the same stream, in a specific order, push the same h2_stream pointer onto an internal cleanup array twice. A later pass through that array calls apr_pool_destroy on already-freed memory. On default builds with the APR mmap allocator, the second free munmaps pages that the process is about to dereference. One TCP connection and two frames are enough to crash a worker process. With a bit more work, the same primitive lands shellcode. Our proof of concept runs in a controlled lab environment and assumes an info leak that supplies system() and scoreboard addresses; we did not build a self-contained remote ASLR bypass.

CVE-2026-23918 covers the bug. CISA-ADP scored it 8.8 High with vector CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H; Apache rates it Important. The PR:L component is conservative. The trigger requires no authentication, no specific path, and no special headers, just a TCP connection that completes the HTTP/2 preface. We confirmed the chain on Apache httpd 2.4.66 with mod_http2 and a multi-threaded MPM (event or worker). MPM prefork is not affected.

Striga surfaced the bug during open-source research on Apache httpd 2.4.66, the current stable release at the time. The end-to-end scan ran on open-weights models and cost under $100 in compute. We reported the finding to the Apache security team. Fixed in 2.4.67.

The same crash had been reported on Apache Bugzilla 69899 in December 2025 as a stability bug and patched in trunk two days later. No CVE was assigned and 2.4.66 remained the public stable release. We came across the bug independently through source code analysis, with no visibility into trunk or open tickets, and recognised it as a remote pre-auth double-free with an exploit path. The security report is what led to the CVE and the 2.4.67 release.

How mod_http2 Cleans Up Streams

Apache's HTTP/2 implementation runs on a multiplexer (h2_mplx) that owns a connection's streams. Each h2_stream lives in its own APR pool. When a stream finishes, mod_http2 does not free it inline. It pushes the pointer onto a cleanup array called m->spurge, and a later pass walks the array and destroys each entry's pool.

modules/http2/h2_mplx.c

static void m_stream_cleanup(h2_mplx *m, h2_stream *stream)
{
    h2_conn_ctx_t *c2_ctx = h2_conn_ctx_get(stream->c2);
 
    /* ... unsubscribe, drop from registries ... */
 
    if (c2_ctx) {
        if (!stream_is_running(stream)) {
            APR_ARRAY_PUSH(m->spurge, h2_stream *) = stream;
        }
        else {
            h2_c2_abort(stream->c2, m->c1);
            h2_ihash_add(m->shold, stream);
        }
    }
    else {
        /* never started */
        APR_ARRAY_PUSH(m->spurge, h2_stream *) = stream;
    }
}

The "never started" branch is the one that matters. It runs for streams that the multiplexer has accepted but not yet routed to a worker. The pointer goes onto spurge with no prior bookkeeping.

c1_purge_streams walks the array later, on the connection thread, and calls h2_stream_destroy on each entry:

static void c1_purge_streams(h2_mplx *m)
{
    h2_stream *stream;
    int i;
 
    for (i = 0; i < m->spurge->nelts; ++i) {
        stream = APR_ARRAY_IDX(m->spurge, i, h2_stream*);
        ap_assert(stream->state == H2_SS_CLEANUP);
        /* ... */
        h2_stream_destroy(stream);
    }
    apr_array_clear(m->spurge);
}

h2_stream_destroy calls apr_pool_destroy on the stream's pool, which on the APR mmap allocator returns the underlying pages to the kernel via munmap.

The function that pushes onto spurge does not check whether the same pointer is already there.

The Double-Push

mod_http2 hands HTTP/2 protocol parsing off to nghttp2 and registers callbacks for frame events. Two of those callbacks both end at h2_mplx_c1_client_rst, the function that asks the multiplexer to clean up a stream after a reset.

The frame-receive callback handles RST_STREAM:

modules/http2/h2_session.c

case NGHTTP2_RST_STREAM:
    /* ... */
    h2_mplx_c1_client_rst(session->mplx, frame->hd.stream_id, stream);
    break;

The stream-close callback fires whenever nghttp2 considers the stream finished. When the close is the result of a reset (non-zero error_code), it calls the same multiplexer hook:

static int on_stream_close_cb(nghttp2_session *ngh2, int32_t stream_id,
                              uint32_t error_code, void *userp)
{
    h2_session *session = (h2_session *)userp;
    h2_stream *stream = get_stream(session, stream_id);
    if (stream) {
        if (error_code) {
            h2_stream_rst(stream, error_code);
            h2_mplx_c1_client_rst(session->mplx, stream_id, stream);
        }
    }
    return 0;
}

For a stream that has already been registered with the multiplexer, neither call triggers m_stream_cleanup. Both fall through the registered branch, which only bumps an internal counter via m_be_annoyed. Cleanup of registered streams happens later, on the worker-finished path.

The early-RST case is different. If the client opens a stream with HEADERS and immediately sends RST_STREAM with a non-zero error code, the stream is never registered with the multiplexer. nghttp2 still fires both callbacks. Both calls to h2_mplx_c1_client_rst see !registered, both fall into the "very early RST, drop" branch, both call m_stream_cleanup:

apr_status_t h2_mplx_c1_client_rst(h2_mplx *m, int stream_id, h2_stream *stream)
{
    apr_status_t status = APR_SUCCESS;
    int registered;
 
    H2_MPLX_ENTER_ALWAYS(m);
    registered = (h2_ihash_get(m->streams, stream_id) != NULL);
    if (!stream) { /* RST on already-forgotten stream */ }
    else if (!registered) {
      /* very early RST, drop */
      h2_stream_set_monitor(stream, NULL);
      h2_stream_rst(stream, H2_ERR_STREAM_CLOSED);
      h2_stream_dispatch(stream, H2_SEV_EOS_SENT);
      m_stream_cleanup(m, stream);
      m_be_annoyed(m);
    }
    /* ... */
}

The same pointer ends up on spurge twice, and the second h2_stream_destroy calls apr_pool_destroy on memory already returned to the kernel.

A single connection sending HEADERS followed by RST_STREAM with error_code=8 (CANCEL) is enough to crash the worker, with no authentication, no specific URL, and no special headers. Apache respawns the worker, but every request it was serving is dropped.

From Double-Free to Code Execution

The crash is the trivial outcome. The interesting one is what happens between the two apr_pool_destroy calls.

The first call returns the stream's pool memory to the kernel via munmap. Any subsequent allocation from the same allocator that hits the same size class can land at the same virtual address through mmap reuse. The second call walks the freed pool's bookkeeping: it reads the pre_cleanups list and invokes each cleanup function with the pointer the cleanup recorded.

That makes the freed pool a typed write target. If we can place attacker-controlled bytes at the freed pool's virtual address before the second free runs, we choose what apr_pool_destroy reads, which means we choose what function gets called and with what argument.

Two problems stand in the way. The address of the freed pool is unpredictable across runs, and the contents we need to place there contain NUL bytes that most copy paths would truncate.

Apache solves the first problem for us. The scoreboard, a shared memory region used by the status module, sits at a fixed address for the lifetime of the parent process. Worker processes inherit the mapping. The request field of scoreboard->servers[0][0], sixty-four bytes of stable storage at a known address, is populated from the request line of whatever request the worker is currently servicing. The payload only needs to land at this known address; the freed pool then needs to point at it.

The spray achieves both at once. Continuous HTTP/1.1 requests are sent from many worker threads. Each request line carries a crafted byte sequence. The request line gets copied into scoreboard->servers[0][0].request via apr_cpystrn, and the crafted bytes describe a fake apr_pool_t whose first pre_cleanup entry has a function pointer set to system() and a data pointer set to the address of a command string we also placed in the scoreboard. The same crafted bytes also serve as the eventual contents of the freed pool itself: when one of those request bodies happens to land in mmap memory at the freed-pool address, the bookkeeping that apr_pool_destroy is about to walk now points into our scoreboard payload.

apr_cpystrn truncates at the first NUL, which is a problem because pointers contain plenty of zero bytes. The scoreboard request field is sixty-four bytes long and we need to assemble a fake apr_pool_t, a fake cleanup_t, and a command string inside it. The spray sends the longest version first, with the internal NULs replaced by a placeholder byte and a single trailing NUL, so the copy reaches all sixty-four bytes. Each successive write is shorter, restoring real NUL bytes at progressively earlier positions while leaving the tail bytes from the longer write untouched. After enough passes, the buffer holds the full payload with NULs in their correct positions.

In release builds, h2_stream_destroy dereferences stream->session->c1 for trace logging before reaching apr_pool_destroy, so the fake h2_stream has session and c1 pointing into known scoreboard regions to keep the dereference valid.

Proof

The lab setup runs the official httpd:2.4.66 image with the status module enabled and an info-leak helper to extract the addresses the spray needs. The chain itself is architecture-independent; we tested it on x86_64. The PoC assumes scoreboard->servers[0][0].request and system() addresses are known. Both can come from a separate info leak or from local access, and the kit ships with a helper that reads /proc/PID/mem to extract them.

Triggers fire in twenty-second bursts on one connection while the spray runs continuously across many connections. Each iteration is probabilistic. Successful execution lands in minutes to hours, depending on hardware and the level of competing traffic on the scoreboard.

$ python3 poc.py --host localhost --port 9443 \
    --cmd 'date >> /tmp/win' --workers 64 \
    --system 0x7f4a... --scoreboard 0x7f4a...

poc.zip contains the full reproduction kit:

  • poc.py: spray and HTTP/2 trigger script
  • getaddr.py: helper that reads /proc/PID/mem to extract scoreboard and system() addresses
  • Dockerfile: builds a vulnerable httpd:2.4.66 image with the status module enabled
  • README.md: build and run instructions

Impact

A network attacker with access to an HTTP/2 endpoint can crash any worker process at will, with one connection and two frames. Sustained attacks turn that into ongoing service disruption, with every in-flight request on the crashing worker dropped.

On Apache httpd 2.4.66 builds with the APR mmap allocator, a multi-threaded MPM, and mod_status loaded, the same primitive supports a Remote Code Execution chain when paired with a separate info leak for system() and scoreboard addresses. The mmap allocator is the default on Debian-derived systems and on the official httpd Docker image. mod_http2 ships in default builds and HTTP/2 is widely enabled in production deployments. The default MPM is event, which is multi-threaded. mod_status populates the scoreboard request field that the chain relies on as a stable container, and is commonly enabled in production for monitoring. The combination is common.

MPM prefork is not affected. Each connection runs in a single-threaded child process, and the bug requires the multi-threaded cleanup path.

Fix

Apache 2.4.67 deduplicates entries before pushing onto spurge in m_stream_cleanup, ensuring the same pointer cannot be added twice.

Timeline

DateEvent
2025-12-09Crash reported on Apache Bugzilla 69899 as a stability bug
2025-12-11Double-purge fix committed to trunk (r1930444)
2026-03-26Striga surfaces the bug in 2.4.66, reported to Apache security
2026-05-04Apache httpd 2.4.67 released, CVE-2026-23918 made public
2026-05-06Public writeup

References