Содержание:

В первой части мы разобрали, как запрос проходит через несколько серверов. Теперь посмотрим, что происходит внутри одного nginx или Apache. Конфиг легко прочитать не в том порядке, потому что видишь auth_request, rewrite, пару location и кажется, что сначала сработает верхняя строчка, потом нижняя. Но у сервера есть отдельные этапы обработки. Порядок строк важен внутри некоторых из них, а переход к другому правилу может поменять и путь, и программу, которая получит запрос.

На багбаунти этих строк нет перед глазами. Конфиги ниже — учебные примеры: отличие SNI от Host подскажет проверить выбор сайта, а правило alias — построение пути к файлу.

Возьмём короткую цепочку:

браузер -> CDN -> nginx -> PHP-FPM -> приложение

Nginx сам не выполняет PHP-код. Он разбирает HTTP-запрос и по протоколу FastCGI передаёт его PHP-FPM - службе, которая держит запущенные процессы PHP и распределяет между ними работу. Приложением здесь называется уже сам PHP-код сайта.

HTTP здесь разбирают CDN и nginx. PHP-FPM получает уже поля FastCGI, а PHP-код использует переданные ему метод, путь, параметры и тело. На учебной схеме можно проследить, как меняется путь и какую строку проверяет каждый сервер, но на чужом таргете точное место изменения может остаться неизвестным. По ответу проверяем, удалось ли получить закрытый файл или выполнить запрещённое действие.

Сначала TLS, потом HTTP

При первом HTTPS-подключении клиент сначала устанавливает TLS-соединение. TLS шифрует передаваемые данные и позволяет клиенту проверить сертификат сервера. В первом сообщении, ClientHello, клиент перечисляет поддерживаемые параметры. Там же обычно находятся два расширения, важные для выбора сайта и формата HTTP.

SNI, или указание имени сервера, передаёт имя сайта, например example.com. Одному IP-адресу могут соответствовать десятки сайтов с разными сертификатами, а HTTP-запрос ещё не начался. Поэтому имя приходится сообщать уже во время TLS-рукопожатия. По нему сервер выбирает сертификат и часть настроек виртуального хоста.

ALPN — расширение для выбора протокола внутри зашифрованного соединения. Браузер может предложить h2 и http/1.1, после чего сервер выбирает один вариант. h2 означает HTTP/2. При обычном первом подключении HTTP передаётся после завершения этого обмена. Отдельный случай — это ранняя передача данных при возобновлении TLS-сессии и её разберём в части 11.

После этого имя сайта приходит ещё раз внутри HTTP: в Host для HTTP/1.1 или в :authority для HTTP/2 и HTTP/3. Клиент может указать имя, отличающееся от SNI. При mTLS сервер вдобавок запрашивает и проверяет сертификат клиента. Если требование этого сертификата определяется по SNI, а сайт выбирается по Host, нужно проверить, нельзя ли попасть на защищённый сайт через соединение с другими настройками проверки.

Если браузер подключается к CDN, nginx не получает это TLS-соединение. CDN расшифровывает запрос и при необходимости устанавливает отдельное TLS-соединение к nginx. В нём SNI задаёт уже CDN. Поэтому при проверке важно записывать, какое соединение и чьи параметры мы рассматриваем.

Форматы HTTP/1.1 и HTTP/2 мы уже сравнили в части 1. Здесь достаточно помнить, что протокол между CDN и nginx выбирается отдельно. При преобразовании HTTP/2 в HTTP/1.1 прокси обязан проверить входящий запрос. content-length должен совпадать с длиной тела, а Transfer-Encoding в HTTP/2 запрещён. Некорректный запрос нельзя молча исправить и переслать. Для корректного запроса прокси затем задаёт границу тела HTTP/1.1 через Content-Length либо chunked-разметку. Ошибки этого преобразования, H2.CL и H2.TE, подробно разберём в части 9.

Как nginx выбирает конфиг

Для обычной обработки в nginx порядок такой:

выбор server -> rewrite уровня server -> выбор location
-> rewrite внутри location -> проверка доступа
-> файл или запрос к приложению -> обработка ответа -> журнал

rewrite ... last внутри location -> новый поиск location

server — блок настроек сайта, или виртуального хоста. location задаёт обработку пути, upstream — группу серверов приложения. Сначала выполняются правила rewrite из блока server. Только потом nginx ищет подходящий location. Если rewrite ... last внутри него изменит путь, поиск повторится. С флагом break обработка продолжится в том же location.

try_files, error_page и заголовок ответа X-Accel-Redirect тоже могут вызвать внутреннее перенаправление. Nginx снова начинает обработку запроса внутри себя; браузеру при этом не приходит ответ 301 или 302. Сервер выбирает другой URI или блок location с именем вида @fallback. Такой именованный блок вызывается из других правил nginx и не выбирается напрямую по URL клиента. Для одного запроса допускается не более десяти внутренних перенаправлений; при превышении лимита nginx возвращает 500.

server - SNI и Host не одно поле

Для обычного HTTP nginx сначала смотрит на адрес и порт из listen, затем на Host. При TLS ещё до HTTP участвует SNI. Отсюда вопрос к любой проверке доступа по домену. По какому имени она работает?

Нужен отдельный сервер по умолчанию, который не примет произвольный Host:

server {
    listen 80 default_server;
    server_name _;
    return 444;
}

server {
    listen 443 ssl default_server;
    ssl_reject_handshake on;
}

444 — внутренний код nginx: соединение закрывается без HTTP-ответа. ssl_reject_handshake появился в 1.19.4 и отклоняет TLS-рукопожатие. Без такого ограничения неизвестный SNI обрабатывается виртуальным хостом по умолчанию для данного адреса и порта. Если default_server не указан явно, им становится первый блок server для этого адреса и порта. Показанная настройка отклоняет неизвестное SNI ещё до HTTP-запроса; проверки Host внутри разрешённых соединений она не заменяет.

Есть настройка, которая применяется до чтения Host: merge_slashes. По умолчанию она сводит несколько соседних / к одному. В HTTP/1.1 путь находится в строке запроса, раньше заголовков. Когда nginx разбирает //admin, он ещё не прочитал Host, поэтому берёт значение merge_slashes из конфигурации по умолчанию или из уже выбранного по SNI виртуального хоста.

В исходнике это вызов ngx_http_parse_complex_uri(r, cscf->merge_slashes) из ngx_http_process_request_uri(). В документации тот же порядок описан в разделе выбора виртуального хоста. Поэтому merge_slashes задаётся на уровне http или server, а не внутри location: до выбора location путь уже обработан.

При определённой конфигурации это позволяет обойти правило пути. Для SNI выбирается сайт с merge_slashes off, а в Host указан соседний сайт, где доступ закрыт через location = /admin. Если nginx допускает такое сочетание имён, путь //admin не совпадёт с этим точным location. Допустим, другой блок передаст запрос приложению, а оно объединит слэши и откроет /admin без собственной проверки прав. При таком сочетании запрет nginx будет обойдён.

Проверка снаружи начинается с двух известных имён в области тестирования, а не с перебора соседей по общему IP. Допустим, это public.example и private.example. Сначала запрашиваю каждый сайт с его обычным именем в TLS и HTTP. Затем оставляю SNI публичного сайта, а Host меняю на закрытый:

curl --http1.1 -i --resolve public.example:443:192.0.2.10 \
  'https://public.example/account' -H 'Host: private.example'

Здесь все адреса условные. --resolve задаёт IP подключения; имя из URL остаётся именем для SNI и проверки сертификата. Заголовок меняет только HTTP-имя. В этом сравнении проверка сертификата остаётся включённой - так видно, что TLS-соединение действительно подходит имени из URL. Не следуем редиректам автоматически, чтобы не спутать ответ проверяемого соединения с ответом другого сайта.

Если curl читает ваши настройки из .curlrc, они тоже участвуют в запросе: например, там может быть включён переход по редиректам или задана авторизация. Чтобы не учитывать этот файл, поставьте -q первым аргументом, сразу после curl. Нужные для конкретной пробы cookies и остальные параметры тогда указываются явно.

Если смена Host выбрала другой общедоступный сайт, это обычная маршрутизация. В опыте с mTLS нужен ресурс, закрытый именно требованием клиентского сертификата: обычное соединение с private.example без сертификата не даёт его прочитать, а смешанная пара имён — даёт. Дополнительная сессия приложения, если она нужна, остаётся одной и той же. Проверку владельца документа такая смена имён не отменяет.

Для merge_slashes проверка отдельная - тут мы сравниваем /admin и //admin при тех же SNI, Host и правах. В примере выше ограничение задано точным location, а приложение доверяет ему. Если оно само запрещает доступ к /admin, то запрет сохранится и после объединения слэшей. Даже успешный обход ещё не раскрывает значение merge_slashes: похожую разницу могут создать другие правила пути.

В location первое совпадение не всегда побеждает

Для блоков location одного уровня сначала ищется точное совпадение location =. Затем nginx запоминает самый длинный подходящий префикс. Если он не помечен ^~, проверяются регулярные выражения в порядке записи. Первое совпавшее выражение выбирается вместо префикса. Вложенные location добавляют свои этапы поиска; в примере ниже вложенности нет.

Вот конфиг:

location /api/ {
    proxy_pass http://app;
}

location ~ \.php$ {
    fastcgi_pass unix:/run/php/php-fpm.sock;
}

/api/test.php подходит обоим блокам, но победит регулярка \.php$. Запрос уйдёт не в http://app, а в PHP-FPM. Этот сокращённый конфиг показывает выбор обработчика; для выполнения PHP нужны ещё параметры FastCGI, включая путь к скрипту. Затем проверяют, какой файл выбрал FPM, может ли пользователь его создать и разрешено ли это расширение в настройках выбранного пула FPM. Здесь пул — группа процессов PHP с общими настройками. Полный пример ниже.

На внешнем тесте мы берем известный URL своей загрузки и сначала сохраняем обычный ответ. Затем проверяю варианты имени или хвоста пути, которые объясняются показанными правилами. Если JSON-ошибка приложения сменилась сообщением PHP-FPM, запрос, возможно, попал к другому обработчику. Выполнение кода можно проверить по вычисленному результату из собственного тестового файла. В части 16 разберём такую проверку подробнее.

alias без слэша уводит из каталога

Классический случай, когда проверенный URL расходится с реально открытым файлом, - alias в location без завершающего слэша. Уязвимый вариант:

location /i {
    alias /data/w3/images/;
}

Запрос /i../app/config.py nginx склеит в /data/w3/app/config.py. Часть /i совпала с location, а остаток ../app/config.py дописывается прямо к значению alias, и путь выходит за пределы images. Никакого %2f и двойного декодирования не нужно - хватает одного отсутствующего символа. Лечится тем, что слэшем оканчиваются и location, и alias:

location /i/ {
    alias /data/w3/images/;
}

Снаружи начну с реально существующей статики. Допустим, сайт загружает /i/logo.svg. Для показанного конфига запрос /i../images/logo.svg снова указывает на этот же файл: после подстановки получается /data/w3/images/../images/logo.svg. Если оба адреса отдали одинаковый SVG, это кандидат на такую склейку. Контроль с несуществующим именем помогает отсеять сайт, который отдаёт одну заглушку на любые пути:

curl --path-as-is -i 'https://example.com/i/logo.svg'
curl --path-as-is -i 'https://example.com/i../images/logo.svg'
curl --path-as-is -i 'https://example.com/i../images/no-such-pgi-42.svg'

Каталог images здесь известен из учебного конфига; в чужой системе его ещё надо установить, например по опубликованному исходному коду или сообщению с путём. Совпадение публичного файла не доказывает чтение за пределами разрешённого каталога. Для этого нужен отдельный файл, недоступный через предусмотренный публичный маршрут: собственный закрытый тестовый документ либо иной минимальный пример раскрытия, допустимый условиями проверки. В отчёт идут оба запроса и содержимое, которое отличает этот файл от заглушки. Один 200 после .. недостаточен.

Для gixy нужен сам конфиг — со своего сервера или выданный на пентесте. Его проверка alias_traversal ищет такие off-by-slash. Ставится анализатор командой pip install gixy-ng из форка dvershinin/gixy; конфигурацию удалённого сайта по URL он не получает.

С alias связан и другой баг, раскрытый 14 октября 2026 года: CVE-2026-15176. Здесь уже переполнялся буфер, а не просто открывался соседний каталог. Нужны alias, изменение URI через rewrite и вычисление $request_filename, например в set, return или формате журнала. При построении имени после переписывания пути уязвимый код мог выйти за выделенную память. Первые исправленные выпуски — 1.31.7 и 1.30.6.

Поэтому два одинаковых SVG из примера выше не подтверждают эту CVE. Они помогают исследовать склейку пути. Для ошибки памяти нужны её дополнительные условия; обрыв соединения не раскрывает ни содержимое конфига, ни причину сбоя. Снаружи сначала проверяем обычное чтение файлов, а воспроизведение переполнения выносим на согласованный стенд. Подробности модулей и версий — в части 16. Описание F5.

$request_uri и $uri

$request_uri хранит исходный URI вместе с параметрами после ?. $uri — текущий путь без этих параметров. Перед выбором location nginx декодирует в нём %XX, обрабатывает сегменты . и .., а при включённом merge_slashes объединяет повторные слэши. Эта предварительная обработка пути называется нормализацией. После rewrite или внутреннего перенаправления $uri может измениться снова.

Например, для запроса /api/a/%2e%2e/users?sort=name получаются такие значения:

$request_uri = /api/a/%2e%2e/users?sort=name
$uri         = /api/users

%2e — запись точки, поэтому a/%2e%2e/ превращается в a/../ и удаляется при нормализации. По пути /api/users nginx выбирает location, но исходная строка всё ещё доступна в $request_uri.

Ни одна из переменных не «безопаснее» сама по себе. Для имени файла, редиректа, ключа кэша и запроса к бэкенду нужны разные проверки. Смотреть надо на то, куда переменную подставили и на каком этапе.

Как nginx декодирует путь

В ngx_http_parse_complex_uri() путь разбирается посимвольно. Переменная state хранит, что уже прочитано: обычный символ, слэш, одна точка, две точки или начало %XX. Такой способ разбора называется автоматом состояний. Например, после / функция запоминает начало нового сегмента, чтобы отличить отдельный .. от точек внутри имени файла.

Когда встречается %, состояние сохраняется в quoted_state, а две следующие шестнадцатеричные цифры собираются в один байт. Присваивание state = quoted_state возвращает разбор в состояние, которое было до знака %. Декодированный / или . обрабатывается по тем же правилам, что и незакодированный. Поэтому /a/%2e%2e/b нормализуется в /b ещё до выбора location.

Для %25, %23 и %3f есть отдельные ветки. Полученные %, # и ? сразу записываются в результат и не обрабатываются повторно как начало кодировки, фрагмента или строки параметров. Поэтому %252f за этот проход станет %2f, а не /. В состоянии sw_quoted_second проверки разделены по второй цифре кодировки: для %25 и %23 она числовая, для %3f — буквенная.

С управляющими символами нужно быть осторожнее. Декодированный NUL (%00) nginx отвергает. Но CR и LF из %0d и %0a могут попасть в $uri. В таблице usual[] они не отмечены как обычные символы, а общая ветка обработки записывает полученный байт. Проверка управляющих символов при чтении исходной строки запроса не выполняется заново над этим результатом.

Поэтому безопасность подстановки $uri или равного ему $document_uri зависит от назначения. В return 200 "$uri"; значение попадает в тело ответа. В перенаправлении, заголовке или строке нового HTTP-запроса CRLF может изменить структуру сообщения. В доступном для анализа конфиге важны конкретный аргумент rewrite, return, add_header, proxy_set_header или proxy_pass и способ его записи в ответ либо запрос. Такие подстановки ищет и проверка http_splitting в gixy. Параметры FastCGI имеют другую разметку: перенос строки в них сам по себе не означает HTTP splitting.

Без конфига я ищу функцию, которая уже вставляет путь в ответ, например, перенаправление с добавлением конечного слэша. Подставляю в путь %0d%0aX-Audit-Note:%20pgi-42 и читаю первый ответ без перехода по Location. Если появилось отдельное поле X-Audit-Note: pgi-42, переносы действительно изменили заголовки ответа. Если строка осталась внутри HTML или закодированного Location, этого не произошло. Ответ 404 может означать, что изменённый адрес вообще перестал попадать в прежний редирект.

Те же байты могут попасть не в ответ, а в запрос к приложению. Рассмотрим небезопасную подстановку - здесь app — заранее объявленная в блоке http группа upstream с адресом приложения. Если вместо группы используется доменное имя, для такого proxy_pass с переменной нужен DNS-сервер, заданный директивой resolver.

location / {
    proxy_http_version 1.1;
    proxy_set_header Host $http_host;
    proxy_pass http://app$uri;
}

Здесь переменная подставляется в URI как готовая строка. После декодирования запроса с таким путём:

/download/a%20HTTP/1.1%0d%0aX-Audit-Note:%20pgi-42%0d%0aX-End:%20z

начало исходящего HTTP/1.1-запроса может получиться следующим:

GET /download/a HTTP/1.1
X-Audit-Note: pgi-42
X-End: z HTTP/1.1

Версия HTTP, которую дописал прокси, оказалась в значении X-End. А строкой выше появилось отдельное поле X-Audit-Note. Оно находится в запросе к приложению, поэтому искать его среди заголовков ответа бесполезно, если приложение само его туда не выводит. В части 5 проверим действие подложенного поля через обычную функцию сайта и разберём переход к разделению запросов. Такие атаки исследовали Tom Stacey и Tobia Righi в августе 2026 года. CRLF-Powered Desync Attacks.

Что меняет слэш в proxy_pass

Если в proxy_pass нет URI-части (proxy_pass http://app;) и путь не менялся при дальнейшей обработке в nginx, сервер приложения получает исходный URI клиента. Если URI-часть есть (proxy_pass http://app/;), nginx берёт нормализованный путь и заменяет совпавший префикс location указанным URI.

Для одного и того же запроса /api/a/%2e%2e/users?sort=name в блоке location /api/ разница будет такой. Это две альтернативные конфигурации, а не два одинаковых блока в одном файле:

proxy_pass http://app;  -> /api/a/%2e%2e/users?sort=name
proxy_pass http://app/; -> /users?sort=name

Во втором случае нормализованный путь /api/users лишился префикса /api/, вместо которого подставлен /. Здесь нет rewrite и переменных в proxy_pass; для них действуют дополнительные правила.

Как выбирается исходный или нормализованный URI

В структуре запроса nginx хранит и исходный r->unparsed_uri, и нормализованный r->uri. Флаг r->valid_unparsed_uri показывает, можно ли ещё использовать исходный вариант. При обычном разборе он устанавливается выражением r->empty_path_in_uri ? 0 : 1: если путь в исходном URI не был пустым, значение равно единице. Наличие %2f, // или /./ само по себе флаг не сбрасывает.

При формировании запроса ngx_http_proxy_create_request() проверяет ctx->vars.uri.len == 0 && r->valid_unparsed_uri. То есть URI-часть в proxy_pass отсутствует, а исходный путь ещё пригоден. Тогда он и копируется в запрос к приложению. Если дописать /, первая половина условия станет ложной и будет выбрана другая ветка. Сам флаг от добавления слэша не меняется.

В другой ветке берётся нормализованный r->uri, заменяется префикс location и при необходимости выполняется ngx_escape_uri(..., NGX_ESCAPE_URI). Эта функция снова записывает определённые символы как %XX, чтобы они не изменили разметку исходящего URI. Например, декодированный вопросительный знак в пути должен остаться частью пути, а не стать началом параметров.

Таблица этого экранирования оставляет /, ;, : и = без процентной записи. Поэтому %3B после нормализации может уйти к Tomcat как обычный ;. Если Tomcat трактует его как начало параметра сегмента пути, смысл строки изменится. Например, в /item;id=7/view параметр id=7 относится к сегменту item. Правила такой обработки разберём в части 3. Проверка proxy_pass_normalized в gixy-ng отмечает URI-часть в proxy_pass как место для ручной проверки, а не как готовое доказательство уязвимости.

После внутреннего перенаправления ngx_http_internal_redirect() сбрасывает valid_unparsed_uri в ноль. Даже proxy_pass без URI-части тогда передаст изменённый нормализованный путь. Переход в именованный location, например @fallback, сам по себе флаг не сбрасывает: в ngx_http_named_location() такого присваивания нет. Но если путь изменили раньше, переход в именованный блок не восстановит исходное значение флага.

Сравнение /api/users с /api/a/%2e%2e/users сначала проверяет, приводятся ли они к одному ресурсу. В обычном nginx оба пути нормализуются до /api/users ещё до выбора location, поэтому одна лишь эта разница не обходит защиту в том же location. По одинаковым ответам также не определить слэш в proxy_pass: путь могло нормализовать само приложение.

Обход появляется при дополнительном расхождении. Например, сервис авторизации проверяет исходный путь из $request_uri и запрещает обычной учётке только буквальный /api/users. Он пропускает /api/a/%2e%2e/users, а приложение получает нормализованный /api/users и доверяет решению сервиса. Если обработчик сам проверяет роль пользователя, этот пример закончится отказом на обоих путях.

Какой запрос получает сервис авторизации

auth_request /auth; создаёт дополнительный внутренний запрос к /auth. Nginx выполняет его обработку и ждёт результат, прежде чем разрешить основной запрос. Это называется подзапросом. Если /auth проксируется отдельному сервису, тот получает запрос, сформированный настройками этого location, а не копию всего исходного HTTP.

В ngx_http_subrequest() обычный подзапрос создаётся с методом GET. Для сохранения метода есть флаг NGX_HTTP_SUBREQUEST_CLONE, но auth_request передаёт другой — NGX_HTTP_SUBREQUEST_WAITED. Кроме того, ngx_http_auth_request_handler() создаёт пустую структуру тела, чтобы подзапрос не пытался читать тело основного запроса, и устанавливает header_only = 1: для решения о доступе нужен статус ответа, а не его тело.

При обычном proxy_pass сервис поэтому увидит GET к /auth, а не исходный POST к /admin. В официальном примере исходный URI передают заголовком X-Original-URI. Если правила доступа зависят и от метода, его тоже нужно передать и проверить. Например:

location = /auth {
    internal;
    proxy_pass http://auth_service;
    proxy_pass_request_body off;
    proxy_set_header Content-Length "";
    proxy_set_header X-Original-URI $request_uri;
    proxy_set_header X-Original-Method $request_method;
}

Сервис должен принимать решение по этим полям, а не только по своему GET. $request_method здесь берётся из основного запроса (r->main->method_name в исходнике nginx). При необходимости можно изменить и сам исходящий метод через proxy_method $request_method. URI также можно задать в proxy_pass, а тело — сформировать через proxy_set_body.

Остаётся проверить представление пути. $request_uri содержит исходную строку, а location выбран по нормализованному $uri. Если сервис проверяет первую строку, он должен понимать её так же, как программа, которая затем обработает запрос. Иначе можно разрешить один путь, а выполнить другой. Сам факт использования auth_request такого обхода не доказывает.

Ответ подзапроса с кодом 200–299 разрешает основной запрос. В исходнике это ctx->status >= NGX_HTTP_OK && ctx->status < NGX_HTTP_SPECIAL_RESPONSE. Коды 401 и 403 запрещают доступ, остальные считаются ошибкой. Поэтому 302 на страницу входа от сервиса авторизации приведёт к 500, если конфигурация не обрабатывает этот случай отдельно. В части 16 разберём, как error_page 401 может изменить дальнейшую обработку запроса.

Снаружи я проверяю основную функцию, а не предполагаемый /auth, потому что этот адрес может быть внутренним или вообще называться иначе. Для ошибки с методом нужна функция, где чтение разрешено, а изменение запрещено. Если сервис авторизации видит GET вместо исходного POST, он может разрешить запись как чтение; приложение должно при этом полагаться на его решение, а не проверять право записи повторно. На одноразовом тестовом объекте можно сохранить исходное значение, отправить предусмотренный API запрос изменения и затем прочесть объект. Новое значение покажет выполненное действие.

Как тот же запрос обрабатывает Apache

У Apache другая схема. Один модуль переводит URL в имя файла, другой проверяет доступ, третий назначает обработчик, четвёртый формирует ответ. Для этого в коде есть отдельные точки подключения, и модуль встраивается в нужный этап обработки запроса.

Упрощённая последовательность выглядит так:

разбор запроса -> перевод URI в имя файла -> проверка доступа
-> выбор типа и обработчика -> фильтры ответа -> журнал

URI /download/report.pdf, файл /srv/www/download/report.pdf и обработчик default-handler — разные значения. mod_rewrite меняет URI, Alias — путь к файлу, SetHandler — обработчик. В Confusion Attacks 2024 года Orange Tsai показал, как модули Apache по-разному понимали эти значения: Filename Confusion (CVE-2024-38474), DocumentRoot Confusion (CVE-2024-38475) и Handler Confusion (CVE-2024-38476). Механика разобрана в части 4. При чтении конфига нужно проследить, какими стали URI, файл и обработчик после всех mod_rewrite и Alias.

MPM

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

Основных вариантов три:

  • prefork - несколько процессов без рабочих потоков;
  • worker - несколько процессов с потоками;
  • event похож на worker, но часть простаивающих keep-alive соединений обслуживает отдельно.

event экономнее расходует рабочие потоки на keep-alive, но от Slowloris спасает не полностью. Медленно отправленные заголовки и тело по-прежнему занимают рабочий поток, и за это отвечает отдельный модуль mod_reqtimeout с директивой RequestReadTimeout (по умолчанию handshake=0 header=20-40,MinRate=500 body=20,MinRate=500). Лимиты заголовков и тела настраиваются своими директивами.

Старый mod_php часто работал с prefork: не все сборки PHP и сторонние библиотеки были рассчитаны на несколько потоков. Сейчас PHP обычно вынесен в PHP-FPM. Если используется mod_php, совместимость с потоками проверяют для конкретной сборки.

.htaccess

Apache читает .htaccess в каталогах по пути к файлу, если это разрешено через AllowOverride или AllowOverrideList. Владельцу сервера документация Apache советует задавать настройки в основном конфиге, когда он доступен. Так быстрее и понятнее, откуда взялось правило.

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

<Directory "/srv/www/uploads">
    AllowOverride None
    AllowOverrideList None
    Options -ExecCGI
    Require all granted
</Directory>

Обе директивы AllowOverride* нужны, чтобы Apache вообще не читал .htaccess в этом каталоге. Если задать только AllowOverride None, унаследованный AllowOverrideList всё ещё может разрешать отдельные директивы.

Но запрет .htaccess не отменяет обработчики из основного конфига. Apache применяет обычные <Directory> вместе с .htaccess, затем <DirectoryMatch>, затем <Files> и <FilesMatch>, затем <Location> и <LocationMatch>; после них обрабатываются подходящие секции <If>. Значит, <FilesMatch "\.php$"> из основного конфига применяется после <Directory> и может всё равно отправить файл в интерпретатор. Проверять надо итоговый обработчик файла.

На багбаунти полезнее начать с самой загрузки - сохраняется ли выбранное имя, где файл выдаётся и меняется ли его содержимое. Неудачный GET на /.htaccess не доказывает, что Apache такие файлы не читает: запрет выдачи файла и применение его директив — разные вещи.

Что веб-сервер передаёт приложению

После выбора маршрута веб-сервер либо отдаёт файл, либо обращается к следующей программе:

  • по HTTP к другому веб-серверу;
  • по FastCGI к PHP-FPM;
  • по uWSGI к Python-приложению;
  • по SCGI к приложению на Python или Perl;
  • по gRPC поверх HTTP/2.

AJP тоже передаёт запросы приложениям, но в штатной поставке nginx его нет. Apache поддерживает его через mod_proxy_ajp или mod_jk; ниже сравним этот вариант с FastCGI.

Локальное соединение может идти не через TCP-порт, а через Unix-сокет - специальный файл для связи процессов на одной машине.

FastCGI не передаёт PHP-FPM исходный HTTP-запрос. Nginx собирает набор параметров: SCRIPT_FILENAME с путём к PHP-файлу, DOCUMENT_ROOT с корнем сайта, REQUEST_URI с исходным URI. По SCRIPT_FILENAME FPM определяет файл для выполнения. Проверка nginx и выбор FPM должны относиться к одному файлу. PATH_INFO, хвоста после имени скрипта, в штатном fastcgi_params нет. Обычно для него настраивают fastcgi_split_path_info и отдельно передают $fastcgi_path_info; само имя параметра можно задать и другим способом через fastcgi_param.

AJP устроен похожим образом. Apache разбирает HTTP сам и передаёт Tomcat готовые поля — метод, protocol, req_uri, адрес клиента, заголовки и атрибуты. Tomcat заполняет ими свой объект запроса; восстанавливать текстовую строку HTTP/1.1 ему не нужно. Но исходных байтов до обработки в Apache в этих полях уже может не быть. mod_jk с версии 1.2.24 по умолчанию работает в режиме ForwardURIProxy и отдаёт URI, уже разобранный Apache и заново частично закодированный. Tomcat работает с этой переданной формой.

Напрямую сопоставить поля FastCGI или AJP с исходным запросом можно на своём стенде либо на пентесте с доступом к внутреннему обмену. Снаружи смотрим на результат выбранной функции - какой собственный файл она вернула, выполнился ли тестовый скрипт и сохранился ли запрет доступа после изменения пути. Точное содержимое SCRIPT_FILENAME или req_uri из такого ответа само по себе не следует.

Возьмём типовой блок FastCGI:

location ~ \.php$ {
    try_files $uri =404;
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    fastcgi_pass unix:/run/php/php-fpm.sock;
}

try_files $uri =404; проверяет, существует ли запрошенный файл, и останавливает запрос с 404, если его нет. Без этой строки путь /uploads/avatar.jpg/x.php подходит регулярке \.php$. При cgi.fix_pathinfo=1 PHP может отбросить несуществующий хвост /x.php и выбрать существующий /uploads/avatar.jpg как основной файл скрипта.

До выполнения кода дело дойдёт только при дополнительных условиях. Картинка должна содержать PHP-код, а ограничение расширений в PHP-FPM должно разрешать .jpg или быть отключено. По умолчанию security.limit_extensions допускает .php .phar, поэтому штатный FPM отвергнет этот файл. Здесь по отдельности проверяются существование нужного скрипта в nginx, значение cgi.fix_pathinfo и разрешённые расширения в пуле FPM.

Дальше вопросы к итоговому SCRIPT_FILENAME. Может ли клиент увести его неожиданным URI? Проверяет ли nginx существование файла? Одна ли файловая система у nginx и PHP-FPM? Если процессы живут в разных контейнерах, одна и та же строка пути укажет у них на разные файлы.

Какие ответы сравнивать без доступа к серверу

Снаружи полезно сравнивать ответы на запросы, в которых изменён один элемент. Via, X-Cache, Age, CF-Ray и X-Amz-Cf-Id могут подсказать участие прокси или кэша. Но их отсутствие ничего не исключает, а наличие не показывает весь путь обработки. Разница в статусах и обратное обращение на тестовый адрес тоже не определяют автоматически, какой сервер преобразовал URI.

Сравнение имеет смысл на известном маршруте. Для файла проверяю его содержимое, для API — идентификатор объекта и значимые поля, для страницы — собственную метку в данных. Полное равенство HTML не требуется, потому что в нём могут меняться время, токены и идентификаторы запросов. Для сравнения я запрашиваю несуществующий объект или тот же объект из сессии без нужного права.

Отдельно проверяется, что изменение не сделал сам клиент, то есть сохраняю отправленный запрос, для сегментов .. использую --path-as-is, редиректы сначала читаю без автоматического перехода. Повторяю исходный запрос после изменённого. Если результат исчез, нужно отделить действие пробы от истечения сессии, изменения данных или случайного ответа другого сервера.

В части 3 подробнее разберём преобразования пути, как меняется одна строка и какие запросы позволяют проверить последствия без журнала промежуточных URI.

Источники