Node.js vm 모듈을 사용한 스크립트 격리 기법
Node.js의 vm 모듈을 활용하여 안전하게 스크립트를 격리 실행하는 방법과 그 한계를 다룹니다.
Introduction
Node.js에서 동적 스크립트 실행이 필요한 경우가 있습니다. 사용자 정의 플러그인 시스템, 템플릿 엔진, 또는 보안 샌드박스 환경에서 vm 모듈은 유용한 도구입니다. 하지만 vm 모듈은 완전한 샌드박스가 아니라는 점을 이해하는 것이 중요합니다.
Environment
node --version
# v20.11.0
# vm 모듈은 Node.js 기본 내장 모듈
node -e "console.log(typeof require('vm'))"
# objectProblem
다음과 같은 코드를 작성했습니다:
const vm = require('vm');
// 사용자 입력을 받아 스크립트 실행
function executeUserCode(userScript) {
const context = {
result: 0,
console: { log: console.log },
};
const script = new vm.Script(userScript);
const contextObj = vm.createContext(context);
script.runInContext(contextObj);
return context.result;
}
// 테스트
const result = executeUserCode('result = 42 + 8');
console.log(result); // 48
// 하지만 보안 문제가 있음!
const maliciousScript = `
result = process.exit(1); // 프로세스 종료 시도
`;발견된 보안 취약점
// 접근 가능한 글로벌 객체 확인
const leakScript = `
result = typeof process !== 'undefined' ? 'process accessible' : 'safe';
`;
executeUserCode(leakScript);
// "process accessible" - process 객체에 접근 가능!
// 파일 시스템 접근 시도
const fsScript = `
try {
const fs = require('fs');
result = fs.readFileSync('/etc/passwd', 'utf8');
} catch(e) {
result = 'blocked';
}
`;
// 에러 발생하지만 require 자체는 가능할 수 있음Analysis
vm 모듈의 한계
vm 모듈은 보안 샌드박스가 아닙니다. Node.js 공식 문서에도 명시되어 있습니다:
The vm module is not a security mechanism. Do not use it to run untrusted code.
격리 수준 비교
| 방법 | 격리 수준 | 성능 | 유연성 |
|---|---|---|---|
| vm.Script | 낮음 | 높음 | 높음 |
| vm.createContext | 중간 | 중간 | 중간 |
| Worker Threads | 높음 | 중간 | 낮음 |
| 별도 프로세스 | 매우 높음 | 낮음 | 낮음 |
왜 vm이 충분하지 않은가
- 프로토타입 체인 접근:
__proto__를 통해 글로벌 객체 접근 가능 - require 함수: 일부 컨텍스트에서는 require 접근 가능
- process 객체: 기본적으로 process 객체에 접근 가능
Solution
1. 컨텍스트 격리 강화
const vm = require('vm');
function createSecureContext() {
// 빈 객체로 컨텍스트 생성 (프로토타입 제거)
const context = Object.create(null);
// 안전한 기본 함수들만 추가
context.Math = Math;
context.Date = Date;
context.parseInt = parseInt;
context.parseFloat = parseFloat;
context.isNaN = isNaN;
context.isFinite = isFinite;
// 안전한 JSON 메서드
context.JSON = {
parse: JSON.parse,
stringify: JSON.stringify,
};
// console 로그만 허용
context.console = {
log: (...args) => console.log('[SCRIPT]', ...args),
error: (...args) => console.error('[SCRIPT]', ...args),
};
return context;
}
function executeInSandbox(code, timeout = 1000) {
const context = createSecureContext();
const contextObj = vm.createContext(context);
const script = new vm.Script(code, {
filename: 'user-script.js',
timeout: timeout,
});
try {
const result = script.runInContext(contextObj, {
timeout: timeout,
});
return { success: true, result };
} catch (error) {
return {
success: false,
error: error.message,
type: error.name,
};
}
}
// 사용 예시
const safeResult = executeInSandbox(`
let sum = 0;
for (let i = 1; i <= 100; i++) {
sum += i;
}
sum;
`);
console.log(safeResult); // { success: true, result: 5050 }2. 위험한 API 접근 차단
function createRestrictedContext() {
const context = Object.create(null);
// 기본 수학 함수
context.Math = Math;
context.Date = Date;
// Array 프로토타입 조작 방지
context.Array = Object.create(null);
context.Array.isArray = Array.isArray;
context.Array.from = Array.from;
context.Array.of = Array.of;
// Function 생성자 차단
context.Function = function() {
throw new Error('Function constructor is not allowed');
};
// eval 차단
context.eval = function() {
throw new Error('eval is not allowed');
};
// toString 차단하여 프로토타입 정보 노출 방지
context.Object = {
create: Object.create,
keys: Object.keys,
values: Object.values,
entries: Object.entries,
};
return context;
}
// 프로토타입 오염 시도 차단
function validateScript(code) {
const dangerousPatterns = [
/__proto__/,
/constructor/,
/prototype/,
/require\s*\(/,
/process\./,
/global\./,
/globalThis/,
/import\s*\(/,
/eval\s*\(/,
/Function\s*\(/,
/setTimeout/,
/setInterval/,
/setImmediate/,
];
for (const pattern of dangerousPatterns) {
if (pattern.test(code)) {
return {
valid: false,
reason: `Dangerous pattern detected: ${pattern}`,
};
}
}
return { valid: true };
}
// 스크립트 실행 전 검증
function safeExecute(code) {
const validation = validateScript(code);
if (!validation.valid) {
return {
success: false,
error: validation.reason,
};
}
return executeInSandbox(code);
}3. Worker Threads를 사용한 강화된 격리
const { Worker, isMainThread, parentPort, workerData } = require('worker_threads');
if (!isMainThread) {
// Worker 스레드 내부
try {
const script = new vm.Script(workerData.code);
const context = vm.createContext({
Math,
Date,
JSON,
console: {
log: (...args) => parentPort.postMessage({ type: 'log', args }),
},
});
const result = script.runInContext(context, {
timeout: 5000,
});
parentPort.postMessage({ type: 'result', result });
} catch (error) {
parentPort.postMessage({ type: 'error', error: error.message });
}
}
// 메인 스레드에서 실행
function executeInWorker(code) {
return new Promise((resolve, reject) => {
const worker = new Worker(__filename, {
workerData: { code },
});
worker.on('message', (message) => {
if (message.type === 'result') {
resolve(message.result);
} else if (message.type === 'error') {
reject(new Error(message.error));
}
});
worker.on('error', reject);
// 타임아웃 설정
setTimeout(() => {
worker.terminate();
reject(new Error('Execution timeout'));
}, 10000);
});
}
// 사용 예시
async function main() {
try {
const result = await executeInWorker(`
let factorial = 1;
for (let i = 1; i <= 10; i++) {
factorial *= i;
}
factorial;
`);
console.log('Result:', result); // 3628800
} catch (error) {
console.error('Error:', error.message);
}
}
main();Lessons Learned
- vm은 샌드박스가 아님: Node.js 공식 문서를 참고하여 vm의 한계를 명확히 이해하세요
- 프로토타입 체인 제어:
Object.create(null)을 사용하여 프로토타입 오염을 방지하세요 - 위험한 API 차단: require, process, eval 등의 접근을 명시적으로 차단하세요
- Worker Threads 활용: 높은 보안이 필요한 경우 Worker Threads를 사용하세요
- 스크립트 검증: 실행 전 정규식을 사용하여 위험한 패턴을 탐지하세요
This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.