在线编程练习平台、代码测评系统(OJ)、自动化脚本服务等场景都会遇到同一个需求:执行用户提交的任意代码。这类代码天然不可信,如果直接在宿主机的Node.js进程里跑起来,一段require('fs').rmSync('/', {recursive: true})就能让服务器当场下线。本文围绕Node.js环境,先分析几种常见方案的漏洞,再给出基于Docker容器隔离的完整实现,包括镜像准备、容器启动参数、代码注入、结果回收和超时清理的全流程。

为什么原生方案都不够安全
最先被想到的往往是Node.js自带的vm模块。它看起来像一个沙箱:new vm.Script(code)编译后runInNewContext执行,代码运行在一个独立的上下文里,拿不到宿主的全局对象。但官方文档明确说了,vm模块不是安全机制。原因在于被隔离的代码可以通过原型链污染或者构造器逃逸,一行经典代码就能拿到宿主的Function构造器,进而执行任意操作。
// vm沙箱逃逸的经典方式
const vm = require('vm');
const sandbox = {};
vm.runInNewContext(`
const out = this.constructor.constructor('return process')();
out.mainModule.require('child_process').execSync('whoami');
`, sandbox);
// 直接拿到了宿主process对象,沙箱形同虚设第二个常见方案是child_process.execFile配合系统层面的限制,比如降权用户运行。这比vm稍好,但进程依然和宿主共享文件系统、网络和内核,用户代码可以扫描目录、发起内网请求、fork炸弹耗尽资源。社区里流行的vm2库曾经修补了大量逃逸漏洞,但由于逃逸手段层出不穷,作者已于2023年停止维护并建议使用者迁移到进程级或容器级隔离。结论很明确:只有把不可信代码放到一个资源受限、权限最小化的独立环境里,才称得上真正的沙箱。
Docker隔离方案的设计思路
Docker的价值在于它提供了多维度的隔离能力。文件系统层面,容器拥有独立的rootfs,用户代码即使删除文件也只影响容器自身;进程层面,容器内看不到宿主的进程列表;网络层面,可以通过--network none彻底断网,或者使用自定义网络限制访问范围;资源层面,--memory和--cpus能防止恶意代码吃光宿主资源。整体架构上,Node.js主服务负责接收请求、把代码写入临时目录,然后调用Docker启动一次性容器执行,最后收集stdout、stderr和退出码返回给调用方。
先准备一个精简的执行镜像。以运行JavaScript为例,基于node:20-alpine,创建非root用户,并预装一个用于执行用户代码的入口脚本:
# Dockerfile FROM node:20-alpine RUN addgroup -S runner && adduser -S runner -G runner USER runner WORKDIR /app # 把宿主机上的执行器脚本复制进去 COPY --chown=runner:runner runner.js /app/runner.js ENTRYPOINT ["node", "/app/runner.js"]
runner.js的作用是读取挂载进来的用户代码文件并执行,同时接管超时逻辑,避免依赖外部kill时容器内的子进程成为漏网之鱼:
// runner.js
const fs = require('fs');
const path = require('path');
const code = fs.readFileSync('/sandbox/main.js', 'utf8');
const timer = setTimeout(() => {
console.error('__TIMEOUT__');
process.exit(124);
}, 5000); // 容器内5秒超时
clearTimeout(timer);
try {
const result = eval(code);
Promise.resolve(result).then(r => {
if (r !== undefined) console.log(String(r));
process.exit(0);
});
} catch (err) {
console.error(err.stack);
process.exit(1);
}这里有一个细节值得展开:为什么超时控制要做在容器内部而不是只靠外部?因为外部Docker kill容器虽然能终止容器主进程,但如果用户代码启动了后台任务或操作耗时不确定,容器内自行退出能保证日志完整落盘。当然,双保险策略更好——容器内定时器加外部timeout参数同时生效,后文会看到具体配置。
Node.js驱动Docker的完整实现
主服务侧有两种方式与Docker通信:一是直接调用docker命令行,通过child_process.execFile执行;二是通过dockerode这类库走Docker的REST API。命令行方式简单直观,适合快速上线;dockerode方式更易控制日志流和事件监听。下面以命令行方式为例给出完整封装:
// sandbox.js
const { execFile } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
const crypto = require('crypto');
const IMAGE = 'code-runner:latest';
function runInSandbox(code, { timeoutMs = 5000, network = 'none' } = {}) {
return new Promise((resolve) => {
// 每次执行使用独立的临时目录,避免并发互相覆盖
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sandbox-'));
const fileName = 'main.js';
fs.writeFileSync(path.join(workDir, fileName), code);
const containerName = 'run-' + crypto.randomBytes(6).toString('hex');
const args = [
'run', '--rm',
'--name', containerName,
'--network', network,
'--memory', '128m',
'--memory-swap', '128m', // 禁止用swap绕过内存限制
'--cpus', '0.5',
'--pids-limit', '64', // 防止fork炸弹
'--read-only', // 根文件系统只读
'--cap-drop', 'ALL',
'--security-opt', 'no-new-privileges',
'--tmpfs', '/sandbox:rw,size=16m,uid=1000,gid=1000',
'-v', `${workDir}:/sandbox:ro`,
IMAGE
];
const child = execFile('docker', args, { timeout: timeoutMs + 3000 },
(error, stdout, stderr) => {
fs.rmSync(workDir, { recursive: true, force: true });
resolve({
exitCode: error && error.code ? error.code : 0,
stdout: stdout.toString().slice(0, 64 * 1024),
stderr: stderr.toString().slice(0, 64 * 1024),
timedOut: Boolean(error && error.killed)
});
});
});
}
module.exports = { runInSandbox };参数逐个解释一下。--memory和--memory-swap设成相同值是为了禁止swap,否则脚本可以通过不断分配内存触发换页,把宿主拖垮。--pids-limit限制进程数量,是防御fork炸弹的关键。--read-only让容器根文件系统只读,配合--tmpfs挂出一个可写的小目录给临时文件用,用户代码想写文件也只能写这16MB。--cap-drop ALL丢弃全部Linux能力,no-new-privileges阻止提权,这两项合起来即使容器内被攻破也几乎无权限可用。代码目录以只读方式挂载,防止用户代码篡改自己的输入文件。
调用侧就非常简单了,一个Express路由即可挂上服务:
// server.js
const express = require('express');
const { runInSandbox } = require('./sandbox');
const app = express();
app.use(express.json({ limit: '100kb' }));
app.use(express.static('public'));
app.post('/api/run', async (req, res) => {
const { code } = req.body || {};
if (typeof code !== 'string' || code.length > 20000) {
return res.status(400).json({ error: '代码长度超限' });
}
const result = await runInSandbox(code, { timeoutMs: 5000 });
res.json(result);
});
app.listen(3000, () => console.log('沙箱服务已启动'));这个实现还有几个可以继续加固的方向。第一,docker run每次冷启动要几百毫秒,高并发场景可以改用docker create加docker start预热,或者用Firecracker、gVisor(runsc运行时)这类更轻量的隔离技术,gVisor在内核层面拦截系统调用,安全性比普通runc更高。第二,输出截断很重要,如果不限制stdout长度,一个死循环打印脚本能把Node.js主进程内存撑爆。第三,日志里的__TIMEOUT__标记可用于区分超时和正常运行失败,给前端更友好的提示。第四,如果需要支持多语言,只需构建不同语言的镜像,执行时按语言参数选择镜像即可,架构完全不用变。
最后提醒一点:Docker本身也曾出过容器逃逸漏洞,生产环境务必保持Docker版本及时更新,并且永远不要以--privileged方式运行用户代码容器。安全没有银弹,多层防御加最小权限原则,才是代码沙箱长期稳定运行的根基。
Node.js代码沙箱Docker隔离vm2修改时间:2026-09-09 04:10:43