Posts 回测沙箱:锁定内置对象、删除 shell 和提交检查
Post
Cancel

回测沙箱:锁定内置对象、删除 shell 和提交检查

  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 参数、字面量的名字。实现之后被复审连续指出了四种会误拒的写法:

  1. p = ctx.p; p.setdefault("slip", 0.01) 之后再读 ctx.p.slip。现在 .p 只要出现别名、被传给函数、取出会改动它的方法,整份文件都不查;
  2. if ctx.p.get("slip") is not None: 之后再读。ctx.p.get("字面量") 算作保护了这个名字;get 的参数不是字面量,或者出现 items、values、copy、遍历,整个文件不查;
  3. 读取在某个 with 块里,或者 .p 的写入发生在同一份提交的另一个辅助模块里。现在「有没有逃逸」按整份提交的所有 .py 文件合并判断,with 体内的读取一律跳过,因为自定义的 __exit__ 可以吞掉异常;
  4. 读取在 hook 内部嵌套的 def、lambda、class 或生成器表达式里。它们是延后执行的,调用处可能包着 try,跳过。

  项目的校验流程要求,启用新的拒绝检查前,先把生产已完成 run 的源码拉回,经过 checkSubmission 和 analyzeSource,检查是否误拒。历史成功提交只能覆盖已有写法,不能证明所有合法代码都能通过。


outcometick 工程笔记(20/21)

上一篇:盘口节流的丢帧与合并:存储量、重建误差和回测修正

下一篇:回放提速:批量写 stdin,批量喂 gzip

This post is licensed under CC BY 4.0 by the author.

改口径之后重建历史:标记价加权和 Polymarket Protocol V2 接入

回放提速:批量写 stdin,批量喂 gzip