CVE-2026-18482 : neo.mjs
안녕하세요 novice-22입니다. 이번에 제보한 CVE-2026-18482 에 대해 분석글을 작성했습니다.
제보한 취약점은 neomjs/neo 의 file-system MCP 서버에서 발견한 OS 명령 주입 취약점입니다.
코드 분석부터 실제 재현(PoC)까지, 왜 이게 취약한지 까지 쉽게 풀어서 작성하였습니다.
📋 요약
- 대상 : neomjs/neo (영향 버전 13.1.0 이하) · 전송 stdio
- CVE : CVE-2026-18482 | 심각도 High (CVSS 7.8) | CWE-78
- 수정 · 커밋
5acc564e(2026-07-24) 에서 해결. 글 작성 시점까지 npm 릴리스에는 반영되지 않았다 - 한 줄 요약 : 샌드박스 검사를 통과한 경로가 셸 명령 문장에 그대로 끼어져, 경로 뒤에 붙인 명령이 같이 실행된다
- 영향 : 개발자가 자기 컴퓨터에서 띄우는 로컬 서버라, 개발자 계정 권한 그대로 임의 명령이 실행된다. 계정 아래의 SSH 키와 클라우드 자격증명이 있다면 유출까지 가능하다.
- 링크 : NVD - CVE-2026-18482
1. 이 MCP는 무엇을 하는 서버인가
neo.mjs는 크게 두 축으로 나뉜다. 첫 번째 The Body(/src/)는 멀티스레드 자바스크립트 UI 프레임워크를 담당하고, 두 번째 The Brain(/ai/)은 Claude · Gemini · GPT 등 여러 모델을 함께 굴려 자기 코드베이스를 스스로 개발·리뷰·유지보수하게 만든 시스템(Agent OS)이다.
Brain에는 MCP 서버가 6개 있는데, 이번 글에서 다룰 대상은 그중 file-system 서버다. 서버는 AI 에이전트가 프로젝트 파일을 다루도록 도구를 제공한다. 파일을 읽고(read_file), 쓰고(write_file), 디렉터리를 훑는(list_directory) 기본 도구에 더해, 자바스크립트 구문을 검사하고(check_syntax), Playwright 테스트를 실행하는(run_playwright_test), 즉 외부 프로그램을 실행하는 도구까지 포함된다. 전송 방식은 stdio 전용이라 개발자 로컬에서 에이전트에 직접 물려 쓰는 구조다.
눈여겨볼 것은 file-system 서버가 내건 보안 약속이다. 경로를 받는 모든 도구는 ensureSandboxed()를 거치고, ensureSandboxed는 경로를 path.resolve()로 정규화한 뒤 프로젝트 루트로 시작하는지 확인해 밖을 가리키면 거부한다. 샌드박스 약속은 코드에서 유추한 게 아니라 개발자가 직접 밝혀둔 의도다. 취약 버전(13.0.0)의 주석은 ensureSandboxed를 "요청된 경로가 프로젝트 루트 밖으로 벗어나지 않음을 검증한다"고 설명하고, 실패 시 403 Forbidden: Path traversal detected. Operation jailed to <root>를 던진다. 서버 문서(openapi.yaml)도 file-system 서버가 "고도로 샌드박스된 파일시스템 조작(heavily sandboxed file system manipulation)"을 제공한다고 소개한다.
즉 file-system 서버는 "에이전트가 어떤 경로를 넘기든 프로젝트 폴더 밖은 건드릴 수 없다"는 샌드박스 가정 위에 서 있다.
2. 취약점 발견
file-system 서버가 제공하는 도구는 여섯 개다. 상태 확인용인 healthcheck를 빼면 read_file, write_file, list_directory, check_syntax, run_playwright_test 다섯이 남는다. 다섯 중 앞의 셋은 파일을 다루고, 뒤의 둘은 프로그램을 실행한다.
앞의 셋은 Node의 fs 모듈을 쓴다. fs는 Node가 기본 제공하는 파일 다루기 도구로, read_file은 fs.readFile, write_file은 fs.writeFile, list_directory는 fs.readdir를 호출한다. fs 함수를 부를 때 경로는 값 하나로 건네진다. 운영체제는 그 값을 따로 해석하지 않고 받은 글자를 그대로 파일 이름으로 쓴다. 그래서 경로에 이상한 문자가 섞여 있어도 운영체제는 그걸 파일 이름의 일부로만 본다. 그런 파일이 없으면 "파일을 찾을 수 없다"는 에러가 날 뿐, 명령이 실행되지는 않는다.
반면 check_syntax와 run_playwright_test는 파일을 직접 다루는 대신 외부 프로그램을 실행한다. 각각 node --check와 npx playwright test다. 두 도구가 쓰는 것이 Node의 child_process.exec()다. exec는 실행할 명령을 한 줄의 문장으로 만들어 셸에게 던지는 함수다. 셸은 명령을 해석해 실행해주는 프로그램이라, 넘겨받은 문장을 읽는다. 두 도구는 명령 문장을 만들 때 경로를 그대로 이어 붙인다.
`node --check ${safePath}` // ← 경로가 명령 문장 안으로 들어간다
`npx playwright test ${safePath}`문제는 1번에서 본 ensureSandboxed()가 경로가 프로젝트 폴더 안을 가리키는지만 확인할 뿐, 셸이 특별하게 취급하는 문자(;, $() 등)가 섞였는지는 보지 않는다는 점이다. 검증한 기준은 "경로"인데, 그 값이 실제로 쓰이는 곳은 "명령어"다. 검사 기준과 쓰이는 곳의 어긋남이 이번 취약점의 출발점이다.
💡 개념 - CWE-78이란?
실행할 명령을 문장으로 만들어 셸에 넘길 때, 그 문장에 외부에서 들어온 값이 섞이는 경우를 말한다. 셸은 ;나 $() 같은 문자를 "여기서 명령이 끝나고 새 명령이 시작된다"는 신호로 읽기 때문에, 외부에서 들어온 값에 그런 문자가 있으면 원래 시키려던 것과 다른 명령이 함께 실행된다.
3. 취약점 분석
이제 취약점 분석에 들어가보자. 분석은 입력부터 실행까지 경로를 코드별로 분석하여 설명할 것이다.
에이전트의 도구 호출은 stdio를 타고 MCP SDK에 도착한다. SDK는 도구 호출을 서버가 등록해둔 핸들러로 넘기고, 핸들러는 BaseServer.mjs에 있다.
// ai/mcp/server/BaseServer.mjs (핵심만 발췌)
mcpServer.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const {name, arguments: args} = request.params;
await this.beforeToolDispatch({toolName: name, args}); // 서버별 사전 정책 훅
const dispatch = () => toolService.callTool(name, args);
const result = await this.wrapDispatch(dispatch);
return this.formatToolResult(result);
});setRequestHandler는 “CallTool 요청이 오면 이 함수를 실행하라”고 SDK에 등록하는 코드다. 하는 일은 요청에서 도구 이름(name)과 인자(args)를 꺼내 toolService.callTool(name, args)를 호출하는 것이다. 중간의 beforeToolDispatch는 서버가 원하면 사전 정책 검사를 끼워 넣는 자리다. file-system 서버도 beforeToolDispatch를 오버라이드해 두었지만, 그 정책은 write_file이 저장소 루트의 AGENTS_TENETS.md 를 덮어쓰는 경우만 막는다. check_syntax에는 해당하지 않으므로, 추적할 경로 값은 손대지 않은 채 다음으로 넘어간다.
넘겨받은 callTool은 이름을 보고 실행할 함수를 정한다. 우리가 볼 것은 name이 check_syntax인 경우다.
// ai/mcp/ToolService.mjs (핵심만 발췌)
async callTool(toolName, args) {
const tool = this.toolMapping[toolName]; // 이름으로 도구 찾기
if (!tool || !tool.handler) {
throw new Error(`Tool "${toolName}" not found or not implemented.`);
}
const validatedArgs = tool.zodSchema.parse(args); // 인자 형태 검사
if (tool.passAsObject) {
return tool.handler(validatedArgs); // 객체를 그대로 전달
}
const handlerArgs = tool.argNames.map(name => validatedArgs[name]);
return tool.handler(...handlerArgs); // 값만 뽑아 전달
}toolMapping에서 check_syntax를 찾고, 거기 담긴 handler를 실행한다. 실행되는 함수는 FileSystemService.checkSyntax다.
tool.handler가 실행되기 전에, 그 handler와 zodSchema가 어디서 온 것인지 알아야 한다. 둘 다 서버가 뜨는 시점에 미리 만들어진다.
아래는 serviceMapping 코드다. 도구 이름과 실제 함수를 짝지어둔 표이고, callTool이 이름으로 찾는 handler가 serviceMapping에서 온다.
// ai/mcp/server/file-system/services/toolService.mjs
const serviceMapping = {
healthcheck : FileSystemService.healthcheck.bind(FileSystemService),
read_file : FileSystemService.readFile.bind(FileSystemService),
write_file : FileSystemService.writeFile.bind(FileSystemService),
list_directory : FileSystemService.listDirectory.bind(FileSystemService),
check_syntax : FileSystemService.checkSyntax.bind(FileSystemService),
run_playwright_test: FileSystemService.runPlaywrightTest.bind(FileSystemService)
};
const toolService = Neo.create(ToolService, {
openApiFilePath, // 명세 파일
serviceMapping // 이름 → 함수 표
});다음은 명세를 정의해둔 openapi.yaml 코드이다. zodSchema는 이 명세를 바탕으로 만들어진다. 요구하는 것은 "문자열일 것" 하나뿐이다. 길이 제한도, 허용 문자 규칙도 없다. 내용이 무엇이든 문자열이기만 하면 통과한다.
# ai/mcp/server/file-system/openapi.yaml
paths:
/check_syntax:
post:
operationId: check_syntax # ← 도구 이름
requestBody:
content:
application/json:
schema:
required: [absolutePath]
properties:
absolutePath:
type: string # ← 조건은 이것뿐serviceMapping과 openapi.yaml을 함께 넘기면, ToolService가 도구마다 하나의 묶음을 만든다. 값의 형태를 검사할 zodSchema는 openapi.yaml에서, 실제로 실행할 handler는 serviceMapping에서 가져온다.
// ai/mcp/ToolService.mjs (핵심만 발췌)
const openApiDocument = yaml.load(fs.readFileSync(me.openApiFilePath, 'utf8'));
for (const pathItem of Object.values(openApiDocument.paths)) {
for (const operation of Object.values(pathItem)) {
const toolName = operation.operationId; // ← 명세에 적힌 도구 이름
const tool = {
name : toolName,
zodSchema : buildZodSchema(openApiDocument, operation), // ← 명세에서 생성
handler : me.serviceMapping[toolName], // ← serviceMapping에서 가져옴
passAsObject: operation['x-pass-as-object'] === true // ← 인자 전달 방식
};
me.toolMapping[toolName] = tool;
}
}기준이 되는 것은 명세다. ToolService는 openapi.yaml을 읽어 그 안에 정의된 도구를 하나씩 훑고, 각 도구의 operationId를 이름으로 삼는다. check_syntax라는 이름도 여기서 나온다.
operationId로 두 가지를 채운다. 값의 형태를 검사할 zodSchema는 openapi.yaml에서 만들고, 실제로 실행할 handler는 serviceMapping에서 찾아온다. 그래서 명세와 표에 적힌 이름이 서로 맞아야 도구가 완성된다.
그렇게 만들어진 결과가 toolMapping['check_syntax']이고, 앞서 본 callTool이 바로 그 묶음을 꺼내 쓴다.
다시 tool.handler로 돌아오자. callTool이 마지막에 실행하는 handler는 앞서 본 serviceMapping의 이 줄에서 온다.
check_syntax: FileSystemService.checkSyntax.bind(FileSystemService)즉 serviceMapping의 그 줄이 실행되는 순간 호출되는 것은 FileSystemService.checkSyntax이고, 검사를 통과한 경로 값이 checkSyntax로 넘어간다.
한 가지 짚고 넘어가자. 앞서 본 callTool의 두 갈래 중 file-system 서버가 타는 것은 아래쪽이다. 명세에 x-pass-as-object가 선언돼 있지 않기 때문이다. 그래서 handler에는 객체가 아니라 값만 뽑혀 전달되는데, FileSystemService의 함수들은 checkSyntax({absolutePath})처럼 객체를 구조분해로 받도록 쓰여 있다. 13.0.0을 그대로 실행하면 값만 전달되는 차이 때문에 absolutePath가 undefined가 되어 ensureSandboxed에서 TypeError가 난다. 명세에 x-pass-as-object: true 한 줄을 더하면 객체가 그대로 전달된다. 이 누락 자체에 대해서는 글 끝에 따로 정리해 두었다.
아래의 분석은 경로 값이 함수에 전달된 뒤의 동작을 다룬다.
// ai/mcp/server/file-system/services/FileSystemService.mjs (핵심만 발췌)
static async checkSyntax({absolutePath}) {
const safePath = ensureSandboxed(absolutePath);
try {
await execAsync(`node --check ${safePath}`);
return 'Syntax OK';
} catch (error) {
return `Syntax Error Detected: ${error.stderr || error.message}`;
}
}하는 일은 두 가지다. 먼저 ensureSandboxed()로 경로를 검사하고, 통과한 값을 execAsync에 넘겨 실행한다. 우리가 봐야 할 곳도 ensureSandboxed 와 execAsync 둘이다. 하나는 유일하게 남은 검사이고, 다른 하나는 값이 터지는 지점이다.
먼저 검사부터 보자. ensureSandboxed가 하는 일은 두 가지다. 받은 경로를 정리하고, 그 경로가 프로젝트 안을 가리키는지 확인한다.
function ensureSandboxed(absolutePath) {
const rootPath = path.resolve(process.cwd()); // 프로젝트 루트
const targetPath = path.resolve(absolutePath); // 받은 경로를 정리
if (!targetPath.startsWith(rootPath)) { // 밖을 가리키면 거부
throw new Error(`403 Forbidden: Path traversal detected. Operation jailed to ${rootPath}`);
}
return targetPath;
}path.resolve()는 경로를 정리해주는 함수다. 상위 폴더로 올라가는 표기를 실제 위치로 풀어준다. 프로젝트 안에서 시작해 위로 몇 번 올라가는 경로를 넘기면, 정리 후에는 프로젝트 밖의 위치로 바뀐다. 이렇게 풀어두지 않으면 겉보기엔 프로젝트 안이지만 실제로는 밖을 가리키는 경로를 놓치게 된다.
정리한 뒤에는 그 경로가 프로젝트 루트로 시작하는지 확인하고, 아니면 거부한다. 통과하면 return targetPath로 값을 돌려주고, 받는 쪽에서 safePath라는 이름을 붙인다.
ensureSandboxed는 제 역할을 한다. 프로젝트 폴더 밖으로 나가려는 시도는 여기서 실제로 막힌다.
문제는 ensureSandboxed가 확인하는 것이 "이 값이 어디를 가리키는가" 하나뿐이라는 점이다. 값 안에 셸이 특별하게 취급하는 특수문자가 섞여 있는지는 보지 않는다.
그래서 프로젝트 루트로 시작하기만 하면, 뒤에 무엇이 붙어 있든 검사를 통과한다. 통과한 값에 safePath라는 이름이 붙지만, 보장된 것은 "폴더 밖이 아니다" 하나뿐이다.
검사를 통과한 값은 곧바로 실행으로 간다.
await execAsync(`node --check ${safePath}`);위의 한 줄이 값이 터지는 지점이다. 백틱으로 만든 문장 안에 safePath가 그대로 들어가고, 그 문장이 execAsync에 넘어간다. execAsync에 대해서는 아래 코드를 보면 알 수 있다.
import { exec } from 'child_process';
import util from 'util';
const execAsync = util.promisify(exec);promisify는 함수를 await으로 쓸 수 있게 감싸주는 도구일 뿐이라, 실체는 그대로 exec다. 그리고 exec는 넘겨받은 문장을 셸에 전달해 실행한다.
이 지점에서 값은 명령 문장 안으로 들어간다. 따옴표로 감싸지도, 위험한 문자를 무력화하지도 않는다. 검사를 통과한 그 값이 그대로 문장의 일부가 된다.
프로젝트 루트가 /home/user/neo이고, 에이전트가 다음 값을 넘겼다고 하자.
/home/user/neo/README.md; touch /tmp/MARKER앞서 본 검사는 앞부분이 루트와 일치하므로 이 값을 통과시킨다. 그 값이 문장에 들어가면 셸이 받는 것은 이렇다.
node --check /home/user/neo/README.md; touch /tmp/MARKER셸은 문장을 뜯어 읽다가 세미콜론을 만난다. 셸에게 그 기호는 "여기서 명령이 끝나고 새 명령이 시작된다"는 신호다. 따라서 이것은 명령 두 개가 된다.
① node --check /home/user/neo/README.md
② touch /tmp/MARKER앞의 구문 검사가 돌고, 이어서 뒤의 명령이 서버 프로세스 권한으로 실행된다. 그러는 동안 함수가 돌려주는 값은 Syntax OK다. 겉으로는 아무 일도 없었던 것처럼 보인다.
여기서 touch는 증명을 위해 고른 무해한 명령일 뿐이다. 세미콜론 뒤에 무엇을 적든 셸은 똑같이 실행한다.
같은 파일의 runPlaywrightTest도 구조가 같다. 다만 검사가 하나 더 있다.
// FileSystemService.mjs (핵심만 발췌)
static async runPlaywrightTest({absolutePath}) {
const safePath = ensureSandboxed(absolutePath);
if (!safePath.includes('test/playwright/')) {
throw new Error('403 Forbidden: Can only execute Playwright specs within the test/playwright/ directory.');
}
const { stdout, stderr } = await execAsync(`npx playwright test ${safePath}`);
}includes()는 문자열 안에 특정 글자가 들어 있는지만 확인하는 함수다. 어디에 있든, 앞뒤에 무엇이 붙든 상관하지 않는다. 그래서 경로 안에 test/playwright/ 가 들어 있기만 하면 뒤에 붙은 명령은 그대로 지나가고, 똑같은 일이 벌어진다.
취약한 지점은 한 곳이 아니라 두 곳이다.
값이 지나온 길을 되짚어보자.
에이전트 도구 호출 (경로 문자열)
↓
BaseServer 요청에서 이름과 인자를 꺼냄
↓ 검사: 정책 훅(write_file 전용, 해당 없음)
ToolService.callTool 이름으로 함수를 찾고 인자를 확인
↓ 검사: "문자열인가"
checkSyntax ensureSandboxed 호출
↓ 검사: "루트 안을 가리키는가"
execAsync (= exec) 명령 문장을 만들어 셸에 전달
↓ 검사: 없음
셸 문장을 해석해 명령을 나눠 실행설계상 값은 다섯 단계를 지나 셸에 닿는다. 그중 값을 들여다보는 곳은 두 곳이다. 하나는 타입을 보고, 하나는 위치를 본다. 그리고 그 값이 마지막에 명령어로 해석된다는 사실을 확인하는 코드는 어디에도 없다.
왜 이런 결함이 생겼나
- ① 왜 취약한가 : 값을 "경로"로 검사했지만 "명령 문장"으로 사용했다. 셸 문자를 확인하지도, 무력화하지도 않는다.
- ② 왜 이렇게 짰나 : 파일 도구에서는 이 방식이 실제로 안전했다. fs 함수는 값을 해석하지 않기 때문이다. 같은 감각으로 실행 도구를 만들면서, 값이 지나가는 통로가 셸로 바뀌었다는 점을 놓친 것으로 보인다.
- ③ 신뢰경계가 어디서 깨지나 : "프로젝트 폴더 밖은 건드릴 수 없다"는 약속이 무너진다. 파일 접근에는 그 경계가 적용되지만, 명령 실행에는 적용되지 않는다.
by-design 반박
의도된 설계로 보기는 어렵다. 첫째, ensureSandboxed()라는 가드가 존재하고, 주석은 그 반환값을 "safe"라고 부르며, 실패 메시지는 "jailed"라는 표현을 쓴다. 제한하려는 의도가 분명하다. 둘째, 같은 저장소 안에 이미 안전한 패턴이 있다. github-workflow 서버는 execFileAsync('git', ['branch', '--show-current'], {cwd})처럼 프로그램과 인자를 나눠 넘기고, 공용 서비스인 RuntimeFreshnessService와 BootEnvelopeResolver도 같은 방식으로 git과 ps를 호출한다. 셸을 거치지 않으므로 인자에 무엇이 들어 있든 명령으로 해석되지 않는다.
즉 execFile 방식을 몰라서 못 쓴 것이 아니다. 다만 file-system 서버의 두 함수에서만 문자열 조립 방식을 택했을 뿐이다.
🔎 덧붙임 : 도구가 애초에 동작하지 않던 문제
x-pass-as-object 누락으로 handler에 값만 전달되고 함수는 객체를 기대해 TypeError가 나는 이 불일치는, 보안 장치가 아니라 neo 저장소에서 반복돼온 결함이다. 같은 유형이 neural-link(#8332, #14542)와 knowledge-base(#9545)에서 각각 버그로 보고돼 x-pass-as-object 추가로 해결된 이력이 있다.
file-system은 서버가 처음 추가된 커밋(2026-04-07, 5db53c3b)부터 x-pass-as-object 선언이 한 번도 없었다. 13.0.0과 13.1.0에 실제 MCP 클라이언트로 접속해보면 healthcheck를 제외한 다섯 도구가 모두 오류를 돌려준다.
x-pass-as-object 누락은 2026년 7월 31일(UTC) novice-22 이름으로 별도 이슈(https://github.com/neomjs/neo/issues/16231)를 열어 보고했다. 같은 날 메인테이너가 개발 브랜치에서 재현을 확인한 뒤 bug 라벨을 붙여 수락했고, 수정안(명세 다섯 줄과 디스패치 단계 회귀 테스트)도 직접 PR로 제출해 neomjs/neo#16235로 머지됐다. 커밋 9be9156c (2026-08-01) 이다.
4. PoC
이제 실제 도커로 13.0.0 버전으로 환경 구성을 하여 명령어가 실행되는지를 재현할 것이다.
아래가 컨테이너를 만드는 설정 파일인 Dockerfile이다.
FROM node:22-slim
WORKDIR /app
RUN npm install --ignore-scripts neo.mjs@13.0.0
WORKDIR /app/project
RUN echo "console.log(1)" > README.md \
&& mkdir -p test/playwright \
&& echo "console.log(1)" > test/playwright/dummy.spec.js
# 프로젝트 밖에 둘 카나리 파일 (5단계에서 쓸 대상)
RUN mkdir -p /opt/outside \
&& echo "THIS FILE IS OUTSIDE /app/project" > /opt/outside/CANARY.txt
# 실제 배치에서는 프로젝트 폴더의 소유자가 서버를 띄우는 개발자 본인이다
RUN chown -R node:node /app/project
USER node/app/project가 프로젝트 루트다. ensureSandboxed가 기준으로 삼는 process.cwd()가 /app/project 가 된다. README.md와 test/playwright/dummy.spec.js는 두 도구가 요구하는 조건을 맞추기 위한 껍데기 파일이다.
아래는 도커를 빌드한 명령어이다. 2번째 줄에서 네트워크 none 옵션을 추가하여 네트워크 연결을 끊고 실행하였다.
docker build -t neo-poc .
docker run --rm -it --network none neo-poc⓪ 환경 확인
설치된 버전이 13.0.0인지, 프로젝트 루트가 어디인지, 네트워크가 끊겼는지를 먼저 찍었다. 마지막 두 줄은 설치된 소스에서 그대로 읽어온 것으로, 3번에서 지목한 두 지점이 배포된 코드에 실제로 그대로 있다는 확인이다. 경로가 백틱 문자열 안에 들어가 있어, 값이 명령 문장의 일부가 되는 모양이 여기서 그대로 보인다.

① 가드 동작 확인
프로젝트 루트 밖인 /etc/hostname을 요청했다. 가드는 살아 있고 403 Forbidden 오류를 발생시킨다. 가드부터 확인한 이유는, ensureSandboxed가 루트 밖의 경로를 실제로 막고 있다는 것을 보여주기 위해서이다.

② check_syntax
payload를 보면 README.md 경로 뒤에 세미콜론과 touch 명령어를 붙여 입력하였다. 다음 세 줄을 순서대로 보면 실행 전에는 파일이 없었고(absent), 도구는 Syntax OK를 돌려줬고, 실행 후에는 파일이 생겼다(PRESENT).

③ run_playwright_test
run_playwright_test는 경로가 test/playwright/를 포함해야 한다는 검사가 하나 더 있다. 앞부분에 /app/project/test/playwright를 맞춰 주면 검사를 통과하고 뒤의 touch 명령어가 실행된다.

④ 명령 치환
위의 결과를 통해 ; 특수문자를 이용해 touch 명령어로 /tmp/ 폴더 밑에 id를 출력한 결과값으로 파일 이름을 생성한다. 명령 치환까지 동작하므로 임의 명령 실행이 가능하다.

⑤ 경계 넘어 읽기
지금까지는 명령이 실행된다는 것까지만 확인했다. 그래서 실제로 무엇을 할 수 있는지 하나만 더 본다. 대상은 프로젝트 밖에 있는 /opt/outside/CANARY.txt다.
(a) read_file로 그대로 요청하면 ①과 똑같이 403으로 거부된다.
(b) 루트 안의 경로 뒤에 복사 명령을 붙였다. 가드는 앞부분만 보고 통과시키고, 셸은 뒤의 cp를 실행해 그 파일을 프로젝트 안으로 옮긴다. 그 동안 도구가 돌려주는 값은 Syntax OK다.
(c) 는 아무 잘못이 없는 요청이다. 루트 안의 파일을 읽는 정상 호출이고 가드도 정상 작동한다. 그런데 나오는 내용은 (a)에서 거부당한 /opt/outside/CANARY.txt 다.
가드를 뚫은 것이 아니라, 가드가 지키던 것을 가드 안쪽으로 옮겨 놓은 것이다.

마지막으로, 직접 확인
앞의 결과는 스크립트가 스스로 확인해 출력한 것이라, ls 명령어를 통해 ②~④에서 만들어진 파일이 실제로 생성된 걸 확인할 수 있다.

정리하면 이렇다. 프로젝트 밖을 직접 가리키는 경로는 막힌다. 그런데 프로젝트 안을 가리키는 경로 뒤에 명령을 붙이면, 그 명령은 검사를 지나 그대로 실행된다. 막힌 것은 경로였고, 지나간 것은 명령이었다.
5. 영향
공격 경로
file-system 서버는 stdio 전용이라 네트워크에 열려 있지 않다. 공격자가 도구를 직접 부를 방법은 없고, 에이전트를 거쳐야 한다. 에이전트는 자율적으로 도구를 부른다. 그래서 공격자는 에이전트가 읽는 콘텐츠를 노려야 한다. 이슈 본문, README, 로그, 웹페이지처럼 에이전트가 참고하는 텍스트에 조작된 경로를 심어두고, 그것을 도구 인자로 넘기도록 유도한다. 이렇게 유도하는 방식을 간접 프롬프트 인젝션이라고 한다.
무엇을 할 수 있나
성공하면 서버 프로세스의 권한으로 임의의 명령이 실행된다. file-system 서버는 개발자가 자기 컴퓨터에서 띄우는 로컬 도구이므로, 실제 권한은 해당 개발자 계정의 권한이다.
여기서 얻는 것은 서버를 띄운 계정의 권한 그대로다. 명령 주입이 권한을 올려주지는 않는다. 다만 개발자 계정을 어떤 권한으로 쓰고 있느냐에 따라 공격자가 할 수 있는 일의 폭은 달라진다.
그리고 개발자 계정 아래에는 보통 SSH 키, 클라우드 자격증명, 레지스트리 토큰 등 중요한 파일이 같이 존재하기 때문에 피해가 클 수 있다.
심각도
CVSS 3.1 기준 7.8 (High) 로 평가했다.
CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H| 항목 | 값 | 이유 |
|---|---|---|
| AV | L (로컬) | tdio 전용이라 네트워크로 닿지 않는다 |
| AC | L (낮음) | 경로 뒤에 명령을 붙이면 끝이다. 조건도 타이밍도 없다 |
| PR | N (권한 불필요) | 도구 호출 자체에 별도 인증이 없다 |
| UI | R (사용자 개입) | 에이전트가 조작된 인자로 도구를 부르도록 유도돼야 한다 |
| S | U (범위 불변) | 권한 경계를 넘지 않는다 |
| C / I / A | H | 개발자 계정 범위 안에서는 기밀성, 무결성, 가용성이 모두 무너진다 |
6. 패치 비교 분석
명령 주입은 커밋 5acc564e (2026-07-24) 에서 수정됐다. 무엇이 달라졌고, 그 수정으로 정말 막히는지 직접 확인해 본다.
무엇이 달라졌나
- import { exec } from 'child_process';
+ import { execFile } from 'child_process';
- await execAsync(`node --check ${safePath}`);
+ await execFileAsync('node', ['--check', safePath]);
- await execAsync(`npx playwright test ${safePath}`);
+ await execFileAsync('npx', ['playwright', 'test', safePath]);바뀐 것은 값을 넘기는 방식 하나다. 전에는 명령을 한 줄의 문장으로 만들어 셸에 던졌고, 이제는 실행할 프로그램과 인자를 나눠서 넘긴다. exec가 execFile이 되면서 셸이 아예 개입하지 않는다.
특수문자를 걸러내는 방식은 쓰지 않았다.
같은 커밋은 3번에서 본 ensureSandboxed 도 함께 손봤다. 명령 주입과는 별개로, 경로 검사 자체가 문자열 비교였던 문제를 같이 정리한 것이다.
- if (!targetPath.startsWith(rootPath)) { ... }
+ rootPath = await fs.realpath(path.resolve(process.cwd()));
+ targetPath = await canonicalize(path.resolve(absolutePath));
+ const relative = path.relative(rootPath, targetPath);
+ if (relative !== '' && (path.isAbsolute(relative) || relative.split(path.sep)[0] === '..')) { ... }세 가지가 달라졌다. 글자 비교가 아니라 path.relative 로 포함 관계를 보고, realpath 로 심볼릭 링크까지 풀어 실제 위치를 맞춰보며, 확인 자체가 실패하면 안전하다고 보지 않고 거부한다.
왜 이게 근본 해결인가
패치에는 그 이유가 주석으로 함께 들어갔다.
execFile, notexec.execspawns a shell, which would make every argument a fragment of a command string. Passing argv keeps a path a path:;,&&,$(…)and spaces reach the child process as literal characters of one argument, so command injection is unrepresentable rather than filtered.
마지막 표현이 핵심이다. 걸러내는 것이 아니라 표현 자체가 불가능해진다.
특수문자를 막는 방식이었다면 목록에서 빠뜨린 문자가 하나만 있어도 뚫린다. 반면 인자를 나눠 넘기면 셸이 문장을 해석하는 단계가 없어서, 값 안에 무엇이 들어 있든 파일 이름의 일부로만 취급된다. 막을 대상이 사라지는 쪽이다.
우회가 가능한가
4번에서 통했던 페이로드를 패치된 코드에 그대로 넣어 봤다.
| 페이로드 | 13.0.0 | 패치 후 |
|---|---|---|
; touch /tmp/MARKER | 마커 생성됨 | 생성 안 됨 |
; touch /tmp/UID_$(id -u) | UID_1000 생성됨 | 아무것도 생성 안 됨 |
; cp <경계 밖 파일> <루트 안> | 복사됨 | 복사 안 됨 |
셋 다 Syntax Error Detected를 돌려준다. 경로 문자열 전체가 하나의 파일 이름으로 취급돼서, node --check가 그런 이름의 파일을 찾지 못하고 실패하는 것이다. 세미콜론 뒤가 명령으로 읽히지 않았다는 뜻이다.
$(id -u)도 글자 그대로 남는다. 셸이 해석하는 자리가 없으니 치환될 곳도 없다.
기존 페이로드가 막혔다는 확인에서 멈추지 않고, 새 가드를 상대로 우회도 직접 시도했다.
- node 옵션처럼 보이는 파일 이름 → 차단. 가드가 돌려주는 값은 항상 절대경로라
/로 시작하고, 그러면 옵션으로 읽힐 자리가 없다 - 루트 이름을 앞에 달고 있는 형제 폴더 (
<root>-evil/) → 차단. 글자가 앞부분만 같은 경우는path.relative가 걸러낸다 - 루트 안에 두고 밖을 가리키게 만든 심볼릭 링크 → 차단.
realpath로 실제 위치를 풀어본 뒤에 검사한다
세 방향 모두 막혔고 우회를 찾지 못했다.
부작용은 없나
막는 데만 집중해서 기능이 죽는 경우가 있는데, 5acc564e 는 그렇지 않다.
- 루트 밖 경로 요청은 여전히
403 Forbidden으로 거부된다 - 정상적인 파일 검사는 그대로
Syntax OK를 돌려준다
검사를 조인 것이 아니라 값이 지나가는 통로를 바꾼 수정이라 그렇다. 원래 되던 일은 그대로 되고, 원래 되면 안 되던 일만 안 된다.
그 뒤에 있었던 일
패치를 검증하다가 같은 경계를 다른 길로 넘을 수 있다는 것을 찾아, novice-22 이름으로 공개 이슈 neomjs/neo#16481을 열어 보고했다. write_file 로 프로젝트 안에 Playwright 스펙 파일을 쓰고, run_playwright_test 로 그 스펙을 실행하는 방식이다. 스펙은 자바스크립트라 안에 적은 코드가 그대로 돌고, 두 호출 모두 가드를 어기지 않는다. 셸을 아예 거치지 않으므로 5acc564e 가 다룰 수 있는 문제도 아니다.
메인테이너는 실행기를 프로세스 단위로 격리하는 것이 진짜 해법이지만 당장 배포할 수 있는 쪽은 아니라고 보고, 대신 지키지 못할 약속을 거두는 길을 택했다. 문서의 "heavily sandboxed" 문구와 가드의 Operation jailed to <root> 메시지가 모두 실제로 보장하는 범위로 바뀌었다. 커밋 88c77fc4 (2026-08-11) 이고, 커밋 메시지에도 novice-22가 외부에서 보고했다고 적혀 있다.
지키지 못할 경계를 약속하는 것보다 경계가 없다고 적는 편이 낫다는 판단이다.
7. 교훈
같은 값이라도 어디로 흘러가느냐에 따라 위험이 갈린다. file-system 서버는 파일을 다루는 도구와 명령을 실행하는 도구가 한 파일에 섞여 있었고, 검사 함수는 하나를 같이 썼다. 파일 쪽에서 충분했던 검사가 셸 앞에서는 아무것도 보장하지 못했다.
그래서 값을 무엇으로 검사했는지와 그 값이 마지막에 무엇으로 해석되는지를 따로 확인해야 한다. safe 라는 이름이 붙은 변수일수록 그 이름이 무엇을 보장하는지 되물어볼 가치가 있다.
찾는 순서도 정리됐다. 저장소에서 셸을 띄우는 호출(exec, system, popen)을 먼저 찾고, 그 인자가 문자열 조립으로 만들어지는지 본다. 같은 저장소 안에 execFile 같은 안전한 패턴이 이미 있다면, 그 불일치 자체가 정책이 아니라 누락이라는 신호다.
문서에 적힌 보안 약속은 그 자체로 검사할 목록이 된다. file-system 서버는 스스로 "heavily sandboxed" 라고 적어두었고, 그 한 문장이 어디를 확인해봐야 하는지를 알려줬다.
NVD - CVE-2026-18482