Сегодня я рассмотрю уязвимость в Apache Tomcat, которая позволяет читать файлы на сервере и при определенных условиях выполнять произвольный код. Проблема скрывается в особенностях реализации протокола AJP, по которому происходит взаимодействие с сервером Tomcat. Для эксплуатации злоумышленнику не требуется каких-либо прав в целевой системе.

Tomcat — это контейнер сервлетов с открытым исходным кодом. Он написан на языке Java и реализует такие спецификации, как JavaServer Pages (JSP) и JavaServer Faces (JSF). Это один из наиболее популярных веб-серверов, особенно часто он используется в корпоративной среде. Его ставят как самостоятельное решение или в качестве контейнера сервлетов в различных серверах приложений, например GlassFish или JBoss.

Баг нашел исследователь из Chaitin Tech в начале этого года. Уязвимость получила статус критической. Как сейчас стало модно, она обзавелась собственным названием — Ghostcat — и логотипом в виде кота-призрака.

Уязвимость позволяет злоумышленнику читать произвольные файлы на целевой системе внутри директории appBase. Реализация протокола AJP (Apache JServ Protocol) позволяет контролировать атрибуты, которые отвечают за формирование пути до запрашиваемых файлов. Специально сформированный запрос на сервер позволяет прочитать содержимое файлов, доступ к которым невозможен в других условиях. Если можно загрузить файл на сервер, существует риск использования уязвимости для выполнения произвольного кода.

INFO

Уязвимости присвоен идентификатор CVE-2020-1938. Ошибка присутствует в актуальных ветках дистрибутива и затрагивает все версии Apache Tomcat ниже 9.0.31, 8.5.51 и 7.0.100.
 

Тестовый стенд

Начнем со стенда для тестирования уязвимости. Для этого достаточно запустить контейнер Docker из официального репозитория Tomcat.

docker run -it --rm -p 8080:8080 -p 8009:8009 tomcat:9.0.30

Очень важно расшарить порт 8009, это AJP-протокол, в котором и была найдена уязвимость.

Если хочется вместе со мной возиться с отладкой приложения, то нужно действовать немного по-другому. Для дебага я буду использовать IntelliJ IDEA. Сначала скачаем уязвимую версию Apache Tomcat. Я взял 9.0.30. Распакуем и откроем проект в IDEA. Теперь создадим новую конфигурацию отладки с шаблоном Remote.

Создание шаблона Remote в IDEA
Создание шаблона Remote в IDEA

Здесь в поле Command line arguments строка с параметрами, которые нужно указать при запуске сервера. Рекомендую выбрать версию JDK 1.4.x.

Конфигурация отладки. Аргументы для запуска Tomcat в режиме удаленной отладки
Конфигурация отладки. Аргументы для запуска Tomcat в режиме удаленной отладки

Сами параметры можно передать в Docker с помощью ключа -e или --env. Переменная окружения, используемая для этих целей, называется JAVA_OPTS. Обрати внимание на опцию suspend: если она включена (suspend=y), Java будет приостанавливать загрузку виртуальной машины и ждать подключения отладчика и только после успешного коннекта продолжит запуск. У меня получилась такая строка.

-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=*:5005

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

docker run -it --rm -p 8080:8080 -p 8009:8009 -p 5005:5005 --name=tomcatrce --hostname=tomcatrce -e "JAVA_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=*:5005" tomcat:9.0.30
Запущенный сервер Tomcat 9.0.30
Запущенный сервер Tomcat 9.0.30

Открываем браузер, переходим на запущенный сервер (не забывай, что порт — 8080) и наблюдаем страницу 404. Дело в том, что в последних версиях официального докер-контейнера Tomcat папка webapps со стандартными приложениями была переименована в webapps.dist. Достаточно удалить папку и создать симлинк на оригинальную версию директории.

docker exec tomcatrce rm -rf /usr/local/tomcat/webapps
docker exec tomcatrce ln -s /usr/local/tomcat/webapps.dist /usr/local/tomcat/webapps

После этого обновляем страницу и видим приветствие сервера Tomcat.

Приветственная страница стенда Tomcat
Приветственная страница стенда Tomcat

Tomcat работает, теперь дело за фронтендом, который поможет нам в исследовании AJP. Я создам еще один контейнер на основе Debian.

docker run -it --rm -p 80:80 --name=apache --hostname=apache --link=tomcatrce debian /bin/bash

Понятно из названия, какой веб-сервер я буду использовать в качестве фронта, — Apache. Устанавливаем.

apt update && apt install -y nano apache2

Я выбрал его, так как он проще в настройке прокси до Tomcat. Ты можешь использовать любой другой веб-сервер по желанию.

Включаем модуль для работы прокси с протоколом AJP.

a2enmod proxy_ajp

Теперь редактируем стандартный конфиг виртуального хоста (/etc/apache2/sites-enabled/000-default.conf) и указываем адрес Tomcat.

ProxyPass / ajp://tomcatrce:8009/

И перезагружаем Apache.

service apache2 restart
Проксируем трафик через Apache к Tomcat по протоколу AJP
Проксируем трафик через Apache к Tomcat по протоколу AJP

Помимо веб-сервера, нам также понадобится какой-нибудь сниффер. Я буду использовать Wireshark. На этом стенд готов. Кстати, если ты не любишь Docker, то есть вариант скачать с сайта разработчика версию с готовыми бинарниками. Все версии можно найти в разделе с архивами.

Теперь можно переходить к разбору уязвимости.

 

Детали уязвимости. Чтение произвольных файлов на сервере

Apache JServ Protocol (AJP) — это бинарный протокол, созданный ради избавления от избыточности HTTP. AJP гораздо более эффективен, обладает высокой производительностью благодаря значительной оптимизации и отлично масштабируется.

AJP обычно используется для балансировки нагрузки, когда один или несколько внешних веб-серверов (front-end) отправляют запросы на сервер (или серверы) приложений. Сессии направляются к нужному благодаря механизмам роута, где каждый сервер приложений получает свое имя.

В современных Tomcat используется AJP 1.3 (AJP13). Поскольку это двоичный протокол, браузер напрямую не может отправлять запросы AJP13. Поэтому в качестве фронтенда выступает любой популярный веб-сервер — nginx, Apache, IIS.

Вариант взаимодействия с сервером Tomcat через связку веб-сервер — AJP
Вариант взаимодействия с сервером Tomcat через связку веб-сервер — AJP

WWW

Подробнее о протоколе ты можешь прочитать в официальной документации.

По дефолту Tomcat принимает запросы AJP на порте 8009.

/tomcat9.0.30/conf/server.xml
<!-- Define an AJP 1.3 Connector on port 8009 -->
<Connector port="8009" protocol="AJP/1.3" redirectPort="8443" />

Видим, что директива address отсутствует, поэтому AJP доступен на всех IPv4-адресах локальной машины.

По дефолту Tomcat слушает AJP-порт на всех адресах IPv4
По дефолту Tomcat слушает AJP-порт на всех адресах IPv4

Такая настройка крайне небезопасна, и сейчас ты поймешь почему.

Для начала нам нужно разобраться в формате пакетов AJP. Запустим Wireshark и сгенерируем любой легитимный запрос к серверу по AJP. В этом как раз поможет фронтенд в виде веб-сервера Apache.

Снифаем трафик от Apache к Tomcat по протоколу AJP13 через Wireshark
Снифаем трафик от Apache к Tomcat по протоколу AJP13 через Wireshark

Первые два байта в пакете — это Magic, который меняется в зависимости от направления отправки. Пакеты, отправленные от веб-сервера к контейнеру Tomcat, начинаются с 0x1234, а от контейнера к веб-серверу — 0x4142 (строка AB, если переводить в ASCII). В рамках уязвимости нас интересует только структура пакета, отправленного от клиента к контейнеру, то есть 0x1234.

Следующие два байта — это размер тела пакета. Это обычное числовое значение типа integer. Далее идет байт, который в большинстве случаев указывает на тип сообщения. С него начинается подсчет длины тела пакета.

Существуют следующие типы сообщений от веб-сервера к Tomcat.

Типы пакетов к контейнеру Tomcat
Типы пакетов к контейнеру Tomcat

Сразу привлекает внимание пакет с кодом 0x7 (Shutdown), который выключает сервер. Спешу тебя разочаровать — пакет такого плана обработается только в том случае, если он отправлен с хоста, на котором запущен Tomcat.

Нас интересует код 0х2. С таким кодом отправляются, например, обычные сообщения типа GET/POST. Формат тела такого сообщения выглядит следующим образом.

AJP13_FORWARD_REQUEST :=
    prefix_code      (byte) 0x02 = JK_AJP13_FORWARD_REQUEST
    method           (byte)
    protocol         (string)
    req_uri          (string)
    remote_addr      (string)
    remote_host      (string)
    server_name      (string)
    server_port      (integer)
    is_ssl           (boolean)
    num_headers      (integer)
    request_headers *(req_header_name req_header_value)
    attributes      *(attribut_name attribute_value)
    request_terminator (byte) OxFF

За кодом идет метод. Список соотношения базовых методов с их байт-кодами выглядит таким образом.

OPTIONS => 1
GET     => 2
HEAD    => 3
POST    => 4
PUT     => 5
DELETE  => 6
TRACE   => 7
/tomcat9.0.30/java/org/apache/coyote/ajp/Constants.java
// Translates integer codes to names of HTTP methods
private static final String [] methodTransArray = {
    "OPTIONS",
    "GET",
    "HEAD",
    "POST",
    "PUT",
    "DELETE",
    "TRACE",
    ...

В нашем пакете используется метод GET, поэтому и значение байта — 0x2.

В пакете AJP используется метод GET
В пакете AJP используется метод GET

Далее все содержимое пакета довольно привычно и похоже на обычный HTTP-запрос. После параметра is_ssl начинается блок заголовков запроса (request_headers). Следующие два байта (num_headers) отвечают за общее количество заголовков в запросе. Следом за ним перечисляются сами хидеры. Каждый заголовок имеет следующий формат:

0xA0 + <тип_хидера>[1 байт] + <длина_хидера>[2 байта] + <значение_хидера>[строка_размера_длины_хидера] + <конец_хидера>[байт 0x00]

Коды для стандартных заголовков определены в коде.

Коды стандартных HTTP-заголовков в протоколе AJP
Коды стандартных HTTP-заголовков в протоколе AJP
/tomcat9.0.30/java/org/apache/coyote/ajp/Constants.java
// id's for common request headers
public static final int SC_REQ_ACCEPT          = 1;
public static final int SC_REQ_ACCEPT_CHARSET  = 2;
public static final int SC_REQ_ACCEPT_ENCODING = 3;
public static final int SC_REQ_ACCEPT_LANGUAGE = 4;
public static final int SC_REQ_AUTHORIZATION   = 5;
public static final int SC_REQ_CONNECTION      = 6;
public static final int SC_REQ_CONTENT_TYPE    = 7;
public static final int SC_REQ_CONTENT_LENGTH  = 8;
public static final int SC_REQ_COOKIE          = 9;
public static final int SC_REQ_COOKIE2         = 10;
public static final int SC_REQ_HOST            = 11;
public static final int SC_REQ_PRAGMA          = 12;
public static final int SC_REQ_REFERER         = 13;
public static final int SC_REQ_USER_AGENT      = 14;

Некоторые заголовки крайне важны, например если content-length (0xA008) ненулевой, то Tomcat будет предполагать, что запрос имеет тело (как, например, POST-запрос), и попытается прочитать отдельный пакет.

За блоком хидеров следует блок атрибутов. Его использование опционально, но это самая важная часть для понимания уязвимости.

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

<тип_атрибута>[1 байта] + <длина_атрибута>[2 байта] + <значение_атрибута>[строка_размера_длины_атрибута] + <конец_атрибута>[байт 0x00]
/tomcat9.0.30/java/org/apache/coyote/ajp/Constants.java
// Integer codes for common (optional) request attribute names
public static final byte SC_A_CONTEXT       = 1;  // XXX Unused
public static final byte SC_A_SERVLET_PATH  = 2;  // XXX Unused
public static final byte SC_A_REMOTE_USER   = 3;
public static final byte SC_A_AUTH_TYPE     = 4;
public static final byte SC_A_QUERY_STRING  = 5;
public static final byte SC_A_JVM_ROUTE     = 6;
public static final byte SC_A_SSL_CERT      = 7;
public static final byte SC_A_SSL_CIPHER    = 8;
public static final byte SC_A_SSL_SESSION   = 9;
public static final byte SC_A_SSL_KEY_SIZE  = 11;
public static final byte SC_A_SECRET        = 12;
public static final byte SC_A_STORED_METHOD = 13;

Однако любое количество других атрибутов может быть передано и через тип req_attribute (0x0A). Пара имя:значение атрибута передается сразу же после указания этого кода. В этом случае структура примет следующий вид:

0x0a[тип req_attribute] + <длина_имени_атрибута>[2 байта] + <имя_атрибута>[строка_размера_длины_имени_атрибута] + <конец_имени_атрибута>[байт 0x00] + <длина_значения_атрибута>[2 байта] + <значение_атрибута>[строка_размера_длины_значения_атрибута] + <конец_значения_атрибута>[байт 0x00]

Например, так передаются переменные окружения. После перечисления необходимых атрибутов отправляется байт-терминатор (0xFF), который означает не только конец списка атрибутов, но и окончание всего пакета. Именно до него считается длина тела пакета.

Продолжение доступно только участникам

Вариант 1. Присоединись к сообществу «Xakep.ru», чтобы читать все материалы на сайте

Членство в сообществе в течение указанного срока откроет тебе доступ ко ВСЕМ материалам «Хакера», увеличит личную накопительную скидку и позволит накапливать профессиональный рейтинг Xakep Score! Подробнее

Вариант 2. Открой один материал

Заинтересовала статья, но нет возможности стать членом клуба «Xakep.ru»? Тогда этот вариант для тебя! Обрати внимание: этот способ подходит только для статей, опубликованных более двух месяцев назад.


Оставить мнение