Node harness 和用户策略在同一个 JavaScript realm 中,共享 Array.prototype、JSON、Math。gVisor 隔离宿主机资源,同进程内的引擎状态仍需要保护。10-11 给 harness 加了内置对象锁定,并从镜像删掉 shell;10-08、10-12 另补了外部数据和 Python 参数声明的提交检查。
同一个 realm 里能做什么
引擎把成交行、盘口档位、挂单记录放在普通的数组和 Map 里。策略只要在顶层写一句:
1
Array.prototype.push = function () { return 0; };
引擎之后调用 push 时会进入替换的方法,this 就是引擎内部数组。测试还覆盖了这些路径:
- 在
Object.prototype上defineProperty一个 setter,引擎构造对象时一赋值,setter 就拿到了引擎实例。用const O = Object起别名绕过静态分析也一样; - 替换
WeakSet.prototype.add,或者替换生成器原型的next,给回放循环喂假事件; - 替换
String.prototype.split或Buffer.prototype.toString。harness 按 64KB 读 stdin,读缓冲里有还没轮到的后续行,劫持了切行函数就能看到未来; - 重绑
JSON.parse,在第一个 hook 触发之前把整段输入解析一遍; - 改
Math.min,或者干脆globalThis.Math = {...},引擎在调用时按名字查全局; - 给
Object.prototype加一个toJSON,序列化结果行时把markets_run改成 99999。
结果通道已有 HMAC,但 harness 自己在同一个 realm 中序列化结果。若序列化会调用被策略改过的 toJSON,被改写的内容也可能进入 harness 的签名路径。
锁定内置对象
10-11 的改动参考 SES 的 lockdown:import 策略前锁定选定的内置对象,把引擎按名字查找的全局绑定设为只读,提前捕获 JSON.parse。结果行用 harness 的 stringify 序列化,jsonString 负责其中的字符串转义,不调用 JSON.stringify 或继承来的 toJSON。锁定清单包括 Array、Map、Set、WeakMap、WeakSet 及原型、迭代器原型,生成器原型,String、RegExp、Number、Boolean、Symbol、BigInt、Promise,以及 TypedArray 基类、Float64Array、Uint8Array、Buffer 及原型,再加上 Object、Function 的原型,Math、Reflect、Object 的静态属性。JSON 的全局绑定只读,属性没有整体冻结。
直接 Object.freeze 有一个副作用,就是所谓的 override mistake:原型上的属性变成不可写之后,在继承它的普通对象上给同名属性赋值也会失败。decimal.js 这类库会这样写:
1
2
const P = {};
P.toString = function () { return 'p'; };
Object.prototype.toString 冻结后,这一句在严格模式下抛 TypeError。所以 Object.prototype 这一层不能直接冻结,要先把每个可写的数据属性换成访问器:
1
2
3
4
5
6
7
8
9
10
11
12
defineProperty(proto, key, {
configurable: false,
enumerable: d.enumerable,
get() { return value; },
set(v) {
if (this === proto) {
if (v === value) return;
throw new TypeError(`Cannot assign to read only property '${String(key)}' of a built-in`);
}
defineProperty(this, key, { value: v, writable: true, enumerable: true, configurable: true });
},
});
在派生对象上赋值,就在那个对象上定义一个自有属性,行为和没冻结时一样;在内置对象本身上赋值则抛错。
第一版(镜像 1.21.0)中,白名单依赖 danfojs-node 加载失败。它自带的 mathjs 会执行这样一句:
1
Number.isFinite = Number.isFinite || …
值没变,但 Number 已经冻结,这一句赋值让整个包加载失败。上面 setter 里那句 if (v === value) return; 就是为它加的,构造函数的静态属性也因此改走访问器。
所有目标都换成访问器再冻结,沙箱实测回放慢了一倍,Array、String、Map 方法的热路径会多一次 getter。1.21.1 改为:Object、Function 的原型、构造函数静态属性、Math、Reflect 用访问器,其余原型直接冻结。
改动之后,三个允许的依赖(decimal.js、mathjs、danfojs-node)都在真镜像里跑了一遍,runner/sandbox.test.mjs 里有对应用例。harness 的单测里,上面那些攻击路径一条一条断言必须被拒(E_RUNTIME),另有一条反向用例,确认普通代码给自己的对象设 toString、继承 Error 改 name 仍然能跑。
镜像里不要有 shell
沙箱镜像基于 Debian slim,自带 dash 和 bash。harness 和白名单依赖在运行时用不到 shell。build.sh 原来的检查发现 shell 只打 WARNING;10-11 的镜像 1.21.3 改为删除 shell,并把检查改成构建失败条件。
Dockerfile 最后一步用 exec 形式直接调用 rm,执行这一步不依赖 /bin/sh:
1
RUN ["/usr/bin/rm", "-f", "/usr/bin/sh", "/usr/bin/dash", "/usr/bin/bash", "/usr/bin/rbash"]
build.sh 对 sh、dash、bash、rbash 逐个尝试 docker run --entrypoint,任何一个能跑起来就构建失败。
这次还暴露了镜像指纹的一个缺口。部署时会比对镜像上烙的源码指纹和当前代码,防止跑着旧镜像。原来的指纹只哈希 COPY 进去的文件,而删 shell 只加了一行 RUN,什么都没 COPY,于是没有这一步的旧镜像和新镜像指纹相同,部署检查会放行。现在指纹纳入 Dockerfile 全文。回归测试把那行 RUN 从 Dockerfile 文本里去掉,断言指纹必须变化。
提交前能拦住的错
另一类问题跟安全无关,是客户的 run 在运行时才失败,白白排队、白跑一遍。
10-08,一位客户的同一份代码三次在运行时失败,原因是 ctx.ref(...) 读了清单里没有声明的外部数据。现在 /check、/submit 和本地 ot check 在提交前就返回 E_MANIFEST。
10-12 又有一个 run 因为 Python 策略读了 ctx.p.slip,而 slip 没写进清单的 params。Python 侧 ctx.p 恰好就是清单里的 params,缺一个名字会在第一个读它的市场抛异常。JS 不查:JS 里缺参读出来是 undefined,ctx.p.x ?? 默认值 是合法写法,查了反而误拒。
这类检查的原则是宁漏勿误拒,只拒绝能确定的:entry 类里已声明的 hook、hook 自己的 ctx 参数、字面量的名字。实现之后被复审连续指出了四种会误拒的写法:
p = ctx.p; p.setdefault("slip", 0.01)之后再读ctx.p.slip。现在.p只要出现别名、被传给函数、取出会改动它的方法,整份文件都不查;if ctx.p.get("slip") is not None:之后再读。ctx.p.get("字面量")算作保护了这个名字;get的参数不是字面量,或者出现 items、values、copy、遍历,整个文件不查;- 读取在某个
with块里,或者.p的写入发生在同一份提交的另一个辅助模块里。现在「有没有逃逸」按整份提交的所有.py文件合并判断,with体内的读取一律跳过,因为自定义的__exit__可以吞掉异常; - 读取在 hook 内部嵌套的 def、lambda、class 或生成器表达式里。它们是延后执行的,调用处可能包着 try,跳过。
项目的校验流程要求,启用新的拒绝检查前,先把生产已完成 run 的源码拉回,经过 checkSubmission 和 analyzeSource,检查是否误拒。历史成功提交只能覆盖已有写法,不能证明所有合法代码都能通过。
outcometick 工程笔记(20/21)