isolated-vm × child_process 双层沙箱:脚本执行的绝对安全是怎么炼成的
isolated-vm × child_process 双层沙箱:脚本执行的绝对安全是怎么炼成的
一、一行失控代码能把对账系统拖垮到什么程度
2025 年某 SaaS 公司对账平台出过一起事故:业务人员上传了一个解析脚本,本意是把"账户流水月报.csv"中的"交易号"列映射成 businessOrderNo。脚本里有一句看似无害的代码——while (true) { ... },模块级死循环。这个脚本进了生产沙箱后,沙箱只设置了脚本级超时,没有进程级兜底,子进程把宿主机的 8 个 CPU 核心全部吃满,整整 7 分钟没有响应。
事故复盘得出三个教训:第一,脚本级超时拦不住 V8 死循环(V8 在死循环里根本无暇检查 timer);第二,单层沙箱(无论 V8 隔离多强)都不能兜住所有边界;第三,对账系统的沙箱与电商主营系统在同一进程域内,沙箱逃逸等于核心财务链路暴露。
这就是为什么任何严肃的脚本沙箱设计都不能只靠"V8 隔离"这一层。哪怕你有最好的 V8 isolate,也得再叠一层进程级隔离——把"沙箱失控"和"主进程崩溃"之间隔一道物理墙。
我们用一句话定下基线:
V8 实例级隔离解决"脚本读到什么",进程级隔离解决"脚本失控时主进程还活着吗"。
这就是业内反复出现的"双层沙箱"提法——isolated-vm × child_process。本文要讲清楚的是:双层沙箱的工程边界、4 道防线的代码落地、以及容易踩的 4 个真实陷阱。
二、双层沙箱到底在防什么
我们先把"沙箱要拦的事"列清楚,再讲怎么拦。
2.1 对账沙箱面临的威胁模型
电商对账系统的用户脚本(解析、对账、分摊、集成转换四类)允许用户做大量业务字段映射。但用户脚本不可信:
| 威胁 | 典型攻击/失误 | 单层隔离能做到吗 |
|---|---|---|
| 读取主进程环境变量(DB URL、JWT Secret) | process.env | V8 隔离能拦 |
require('child_process').exec('curl ...') | 子进程逃逸 | V8 隔离能拦(require 被禁) |
while (true) {} 模块级死循环 | 业务人员误写 | 拦不住(V8 在循环里无暇响应 timer) |
大内存分配 new Array(1e9) OOM | 业务脚本误循环 | 拦不住(V8 同进程 OOM 拖垮父) |
setTimeout 递归爆栈 | 业务脚本回调地狱 | 部分能拦(取决于 V8 版本) |
| 抛出未捕获异常 → V8 异常逃逸 | 业务脚本未 try/catch | 拦不住(V8 异常隔离是 promise 边界) |
可以看出,前两条单层 V8 隔离就能防住——只要禁用 require/process/Buffer/fetch 这些全局变量,沙箱里跑的就是"无文件系统、无网络、无 shell"的纯 JS 环境。但后四条全是 V8 隔离的盲区:死循环、OOM、递归异常,全是 V8 实例本身在失控。
所以双层沙箱的本质是:V8 隔离解决"逻辑层",进程隔离解决"运行时层"。
2.2 双层沙箱架构全景
下面的双层沙箱架构:

这张图把整个沙箱体系拆成两个并列分支:
- Parse 沙箱:以行为单位处理账单解析。每批最多 100 行(
batchSize = 100),由父进程 fork 一个子进程跑完即退;V8 Context 隔离靠 Node.js 内置的vm.createContext实现,全局禁用require / process / fs / http / Buffer / fetch,只暴露JSON / Math / Date / Decimal / query等安全子集。 - Script 沙箱:对账脚本、费用分摊脚本、集成转换脚本——逻辑更重、上下文更多,跑的是批次级单次执行(一次 fork = 一次脚本执行),不拆行拆批;同样走 V8 Context 隔离 + 进程隔离,区别在超时常量(集成转换 30 秒,比 parse 60 秒更短,因为转换必须实时返回给前端预览)。
两个分支共用同一道父进程兜底——child.kill('SIGKILL') 强制终止 + stderr 前 500 字符异常捕获 + IPC 失败兜底。
来看一次完整的 Parse 沙箱执行流程:

这张流程图把 Parse 沙箱的 5 个生命周期节点摆开:脚本编写 → V8 Context 隔离 → 用户脚本执行 → 结果归一化 → BillRow 落库。每个节点都是独立的失败点,沙箱的责任是把"失控"卡在前 3 步,不让它污染到第 4 步之后的状态机。
三、4 道防线的代码落地(最关键的部分)
轻易云智能对账系统的双层沙箱不是抽象口号,是落在代码里的 4 道防线。本节要讲清楚的是:这 4 道防线分别在代码的哪一层、起什么作用?
防线 1 │ child_process.fork 进程级隔离(物理墙)
防线 2 │ vm.createContext V8 上下文隔离(全局变量层)
防线 3 │ vm.Script.runInContext { timeout: 5000 } 编译期/顶层 5 秒兜底
防线 4 │ setTimeout → child.kill('SIGKILL') 整批超时强制终止
下面这 4 段代码选自集成转换沙箱宿主与子进程两端(集成转换沙箱是 Script 沙箱家族里最年轻的实例,覆盖了最完整的代码骨架)。
防线 1:进程级隔离(fork 子进程)
// transform-sandbox-runner-host.ts:232-241
let child: ChildProcess;
try {
child = fork(getRunnerScriptPath(), [], {
stdio: ["pipe", "pipe", "pipe", "ipc"],
serialization: "advanced",
});
} catch (err) {
reject(new Error(`沙箱启动失败: ${(err as Error).message}`));
return;
}
注意 3 个细节:
stdio: ["pipe", "pipe", "pipe", "ipc"]——四个 stdio 流全 pipe 出去。ipc通道用于父进程与子进程交换结构化数据(不是字符串),所以同时设置serialization: "advanced"。这样子进程崩溃时父进程能拿到 stdout/stderr/IPC 三个独立信号源。getRunnerScriptPath()三候选回退——dev 模式__dirname在dist/,vitest 从src/跑,自动回退到编译产物。这是工程稳健性的细节,不在 README 里写出来,踩过坑才懂。- fork 失败立即 reject——子进程连启动都失败,没必要继续等。失败语义通过 Promise reject 暴露给上层 JobTask,触发状态机
FAILED。
防线 2 + 3:V8 Context 隔离 + 编译期 5 秒兜底
这是子进程内部的事(transform-sandbox-runner.ts:358-407):
async function processOnce(input: TransformSandboxInput): Promise<TransformSandboxResult> {
const context = vm.createContext({
...ALLOWED_GLOBALS, // JSON/Math/Decimal/query 等安全子集
query: { /* 只读数据出口 */ },
});
// ESM 不被 vm.Script 解析 → 包 IIFE + export default → return 改写
const transformed = input.scriptCode.replace(
/^\s*export\s+default\s+/m,
"return ",
);
const wrapped = `(function() {\n${transformed}\n})()`;
let script: vm.Script;
try {
script = new vm.Script(wrapped, {
filename: "user-transform-script.js",
});
} catch (err) {
return { error: `脚本编译失败: ${(err as Error).message}` };
}
let transformFn: (input: unknown) => unknown;
try {
const compiled = script.runInContext(context, {
timeout: 5000, // 防线 3:顶层求值 5 秒兜底
displayErrors: true,
});
if (typeof compiled !== "function") {
return { error: `脚本必须 export default 一个 transform 函数(编译结果:${typeof compiled})` };
}
transformFn = compiled as (input: unknown) => unknown;
} catch (err) {
return { error: `脚本执行失败: ${(err as Error).message}` };
}
try {
const result = await transformFn(input.input);
return { output: normalizeOutput(result) };
} catch (err) {
return { error: `脚本异常: ${(err as Error).message}` };
}
}
4 个关键细节:
ALLOWED_GLOBALS白名单——这是 V8 Context 隔离的核心。除了JSON / Math / Date / Decimal / RegExp / Map / Set / parseInt这些纯计算 API,还显式require: undefined / process: undefined / Buffer: undefined / fetch: undefined——即"故意挂上 undefined",让用户脚本里的process.env立即拿到undefined,而不是 ReferenceError。console还做了个 shim,写入子进程 stderr(父进程 stdio 是 pipe,能收到),防止 AI 生成的脚本里console.log直接抛 ReferenceError 判 FAILED。vm.createContext是 Node.js 原生 V8 Context 隔离——每个 Context 有独立的 globalThis、全局变量互不污染。这是"isolated-vm"产品定位的工程落地——绕开 native binding 依赖,用 Node.js 自带能力实现同等隔离。这条经验来自 v3 重构的「去过度设计」决策:能不发明就不发明,能用标准库就用标准库。timeout: 5000只拦截 IIFE 顶层求值——这是防线 3 的边界。它能拦住"模块级死循环",但拦不住用户脚本里transform(input)函数内部调用query后产生的死循环(那部分代码进入了await transformFn(...),不在 vm 超时管辖范围)。所以必须靠防线 4 兜底。normalizeOutput归一化——对返回值做类型校验:缺targetSystem报错、documents不是数组报错、entries不是数组报错。这一步是契约强制,不允许脚本返回"差不多对"的松散对象。
防线 4:整批超时强制终止(最容易被忽视的兜底)
// transform-sandbox-runner-host.ts:243-246
const timer = setTimeout(() => {
child.kill("SIGKILL");
reject(new Error(`沙箱执行超时(${timeoutMs}ms),已强制终止`));
}, timeoutMs);
这一段只有 4 行,但藏着 4 个陷阱:
SIGKILL而非SIGTERM——SIGTERM给子进程一个 cleanup 机会,但用户脚本可能注册了自己的process.on('SIGTERM', ...)拦截,反而让超时检测失真。SIGKILL是 Linux 内核强制杀进程,用户脚本拦不住。代价是子进程没有 cleanup 机会,但对于沙箱来说无所谓——子进程本来就不应该持久化任何状态。- 超时常量 30 秒(集成转换)——比 parse 沙箱的 60 秒更紧。集成转换是用户前端实时预览触发的,必须 30 秒内返回;解析沙箱是后台异步任务,可以容忍 60 秒。这条边界写在
executeTransformInSandbox的默认参数:const { timeoutMs = 30_000 } = options;。 - stderr 前 500 字符——超时 / 崩溃时,错误消息里会塞
stderr=${stderrBuf.slice(0, 500)}。这 500 字符是排查问题的关键证据。Node.jsstderr默认是 line-buffered,子进程死循环时也会被 V8 panic 或系统 OOM killer 写一行到 stderr,足够定位"是死循环还是 OOM"。 - stdout 兜底 + exit 兜底——
child.on('exit')监听子进程退出。如果 stdout 里有内容,先尝试JSON.parse(stdoutBuf)(兼容 IPC 通道偶尔失败的极端场景);解析失败则按signal === 'SIGKILL'/code !== 0/ "沙箱未返回结果" 三档 reject。最后一行兜底注释特别值得看——"正常路径已由 message 事件 settle,此处 reject 为幂等 no-op"——这是处理"退出码 0 但 IPC 消息丢失"的反直觉场景:不能让 Promise 永不 settle。
四、Parse 沙箱 vs Script 沙箱:4 个真实差异
容易踩的坑:以为两类沙箱"差不多"。它们的工程差异至少 4 处:
差异 1:拆批 vs 单次执行
Parse 沙箱每 100 行一批(batchSize = 100),一次 fork 跑完 100 行即退,由 executeParseBatchInSandbox 内的 reduce 串行处理多批。Script 沙箱(reconcile / allocate / transform)一次 fork = 一次脚本执行,不拆批;脚本异常 = 整批失败。这条差异决定了:脚本调试时 transform 比 parse 更容易定位错在哪一行,因为 transform 一次只跑一份数据。
差异 2:超时常量
| 沙箱类型 | 默认 timeoutMs | 业务约束 |
|---|---|---|
| Parse | 60_000 (60s) | 后台异步,可容忍较长 |
| Reconcile | 60_000 (60s) | 后台异步,但更重的算法 |
| Allocate | 60_000 (60s) | 后台异步,分摊计算开销大 |
| Transform | 30_000 (30s) | 前端实时预览,必须秒级返回 |
batchSize 也差异明显:parse 默认 100 行,allocate 默认 20 个聚合。差异来自"单条记录的计算开销"——parse 是字段映射,allocate 是公摊分摊数学计算。
差异 3:AbortSignal 支持
只有 Parse 沙箱支持外部信号取消:
// parse-scripts/sandbox-runner-host.ts:217-229
const onAbort = () => {
clearTimeout(timer);
child.kill("SIGKILL");
reject(new TaskCancelledError());
};
if (signal) {
if (signal.aborted) {
child.kill("SIGKILL");
reject(new TaskCancelledError());
return;
}
signal.addEventListener("abort", onAbort, { once: true });
}
这意味着用户在 BullMQ Worker 任务里点"停止",能立即 SIGKILL 当前子进程并 reject TaskCancelledError(与超时错误区分)。reconcile / allocate / transform 没有这条优化——它们的"取消"语义由 JobTask 状态机兜底,不直接干预子进程。
差异 4:返回值归一化严格度
Parse 沙箱返回的是行级数组,每行 10+ 个字段都是可选(缺啥就不写),归一化逻辑松散。Transform 沙箱返回值必须含 targetSystem(非空字符串)+ documents(数组)+ 每个 documents[i] 必须含 docType / head / entries——任何一项不合法都 reject。这是业务决定的:parse 是字段映射,缺一两个字段不影响后续对账;transform 是要推送金蝶的单据,缺一个字段就报错。
集成转换作为 Script 沙箱最复杂的应用场景,它的完整流程值得单独看一眼:

这张图把 Script 沙箱在集成转换里的角色讲清楚:对账结果(已 CONFIRMED)→ 生成批次(TFB-YYYYMMDD-NNNN)→ 转换沙箱执行(30s 闸,SIGKILL 兜底)→ 转换单据预览(transform_documents 头表 + 体行表 + payload JSONB)→ 金蝶推送(HMAC 双签名)。整个链路的瓶颈在第 3 步——Script 沙箱必须 30 秒内返回,否则前端预览就超时了。这就是为什么 transform 沙箱比 parse 沙箱的超时更紧。
五、4 个真实陷阱(生产环境踩过才写得出来)
陷阱 1:把 console.log 当 stdout 调试
子进程的 console.log 不写入 stdout,而是 shim 到 process.stderr.write。如果你在生产环境用 console.log 给业务人员展示解析进度,会发现前端什么都看不到——日志全在父进程的 stderr 里,得从 JobTask 的 errorMessage 字段间接拼出来。
正确做法:在 ALLOWED_GLOBALS.console shim 里把 log 重定向到 IPC 通道,或者用沙箱内的 query.log(...) 受控 API。我们当前是 shim 到 stderr——简单但够用,前端不依赖沙箱内日志。
陷阱 2:runInContext 的 timeout 拦不住 async 函数死循环
const compiled = script.runInContext(context, {
timeout: 5000, // 只拦截 IIFE 顶层求值
});
如果脚本 export default async function transform(input) { while (true) { ... } },runInContext 立刻返回 Promise(因为 async 函数立即返回 Promise),timeout: 5000 看着无害,实际 0 秒后就进入 await transformFn(...),5 秒防线完全失效。所以必须靠防线 4 的 setTimeout → SIGKILL 兜底。
陷阱 3:vm.Script 不支持 ESM
// 把 export default 改写为 return,让 IIFE 返回 transform 函数
const transformed = input.scriptCode.replace(
/^\s*export\s+default\s+/m,
"return ",
);
const wrapped = `(function() {\n${transformed}\n})()`;
vm.Script 只接受 CommonJS 风格的脚本体。如果用户写 import x from 'y',直接编译失败。regex 只匹配 export default,不处理 import / export const。这是已知边界——业务团队写脚本时必须用 CommonJS 风格或纯 ESM 函数体(无 import)。
陷阱 4:stdout 兜底是单点脆弱
// transform-sandbox-runner-host.ts:283-307
child.on("exit", (code, signal) => {
clearTimeout(timer);
if (stdoutBuf.trim()) {
try {
const parsed = JSON.parse(stdoutBuf) as TransformSandboxResult;
// ... 解析成功直接 resolve
} catch {
/* fallthrough */
}
}
// SIGKILL / code !== 0 / 兜底 reject
});
stdout 兜底是为了兼容 IPC 通道偶发不可用。但 stdout 是大字符串、无结构化、还可能被用户脚本里的 process.stdout.write 污染(虽然 ALLOWED_GLOBALS 没暴露 process,但子进程内的 console.log shim 写的是 stderr,stdout 不受沙箱控制)。所以 stdout 兜底是"假如 IPC 完全失败、子进程恰好把结果写到 stdout 才有用"——这是救命稻草,不是首选通道。
六、4 道防线的取舍与心智模型
把这套设计画到一张心智图上:
失控脚本可能发生的"坏事情"
│
┌──────────────┼──────────────┐
▼ ▼ ▼
进程逃逸 V8 隔离失守 运行时失控
(require/process/ (读主进程 (死循环 / OOM /
fetch) 全局变量) 异常逃逸)
│ │ │
▼ ▼ ▼
进程级隔离 V8 Context 隔离 SIGKILL 兜底
child_process vm.createContext setTimeout →
.fork() + 白名单全局 child.kill
│ │ │
└──────────────┴──────────────┘
▼
父进程毫发无伤 + stderr 500 字异常
4 道防线的取舍:
- 防线 1(进程):拦成本最高的"子进程崩溃拖垮主进程"。代价:fork + IPC 通信有 ~10-30ms 启动开销。
- 防线 2(Context):拦"读到什么"。代价:每个 Context 有自己的 V8 实例内存,不能跨 Context 共享状态(必须显式序列化)。
- 防线 3(编译期 timeout):拦"模块级死循环"。代价:5 秒是经验值,写复杂 import 链路时可能误杀。
- 防线 4(SIGKILL):拦"运行时失控"。代价:超时即整批失败,无 partial success。
一个都不能少。砍掉任何一道,都会留下可被利用的边界。
七、双层沙箱的代价与收益
最后谈谈双层沙箱给对账系统带来的真实价值。这套设计在轻易云生产环境跑了一年以上,每天稳定支撑四类脚本的并行执行。
收益 1:业务人员自助编写脚本成为可能。 4 类沙箱脚本(parse / reconcile / allocate / transform)全部支持业务人员用 JS 直接编写,AI Agent 也能直接调用工具保存脚本。这意味着对账系统的边界扩展(新平台接入、新规则适配)不再依赖研发排期。
收益 2:脚本崩溃不影响对账主链路。 Parse 沙箱拒收、reconcile 沙箱超时、transform 沙箱异常——所有失败都由 JobTask 状态机接管,标记为 FAILED 并允许重跑,不会让一条坏脚本把整个 5 万行账单拖入"对账中"的死循环状态。
收益 3:审计可追溯。 stderr.slice(0, 500) 写进 JobTask.errorMessage,整条失败链可复盘:JobTask → 沙箱调用 → stderr 报错 → 用户脚本代码行 → 数据行号。这是企业级 SaaS 的合规底线。
代价 1:fork + IPC 启动开销 ~10-30ms/批。对于 10 万行大账单,需要 fork 1000 次——开销不可忽视。我们靠 BullMQ 队列并行 + Node.js 进程池覆盖,transform 单次预览反而最快(单次 fork + 30s 闸)。
代价 2:调试体验不如同进程 VM。 子进程崩溃后,IDE 调试器看不到变量栈。业务人员只能依赖沙箱内 query.log(...) + stderr 上报,开发体验打折。
代价 3:V8 Context 隔离 ≠ 内存隔离。vm.createContext 不设 memoryLimit,OOM 风险完全靠 child_process 兜底(即 OS 杀进程)。如果生产环境内存吃紧,可能出现"沙箱子进程被 OOM killer 干掉,父进程误以为是脚本异常"的误判。
沙箱脚本契约的具体细节(参数全兜底、
export default必须await、试跑端点的 30 秒闸等),在parse-script-development/reconciliation-script-development/integration-transform-script-development三份专项 skill 文档里有更细的描述。
八、给安全工程师的 3 条实践建议
如果你的团队正在自研脚本沙箱,我们踩过的 3 个建议:
- 永远不要只做单层隔离——V8 隔离再强也拦不住死循环。哪怕只是 demo,也要 fork 子进程 +
setTimeout → SIGKILL。 stderr.slice(0, 500)是排查关键——不要丢弃 stderr。哪怕只用前 500 字符,也能告诉你"是死循环 / OOM / 异常逃逸"中的哪一种。- 超时参数分场景——同步预览 30 秒、后台批处理 60 秒、CI/CD 试跑 90 秒。没有"统一的"沙箱超时,只有"按业务 SLA 调"的超时。
九、收尾
回到文章开头那句基线:
V8 实例级隔离解决"脚本读到什么",进程级隔离解决"脚本失控时主进程还活着吗"。
双层沙箱不是技术炫技,是用两层独立的故障域换"主进程永远不崩"这条底线。在企业级对账系统里,主进程不崩意味着:哪怕业务人员写错脚本,最坏的结果是这一批 100 行账单标记 FAILED 等重跑,而不是整个对账链路瘫掉。
这也是为什么 v3 重构下决心删掉 1,800 行自研沙箱、换用 isolated-vm × child_process 双层成熟组件——简洁优先的双层,比花哨的五层更安全。
如果你正在评估对账系统的脚本能力,问厂商一句话:"你们的沙箱,SIGKILL 是哪一层做的?"——答不上来的,都是单层沙箱。