Содержание:

В серии 20 частей:

Порядок двадцати статей серии

Узнал тут недавно, что по серии статей про JWT люди написали скилы для Claude Code и даже зарепортили валидные баги в некоторые программы бб. Теперь разберём веб-серверы. Что они делают с запросом до приложения и где это можно сломать.

Проверять будем чёрным ящиком, как на багбаунти. У нас есть сайт, свои тестовые учётки и возможность отправлять запросы. Доступа к конфигу, серверным журналам и процессам на хосте нет. Разбор внутреннего устройства поможет понять, какой байт изменить в запросе, что сервер может с ним сделать и где искать результат.

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

Журнал запросов в Burp, вкладка Network («Сеть») и записи своего Collaborator доступны нам и при работе чёрным ящиком. В них видны отправленные нами запросы, полученные ответы и обращения на выданный нам адрес для проверки. Промежуточных значений внутри чужого сервера эти записи не показывают.

Когда открываешь сайт, запрос часто проходит через несколько программ. Например:

браузер -> CDN -> WAF -> балансировщик -> nginx -> приложение

CDN принимает соединение на одном из своих серверов и может отдать сохранённую копию ответа. WAF проверяет запрос по правилам защиты от атак. Балансировщик выбирает сервер из группы. Nginx отдаёт файл сам или передаёт запрос приложению. Это роли, а не обязательно отдельные машины, потому что например CDN может заодно выполнять проверку WAF и балансировку. Бывает конечно и короткая схема, где перед приложением стоит только nginx.

Например, браузер подключается к CDN. Nginx этого соединения вообще не видит. CDN сам принимает запрос браузера, разбирает его и решает, что делать дальше: заблокировать, отдать ответ из кэша или отправить запрос следующему серверу.

Если запрос надо отправить дальше, CDN открывает отдельное соединение к nginx или использует уже открытое. По нему CDN передаёт не обязательно те же байты, которые получил от браузера. Браузер мог прислать HTTP/2, а CDN сформировать для nginx новый HTTP/1.1-запрос. В нём будут строка с методом и путём, заголовки и тело. Следующий прокси снова разберёт этот запрос и может собрать ещё один для приложения.

Вот обычный HTTP/1.1-запрос:

POST /api/users HTTP/1.1
Host: example.com
Content-Length: 20
Content-Type: application/json

{"name":"pingiskok"}

Из него надо получить метод POST, путь /api/users, три заголовка и тело с JSON. Уже здесь появляются спорные места. Что делать, если одновременно пришли Content-Length и Transfer-Encoding? Когда раскодировать %2F в пути? Как поступить с двумя заголовками Host?

Допустим, WAF решил, что запрос идёт в открытый раздел сайта, и разрешил его. А приложение прочитало путь иначе и открыло админку. WAF проверил не тот путь, который использовало приложение. Обход получится, если само приложение не запретит этот доступ.

В следующих примерах нас интересуют четыре решения:

  1. Путь - кто и на каком шаге раскодировал %2F.
  2. Длина тела - где узел считает, что запрос кончился и начался следующий.
  3. Служебные заголовки - кто дописал X-Forwarded-For и X-User и кто им поверил.
  4. Ключ кэша - по каким полям запроса CDN выбирает сохранённый ответ.

Как HTTP/1.1 отделяет один запрос от следующего

Сначала посмотрим, как сервер читает HTTP/1.1. Первая строка в примере называется строкой запроса:

POST /api/users HTTP/1.1

В ней через пробел записаны три значения: метод POST, адрес ресурса /api/users и версия протокола HTTP/1.1. Дальше по одной строке идут заголовки.

В редакторе в конце каждой строки виден обычный перенос, а в сообщении HTTP/1.1 на его месте идут два байта: 0d 0a в шестнадцатеричной записи. Первый называется CR, «возврат каретки», второй — LF, «перевод строки». Вместе их записывают как \r\n, или CRLF. При HTTPS эти байты видны после расшифровки TLS, а не прямо в захваченном сетевом пакете. После последнего заголовка идёт ещё один CRLF. Поэтому конец заголовков выглядит как две пары подряд:

POST /api/users HTTP/1.1\r\n
Host: example.com\r\n
Content-Length: 20\r\n
Content-Type: application/json\r\n
\r\n
{"name":"pingiskok"}

Пустая строка заканчивает только заголовки. Если у запроса есть тело, сервер ещё должен понять его длину. В этом примере Content-Length: 20 приказывает прочитать следующие 20 байт. JSON действительно занимает 20 байт, потому что все символы здесь однобайтовые. Двадцать первый байт уже может быть началом следующего запроса.

TCP передаёт непрерывный упорядоченный поток байтов, а не готовые HTTP-запросы. В нём нет метки «первый запрос закончился здесь». Сервер сам находит CRLF, разбирает заголовки и вычисляет длину тела. Если через одно постоянное соединение идут несколько запросов, они лежат друг за другом без дополнительного разделителя.

Где заканчивается тело

После заголовков сервер должен определить, сколько байтов относится к телу. В HTTP/1.1 для этого обычно используют один из двух способов:

  • Content-Length: 100 - после пустой строки идут ровно 100 байт;
  • Transfer-Encoding: chunked - тело приходит кусками, перед каждым записан его размер.

Chunked нужен, когда отправитель ещё не знает итоговую длину, например когда приложение генерирует ответ по ходу работы. Размер каждого куска записывается шестнадцатеричным числом. После него идут CRLF, указанное число байт и ещё один CRLF. Нулевой кусок отмечает конец. Вот те же правила на теле из двух кусков:

4\r\n
Wiki\r\n
5\r\n
pedia\r\n
0\r\n
\r\n

Здесь тело равно Wikipedia. Сначала четыре байта Wiki, затем пять байт pedia. Последовательность 0\r\n\r\n сообщает, что новых кусков и завершающих заголовков нет.

Отправитель не должен ставить Content-Length и Transfer-Encoding одновременно. Получивший такой запрос сервер может сразу его отклонить. RFC 9112 также разрешает разобрать тело только по Transfer-Encoding, но после ответа соединение всё равно закрывается. Прокси, который всё-таки отправляет запрос дальше, обязан сначала удалить Content-Length.

Предположим, первый сервер вопреки этим правилам использует Content-Length, но передаёт дальше и Transfer-Encoding. Второй использует chunked-разметку. Тогда один и тот же участок байтов они могут принять за тело разной длины. Это схема CL.TE: слева указан способ чтения тела первым сервером, справа — вторым. Для атаки нужна конкретная пара реализаций с таким расхождением. Обычный современный nginx запрос с распознанными Content-Length и Transfer-Encoding: chunked отклонит с 400.

Особенно опасно, когда прокси использует одно постоянное соединение с приложением для запросов разных пользователей. Если сервер приложения оставил часть первого запроса непрочитанной, то начнёт разбирать следующий с этих байтов. Атакующий может подставить туда начало своего запроса, к которому допишется запрос другого пользователя. Этот класс атак называется request smuggling; расхождение границ запросов часто называют десинком. Практические примеры будут в части 5.

В названии 0.CL ноль означает, что первый сервер считает тело пустым, а второй ожидает длину из Content-Length. Например, первый сервер не распознал заголовок из-за особенностей его записи, но переслал его второму. Первый уже ждёт ответ, второй ещё ждёт тело. Получается взаимное ожидание. В частях 5–6 разберём конкретные причины такого поведения и условия, которые позволяют продолжить атаку.

В CL.0 роли меняются. Первый сервер передаёт тело по Content-Length, а второй отвечает, не дочитав его, и ошибочно использует оставшиеся байты как новый запрос. Сам по себе ранний ответ не уязвимость, потому что сервер может дочитать тело или закрыть соединение. Опасно именно повторное использование соединения с непрочитанным телом. Бывает и client-side desync, где путаница возникает на соединении браузера с сервером. Для такого варианта общее соединение нескольких пользователей не нужно. Подробнее будет в частях 5–6.

Исправления кода разбора HTTP и строгая проверка входящих запросов закрывают конкретные варианты этих атак. Если все участники поддерживают HTTP/2, стоит также отказаться от преобразования в HTTP/1.1 между прокси и приложением. Это убирает описанные способы разделить общий поток HTTP/1.1 по-разному, но не отменяет обновления и проверку заголовков.

Поэтому из строки Server: nginx нельзя заключить ни «smuggling возможен», ни «здесь всё исправлено». Снаружи сначала проверяют, может ли один собственный запрос изменить обработку следующего. Повторное использование соединения и разные правила чтения тела объясняют возможную причину. Сам заголовок Server не показывает, как прокси обменивается запросами с приложением. И как отделить такое влияние от случайного таймаута, будет тоже разобрано в частях 5–6.

Один запрос, два пути

Первое место расхождения - путь. Клиент отправляет:

GET /admin%2Fusers HTTP/1.1
Host: example.com

%2F обозначает байт /: после знака % записан его шестнадцатеричный код 2F. Допустим, WAF запрещает пути, которые начинаются с /admin/, и проверяет строку до декодирования. У /admin%2Fusers такого начала нет, и WAF пропускает запрос. Приложение декодирует %2F, получает /admin/users и выбирает обработчик списка пользователей.

Если доступ к этому обработчику защищал только WAF, запрет байпаснут. Если приложение само проверит права и вернёт 403, описанного обхода доступа не получится.

Отправляю /admin/users, затем /admin%2Fusers, не меняя cookie и остальные заголовки. Если первый запрос отклонён, а второй вернул данные того же обработчика, закодированный слэш позволил пройти запрет. В этой схеме его применяет только WAF, до передачи запроса приложению.

Если же обработчик после разбора URL сам проверяет роль или владельца объекта, замена / на %2F этой проверки не отменяет. Запрет одной учётке читать отчёт другой — отдельная проверка доступа. Его обход возможен, например, если изменённый путь выбирает другой обработчик без проверки. Одного декодирования для этого недостаточно.

Здесь WAF проверил исходную строку, а приложение — декодированную. В других случаях оба декодируют путь, но один делает это дважды. Например, %252F за один проход станет %2F, а за два — /. В части 3 разберём, какие преобразования меняют смысл пути и как отличить обычную обработку URL от уязвимости.

Откуда берётся HTTP/1.1, если браузер отправил HTTP/2

Разные программы могут не согласиться и с длиной тела. Это бывает между двумя серверами HTTP/1.1, а также при преобразовании HTTP/2 в HTTP/1.1. Разберём второй случай: почему для браузера запрос уже закончился, а nginx ещё ждёт данные или, наоборот, начинает читать следующий запрос.

Браузер может подключиться к CDN по HTTP/2, а CDN к nginx - по HTTP/1.1:

браузер --HTTP/2--> CDN --HTTP/1.1--> nginx

HTTP/2 передаёт те же метод, путь, заголовки и тело, но делит данные на кадры. Кадр — участок байтов с собственным заголовком. В его первых девяти байтах записаны длина следующего участка, тип кадра, флаги и номер потока. Поэтому получателю не нужно искать перенос строки, чтобы узнать, где кадр закончился: он читает указанное число байт.

Поток связывает кадры одного запроса и ответа на него. Например, кадр с номером потока 1 относится к первому запросу, а с номером 3 — к другому. По одному соединению кадры этих потоков могут идти вперемешку. Получатель различает их по номерам. В части 9 разберём служебные поля по байтам и посмотрим, что происходит при закрытии потока.

Основных типов для обычного запроса два:

  • HEADERS начинает блок с методом, путём и заголовками; если блок не помещается, его продолжают кадры CONTINUATION;
  • DATA несёт тело.

Заголовки внутри HEADERS сжаты алгоритмом HPACK, который заменяет повторяющиеся имена и значения ссылками на таблицы. Поэтому следующая схема показывает уже распакованное содержимое, а не буквальные байты из сети:

HEADERS, поток 1, END_HEADERS
  :method    = POST
  :scheme    = https
  :authority = example.com
  :path      = /api/users
  content-type = application/json

DATA, поток 1, END_STREAM, длина 20
  {"name":"pingiskok"}

Поля с двоеточием в начале называются псевдозаголовками. Они заменяют данные, которые в HTTP/1.1 находились в строке запроса и в Host. :method содержит POST. :path содержит путь вместе со строкой параметров после ?, например /search?q=nginx. :authority содержит домен и, если он указан, порт. :scheme для обычного сайта равен http или https.

Флаг END_HEADERS означает, что блок заголовков закончился, а сам запрос на этом может продолжаться - следом идёт тело. Конец всего запроса отмечает флаг END_STREAM. Обычно он стоит на последнем DATA. Если тела нет, его ставят уже на первом HEADERS; если после тела идут завершающие заголовки, то на их HEADERS.

В HTTP/2 конец запроса не ищут по пустой строке, а Content-Length не делит поток на кадры. Длину каждого кадра получатель берёт из самого кадра. Флаг END_STREAM сообщает, что отправитель закончил передавать запрос. Заголовок content-length в HTTP/2 разрешён, но указанное в нём число обязано совпасть с количеством байтов тела во всех DATA. Заголовки кадров и необязательное заполнение в это число не входят. Если числа расходятся, прокси должен отклонить запрос, а не пересылать его дальше.

В нашей схеме браузер обменивается HTTP/2 только с CDN. Соединение между CDN и nginx настроено отдельно и здесь использует HTTP/1.1. Версия протокола на этом участке не определяется тем, что выбрал браузер. Из показанных кадров CDN соберёт такой запрос:

POST /api/users HTTP/1.1
Host: example.com
Content-Type: application/json
Content-Length: 20

{"name":"pingiskok"}

При сборке псевдозаголовки становятся строкой запроса и Host, содержимое кадров DATA - телом. В HTTP/1.1 надо отдельно указать, где тело кончится. CDN может посчитать байты и записать Content-Length либо передать тело кусками с Transfer-Encoding: chunked.

Уязвимость возникает, если прокси нарушает эту проверку: копирует заниженный content-length из запроса клиента, но передаёт больше байтов тела. Например, записывает Content-Length: 5, а после пяти байтов hello отправляет ещё и текст GET /admin HTTP/1.1\r\n.... Для прокси этот текст был частью тела HTTP/2. Для сервера HTTP/1.1 тело уже закончилось после hello, и следующие байты могут начать новый запрос.

Это ошибка конкретной реализации, а не нормальная работа CDN или неизбежное свойство HTTP/2. James Kettle описал такой случай на Netflix в 2021 году. Его называют H2.CL: первый сервер определяет границу по HTTP/2, второй — по Content-Length. Полный запрос, условия эксплуатации и соседний вариант H2.TE разберём в части 9.

Заголовки, которые добавляет прокси

Третье место расхождения - служебные заголовки. Приложение не видит исходное соединение браузера, если перед ним стоит прокси. Поэтому прокси дописывает свои:

  • X-Forwarded-For - адрес клиента;
  • X-Forwarded-Proto - пришёл клиент по HTTP или HTTPS;
  • X-Forwarded-Host - к какому имени сайта он обратился;
  • X-User или X-Email - кого уже проверил прокси авторизации вроде oauth2-proxy.

Проблема начинается, если клиент может прислать такой же заголовок сам. Допустим, приложение пускает в админку пользователя из X-User, потому что считает этот заголовок результатом проверки на прокси. Прокси обязан удалить клиентский X-User и записать свой. Если он просто передаст X-User: admin дальше, приложение поверит строке, которую написал атакующий.

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

Host расходится так же. RFC 9112 требует ответить 400, если в запросе больше одного Host, но на практике узлы выбирают по-разному: кто-то берёт первый, кто-то последний, кто-то склеивает значения через запятую. Расхождение здесь даёт тот же эффект, что и с %2F. WAF проверил один домен, а приложение обслужило другой. Атаки на Host и X-Forwarded-Host разбираю в части 12.

Отравление и обман кэша

CDN может один раз получить страницу у приложения и потом отдавать её другим посетителям. Чтобы найти сохранённый ответ, он составляет ключ из полей запроса. Допустим, для https://example.com/news ключом служит сочетание схемы, домена и пути. Остальные заголовки в нашем примере в ключ не входят.

Отправляю запрос к /news с X-Forwarded-Host: attacker.example. Если приложение доверяет этому заголовку, оно может вставить в HTML ссылку на attacker.example. Если CDN сохранит такой ответ, обычный запрос к /news без подставного заголовка получит тот же ключ и ту же страницу. Посетителю достанется ответ, который изменил атакующий. Это cache poisoning, или отравление кэша. Нужны оба условия: заголовок влияет на ответ, а кэш не различает запросы с ним и без него.

В web cache deception атакующий добивается сохранения чужой личной страницы. Например, приложение обрабатывает /account/profile.css как профиль вошедшего пользователя, а CDN из-за окончания .css считает ответ общедоступным статическим файлом. Если такой ответ действительно сохраняется и выдаётся без сессии, его можно прочитать чужим запросом. Само окончание .css этого не гарантирует — решают правила кэша и приложения. Эти две атаки подробно разберём в частях 7 и 8.

Веб-сервер умеет больше, чем раздавать файлы

Ошибками разбора запросов дело не ограничивается. Две установки одной версии nginx могут отличаться модулями, настройками и исправлениями в пакете. Рядом с веб-сервером часто работают отдельные службы:

  • WebDAV умеет создавать и менять файлы по HTTP;
  • FastCGI передаёт запросы PHP-FPM;
  • AJP связывает Apache с Tomcat;
  • Lua и njs выполняют код прямо внутри веб-сервера;
  • панель управления меняет конфиг и перезапускает службу;
  • экспортёры метрик, например nginx-prometheus-exporter, отдают показатели работы сервера по HTTP; у сборщиков логов тоже есть свои порты и маршруты.

Самого наличия модуля ещё недостаточно для эксплуатации. Нужно проверить, может ли запрос дойти до его уязвимого кода и какие настройки для этого требуются. Например, CVE-2023-25690 в Apache требует определённых правил RewriteRule или ProxyPassMatch, а не просто загруженного mod_proxy. Но фраза «мы этим модулем не пользуемся» тоже не заменяет проверку конфигурации: обработчик мог быть включён общим конфигом или другим виртуальным хостом.

Панели nginx-ui, Plesk и cPanel позволяют управлять сервером, поэтому доступ к ним проверяется отдельно от сайта. Встроенная панель Traefik устроена иначе: через неё и связанный с ней API можно просматривать маршруты и действующие настройки. Она не переписывает конфиг nginx, но открытый доступ к ней тоже может раскрыть внутренние адреса и настройки. Панели и служебные интерфейсы разберу в части 17.

Почему условия важнее громкого названия CVE

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

Apache 2.4.49 и 2.4.50

CVE-2021-41773 была в обработке точек и кодированных символов пути в Apache 2.4.49. При определённой конфигурации Alias связывал URL с каталогом на диске, а кодированный ../ позволял выйти из этого каталога и читать другие файлы.

Читались при этом только те файлы, которые не закрыты обычным Require all denied из конфигурации по умолчанию. Выполнение кода получалось вообще не в каждой установке. Чтобы выполнить команду, найденный файл должен был попасть под CGI и запуститься как программа. Исправление в Apache 2.4.50 оказалось неполным, байпасу присвоили CVE-2021-42013. Обе ошибки закрыли в 2.4.51.

Confusion Attacks в Apache

В 2024 году Orange Tsai показал несколько ошибок, где модули Apache путали URI, имя файла на диске и назначенный обработчик. Условия там разные: где-то нужен mod_rewrite, где-то mod_proxy, CGI, PHP или Windows. Под общим названием собрано несколько разных багов, и каждый срабатывает в своей конфигурации. Полную механику Confusion Attacks разбираю в части 4.

Request smuggling в ASP.NET Core

CVE-2025-55315 затронула конкретные версии ASP.NET Core за промежуточным сервером. Прокси и Kestrel по-разному разбирали конец chunked-запроса, из-за чего остаток одного запроса мог приклеиться к следующему на общем соединении. Механику ошибки разбираю в части 6.

QUIC в HAProxy

В феврале 2026 года HAProxy закрыл две ошибки, до которых можно добраться только при включённом QUIC. В CVE-2026-26080 обработка обрезанного числа в пакете зацикливалась. Механизм контроля зависаний watchdog — замечал, что процесс перестал выполнять остальную работу, и завершал его. В CVE-2026-26081 проверка длины токена в начальном QUIC-пакете приводила к падению процесса. Бюллетень описывает для обеих CVE удалённый DoS. Разбор обеих - в части 10.

Версия в заголовке может соврать

Установки различаются конфигом, модулями, параметрами сборки и патчами пакета и строка nginx 1.x этого состава не показывает.

Debian и Red Hat часто переносят исправление в старую версию программы, не меняя её основной номер. При доступе к хосту на пентесте сверяют полный номер пакета с бюллетенем дистрибутива, а состав сборки — со списком модулей. Снаружи баннер помогает выбрать условия CVE для исследования.

Например, в бюллетене nginx для CVE-2026-42533 описано переполнение буфера при сочетании map и регулярных выражений. После публикации такого бюллетеня прежний совет «эта версия безопасна» нужно пересмотреть: ошибка существовала и раньше, просто о ней ещё не знали. В части 20 есть сводка версий с датой проверки. При проверке своей установки нужно сверять пакет со свежим бюллетенем, а не полагаться только на таблицу в статье.

В части 2 проследим, как nginx и Apache выбирают сайт, правило обработки, файл и приложение. Без этого трудно понять, почему один и тот же URL в двух конфигурациях приводит к разным результатам.

Источники