С чего всё начинается: почему сервер вообще может не понять, где кончается запрос
Чтобы разобраться в HTTP Request Smuggling, надо сначала честно ответить на вопрос, который в обычной жизни может прозвучать абсурдно: а как сервер понимает, где заканчивается один HTTP-запрос и начинается следующий? Кажется, что ответ очевиден — запрос это запрос, отправил и отправил. Но эта очевидность держится ровно до тех пор, пока запрос один и сервер один. Как только их становится много, а серверов в цепочке хотя бы два, выясняется, что «границу запроса» надо где-то явно записать, и способов записать её оказывается не один, и вот в этом зазоре и живёт вся уязвимость.
Начнём с того, как устроена современная доставка запроса от браузера до кода приложения. Почти никогда браузер не соединяется напрямую с тем сервером, который выполняет логику. Между ними стоит цепочка: сначала фронтенд — балансировщик нагрузки, реверс-прокси, CDN-узел, — который принимает соединение от клиента, а затем передаёт запрос дальше, на бэкенд, где живёт собственно приложение. Это не архитектурное излишество, а норма: фронтенд терминирует TLS, распределяет нагрузку, кэширует, фильтрует, и без него облачный сервис почти не строится.
Ключевая деталь в том, как фронтенд общается с бэкендом. Открывать новое TCP-соединение под каждый запрос дорого, поэтому фронтенд держит с бэкендом постоянные соединения и переиспользует их: по одному и тому же соединению он отправляет запрос за запросом, встык, друг за другом. И вот здесь бэкенд оказывается в положении, где он получает не отдельные аккуратно упакованные запросы, а непрерывный байтовый поток, в котором запросы склеены. Разбирать этот поток на отдельные запросы — его задача. Он должен точно знать, сколько байт занимает первый запрос, чтобы понять, где начинается второй.
Пока фронтенд и бэкенд считают длину запроса одинаково, всё работает. Проблема возникает, когда они считают её по-разному. Тогда фронтенд отправляет по соединению то, что он считает одним запросом, а бэкенд, читая тот же поток, проводит границу в другом месте — и часть байтов, которые для фронтенда были концом первого запроса, для бэкенда становятся началом второго. Это состояние называется десинхронизацией, десинком, и именно поэтому вторая половина имени атаки — HTTP Desync. А байты, которые атакующий положил в этот зазор, оказываются приклеены к следующему запросу — своему же следующему или, что гораздо интереснее, к запросу постороннего пользователя, чьё соединение попало на тот же бэкенд.
Два способа измерить длину, которые спорят друг с другом
Теперь к корню проблемы. Спецификация HTTP/1.1 оставила два разных механизма, которыми можно указать длину тела запроса, и они совершенно легитимны оба.
Первый — заголовок Content-Length. Он предельно прост: содержит десятичное число, равное количеству байт в теле запроса. Если написано Content-Length: 11, значит после заголовков и пустой строки идёт ровно одиннадцать байт тела, и на этом запрос кончается. Понятно, однозначно, легко считать.
Второй способ — заголовок Transfer-Encoding: chunked. Он говорит, что тело передаётся не одним куском, а порциями, чанками, и заранее общая длина не объявляется. Вместо этого тело устроено как последовательность блоков, где каждый блок начинается со своего размера, записанного шестнадцатеричным числом, затем идёт перевод строки, затем сам блок данных. За одним блоком следует другой, и так до тех пор, пока не встретится блок нулевого размера — одинокий 0, за которым идёт перевод строки. Этот нулевой блок и есть сигнал «тело закончилось». Механизм придуман для ситуаций, когда сервер начинает отдавать данные, ещё не зная их итогового размера — например, стримит ответ на лету.
Здесь важно зафиксировать одну вещь, вокруг которой потом крутится вся эксплуатация: концом chunked-тела является последовательность нулевого чанка. Именно 0 и следующие за ним переводы строки означают «стоп, дальше тела нет». Кто дочитал до нолика — считает запрос завершённым. Кто до нолика не дочитал или, наоборот, проскочил его — проведёт границу не там.
Теперь представим, что в одном запросе присутствуют оба заголовка сразу — и Content-Length, и Transfer-Encoding. Это ненормально, но технически возможно, никто физически не мешает атакующему вписать оба. Спецификация предвидела коллизию и постановила: если пришли оба заголовка, Content-Length игнорируется, слушать надо Transfer-Encoding. Правило разумное, и будь в системе один сервер, его бы хватило. Но серверов в цепочке минимум два, и они писались разными людьми, разными вендорами, в разные годы, и соблюдают спецификацию с разной степенью аккуратности. Один честно отдаёт приоритет Transfer-Encoding. Другой, глядя на тот же запрос, по каким-то своим причинам смотрит на Content-Length. И как только их мнения разошлись — граница запроса разъехалась, и атака состоялась.
Причин, по которым сервер может «неправильно» обработать Transfer-Encoding, две. Либо он вообще не поддерживает этот заголовок в запросах и всегда падает обратно на Content-Length. Либо он поддерживает, но его можно заставить не распознать заголовок, слегка исказив его написание так, что формально это уже не совсем Transfer-Encoding, но конкретный парсер всё равно его проглотит, а его сосед — нет. Отсюда и вырастают три классические разновидности атаки.
CL.TE: фронтенд верит длине, бэкенд верит чанкам
Первый вариант принято обозначать CL.TE, и читается это как «фронтенд использует Content-Length, бэкенд использует Transfer-Encoding». Разберём на конкретном запросе.
POST / HTTP/1.1 Host: vulnerable-website.com Content-Length: 13 Transfer-Encoding: chunked 0 SMUGGLED
Фронтенд, обрабатывая это, смотрит на Content-Length: 13. Он считает, что тело запроса — тринадцать байт. Отсчитав тринадцать байт после пустой строки, он захватывает и нулевой чанк, и пустую строку за ним, и слово SMUGGLED — всё это укладывается в тринадцать байт, если помнить, что перевод строки это два байта. Для фронтенда запрос целый и завершённый, он передаёт его бэкенду полностью, ничего не заподозрив.
Бэкенд получает те же байты, но смотрит на Transfer-Encoding: chunked и начинает разбирать тело как последовательность чанков. Первое, что он видит — чанк размером 0. Ноль означает конец тела. Для бэкенда запрос закончился ровно здесь, на нулевом чанке, а всё, что идёт после — пустая строка и слово SMUGGLED — это уже не часть текущего запроса. Бэкенд оставляет эти байты в буфере соединения и трактует их как начало следующего запроса. Слово SMUGGLED повисает в трубе и ждёт, пока к нему не приклеится то, что придёт по этому соединению дальше.
Суть в том, что фронтенд и бэкенд провели границу запроса в разных местах. Фронтенд считал, что запрос кончается на SMUGGLED. Бэкенд считал, что запрос кончился раньше, а SMUGGLED — это уже чей-то следующий запрос. Атакующий управляет этим «хвостом» и тем самым управляет началом чужого запроса.
TE.CL: зеркальная ситуация
Второй вариант, TE.CL, устроен наоборот: фронтенд слушает Transfer-Encoding, бэкенд — Content-Length.
POST / HTTP/1.1 Host: vulnerable-website.com Content-Length: 3 Transfer-Encoding: chunked 8 SMUGGLED 0
Фронтенд разбирает тело как chunked. Он видит чанк, заявленный размером 8 — восемь байт, — и вычитывает восемь байт, то есть слово SMUGGLED. Затем видит чанк размером 0 и понимает, что тело кончилось. Для фронтенда весь запрос, включая SMUGGLED, — это единое целое, и он передаёт его бэкенду полностью.
Бэкенд смотрит на Content-Length: 3. Он считывает ровно три байта тела: это символ 8 и следующий за ним перевод строки. На этом, по мнению бэкенда, тело заканчивается. Всё, что идёт дальше — начиная с SMUGGLED и до конца, — остаётся необработанным, зависает в буфере соединения и становится префиксом следующего запроса.
Логика та же, что в CL.TE, но роли серверов поменялись местами: теперь фронтенд прочитал больше, а бэкенд обрубил тело раньше и оставил хвост болтаться. Практическая тонкость, о которой стоит помнить сразу: при отправке такого запроса через Burp Repeater нужно отключить автоматическое обновление Content-Length, иначе инструмент сам подгонит заголовок под реальную длину и сломает атаку, а также не забыть про завершающую последовательность переводов строк после финального нуля — без неё бэкенд не поймёт, что чанк-структура завершена.
TE.TE: когда оба умеют chunked, но одного можно обмануть
Третий вариант, TE.TE, возникает, когда и фронтенд, и бэкенд в принципе умеют обрабатывать Transfer-Encoding. Казалось бы, тут расхождению взяться неоткуда — оба слушают один заголовок, оба проведут границу одинаково. Но именно здесь в игру вступает обфускация: заголовок можно исказить так, что один сервер его всё ещё распознает как Transfer-Encoding, а другой — уже нет и откатится на Content-Length. Как только один из двух серверов «ослеп» на Transfer-Encoding, ситуация сводится обратно либо к CL.TE, либо к TE.CL, в зависимости от того, кто именно перестал видеть заголовок.
Способов исказить заголовок огромное количество, и все они — микроскопические отступления от буквы спецификации, которые разные реализации терпят по-разному:
Transfer-Encoding: xchunked Transfer-Encoding : chunked Transfer-Encoding: chunked Transfer-Encoding: x Transfer-Encoding:[tab]chunked [space]Transfer-Encoding: chunked X: X[\n]Transfer-Encoding: chunked Transfer-Encoding : chunked
Где-то это лишний пробел перед двоеточием, где-то табуляция вместо пробела, где-то перенос строки внутри заголовка, где-то дублирование с добавлением мусорного значения. Задача атакующего — перебором найти ту конкретную кривизну, которую фронтенд и бэкенд трактуют по-разному. Реальный код, реализующий разбор HTTP, почти никогда не следует спецификации с абсолютной точностью, и в этих микрорасхождениях терпимости и живёт TE.TE.
Это не теория ради теории. Показательный случай зафиксирован в реестре CAPEC: связка HAProxy 1.5.3 в роли фронтенда и Node.js в роли бэкенда позволяла обойти ограничение на URI, отправив два заголовка Transfer-Encoding — один валидный, другой искажённый, — потому что Node распознавал первый и игнорировал второй, а HAProxy вёл себя иначе. Уязвимость получила номер CVE-2020-8287. А исторически ещё раньше, в CVE-2005-2088, отметился Apache, который при наличии обоих заголовков собирал chunked-тело, не убирая существующий Content-Length, — классический рецепт рассинхрона.
Почему подсчёт байтов — это не занудство, а половина дела
Есть момент, который отделяет человека, прочитавшего про smuggling, от человека, который умеет его эксплуатировать, и момент этот унизительно приземлённый: надо уметь считать байты. Атака целиком построена на том, что заявленная длина не совпадает с фактической ровно на нужное количество байт, и ошибка в один символ означает, что рассинхрон не случится или случится не там.
Первое, что надо держать в голове: перевод строки в HTTP — это два байта, а не один. Возврат каретки и перевод строки, \r\n, идут парой. Именно на этом чаще всего промахиваются, потому что в текстовом редакторе перевод строки выглядит как одно нажатие, а в протоколе это два байта, и оба считаются в Content-Length.
Второе касается механики chunked-кодирования. Размер чанка пишется шестнадцатеричным числом — поэтому b это одиннадцать, а 7e это сто двадцать шесть, и путать десятичную с шестнадцатеричной здесь нельзя. При этом перевод строки, который идёт после размера чанка, в сам размер не входит: размер описывает только байты полезных данных блока, а служебные переносы строк считаются отдельно. И вся chunked-передача обязана заканчиваться нулевым чанком с завершающими переводами строк — если их не дослать, принимающая сторона будет считать, что тело ещё не закончилось, и это тоже способ вызвать десинк, но уже осознанно.
Третья мелочь, без которой ничего не заработает: на первом запросе почти всегда нужен заголовок Connection: keep-alive. Если соединение закроется после запроса, оставленному в буфере хвосту некуда будет протечь — не будет следующего запроса на том же соединении, к которому он мог бы приклеиться. Вся атака держится на переиспользовании соединения, поэтому его надо явно попросить не закрываться.
Что рассинхрон даёт на практике
Сам по себе десинк — это лишь способность сдвинуть границу между запросами. Ценность появляется, когда через эту способность делают что-то полезное для атакующего, и вариантов тут неприятно много.
Самый прямолинейный сценарий — обход ограничений фронтенда. Часто именно фронтенд отвечает за то, чтобы наружу не пускать запросы к административным путям: обращение к /admin он блокирует, а всё остальное пропускает. Но если спрятать запрос к /admin в теле smuggling-запроса, фронтенд его не увидит — для него это просто тело, безобидные байты. А бэкенд, разобрав поток по-своему, увидит полноценный запрос к /admin и выполнит его, потому что до бэкенда фильтрующая логика фронтенда не дотягивается. Ровно этот сценарий разбирается в учебной лабе PortSwigger по TE.CL, где через рассинхрон получают доступ к заблокированной админ-панели и удаляют пользователя.
Более ценный и более пугающий сценарий — кража чужих запросов и сессий. Поскольку оставленный в буфере хвост приклеивается к следующему запросу на соединении, а следующим вполне может оказаться запрос постороннего пользователя, атакующий может устроить так, что данные жертвы — её куки, её сессионный токен — окажутся в ответе, который вернётся атакующему. Типичный вид такой атаки: приложение где-то отражает часть пришедшего запроса обратно в ответ — скажем, сохранённый комментарий или значение поля, — и через рассинхрон в это отражение подтягивается кусок запроса другого пользователя вместе с его куками. Атакующему остаётся подставить эти куки себе и перезагрузить страницу, чтобы оказаться в чужой сессии — без всякого брутфорса и подбора паролей, просто выдернув чужой сессионный идентификатор из потока.
Третий крупный сценарий — отравление кэша, web cache poisoning, где smuggling соединяется с кэширующим фронтендом в особенно разрушительное комбо. Идея в том, чтобы через рассинхрон заставить сервер закэшировать вредоносный ответ на месте легитимного ресурса — например, вместо настоящего tracking.js положить в кэш редирект на сервер атакующего. Дальше уже никакая дополнительная активность не нужна: каждый пользователь, чей браузер запросит этот JavaScript, получит из кэша отравленную версию и уедет на чужой сайт или исполнит чужой код. Один точный запрос заражает всех последующих посетителей — это ровно вторая лаба из разбора, где smuggling используется как средство доставки отравы в кэш.
Формальная классификация CAPEC добавляет к этому списку повышение привилегий, обход аутентификации, пробрасывание XSS и подмену контента — но все они вырастают из одного корня: атакующий научился встраивать свой запрос в чужой поток, а дальше уже вопрос фантазии и конкретного приложения.
Почему HTTP/2 обещает лечение и почему обещание нарушается
Логично спросить: если корень зла в том, что HTTP/1.1 оставил два конфликтующих способа задавать длину, нельзя ли просто перейти на версию протокола, где такой двусмысленности нет? И да, HTTP/2 действительно решает проблему по-честному. В нём длина запроса определяется единым, встроенным в протокол механизмом на уровне бинарного фрейминга — там нет ни Content-Length в старом смысле, ни chunked-кодирования, конкурирующих между собой. Сообщение имеет однозначную длину по построению, и внести ту неоднозначность, на которой держится smuggling, там негде. Сайт, который говорит по HTTP/2 от клиента до самого бэкенда, сквозным образом, к классическому request smuggling попросту невосприимчив.
Проблема в слове «сквозным». На практике очень часто внешний периметр уже переехал на HTTP/2 — фронтенд, CDN, балансировщик радостно принимают h2, — а вот бэкенд остался на HTTP/1.1, потому что это старое приложение, старый сервер, легаси, которое никто не трогает. И тогда фронтенд вынужден переводить запрос из HTTP/2 в HTTP/1.1, прежде чем отдать его бэкенду. Этот перевод называется HTTP downgrading, и именно в момент перевода вся с трудом достигнутая однозначность HTTP/2 снова теряется: фронтенд, транслируя запрос вниз, заново расставляет Content-Length и решает вопросы фрейминга, и если он делает это недостаточно строго, злоумышленник, отправивший хитрый HTTP/2-запрос, может добиться того, что после даунгрейда он превратится в неоднозначный HTTP/1.1-запрос. Так рождается отдельное семейство атак — H2.CL, H2.TE и прочие, — где источником рассинхрона становится сам процесс трансляции. То есть переход на HTTP/2 на границе, без перевода бэкенда, не закрывает проблему, а лишь смещает точку, где она возникает.
Куда двинулась тема к 2026 году
Классические CL.TE, TE.CL и TE.TE — это фундамент, но за последние годы исследователи вытащили на свет целое семейство вариантов, которые не сводятся к «два заголовка длины конфликтуют», и знать про них полезно, потому что именно они сейчас находят живые цели там, где классику уже давно закрыли.
Первое расширение — сценарии, где одна из сторон считает, что тела нет вовсе, хотя оно есть. Это CL.0 и его зеркало: фронтенд читает Content-Length и видит тело, а бэкенд по каким-то причинам полностью игнорирует этот заголовок, трактует запрос как бестелесный, и всё «тело» для него превращается в начало следующего запроса. Симметричный случай TE.0 делает то же самое, но через Transfer-Encoding. А 0.CL — обратная ситуация, где уже фронтенд не учитывает длину, а бэкенд ждёт тело, которое к нему не придёт, из-за чего возникает подвисание, само по себе безобидное, но конвертируемое в полноценную атаку двойным рассинхроном.
Второе — работа с дублированными и искажёнными заголовками длины на новом уровне. Оказалось, что некоторые серверы при виде двух заголовков Content-Length, даже совершенно одинаковых, валидных и точно соответствующих телу, решают, что тела нет. Достаточно послать Content-Length: 28 дважды, и если жертва, чей запрос попал следом, получит в ответ странный код вроде 505 HTTP Version Not Supported — это сигнал, что встроенный запрос пересёк границу и его версия протокола была прочитана бэкендом как отдельная строка запроса. Такой пробник хорош тем, что фрейминг выглядит совершенно чистым, никаких кривых chunked, и многие защиты его пропускают.
Третье направление — эксплуатация того, что HTTP/1.0 в сочетании с Transfer-Encoding ломает фрейминг у некоторых цепочек, даже когда значение заголовка вовсе не chunked. Например, Transfer-Encoding: gzip в HTTP/1.0-сообщении оказывалось достаточным, чтобы вызвать десинк в стиле CL.0, потому что один узел продолжал уважать Content-Length, а другой считал такое сообщение некорректно оформленным.
Четвёртое — то, что исследователи назвали путаницей общего парсера. Суть в том, чтобы засунуть в запрос заголовки, которые вообще-то предназначены для ответов, — Content-Type: multipart/byteranges, Location, Set-Cookie, Range, — и поймать ситуацию, где один из компонентов цепочки переиспользует логику разбора ответов и из-за этого трактует запрос иначе, чем сосед. Заголовок, осмысленный на стороне ответа, на стороне запроса сбивает фрейминг.
И отдельно стоит упомянуть класс, который делает smuggling досягаемым прямо из браузера. Классические атаки требуют отправки заведомо кривых запросов через специальный инструмент вроде Burp — браузер такое сформировать не даст. Но если прокси где-то на пути URL-декодирует пользовательские данные перед тем, как вставить их в запрос к вышестоящему серверу, то последовательность %0d%0a перестаёт быть просто инъекцией заголовка и становится примитивом для request smuggling. Классический пример — конфигурация Nginx с proxy_pass, использующим переменную $uri, которая нормализуется до сборки запроса наверх. Тот же дефект может прятаться в регулярках, значениях кук, параметрах запроса — везде, где пользовательские данные попадают в исходящий запрос после декодирования. И поскольку управляющие байты могут жить в пути URL или в теле POST, а не в запрещённом кастомном заголовке, такую атаку можно запустить обычной навигацией или через fetch(), что превращает её в клиентский вектор, достающий даже одиночные серверы.
Как защищаться
Поскольку уязвимость рождается из расхождения в том, как фронтенд и бэкенд определяют границы запроса, вся защита сводится к тому, чтобы это расхождение устранить или обесценить.
Самая радикальная и правильная мера — использовать HTTP/2 сквозным образом, от клиента до бэкенда, и отказаться от даунгрейда там, где это возможно. Единый механизм фрейминга HTTP/2 не оставляет места для двусмысленности, и пока запрос не переводится в HTTP/1.1, вносить рассинхрон некуда. Если же даунгрейд неизбежен, критично, чтобы фронтенд при трансляции валидировал получившийся HTTP/1.1-запрос по всей строгости спецификации — отвергал запросы с переводами строк внутри заголовков, с двоеточиями в именах заголовков, с пробелами в методе запроса, — то есть не давал протащить через себя то, что после перевода станет неоднозначным.
Вторая линия — заставить фронтенд нормализовывать неоднозначные запросы, а бэкенд — отвергать всё, что осталось неоднозначным после нормализации, разрывая при этом соединение. Если запрос содержит одновременно Content-Length и Transfer-Encoding, либо кривой Transfer-Encoding, либо дублирующиеся заголовки длины — это повод не пытаться угадать намерение, а отклонить запрос и закрыть соединение, чтобы никакой хвост не мог протечь дальше.
Третья мера, простая и действенная, — отключить переиспользование соединений между фронтендом и бэкендом. Вся атака держится на том, что по одному соединению идёт несколько запросов встык; если каждое соединение обслуживает ровно один запрос, оставленному в буфере хвосту физически некуда приклеиться. Мера не бесплатна по производительности и не закрывает все продвинутые варианты вроде request tunnelling, но целый класс атак снимает.
Помогает и унификация софта на фронтенде и бэкенде: чем меньше разница в реализациях парсеров, тем меньше шансов, что они разойдутся в трактовке. Плюс поверх всего стоит WAF с детектом аномалий, способный ловить характерные искажения заголовков, — но именно как дополнительный слой, а не как основная защита, потому что смысл smuggling ровно в том, чтобы проскользнуть мимо фильтров, которые фильтруют по «нормальному» виду запроса.
И отдельно стоит помнить про новые классы вроде CL.0 и клиентских десинков: их фундаментальная причина — предположение, что у запроса определённого типа не может быть тела. Никогда не полагаться на это предположение, всегда считать, что тело может присутствовать там, где его не ждут, — базовый принцип, закрывающий целую ветку современных атак.
Итог
HTTP Request Smuggling — это уязвимость не в коде приложения, а в стыке между компонентами инфраструктуры, и в этом её коварство: каждый сервер по отдельности может быть настроен идеально и вести себя корректно, а уязвимость рождается ровно в зазоре между их трактовками одного и того же байтового потока. Корень — историческая двойственность HTTP/1.1, оставившего два конкурирующих способа задавать длину тела; развитие — множество способов заставить два сервера посчитать эту длину по-разному, от грубого совмещения Content-Length и Transfer-Encoding до тонкой обфускации и совсем свежих трюков с дублированными заголовками и путаницей парсеров ответов. Последствия варьируются от обхода фильтрации до кражи чужих сессий и массового отравления кэша, и от одной искажённой табуляции в имени заголовка до чужой административной сессии бывает всего несколько точно посчитанных байт. Радикально проблему закрывает только сквозной HTTP/2; всё остальное — нормализация, отказ от переиспользования соединений, строгая валидация при даунгрейде — это эшелон, где каждый слой ловит то, что просочилось сквозь предыдущий.

