给机器写的 JavaScript 造个笼子

bash 复制代码
作者: 一了 <1liao3@funlang.org>
日期: 2026-11-08

一句话总结: 上游二十几笔提交做出 `fun-jsbox`:一个不链 `quickjs-libc` 的独立进程,替 Fun 跑机器写的 JavaScript,只收 JSON、只回 JSON。能力先拿掉,进程隔开,随机和时钟钉死,四道上限,Linux 上再加一道 seccomp 白名单。
  

给机器写的 JavaScript 造个笼子

11 月 7 日晚上 8 点 50 分,上游多了一个 797 行的 main.c。到第二天中午 11 点 45 分,围着它一共二十几笔提交,八个文件加了 2391 行。这就是 fun-jsbox:让 Fun 把一段 JavaScript 交给另一个进程去跑,跑完只拿回一个 JSON。

做这个的由头很直接。现在写代码的常常不是人,是模型。模型生成的 JS 扔进主进程里 eval,等于把家里的钥匙交出去:它能读文件、开 socket、起进程,出了事你连它碰过什么都说不清。所以第一件事不是加固,是隔离。

(吐槽:安全这事,九成时间在补自己没想到的洞,剩下一成在补同事想到的洞。)

一、先把能力拿掉

QuickJS-ng v0.18.0,构建脚本按需下载,SHA256 校验,只链四个文件:quickjs.c、libregexp.c、libunicode.c、dtoa.c。

quickjs-libc 故意不链。少了它,就没有 std、os、console,也没有 module loader。脚本里没有 require,没有 process,没有 print,除了 JSON 形式的 input,没有任何宿主对象。

所以脚本长这样:

bash 复制代码
use 'fun/lib/lib-jsbox.fun' as jsb;

var r = jsb.run(
  'var s = 0; for (var i = 0; i < input.rows.length; i++) s += input.rows[i].n; return {sum: s};',
  [rows: [[n: 1], [n: 2], [n: 3]]],
  [timeout: 200, memory: 4 * 1024 * 1024, output: 256 * 1024]
);

if r.ok then ?. r.value.sum; else ?. r.error.kind; end if;

注意它不是一段完整脚本,是一个函数体,前面被套了 (function (input) { "use strict";,后面补 })。输入是 JSON,输出也得是 JSON。Map、Set、类实例会被 JSON.stringify 按自有可枚举属性序列化,多数时候就是 {},所以文档里直说:要么自己转成普通对象,要么别返回。

这种设计不好用,但好用不是目的。能力不是运行时关掉的,是编译时就没装上去。没装的东西,漏洞也拿不到。

二、一次一个进程

每个请求起一个新进程,新 runtime,新 context。上一个脚本改过 Object.prototype,下一个看不见。测试里专门有一条:先跑 Object.prototype.x = 1; globalThis.y = 2;,再跑一段读这两个值的代码,读不到。

进程隔离比「在同一个 VM 里清 context」彻底。后者总有漏网的引用、没回收的句柄、忘了复位的状态。前者最坏也就是子进程死掉,父进程收到一个非零退出码。

父进程这边是 process.run,worker 路径按三档找:FUN_JSBOX 环境变量、解释器旁边的 fun-jsbox、PATH。第三档只在拿不到解释器路径时才用。服务里应该显式设 FUN_JSBOX 到绝对路径,别让 worker 从可写目录里被换掉。

三、重跑要能重跑出同一个结果

Math.random 换成固定种子的生成器,种子从请求里来,默认 1。Date.now 直接返回请求里的 now_ms,默认 0。两处都是往待执行的源码前面塞几行,失败了就悄悄跳过。

为什么要较这个真。模型写的东西,第一次跑出 42,第二次跑出 17,你分不清是代码变了、输入变了,还是运气变了。把随机数和时钟钉住,至少这两个变量没了。new Date() 还是跟平台走,真要时间,从 input 传进去。

测试里也有一条:同一个种子两次结果相同,不同种子结果不同。

四、四道闸门

  • 代码 4 MiB,超过直接回 input_limit,连进程都不起。
  • 请求 16 MiB。超了不立刻甩手,先有界地排干最多 64 MiB 再报错,不然喂它一个 /dev/zero 就能把 worker 挂在那里读。
  • 时间 200 毫秒,硬顶 10 秒。两套机制:QuickJS 的中断回调负责打断 JS 循环,父进程的 process.run 超时负责杀进程。灾难性回溯的正则打断不了,只能靠后者。
  • 内存 4 MiB,硬顶 64 MiB。引擎侧 JS_SetMemoryLimit,内核侧 RLIMIT_AS 再留 128 MiB 余量。栈是内存的一半,最低 256 KiB,最高 2 MiB。
  • 输出 256 KiB,硬顶 4 MiB。worker 自己先量一遍 JSON,父进程再用 process.run 的字节预算兜一层。

最后这层是 11 月 8 日补的。之前的漏洞很实在:worker 的结果有上限,但它在退出前可以往 stdout 无限写。给 process.run 加了 output 选项,stdout 和 stderr 共用一个预算,超了直接 kill(pid, 9),不是只把缓冲区截断。

五、向内核再借一层

引擎没有文件 API,不等于引擎攻不破。QuickJS 里出个内存破坏,攻击者拿到的是原生代码执行,那时候 open/socket/fork 全在 libc 里等着。

所以 Linux 上在求值之前装 seccomp-bpf。白名单,只放引擎收尾要用的:brk、mmap、mprotect、munmap、mremap、madvise,read、write、writev、close、lseek、fstat、newfstatat、statx,几个信号相关的,clock_gettime、gettimeofday、futex,外加 exit 和 exit_group。

其余的,SECCOMP_RET_KILL_PROCESS。不是返回 EPERM,是直接杀。x32 ABI 的系统调用号单独挡一道,因为它把 0x40000000 那一位当标志,不挡就能绕过去。

装不上就 fail closed,回 kind: "sandbox"。想验证的话有 --selftest-seccomp:装完过滤器去 openat,进程应该被 SIGSYS 杀掉,shell 退出码 159,也就是 128 加 31。

同一段里还有几件小事:PR_SET_NO_NEW_PRIVS,PR_SET_DUMPABLE 置 0,RLIMIT_NPROC 设 0,RLIMIT_NOFILE 设 16,core dump 关掉,从 3 到 31 的 fd 全部 close。最后这条防的是父进程漏下来的描述符,比如一个已经连上的 socket。

六、另外两个平台,各借各的

Windows 没有能直接用的 seccomp。worker 把自己加进一个 Job Object:提交内存上限跟着请求走,活动进程数 1,关句柄即杀。文件和网络的隔离,还是只能靠「运行时没装这些能力」,加上父进程可信。

macOS 用 sandbox_init,profile 里 (deny network*)、(deny process-exec*)、(deny process-fork)。这个函数早就标了 deprecated,某些受限环境里根本调不到,那就退回 RLIMIT_*,并且老老实实报告 sandbox.kind = "limits"。

调试时可以 --no-sandbox 或 FUN_JSBOX_SANDBOX=0,文档里写得很直白:别对不可信代码这么干。

七、错误是给模型看的

失败时返回的 error 有稳定的字段:

bash 复制代码
{
  "ok": false,
  "value": null,
  "error": {
    "kind": "syntax", "name": "SyntaxError", "message": "variable name expected",
    "line": 1, "column": 5, "stack": "...", "retryable": true
  },
  "usage": {"wall_ms": 1, "timeout_ms": 200, "memory_bytes": 4194304, "output_bytes": 262144}
}

kind 是枚举:syntax、runtime、timeout、oom、output_limit、input_limit、worker、protocol、sandbox、internal。语法和运行时错误带从一开始的行列号,比如 var = ; 报 variable name expected,位置 1 行 5 列。retryable 告诉调用方,这个错重试有没有意义。

这些字段不是给人读的,是给生成代码的那一方读的。它得知道自己是语法错了、超时了、还是内存超了,才知道下一版怎么改。

有两个坑值得单说。

Promise 结果直接拒掉,kind: "runtime",消息是 return a plain JSON value, not a Promise。早先它会静默变成一个 {},模型以为自己返回了数据,调用方以为成功了,两边都错。

JSON 没有环。Fun 这边成环的集合,序列化时在重复的节点写 "(Object)",JS 看到的是有限树,不是环。这条还牵着另一个修复:深拷贝遇到环会爆原生栈,11 月 7 日晚上一并改了,用祖先栈判重,比最初的 hash 方案快,clone 基准测下来大约 3% 开销。

还有 2^53。JSON 数字是 IEEE-754 双精度,Fun 的 Int64 超过这个数就丢精度。大 ID、以分为单位的金额、64 位哈希,一律用十进制字符串传。

八、返回的 JSON 也不能直接信

worker 只保证「这是一段合法的 JSON」。里面是什么形状,它不管。控制器拿到之后应该先校验再使用,站内测试给了一个现成写法:

bash 复制代码
use 'fun/lib/jsonschema.fun';

var schema = '{"type":"object","required":["total","count"],' &
  '"properties":{"total":{"type":"integer","minimum":0},' &
  '"count":{"type":"integer","minimum":0}}}';
if r.ok and JsonSchema.valid(r.value, schema) then
  ?. r.value.total;
end if;

这个例子同时也是 jsonschema 补全 v0.x 关键字的回归用例。两个 11 月 7 日的新东西,在这里碰上了。

顺带一句,这个桥接用的是 process.run 和 env.get,都要 FFI,所以 -safe 构建加载不了。受信任的控制器必须是普通构建,别指望用 Safe 模式把它关起来。

九、笼子还没封顶

没做的清单也写在 README 里,一条没藏:

unshare、bubblewrap、nsjail 那套网络和挂载隔离还没接;macOS 的 profile 目前不挡文件读取;bytecode cache 明确不做,对机器写的代码只有坏处。

所以现在这个笼子,防的是「引擎被攻破之后乱来」,不是「多租户之间互相算计」。Linux 的 seccomp 是一道很硬的本地边界,但真要在一台机器上同时跑很多互不信任的租户,还差命名空间那一层。

(吐槽:写了二十几笔提交,README 里「Not done yet」还是三行。安全这东西的进度条,永远比功能慢。)

结语

一个 1214 行的 C 文件,加上 212 行的 Fun 桥接和 282 行测试,把「跑一段机器写的 JS」从一句 eval 变成了一条有边界的流程:能力先拿掉,进程再隔开,随机和时钟钉死,资源四道上限,内核再加一道白名单,错误写清楚,返回值再校验。

麻烦吗?麻烦。但把不可信代码放进自己进程里跑,省下的那点麻烦,迟早会以别的方式还回来。

钥匙一直在父进程手里,这一点从第一笔提交到最后都没变。

相关推荐
普通人615 分钟前
PHP文件读写完全指南
开发语言·php·1024程序员节
古茗前端团队25 分钟前
由 key 引发的思考
前端·javascript·react.js
kyriewen1 小时前
我扫了 422 个仓库,近一半 CI 在裸装依赖
前端·javascript·程序员
坤坤子吖1 小时前
Linux网络编程:TCP服务器的多进程与多线程实现
linux·服务器·开发语言·网络·c++·笔记·学习
一晌小贪欢2 小时前
Python基础:第 09 篇 · while 循环:重复直到不满足
开发语言·c++·python·数据可视化·面向对象·python办公
xiaoye-duck2 小时前
《C++ 项目:手写大模型接入 SDK》LLMManager架构设计与实现
开发语言·网络·c++·语言模型
一条大祥脚3 小时前
CuTe 与 CuTe DSL:从 C++ 模板元编程到 Python DSL 的 GPU 编程演进
开发语言·c++·python·cutlass·cute·cute dsl
FRing033 小时前
vue2干货总结8(路由模块封装、router-link、声明式导航、路由重定向、编程式导航)
开发语言·前端·vue.js·vue2
旖旎夜光3 小时前
【C++ 基础入门】C++ 多态(上):虚函数、重写、override/final 与抽象类
开发语言·c++·笔记·学习