本文详细介绍了 Solana 程序的模糊测试框架 Trident。通过一个代币托管程序的实战案例,展示了如何配置测试环境、编写手动引导的模糊测试流、检测程序漏洞以及使用仪表盘监控测试结果,旨在提升 Solana 合约的安全性。
信息
Trident 由 Ackee Blockchain Security 创建并维护。本指南由 Quicknode 和 Ackee Blockchain Security 合作制作。
根据 DeFiLlama 的数据,截至 2026 年 3 月,Solana 目前的总锁定价值(TVL)超过 65.9 亿美元。随着 TVL 的增长和现实世界资产(RWA)上链,攻击面也随之扩大。
单元测试可以验证少数边缘情况,但它们仅涵盖开发者想到要测试的输入。审计可以发现已知的漏洞模式,但审计费用昂贵,且通常仅在项目生命周期的早期进行。这两种方法都无法对程序在生产环境中将面临的数百万次随机交易进行压力测试。
模糊测试(Fuzzing)填补了这一空白。它通过随机、有效、无效和意外的输入对程序进行轰炸,以发现手动测试遗漏的 Bug:缺失的账户约束、错误的 Token 处理、意外的状态转换等。
Trident 是专为 Solana 构建的模糊测试框架。与随机触发指令的黑盒模糊测试器不同,Trident 使用 manually guided fuzzing(手动引导模糊测试),开发者可以指定现实的指令序列(首先是 setup,中间是排列组合,最后是 teardown),以便模糊测试器探索有意义的状态路径。
本指南将介绍如何设置 Trident、为 Token 托管(Escrow)程序编写模糊测试、捕捉真实的约束漏洞,并使用内置的指标仪表板监控结果。
太长不看 (TL;DR)
#[init]、#[flow] 和 #[end] 注解定义指令流本指南假设你具备编写和测试 Anchor 程序的经验。你还应该具备:
你还需要安装以下内容:
| 依赖项 | 版本 |
|---|---|
| Solana CLI | 3.0.4 |
| Anchor | 0.32.1 |
| Rust | 1.93.1 |
| Node.js | 24 |
| trident-cli | 0.12.0 |
手动引导模糊测试是一种安全测试技术,开发者在其中定义指令序列的结构:首先是 setup,中间是排列组合,最后是 teardown,而模糊测试器在此结构内随机化输入、金额和账户。
传统的“黑盒”模糊测试器生成完全随机的指令序列和数据。这对于简单的程序有效,但 Solana 程序期望特定的指令顺序。make_offer 指令必须在 take_offer 之抢跑。黑盒模糊测试器的大部分时间都浪费在运行时会立即拒绝的无效顺序上。
| 黑盒模糊测试 | 手动引导模糊测试 | |
|---|---|---|
| 指令排序 | 完全随机 | 开发者指定的结构 |
| 无效序列处理 | 在被拒绝的顺序上浪费周期 | 设计上跳过无效顺序 |
| 输入随机化 | 随机 | 开发者定义范围内的随机 |
| 语义不变量 | 无 | 开发者编码的断言 |
| 最适用于 | 简单的无状态程序 | 具有有序指令的有状态程序 |
模糊测试器在每个阶段 内 进行随机化,选择运行哪些排列指令、以何种顺序以及使用何种输入,但遵循整体结构。这意味着每次模糊测试迭代都执行一个现实的交易序列,增加了发现真实 Bug 的机会。
Trident 直接与 Anchor 项目集成。它读取程序的接口定义语言(IDL)以理解指令签名和账户结构,然后生成模糊测试模板。开发者填充指令流、输入范围和账户存储配置。Trident 处理执行、指标收集和崩溃重现。
Trident 是完全手动引导的。你可以控制每次模糊测试迭代的形态:哪些流运行、以什么顺序运行以及它们接收什么值。对于每个指令参数,你可以提供固定值或告诉 Trident 生成随机值(例如 trident.random_from_range(0..u64::MAX))。账户也是如此。你配置进入 AddressStorage 的内容,Trident 会在运行时从中随机选取。
在没有任何不变量的情况下,Trident 将:
Trident 会通过随机账户替换自动尝试传递伪造的 Mint,但如果没有 assert!(result.is_error(), ...),它会认为交易成功并继续。它无法知道成功的交易就是 Bug。你必须编码正确行为的样子。
这同样适用于所有语义安全属性:
业务逻辑:“成功 take 后,maker 必须准确收到 token_b_wanted_amount”需要了解预期的语义。
避免将程序中的数学逻辑复制到你的不变量中。如果程序在计算值时有 Bug,同样的 Bug 会出现在断言的两侧,不变量将始终通过,即使程序已损坏。
两种更安全的方法是:
授权不变量:“只有 maker 才能取消他们自己的报价”需要你定义未经授权的成功是什么样的。
余额不变量:“所有账户的总 Token 供应量必须守恒”需要读取状态并断言关系。
Trident 可以在无需你指导的情况下自主发现两类问题:
assert!。这些代表了你编码的语义属性,如 Token 余额超出范围、未经授权的状态更改,或你的程序绝不允许的任何情况。对于 语义安全属性,例如“这绝不应该成功”或“只有 maker 可以取消”,你需要知道这些属性是什么并将它们编码为不变量。这些知识来自安全审计清单、威胁建模或领域专业知识,而不是来自模糊测试器本身。
本指南使用来自 Solana Developers Program Examples 仓库的 Token 托管程序。该程序实现了去信任的点对点 Token 交换:创建者 (maker) 通过将 Token 存入 PDA 控制的金库 (vault) 来创建报价 (offer),接受者 (taker) 通过向 maker 发送请求的 Token 并从金库接收返回的 Token 来接受报价。
该程序有两个指令:
make_offer —— maker 指定两个 Token Mint(A 和 B),将 Token A 存入 PDA 控制的金库,并记录他们想要换回多少 Token B。take_offer —— taker 向 maker 发送请求的 Token B,并从金库接收 Token A。交换后,报价账户和金库将被关闭。你将添加一个模糊测试器会捕捉到的故意留下的漏洞:
TakeOffer 账户验证在报价账户上缺少一个 has_one = token_mint_b 约束。这意味着 taker 可以替换一个毫无价值的 Token Mint,向 maker 发送伪造的 Token,但仍然能从金库收到真实的 Token A。克隆 Solana Program Examples 仓库并导航到托管程序:
git clone https://github.com/solana-developers/program-examples.git
cd program-examples/tokens/escrow/anchor
由于 Trident 读取 IDL 来生成模糊测试代码脚手架,你必须首先构建项目以生成 IDL:
anchor build
每当你更改 Anchor 程序中的指令签名或账户结构时,你需要在模糊测试之前重新运行 anchor build,否则你的模糊测试结构将与程序实际预期的不一致。
确认编译后的二进制文件存在:
ls target/deploy/escrow.so
运行现有的 Anchor 测试以验证示例托管项目开箱即可正常工作。这些测试是示例仓库的一部分,不会被修改:
npm install
anchor test
预期输出:
[DEBUG LOGS ...]
escrow
✔ Puts the tokens Alice offers into the vault when Alice makes an offer
✔ Puts the tokens from the vault into Bob's account, and gives Alice Bob's tokens, when Bob takes an offer
为了演示 Trident 可以捕捉到什么,你将在编写模糊测试之前故意引入一个漏洞。在 take_offer.rs 的第 58 行注释掉 has_one = token_mint_b 约束。
如果没有这项检查,程序将不再验证 taker 发送的 Token 是否与 maker 最初请求的 Mint 匹配。这将允许攻击者替换毫无价值的伪造 Token 并掏空金库。
programs/escrow/src/instructions/take_offer.rs
...
#[account(
mut,
close = maker,
has_one = maker,
has_one = token_mint_a,
// has_one = token_mint_b,
seeds = [b"offer", maker.key().as_ref(), offer.id.to_le_bytes().as_ref()],
bump = offer.bump
)]
offer: Account<'info, Offer>,
...
Trident 以 Cargo 包的形式分发,因此安装只需一个 cargo install 命令。这将从 crates.io 拉取 CLI 的最新发布版本,并使 trident 二进制文件在你的 shell 中可用。
cargo install trident-cli
验证安装:
trident --version
预期输出:
Trident 0.12.0
trident init 读取你编译的 Anchor IDL,并为你的模糊测试生成类型化的 Rust 脚手架。Trident 直接从 IDL 派生指令参数类型、账户结构和鉴别器(discriminator),因此你的模糊测试始终与程序的实际接口保持同步。
从项目根目录开始,搭建模糊测试文件脚手架:
trident init
这将生成一个 trident-tests/ 目录,结构如下:
trident-tests/
├── Cargo.toml # 模糊测试依赖项(独立的工作区)
├── Trident.toml # 模糊测试器配置
└── fuzz_0/
├── test_fuzz.rs # 入口点:FuzzTest 结构、流和 main
├── fuzz_accounts.rs # AccountAddresses 结构(账户地址存储)
└── types.rs # 自动生成的指令类型和 Offer 结构
Trident.toml 包含指向编译后的程序二进制文件的路径、指标和仪表板设置以及覆盖率配置。通过添加 dashboard = true 在 trident-tests/Trident.toml 中启用仪表板:
trident-tests/Trident.toml
[fuzz.metrics]
enabled = true
dashboard = true
[[fuzz.programs]]
address = "qbuMdeYxYJXBjU6C6qFKjZKjXmrU83eDQomHdrch826"
program = "../target/deploy/escrow.so"
Cargo.toml 声明 trident-fuzz 为依赖项并定义 fuzz_0 二进制目标。Trident 使用独立的工作区以避免与你的 Anchor 项目产生依赖冲突。
由于托管程序使用 SPL Token 账户,请启用 token 特性以拉取在设置和流方法中使用的 SPL Token 助手:
trident-tests/Cargo.toml
...
[dependencies.trident-fuzz]
version = "0.12.0"
features = ["token"]
...
fuzz_0/test_fuzz.rs 是模糊测试的入口点。它声明了 FuzzTest 结构、助手函数、指令流和 main。两个配套文件 fuzz_accounts.rs 和 types.rs 通过 mod 包含为 Rust 模块。
当你运行 trident init 时,Trident 会读取 Anchor IDL 并自动生成 trident-tests/fuzz_0/types.rs。请勿手动编辑此文件(除非你使用的是 Anchor 0.29 或更早版本 —— 见下文备注)。如果程序 IDL 发生变化,请使用以下命令重新生成:
trident fuzz refresh fuzz_0
types.rs 定义了构建和提交程序指令所需的一切,而无需直接导入程序 crate:
escrow::program_id(): 程序的部署地址MakeOfferInstructionAccounts / TakeOfferInstructionAccounts: 每个所需账户的 Pubkey 结构体MakeOfferInstructionData / TakeOfferInstructionData: 可 Borsh 序列化的指令参数结构体MakeOfferInstruction / TakeOfferInstruction: 构建器,用于组装带有正确 8 字节鉴别器和账户元数据(预设签名者/可写标志)的序列化 InstructionOffer: 一个 Borsh 可反序列化的链上 Offer 结构体镜像,用于在迭代过程中读取账户状态关联 Token 程序和系统程序的地址硬编码在每个指令的 accounts() 构建器中,因此你无需手动传递它们。
Anchor 0.29
Anchor 0.29 及更早版本的 IDL 不包含程序 ID 或指令鉴别器。如果你使用的是旧程序,生成的 types.rs 将包含占位符值,你需要在运行模糊测试器之前手动填入程序 ID 和鉴别器。
fuzz_accounts.rs 定义了 AccountAddresses,一个包含 AddressStorage 字段的结构体,程序中每个命名的账户对应一个字段。trident init 根据 IDL 生成其脚手架,你可以手动添加自定义字段(如 fake_mint)并删除不使用的字段。
trident-tests/fuzz_0/fuzz_accounts.rs
use trident_fuzz::fuzzing::*;
/// 模糊测试中使用的所有账户地址的存储。
#[derive(Default)]
pub struct AccountAddresses {
pub maker: AddressStorage,
pub token_mint_a: AddressStorage,
pub token_mint_b: AddressStorage,
pub offer: AddressStorage,
pub vault: AddressStorage,
pub associated_token_program: AddressStorage,
pub token_program: AddressStorage,
pub system_program: AddressStorage,
pub taker: AddressStorage,
pub fake_mint: AddressStorage,
}
通过将所有地址存储分组到一个结构体中,每个流方法都可以共享状态,而无需在每个方法中传递单个变量。
test_fuzz.rs 以模块导入开始,并定义了 FuzzTest 结构体。该结构体持有两个字段:
trident: 执行交易、提供随机性并公开 Token 助手的 Trident 客户端fuzz_accounts: 来自 fuzz_accounts.rs 的 AccountAddresses 实例,它汇总了所有参与者和 Token 的地址#[derive(FuzzTestMethods)] 宏生成测试执行循环(迭代控制、流选择、计时)。impl 块上的 #[flow_executor] 属性将其标记为 #[init]、#[flow] 和 #[end] 方法的来源。
在接下来的部分中,你将编写:
setup 助手make_offer 流take_offer 流在 test_fuzz.rs 的顶部添加三个助手函数,以减少设置方法中的重复。
trident-tests/fuzz_0/test_fuzz.rs
// use/mod statements
// 使用给定的权限和 6 位小数初始化一个新的 Token Mint
fn setup_mint(trident: &mut Trident, payer: &Pubkey, mint: &Pubkey, authority: &Pubkey) {
let ixs = trident.initialize_mint(payer, mint, 6, authority, None);
trident.process_transaction(&ixs, None);
}
// 为给定的所有者和 Mint 创建关联 Token 账户,但不进行注资
fn setup_ata(trident: &mut Trident, payer: &Pubkey, mint: &Pubkey, owner: &Pubkey) {
let ix = trident.initialize_associated_token_account(payer, mint, owner);
trident.process_transaction(&[ix], None);
}
// 在两次交易中创建 ATA 并将 Token 铸造到其中
fn setup_funded_ata(
trident: &mut Trident,
payer: &Pubkey,
mint: &Pubkey,
owner: &Pubkey,
authority: &Pubkey,
amount: u64,
token_program: &Pubkey,
) {
let ix = trident.initialize_associated_token_account(payer, mint, owner);
trident.process_transaction(&[ix], None);
let ata = trident.get_associated_token_address(mint, owner, token_program);
let ix = trident.mint_to(&ata, mint, authority, amount);
trident.process_transaction(&[ix], None);
}
#[derive(FuzzTestMethods)]
...
模糊测试目标可以定义一个 #[init] 方法,Trident 在每次模糊测试迭代开始时运行该方法。trident init 生成一个空的 #[init] 处理程序。将该存根替换为以下内容,以便每次迭代都能获得新鲜的密钥对、Mint、Token 账户和存储的程序 ID。
trident-tests/fuzz_0/test_fuzz.rs
#[flow_executor]
impl FuzzTest {
fn new() -> Self {
Self {
trident: Trident::default(),
fuzz_accounts: AccountAddresses::default(),
}
}
#[init]
fn start(&mut self) {
self.fuzz_accounts.token_program.insert_with_address(pubkey!("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"));
self.fuzz_accounts.associated_token_program.insert_with_address(pubkey!("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"));
self.fuzz_accounts.system_program.insert_with_address(pubkey!("11111111111111111111111111111111"));
let maker = self.fuzz_accounts.maker.insert(&mut self.trident, None);
let taker = self.fuzz_accounts.taker.insert(&mut self.trident, None);
self.trident.airdrop(&maker, 10_000_000_000);
self.trident.airdrop(&taker, 10_000_000_000);
let mint_a = self.fuzz_accounts.token_mint_a.insert(&mut self.trident, None);
let mint_b = self.fuzz_accounts.token_mint_b.insert(&mut self.trident, None);
let fake_mint = self.fuzz_accounts.fake_mint.insert(&mut self.trident, None);
setup_mint(&mut self.trident, &maker, &mint_a, &maker);
setup_mint(&mut self.trident, &taker, &mint_b, &maker);
setup_mint(&mut self.trident, &taker, &fake_mint, &taker);
let token_program = self.fuzz_accounts.token_program.get(&mut self.trident).unwrap();
setup_funded_ata(&mut self.trident, &maker, &mint_a, &maker, &maker, 1_000_000_000, &token_program);
setup_funded_ata(&mut self.trident, &taker, &mint_b, &taker, &maker, 1_000_000_000, &token_program);
setup_funded_ata(&mut self.trident, &taker, &fake_mint, &taker, &taker, 1_000_000_000, &token_program);
// 预先创建 ATA,否则 take_offer 的 init_if_needed 会由 taker 出资,
// 这会导致即使在退还金库关闭的租金后,taker 的 SOL 余额也会减少。
setup_ata(&mut self.trident, &taker, &mint_a, &taker); // taker 接收 Token A
setup_ata(&mut self.trident, &maker, &mint_b, &maker); // maker 接收 Token B
// 重置 offer/vault,以便 take_offer 不会针对来自前一次迭代的
// 陈旧 PDA 运行(那将会有不同的 token_mint_b)。
self.fuzz_accounts.offer = AddressStorage::default();
self.fuzz_accounts.vault = AddressStorage::default();
}
// 在此处添加测试流
}
标记为 #[flow] 的方法定义了模糊测试器在每次迭代中随机选择并执行的指令。
在 start 方法之后,添加第一个流 make offer:
trident-tests/fuzz_0/test_fuzz.rs
// 流: make_offer
//
// 模拟 maker 创建托管报价。每次调用都会生成新鲜的随机报价 ID、提供金额和
// 需求金额,因此模糊测试器在多次迭代中可以练习各种数值输入和 PDA 地址。
//
// 在交易成功后,该流验证:
// - 正确数量的 Token 已从 maker 移入金库(没有额外的 Token 被创建或在传输中丢失)。
// - 写入链上 Offer 账户的每个字段都与传递给指令的值匹配(防止 save_offer
// 或 Borsh 序列化中的静默数据损坏)。
// - 金库 Token 账户归报价 PDA 所有,而非归 maker 所有,确保 maker 无法单方面提取 Token。
// - 如果需求金额为零,程序仍然接受该报价,这是一个潜在的策略问题 —— 会打印一个警告,
// 使其在模糊测试输出中可见而不会硬性报错(程序对此没有明确的保护)。
#[flow]
fn make_offer(&mut self) {
let Some(maker) = self.fuzz_accounts.maker.get(&mut self.trident) else { return; };
let Some(mint_a) = self.fuzz_accounts.token_mint_a.get(&mut self.trident) else { return; };
let Some(mint_b) = self.fuzz_accounts.token_mint_b.get(&mut self.trident) else { return; };
let Some(token_program) = self.fuzz_accounts.token_program.get(&mut self.trident) else { return; };
// 随机化所有报价参数,以便模糊测试器探索完整的输入空间。
// wanted_amount 从 0(而非 1)开始,以测试零金额的边缘情况。
let id: u64 = self.trident.random_from_range(0..u64::MAX);
let offered_amount: u64 = self.trident.random_from_range(1u64..100_000u64);
let wanted_amount: u64 = self.trident.random_from_range(0u64..100_000u64);
// 以与程序相同的方式派生报价 PDA 和金库地址,
// 这样指令账户始终与链上 Seed 保持一致。
let id_bytes = id.to_le_bytes();
let (offer_pda, _) = self.trident.find_program_address(
&[b"offer", maker.as_ref(), &id_bytes],
&escrow::program_id(),
);
let maker_ata_a =
self.trident.get_associated_token_address(&mint_a, &maker, &token_program);
let vault =
self.trident.get_associated_token_address(&mint_a, &offer_pda, &token_program);
let accounts = escrow::MakeOfferInstructionAccounts::new(
maker,
mint_a,
mint_b,
maker_ata_a,
offer_pda,
vault,
token_program,
);
let data = escrow::MakeOfferInstructionData::new(id, offered_amount, wanted_amount);
let ix = escrow::MakeOfferInstruction::data(data)
.accounts(accounts)
.instruction();
// 在交易前快照 maker 的 Token A 余额,以便验证离开其账户的确切金额。
let maker_ata_a_before = self.trident
.get_token_account(maker_ata_a)
.map(|a| a.account.amount)
.unwrap_or(0);
let result = self.trident.process_transaction(&[ix], Some("make_offer"));
if result.is_success() {
let vault_balance = self.trident
.get_token_account(vault)
.map(|a| a.account.amount)
.unwrap_or(0);
let maker_ata_a_after = self.trident
.get_token_account(maker_ata_a)
.map(|a| a.account.amount)
.unwrap_or(0);
// 检查金库是否准确收到了 offered_amount 数量的 Token,以及 maker 账户是否
// 减少了相同数量 —— 没有 Token 被凭空创建或丢失。
assert_eq!(vault_balance, offered_amount,
"make_offer: vault balance {vault_balance} != offered_amount {offered_amount}");
assert_eq!(maker_ata_a_after, maker_ata_a_before - offered_amount,
"make_offer: maker_ata_a drained incorrectly");
// 写入报价 PDA 的每个字段必须能够正确往返。
// save_offer 中的 Bug(如错误的字段顺序、Borsh 布局中的偏移量误差)
// 会静默存储错误数据,这些数据仅在执行 take 时才会显现。
let offer_state = self.trident
.get_account_with_type::<crate::types::Offer>(&offer_pda, 8)
.expect("offer PDA not readable after make_offer");
assert_eq!(offer_state.maker, maker, "offer.maker mismatch");
assert_eq!(offer_state.token_mint_a, mint_a, "offer.token_mint_a mismatch");
assert_eq!(offer_state.token_mint_b, mint_b, "offer.token_mint_b mismatch");
assert_eq!(offer_state.token_b_wanted_amount, wanted_amount, "offer.wanted_amount mismatch");
assert_eq!(offer_state.id, id, "offer.id mismatch");
// 金库 Token 账户必须归报价 PDA 所有,而非归 maker 所有。
// 如果 maker 是权限持有者,他们可以随时清空金库,从而绕过托管逻辑。
let vault_acct = self.trident.get_token_account(vault).unwrap();
assert_eq!(vault_acct.account.owner, offer_pda,
"make_offer: vault authority is not the offer PDA");
assert_eq!(vault_acct.account.mint, mint_a,
"make_offer: vault has wrong mint");
self.fuzz_accounts.offer.insert_with_address(offer_pda);
self.fuzz_accounts.vault.insert_with_address(vault);
}
// 程序没有显式的 wanted_amount > 0 保护。如果模糊测试器生成了
// wanted_amount=0 且交易成功,taker 就可以在不转移任何回报的情况下清空金库。
if wanted_amount == 0 && result.is_success() {
eprintln!("WARNING: make_offer succeeded with wanted_amount=0 — verify this is intentional");
}
self.trident.record_histogram("offered_amount", offered_amount as f64);
}
接下来,添加 take offer 的流:
trident-tests/fuzz_0/test_fuzz.rs
// 流: take_offer
//
// 模拟 taker 接受现有的托管报价。仅当池中存在活跃的报价 PDA(由 make_offer 创建)时,
// 才会尝试该流。
//
// Token 替换攻击:每次调用时,模糊测试器都会随机选择传递正确的 token_mint_b
// 或伪造的 Mint。传递错误的 Mint 必须始终被拒绝 —— 如果成功,taker 就可以在使用
// 毫无价值的 Token 支付的同时清空金库。
//
// 在成功的交换后,该流验证:
// - taker 准确收到了 Token A 的金库余额(所有托管 Token 已转移,没有遗留或双花)。
// - taker 准确支付了 Token B 的 wanted_amount(没有少付)。
// - maker 准确收到了 Token B 的 wanted_amount(没有多付或 Token 被重定向给第三方)。
// - 金库 Token 账户已关闭(执行了 Anchor 的 `close` 指令)。
// - 报价 PDA 账户已关闭(账户数据和 lamports 已清空)。
// - maker 和 taker 都收回了来自已关闭账户的租金免除 SOL(maker 获得报价 PDA 的租金,
// taker 获得金库的租金)。
#[flow]
fn take_offer(&mut self) {
let Some(taker) = self.fuzz_accounts.taker.get(&mut self.trident) else { return; };
let Some(maker) = self.fuzz_accounts.maker.get(&mut self.trident) else { return; };
let Some(mint_a) = self.fuzz_accounts.token_mint_a.get(&mut self.trident) else { return; };
let Some(offer_pda) = self.fuzz_accounts.offer.get(&mut self.trident) else { return; };
let Some(token_program) = self.fuzz_accounts.token_program.get(&mut self.trident) else { return; };
// 从报价 PDA 派生金库地址,而不是从存储中读取 —— 这样可以避免
// 任何池同步问题,且这正是程序本身的操作方式。
let vault =
self.trident.get_associated_token_address(&mint_a, &offer_pda, &token_program);
// 从活跃的链上状态读取 wanted_amount,而不是从本地变量读取,
// 这样断言就能反映程序实际存储的内容。
let Some(offer_state) = self.trident
.get_account_with_type::<crate::types::Offer>(&offer_pda, 8) else { return; };
let wanted_amount = offer_state.token_b_wanted_amount;
// Token 替换攻击:随机提供正确的 Mint 或无关的伪造 Mint。
// 程序必须拒绝任何与报价 PDA 中记录的 Mint 不匹配的 Mint。
let use_fake_mint = self.trident.random_bool();
let mint_b = if use_fake_mint {
let Some(m) = self.fuzz_accounts.fake_mint.get(&mut self.trident) else { return; };
m
} else {
let Some(m) = self.fuzz_accounts.token_mint_b.get(&mut self.trident) else { return; };
m
};
let taker_ata_a =
self.trident.get_associated_token_address(&mint_a, &taker, &token_program);
let taker_ata_b =
self.trident.get_associated_token_address(&mint_b, &taker, &token_program);
let maker_ata_b =
self.trident.get_associated_token_address(&mint_b, &maker, &token_program);
// 在交易前快照 SOL 余额,以便验证双方在账户关闭时是否都收回了
// 租金免除 lamports。
let maker_sol_before = self.trident.get_account(&maker).lamports();
let taker_sol_before = self.trident.get_account(&taker).lamports();
// 在交易前快照所有 Token 余额,以便精确检查交易后的变化量。
let vault_before = self.trident
.get_token_account(vault)
.map(|a| a.account.amount)
.unwrap_or(0);
let taker_ata_a_before = self.trident
.get_token_account(taker_ata_a)
.map(|a| a.account.amount)
.unwrap_or(0);
let taker_ata_b_before = self.trident
.get_token_account(taker_ata_b)
.map(|a| a.account.amount)
.unwrap_or(0);
let maker_ata_b_before = self.trident
.get_token_account(maker_ata_b)
.map(|a| a.account.amount)
.unwrap_or(0);
let accounts = escrow::TakeOfferInstructionAccounts::new(
taker,
maker,
mint_a,
mint_b,
taker_ata_a,
taker_ata_b,
maker_ata_b,
offer_pda,
vault,
token_program,
);
let data = escrow::TakeOfferInstructionData::new();
let ix = escrow::TakeOfferInstruction::data(data)
.accounts(accounts)
.instruction();
let result = self.trident.process_transaction(&[ix], Some("take_offer"));
// Token 替换检查:任何尝试使用与报价 PDA 中存储的不匹配的 Mint
// 进行交换的行为都必须被程序拒绝。
if use_fake_mint {
if result.is_success() {
eprintln!("VULNERABILITY: take_offer accepted a fake token mint (token substitution attack succeeded)");
self.trident.record_histogram("fake_mint_accepted", 1.0);
} else {
self.trident.record_histogram("fake_mint_accepted", 0.0);
}
}
// 所有成功后的不变量仅在使用了正确的 Mint 时才有意义。
// 在成功时,报价 PDA 会在链上关闭,因此我们也重置池,
// 以防止未来的迭代触发无效账户。
if !use_fake_mint && result.is_success() {
let taker_ata_a_after = self.trident
.get_token_account(taker_ata_a)
.map(|a| a.account.amount)
.unwrap_or(0);
let taker_ata_b_after = self.trident
.get_token_account(taker_ata_b)
.map(|a| a.account.amount)
.unwrap_or(0);
let maker_ata_b_after = self.trident
.get_token_account(maker_ata_b)
.map(|a| a.account.amount)
.unwrap_or(0);
// 验证完整的 Token 交换:taker 获得金库的所有份额(Token A),
// taker 准确支付 wanted_amount(Token B),maker 收到相同数量。
// 任何偏差都表明 Token 被创建、销毁或重定向了。
assert_eq!(taker_ata_a_after, taker_ata_a_before + vault_before,
"take_offer: taker did not receive correct token A amount");
assert_eq!(taker_ata_b_after, taker_ata_b_before - wanted_amount,
"take_offer: taker did not pay correct token B amount");
assert_eq!(maker_ata_b_after, maker_ata_b_before + wanted_amount,
"take_offer: maker did not receive correct token B amount");
// 金库 Token 账户必须完全关闭。如果它仍然开启,
// Token 可能会被搁置或账户被意外重用。
assert!(self.trident.get_token_account(vault).is_err(),
"take_offer: vault was not closed after successful swap");
// 报价 PDA 本身必须被关闭且其数据被擦除。
// 开启的 PDA 可能会被重放,或者其存储内容被另一个指令误解。
assert!(self.trident
.get_account_with_type::<crate::types::Offer>(&offer_pda, 8)
.is_none(),
"take_offer: offer PDA still exists after successful swap");
// 关闭报价 PDA 将租金退还给 maker,关闭金库将租金退还给 taker。
// 双方最终拥有的 SOL 都不应少于之前(在本地环境中,收回的租金 > 交易费用)。
let maker_sol_after = self.trident.get_account(&maker).lamports();
let taker_sol_after = self.trident.get_account(&taker).lamports();
assert!(maker_sol_after > maker_sol_before,
"take_offer: maker did not receive offer PDA rent");
assert!(taker_sol_after > taker_sol_before,
"take_offer: taker did not receive vault rent");
self.fuzz_accounts.offer = AddressStorage::default();
}
self.trident.record_histogram(
"take_offer_result",
if result.is_success() { 1.0 } else { 0.0 },
);
}
#[end] 方法在每次迭代完成后运行。在本模糊测试中,不变量检查发生在每个流内部,而不是在 teardown 中。#[end] 块可用于迭代级别的清理或全局不变量:
trident-tests/fuzz_0/test_fuzz.rs
#[end]
fn end(&mut self) {
// 在此处执行任何清理操作,此方法将在每次迭代结束时执行
}
}
fn main() {
FuzzTest::fuzz(1000, 100);
}
FuzzTest::fuzz(1000, 100) 运行 1000 次迭代,每次迭代最多 100 个流 —— 总计约 100,000 次交易。
伪造 Mint 检查在 take_offer 中使用 eprintln! 而非 assert! 进行内联处理,因此成功的伪造 Mint 交换会在控制台打印一行 VULNERABILITY 并记录一个指标,而不会停止模糊测试器。这使得运行可以完成并在结果表中显示全貌。
使用 end 的常见模式:
self.trident.add_to_regression(&pubkey, "label") 将账户添加到回归快照中。Trident 在迭代结束时记录这些账户的链上内容。这对于跨程序版本比较行为非常有用:使用相同的 Seed 对版本 A 和版本 B 运行模糊测试器,然后使用 trident compare 对比快照,验证重构是否未改变可观察的账户状态。导航到 trident-tests/ 目录并运行你刚刚创建的模糊测试:
cd trident-tests
trident fuzz run fuzz_0
Trident 在这个独立的工作区中编译模糊测试二进制文件,然后遍历测试流。在典型机器上,这在几秒钟内就能完成。
预期输出:
...
VULNERABILITY: take_offer accepted a fake token mint (token substitution attack succeeded)
Overall: [00:00:02] [####] 100000/100000 (100%) [00:00:00] Parallel fuzzing completed!
+-------------+---------------+------------+-----------+----------------------+
| Instruction | Invoked Total | Ix Success | Ix Failed | Instruction Panicked |
+-------------+---------------+------------+-----------+----------------------+
| make_offer | 49460 | 49460 | 0 | 0 |
+-------------+---------------+------------+-----------+----------------------+
| take_offer | 24404 | 24404 | 0 | 0 |
+-------------+---------------+------------+-----------+----------------------+
MASTER SEED used: "96c563af7b3ddf3dac6cfd30f6ec8273ebee7f849c3b2129248e3a576150a873"
结果表显示,在 100,000 次总流量执行中,make_offer 被调用了约 50,000 次,take_offer 被调用了约 25,000 次。这个 2:1 的比例是预料之中的。
模糊测试器在每次调用时随机选择两个流中的一个,因此每个流被选中的次数约为 50,000 次,但 take_offer 有一个提前返回的守卫,当池中不存在报价时会跳过执行。由于 start() 在每次迭代开始时会重置池,且每次成功交换后也会清空池,因此有很多窗口期是选择了 take_offer 但没有任何对象可以操作。
这些提前返回不计入调用次数,这就是为什么该数字约为 make_offer 的一半。
控制台打印的 VULNERABILITY 行是关键发现:由于程序中注释掉了 has_one = token_mint_b,take_offer 在每次尝试中都接受了伪造的 Token Mint。
你之前在本指南中已经在 trident-tests/Trident.toml 中启用了指标仪表板([fuzz.metrics] 带有 dashboard = true)。
在另一个终端窗口中,启动 Trident 仪表板服务器以可视化模糊测试结果:
trident server
在浏览器中打开 http://localhost:8000。仪表板显示:

在顶部,仪表板显示了整个会话的摘要计数器。Transaction Statistics 部分按指令细分了这些数字。
每次交易在 Solana 层面都是成功的 —— 没有程序错误,没有 panic。这正是结果中 Token 替换漏洞的样子:漏洞利用不会导致程序崩溃,它会静默接受无效输入。该发现出现在控制台输出(VULNERABILITY 行)和下方的 Custom Metrics 直方图中。
Custom Metrics 部分显示了模糊测试运行中的三个直方图:

| 指标 | 计数 | 范围 | 平均值 | 中位数 | 香农熵 (Shannon Entropy) |
|---|---|---|---|---|---|
fake_mint_accepted |
12,413 | 1.00 – 1.00 | 1.00 | 1.00 | 0.0000 |
offered_amount |
49,821 | 2.00 – 99,999.00 | 49,763.21 | 49,696.00 | 15.1491 |
take_offer_result |
24,589 | 1.00 – 1.00 | 1.00 | 1.00 | 0.0000 |
fake_mint_accepted 和 take_offer_result 的范围都是 1.00 – 1.00,且香农熵为 0.0000。每个值都是 1(成功) —— 伪造 Mint 被接受,且 take_offer 在每一次尝试中都成功了。零熵意味着零变动:漏洞利用是完全可靠的,而不是不稳定的边缘情况。
offered_amount 显示了相反的模式。范围跨越 2 到 99,999,具有高熵(15.15),证实了模糊测试器在所有 make_offer 调用中探索了广泛且分布良好的 Token 金额。
当发生不变量失败或 panic 时,Trident 会打印触发它的 crash seed。你可以使用完整的程序日志重放该确切序列进行调查:
trident fuzz debug fuzz_0 <crash_seed>
这将通过启用日志记录重新运行失败的迭代,以便你可以确切地看到传递了哪些账户、链上状态是什么以及不变量是在哪里触发的。
模糊测试会话结束时,Trident 还会打印整个会话的 Master Seed。它允许你重新运行整个会话以重现相同的随机迭代序列,而 crash seed 则是重现单个失败的输入。如果你想共享或存档一次运行,请保存 Master Seed。
修复 Bug 并重新运行模糊测试器后,对比各次运行的账户状态:
trident compare snapshot_before.json snapshot_after.json
这会显示账户余额、数据字段的差异,以及修复可能引入的任何新错误。
模糊测试器发现了缺失的约束。打开 programs/escrow/src/instructions/take_offer.rs 并将缺失的 has_one = token_mint_b, 约束添加回去:
programs/escrow/src/instructions/take_offer.rs
#[account(
mut,
close = maker,
has_one = maker,
has_one = token_mint_a,
has_one = token_mint_b,
seeds = [b"offer", maker.key().as_ref(), offer.id.to_le_bytes().as_ref()],
bump = offer.bump,
)]
pub offer: Account<'info, Offer>,
从根目录重新构建 Anchor 程序:
cd ..
anchor build
从 trident-tests 文件夹中,重新运行模糊测试:
cd trident-tests
trident fuzz run fuzz_0
你应该看到类似于以下内容的输出:
+-------------+---------------+------------+-----------+----------------------+
| Instruction | Invoked Total | Ix Success | Ix Failed | Instruction Panicked |
+-------------+---------------+------------+-----------+----------------------+
| make_offer | 49648 | 49648 | 0 | 0 |
+-------------+---------------+------------+-----------+----------------------+
| take_offer | 32867 | 16341 | 16526 | 0 |
+-------------+---------------+------------+-----------+----------------------+
take_offer 约 50% 的失败率是符合预期的,实际上这是修复生效的信号。模糊测试器从 3 个 Mint(Token A、Token B 和伪造 Mint)中随机选择,因此很大一部分 take_offer 调用将提供错误的 Mint。由于现在有了 has_one = token_mint_b,这些调用被 Anchor 的约束验证正确拒绝,而不是静默成功。不变量失败降至零,因为漏洞路径已不复存在。
你现在已经完整体验了 Trident 的端到端工作流:搭建模糊测试脚手架、定义流、引入不变量、捕捉真实的漏洞并验证修复。模糊测试能发现手写单元测试中容易遗漏的 Bug,特别是只有在意外输入组合下才会出现的授权和约束问题。
本指南中的模糊测试涵盖了核心的 Trident 工作流,但 Trident 还提供了一些托管程序示例不需要的额外功能。以下部分重点介绍了一些随着程序复杂性增加而可以使用的更强大的功能。
通过 扭曲时钟 来测试与时间相关的逻辑:
// 时间快进一小时
self.trident.forward_in_time(3600);
// 跳转到特定的 Unix 时间戳
self.trident.warp_to_timestamp(1_700_000_000);
// 跳转到特定的 Slot 或 Epoch
self.trident.warp_to_slot(500);
self.trident.warp_to_epoch(10);
托管程序示例使用 TokenInterface,它同时兼容 SPL Token 和 Token Extensions (Token-2022)。Trident 也直接支持 Token Extensions:
// Token-2022 助手
self.trident.initialize_mint_2022(mint_pubkey, authority, decimals, &[]);
self.trident.mint_to_2022(mint_pubkey, token_account, authority, amount);
self.trident.initialize_associated_token_account_2022(wallet, mint);
这让你能够测试程序是否正确处理了两种 Token 标准,当程序假设所有 Token 都使用原始 SPL Token 程序时,这通常是 Bug 的来源。
注入 自定义账户数据 以模拟特定场景:
self.trident.set_account_custom(pubkey, account_data);
这对于将 devnet 或 mainnet 的账户转储到本地模糊测试环境中,以针对类似生产环境的状态进行测试非常有用。
通过存储多个 maker 和 taker 来扩展模糊测试,从而更彻底地测试权限边界:
#[init]
fn setup(&mut self) {
// 存储多个 maker 和 taker
for _ in 0..5 {
self.fuzz_accounts.maker.insert(&mut self.trident, None);
self.fuzz_accounts.taker.insert(&mut self.trident, None);
}
// 为他们所有人注资、创建 Mint 等。
// ...
}
#[flow]
fn take_offer(&mut self) {
// 模糊测试器随机选择一个 taker —— 测试任何 taker 都可以接受任何开放的报价
let Some(taker) = self.fuzz_accounts.taker.get(&mut self.trident) else { return; };
let Some(offer_pda) = self.fuzz_accounts.offer.get(&mut self.trident) else { return; };
// ...
}
在多角色场景下,模糊测试器会探索诸如一个 maker 的报价被不同的 taker 接受,或者同一个 taker 尝试接受多个报价的情况。这增加了发现权限相关 Bug 的机会。
Ackee Blockchain 发布了一个 Solana VS Code 扩展,可以内联可视化 Trident 代码覆盖率。
信息
该 VS Code 扩展需要 VS Code 1.96+ 和 Rust nightly 工具链。它还通过针对常见 Solana 漏洞(如不安全数学、缺失签名者检查和不当的 sysvar 访问)的实时检测器提供静态安全分析。
设置实时覆盖率:
Trident.toml 以启用 JSON 覆盖率输出:trident-tests/Trident.toml
[fuzz.coverage]
format = "json"
loopcount = 100
trident-tests/ 存在时,扩展会自动激活并实时更新覆盖率。Solana: Show Code Coverage 以加载保存的报告。覆盖率数据显示程序的哪些行已被执行以及执行了多少次,突出了需要额外流或输入范围的未测试代码路径。
Trident 是否适用于非 Anchor 程序?
不。Trident 需要 Anchor 并从 Anchor IDL 派生指令类型。程序必须使用 Anchor 0.29.0 或更高版本构建。如果你的程序使用不同的框架(如 Pinocchio 或原生 Solana SDK),则目前与 Trident 不兼容。
手动引导模糊测试与覆盖率引导模糊测试有何不同?
在手动引导模糊测试中,由你定义指令流、账户存储和输入范围。Trident 在每次迭代中随机选择流并从你配置的范围中提取值。你拥有完全的控制权 —— 如果你想要一个 u64 参数变化,可以告诉 Trident 使用 random_from_range(0..u64::MAX);如果你想要它固定,可以硬编码该值。覆盖率引导模糊测试器(如 AFL 或 HonggFuzz)的工作方式不同:它们会自动变异输入以最大化代码路径覆盖率,但它们无法意识到指令排序约束,且有时在不同运行中会产生不同的输入。Trident 放弃了 AFL++,转而采用纯手动引导,因为 Solana 程序具有严格的排序要求,而覆盖率引导模糊测试器对此处理得不好。
我可以使用真实的链上状态进行模糊测试吗?
可以。Trident 支持使用 set_account_custom() 将来自 devnet、testnet 或 mainnet 的账户加载到本地 TridentSVM 环境中。这让你能够在不连接实时集群的情况下针对类似生产环境的条件进行测试。Fork 测试(模糊测试期间的实时链上状态)目前正在开发中。
我可以将 Trident 集成到 CI/CD 中吗?
可以。Trident 仓库包含 GitHub Actions 示例。将 trident fuzz run <target> 添加为 CI 步骤,并设置固定的迭代次数。模糊测试器在发生 panic 或不变量失败时会以非零代码退出,因此它可以自然地集成到 CI 流水线中。
Trident 通常能捕捉到哪些类型的 Bug?
常见的发现包括缺失账户约束(如本指南中的 Token 替换 Bug)、算术溢出和下溢、缺失签名者或权限检查、无效的账户状态转换、错误的 PDA 派生,以及由 panicking 代码路径引起的 DoS 向量。不变量检查系统还能捕捉逻辑错误,如错误的 Token 处理或未经授权的状态更改。
你已经安装了 Trident,为 Token 托管程序搭建了模糊测试脚手架,定义了具有随机输入和 Token Mint 的多角色指令流,捕捉了由缺失 has_one 约束引起的真实 Token 替换漏洞,通过指标仪表板监控了结果,并通过重新运行模糊测试器验证了修复。
这些技术是专业审计员所使用的,而 Trident 正是由 Ackee Blockchain 的安全团队构建的。
模糊测试是一个迭代的过程。每次运行都会发现新的边缘情况,而不变量检查随着程序的演进而提供持续的信心。将其作为你开发工作流的标准组成部分,你的程序将因此变得更加健壮。
如果你有任何反馈或对新主题的需求,请告知我们。我们很乐意收到你的来信。
- 原文链接: quicknode.com/guides/sol...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!