本文是对SP1(一个零知识虚拟机)的全面安全审计指南,重点介绍了SP1的架构、安全注意事项和审计方法,SP1使用户能够证明任意Rust程序的执行,审计需要关注 untrusted host 和 trusted guest 之间的边界,需要对输入进行严格的校验,以防止恶意输入破坏系统,同时,还需要进行外部验证。
零知识虚拟机(zkVMs),如 SP1,在区块链基础设施中变得越来越普遍,尤其是在 rollups 和跨链协议中。作为一名安全审计员,理解这些系统对于识别潜在的漏洞,并确保零知识证明系统的完整性至关重要。
本指南提供了 SP1 架构、常见安全考虑因素以及专门为安全专业人员量身定制的实用审计方法的全面概述。
除了本博客外,要能够审查 SP1 程序,你还需要具备:
你不需要完全了解零知识证明系统的底层数学原理,但了解会有所帮助!
SP1 是由 Succinct Labs 开发的零知识虚拟机(zkVM),允许开发人员证明任意 Rust 程序的执行。与需要用专用语言编写电路的传统零知识证明系统不同,SP1 使开发人员能够编写标准 Rust 代码,并生成其正确执行的密码学证明。
Succinct Prover Network 是一种协议,请求者可以将昂贵的证明外包给专门的提供商。这些提供商因其工作而获得 Succinct 代币 ($PROVE) 的奖励。
SP1 的架构类似于执行标准 RISC-V 程序的 CPU,但有一个关键的区别:每个指令的执行都经过密码学证明。以下是该系统的工作方式:
1. 编译:Rust 代码被编译成标准的 RISC-V ELF 二进制文件(与真实 RISC-V 处理器使用的格式相同)。
2. 执行和证明:SP1 zkVM 逐条指令地执行这些二进制文件,生成 STARK 证明,以证明程序的正确执行并证明公开值。
3. 证明优化:然后将 STARK 证明“包装”到 SNARK 中,SNARK 要小得多,并且在链上验证的成本更低。
4. 验证:任何人都可以验证最终的 SNARK 证明,以确认程序已正确执行,而无需重新运行计算。
SP1 管道:
阶段 | 输入 | 过程 | 输出 | 优势 |
---|---|---|---|---|
1 | Rust 代码 | 标准编译 | RISC-V ELF | 开发人员熟悉 |
2 | RISC-V ELF | SP1 zkVM 执行 | STARK 证明 | 经过验证的执行 |
3 | STARK 證明 | 证明包装 | SNARK | 快速证明 |
4 | SNARK | 数学验证 | 信任保证 | 廉价验证 |
主要优点:
这种方法使零知识证明对于开发人员来说更容易访问,同时保持了正确性和隐私的密码学保证。
理解 provers 和 verifiers 之间的区别是 zkVM 安全的基础:
SP1 程序采用可信组件和非可信组件之间的明确分离进行构建。至关重要的是,主机和 guest 代码都仅在证明期间执行,verifier 执行纯粹的数学验证,而不执行任何源代码。
主机是非可信的 协调器,它:
安全影响:主机所做的任何事情都在密码学保证之外。控制主机的恶意行为者可能会为 guest 程序提供任意输入。
Guest 程序在 VM 中运行,它们无权访问操作系统。这意味着没有互联网连接、数据库、文件或操作系统调用。这些任务都必须由主机完成,并作为非可信输入共享。
guest 是 可信的 程序,它:
安全保证:仅证明 guest 程序的执行。verifier 的数学检查仅验证 guest 代码的执行,主机代码行为永远不会经过可密码学验证。如果验证成功,你可以信任此特定 guest 代码已正确执行以生成所声称的输出。
在深入研究具体的安全考虑事项之前,让我们检查 SP1 程序中使用的基本代码模式及其安全影响。
sp1_zkvm::entrypoint!(main);
let input_data = sp1_zkvm::io::read::<MyStruct>();
sp1_zkvm::io::commit_slice(&output_data);
风险:主机可以为 guest 程序提供任意输入。
缓解措施:Guest 程序必须验证所有输入数据,包括:
示例 - 输入验证模式:
sp1_zkvm::entrypoint!(main);
pub fn main() {
// 读取潜在的恶意输入
let user_id = sp1_zkvm::io::read::<u32>();
let transfer_amount = sp1_zkvm::io::read::<u64>();
let recipient_address = sp1_zkvm::io::read::<[u8; 20]>();
let data_buffer = sp1_zkvm::io::read::<Vec<u8>>();
// 关键:验证所有输入
assert!(user_id > 0 && user_id <= 1_000_000, "无效的用户 ID 范围");
assert!(transfer_amount > 0 && transfer_amount <= 1_000_000_000, "无效的转账金额");
assert!(!recipient_address.iter().all(|&b| b == 0), "不允许零地址");
assert!(data_buffer.len() <= 1024, "数据缓冲区太大");
// 额外的业务逻辑验证
assert!(transfer_amount >= 1000, "未达到最低转账金额");
// 现在安全地处理已验证的输入
process_transfer(user_id, transfer_amount, recipient_address, data_buffer);
}
风险:主机代码可能包含不在证明范围内的 bug 或恶意逻辑。
审计重点:确保关键逻辑在 guest 程序中实现,而不是在主机中实现。
示例 - 正确的逻辑分离:
// ❌ 错误:主机中存在关键逻辑(未证明)
fn host_main() {
let balance = get_user_balance(); // 主机逻辑 - 未证明!
let amount = 1000;
if balance >= amount { // 主机中的关键检查 - 易受攻击!
let proof_input = ProofInput { balance, amount };
generate_proof(proof_input);
}
}
// ✅ 正确:guest 中存在关键逻辑(已证明)
sp1_zkvm::entrypoint!(main);
pub fn main() {
let balance = sp1_zkvm::io::read::<u64>(); // 非信任输入
let amount = sp1_zkvm::io::read::<u64>();
// 关键验证发生在 guest 中 - 经过密码学证明
assert!(balance >= amount, "余额不足");
let new_balance = balance.checked_sub(amount).unwrap();
sp1_zkvm::io::commit(&new_balance);
}
风险:SP1 使用 32 位 RISC-V,这可能导致从 64 位系统移植时出现问题。
重要提示:SP1 的密码学系统使用的 Baby Bear 字段 (~2^31) 不会影响 guest 程序的整数类型,usize
仍然是一个完整的 32 位无符号整数(0 到 2^32 - 1)。
常见问题:
usize
在 guest 中始终为 32 位(无论密码学字段大小如何)示例 - 32 位架构陷阱:
sp1_zkvm::entrypoint!(main);
pub fn main() {
let large_array_size = sp1_zkvm::io::read::<u64>();
// ❌ 危险:32 位系统上可能截断
let vec_size = large_array_size as usize; // 如果 > 2^32,则静默截断
let mut data = Vec::with_capacity(vec_size);
// ✅ 安全:32 位环境的显式边界检查
assert!(large_array_size <= u32::MAX as u64, "数组大小对于 32 位系统而言太大");
let safe_size: usize = large_array_size as usize;
let mut safe_data = Vec::with_capacity(safe_size);
// ❌ 危险:大型指针算法
let base_ptr = safe_data.as_ptr();
let offset = large_array_size; // 可能 > usize::MAX
// let dangerous_ptr = unsafe { base_ptr.add(offset as usize) }; // 未定义的行为
// ✅ 安全:在指针算法之前检查边界
assert!(offset < safe_data.len() as u64, "偏移量超过数组边界");
let safe_ptr = unsafe { base_ptr.add(offset as usize) };
}
风险:为常规平台编写的库和依赖项可能会假定可以访问操作系统、64 位架构或其他在 SP1 zkVM 内部不成立的系统行为。未经修改地使用这些依赖项可能会在为 RISC-V 编译并在 zkVM 中执行时引入细微的错误、未定义的行为或安全问题。
常見问题模式:
缓解措施:
审计重点:将第三方代码视为相对于 SP1 约束不可信的代码 — 验证其是否具有确定性的、32 位安全行为,并删除任何隐藏的平台假设。
风险:Rust 的默认整数溢出行为在调试模式和发布模式之间有所不同。
SP1 特定的建议:由于在 guest 代码中需要 panic,因此通过添加到 guest 程序的 Cargo.toml
中(而不是主机程序中)来在发布模式下启用溢出检查:
[profile.release]
overflow-checks = true
最佳实践:
checked_add
,checked_mul
等)类型转换漏洞:溢出检查不会捕获类型转换问题,这些静默截断必须手动验证:
// 🌶️ 危险:静默截断
let large_value: u64 = u64::MAX;
let truncated: u32 = large_value as u32; // 静默数据丢失
// 安全:显式验证
let safe_cast: u32 = large_value.try_into()
.expect("值对于 u32 而言太大"); // 将适当地 panic
什么是验证密钥:当 SP1 程序被编写和编译时,编译的一部分会生成两个密钥(两者都是公钥,任何人都可以找到它们)。第一个密钥相当大,并提供给 prover,它称为 proving key。proving key 用于证明生成,并且仅对特定程序有效。第二个密钥小得多,称为验证密钥,在验证 SP1 程序时需要此密钥。类似地,验证密钥仅对特定程序有效。验证密钥和 proving key 都是从源代码派生的,更改源代码会更改密钥。只有在使用 proving key 进行的证明生成是针对正确的验证密钥进行验证时,证明才能正确验证。
关键安全属性:每个 guest 程序都有一个唯一的验证密钥,该密钥从其编译的二进制文件派生。
风险:
审计清单:
风险:通过公共输出意外泄露私人信息。
最佳实践:
commit
和 commit_slice
调用示例 - 信息泄露模式:
sp1_zkvm::entrypoint!(main);
pub fn main() {
let private_key = sp1_zkvm::io::read::<[u8; 32]>();
let user_balance = sp1_zkvm::io::read::<u64>();
let user_age = sp1_zkvm::io::read::<u32>();
let transaction_amount = sp1_zkvm::io::read::<u64>();
let salt = sp1_zkvm::io::read::<u128>();
// 验证用户是否可以进行交易(私有计算)
let has_sufficient_balance = user_balance >= transaction_amount;
let is_adult = user_age >= 18;
let can_transact = has_sufficient_balance && is_adult;
// ❌ 危险:泄露私人信息
sp1_zkvm::io::commit(&user_balance); // 显示确切的余额!
sp1_zkvm::io::commit(&user_age); // 显示确切的年龄!
sp1_zkvm::io::commit(&private_key); // 灾难性的泄漏!
// ✅ 正确:仅提交必要的公共信息
sp1_zkvm::io::commit(&can_transact); // 仅显示布尔结果
// ✅ 替代方法:提交交易哈希而不是详细信息(添加随机 salt 以防止 preimage 暴力攻击)
let tx_hash = hash_transaction(transaction_amount, salt);
sp1_zkvm::io::commit(&tx_hash);
// ✅ 正确:提交范围证明而不显示确切的值
let balance_sufficient = user_balance >= 1000; // 证明余额 > 阈值
sp1_zkvm::io::commit(&balance_sufficient);
}
风险:有效的证明可能会在非预期的上下文中重放。
缓解措施:
关键区别:主机与 guest 代码中的 panic 具有根本不同的安全含义。
主机 Panic:
Guest Panic:
assert!
,panic!
或 unwrap()
在无效输入上快速失败关键平衡:
审计重点:
// ✅ 正确:在无效条件下 Panic
assert!(user_balance >= withdrawal_amount, "资金不足");
// ❌ 错误:在应处理的有效边缘情况下 Panic
let result = valid_computation().unwrap(); // 可能在有效但意外的输入上 Panic
证明在时间上通常是昂贵的,而且通常是金钱。Succinct Prover Network 仍然向请求者收取证明费用,即使请求无效。这意味着DoS 向量可能会给协议带来财务成本。
风险:恶意输入可能导致过多的内存或计算消耗。
常见攻击媒介:
sp1_zkvm::entrypoint!(main);
pub fn main() {
let size = sp1_zkvm::io::read::<usize>();
// ❌ 危险:不检查的内存分配
let mut vec = Vec::with_capacity(size); // 可能分配千兆字节!
// ✅ 安全:有界分配
const MAX_SIZE: usize = 1_000_000; // 1MB 限制
assert!(size <= MAX_SIZE, "输入大小超过限制");
let mut safe_vec = Vec::with_capacity(size);
}
关键原则:某些属性不能或不应在 guest 程序内部进行验证。
为什么需要外部验证:
常见示例 - 以太坊区块哈希验证:
sp1_zkvm::entrypoint!(main);
pub fn main() {
// 从主机读取输入
let block_hash = sp1_zkvm::io::read::<[u8; 32]>();
let merkle_proof = sp1_zkvm::io::read::<MerkleProof>();
let account_data = sp1_zkvm::io::read::<AccountData>();
// 可以验证:针对状态根验证 Merkle 证明
assert!(merkle_proof.verify(&block_hash, &account_data), "无效的 merkle 证明");
// 无法验证:block_hash 是否为有效的以太坊区块
// 恶意 prover 可以提供带有精心制作的状态根的伪造 block_hash
// 必须将 block_hash 提交到公共输出以进行外部验证
sp1_zkvm::io::commit(&block_hash);
sp1_zkvm::io::commit(&account_data);
}
需要 Verifier 端验证:
// 在 Solidity 智能合约 verifier 中
function verifyAccountProof(bytes32 proofData) external {
(bytes32 blockHash, AccountData memory account) = abi.decode(proofData, (bytes32, AccountData));
// 外部验证:检查区块哈希是否为最近且有效的
require(blockHash == blockhash(block.number - 1), "无效或过时的区块哈希");
require(block.number - 1 > 0, "不支持创世块");
// 现在我们可以信任 SP1 证明已正确验证 merkle 包含
}
审计方法:
安全风险:如果无法验证的属性未公开提交并进行外部验证,则恶意 prover 可以提供任意值,并且仍然可以生成有效的证明。
在审核 SP1 程序时:
io::read()
调用并确保正确的验证io::commit()
调用是否存在信息泄漏大多数 SP1 程序都遵循三阶段逻辑结构(尽管不一定以线性方式实现):
(a) 加载初始状态和私有输入
由于 zkVM 无法访问外部数据库或 API,因此所有初始状态都必须从主机加载到 guest 中。此阶段对于安全至关重要。
sp1_zkvm::entrypoint!(main);
pub fn main() {
// 阶段 1:加载和验证所有输入
let merkle_root = sp1_zkvm::io::read::<[u8; 32]>();
let account_proofs = sp1_zkvm::io::read::<Vec<AccountProof>>();
let transactions = sp1_zkvm::io::read::<Vec<Transaction>>();
// 关键:验证初始状态完整性
assert!(verify_merkle_root(&merkle_root, &account_proofs), "无效的状态根");
// 提交初始状态哈希以进行外部验证
sp1_zkvm::io::commit_slice(&merkle_root);
}
(b) 状态跃迁逻辑
这是核心计算发生的地方,转换初始状态 + 私有输入 → 输出状态。需要仔细验证边缘情况和业务逻辑。
// 阶段 2:执行状态跃迁
let mut new_state = initial_state.clone();
for transaction in transactions {
// 验证每个跃迁步骤
assert!(transaction.amount > 0, "无效的交易金额");
assert!(new_state.get_balance(transaction.from) >= transaction.amount, "余额不足");
// 应用状态跃迁
new_state.transfer(transaction.from, transaction.to, transaction.amount);
}
(c) 输出公共值
最后阶段将结果提交到公共输出,然后必须由 verifier 与 SP1 数学证明一起验证。
// 阶段 3:提交输出以进行验证
let final_state_root = new_state.compute_root();
sp1_zkvm::io::commit_slice(&final_state_root);
sp1_zkvm::io::commit(&transaction_count);
零知识电路中最常见的漏洞模式是“约束不足的电路”,即允许恶意输入产生有效证明的验证不足。同样的问题也适用于 zkVM 程序。
常见的约束不足模式:
sp1_zkvm::entrypoint!(main);
pub fn main() {
let user_balance = sp1_zkvm::io::read::<u64>();
let withdrawal = sp1_zkvm::io::read::<u64>();
// ❌ 约束不足:缺少关键验证
let new_balance = user_balance - withdrawal; // 没有溢出检查!
sp1_zkvm::io::commit(&new_balance);
// ✅ 正确约束:综合验证
assert!(user_balance >= withdrawal, "余额不足");
assert!(withdrawal > 0, "无效的提款金额");
assert!(withdrawal <= MAX_WITHDRAWAL, "超过提款限额");
let new_balance = user_balance.checked_sub(withdrawal)
.expect("余额计算中出现算术溢出");
sp1_zkvm::io::commit(&new_balance);
}
审计策略:系统地验证每个私有输入都具有适当的约束,以防止恶意的状态跃迁。询问:“如果恶意制作此输入会发生什么?”
输入验证:
所有 sp1_zkvm::io::read()
调用后都进行验证
数值输入的范围检查
集合和字符串的长度限制
强制执行业务逻辑约束
架构:
安全地管理程序验证密钥
在 guest 中(而不是在主机中)实现的关键逻辑
正确分离可信/不受信任的组件
主机代码具有适当的错误处理
经过审计的第三方依赖项是否与 SP1/32 位兼容
数据处理:
没有通过 commit()
调用泄露的私有数据
针对不可验证属性的外部验证
适当使用公共值与私有输入
资源管理:
内存分配有界
计算循环具有合理的限制
没有资源耗尽攻击的潜力
溢出保护:
Guest Cargo.toml
具有 overflow-checks = true
在适当情况下使用经过检查的算术
小心处理类型转换(u64 → u32)
// 需要注意的直接安全问题:
sp1_zkvm::io::read::<Vec<T>>(); // 没有长度验证
user_input as usize; // 潜在的静默截断
Vec::with_capacity(size); // 无界分配
balance - amount; // 没有溢出检查
sp1_zkvm::io::commit(&secret); // 信息泄漏
好消息:深入了解 STARK 和 SNARK 的数学知识对于有效审核 SP1 程序 不是必需的。大多数安全漏洞都发生在应用程序逻辑级别,而不是在密码学原语中。
你需要知道什么:
你不需要什么:
将你的审计重点放在:
// 这是 bug 的所在地 - 而不是密码学
sp1_zkvm::entrypoint!(main);
pub fn main() {
let input = sp1_zkvm::io::read::<UserInput>();
// BUG 领域:应用程序逻辑漏洞
if input.user_type == "admin" { // 字符串比较漏洞?
grant_admin_privileges(); // 逻辑缺陷?
}
let result = process_payment(input.amount); // 整数溢出?
sp1_zkvm::io::commit(&result); // 信息泄漏?
}
// SP1 zkVM 自动处理密码学证明
关键见解:像任何其他关键系统代码一样对待 SP1 程序,专注于输入验证、业务逻辑正确性和正确的错误处理。零知识密码学由 SP1 框架处理,通常不是出现安全问题的地方。
注意:提供的密码学资源仅供参考,但请记住,有效的 SP1 程序审计不需要深厚的数学知识。请将学习时间集中在 Rust 安全模式和本指南中概述的 SP1 特定注意事项上。
SP1 和 zkVM 为创建可验证的计算系统提供了强大的工具,但它们需要仔细的安全考虑。受信任的 guest 程序和不受信任的 host 环境之间的分离创建了独特的攻击媒介,而传统的代码审计可能会遗漏这些攻击媒介。
核心要点:密码学证明仅保证 guest 程序已正确执行,不保证程序的逻辑是安全的或输入是合法的。彻底的验证和适当的架构设计仍然是构建安全 zkVM 应用程序的关键。
通过理解这些架构模式和潜在的陷阱,安全审计员可以确保基于 zkVM 的系统保持其预期的安全属性。
- 原文链接: blog.sigmaprime.io/sp1-z...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!