Solaris是一个用于Solana程序的状态化的、结构感知的、sBPF字节码覆盖引导的模糊测试工具。它通过跟踪跨交易序列的执行状态,理解程序结构,并使用字节码级别的覆盖反馈,系统地探索目标程序状态空间,从而发现需要特定状态条件和指令排序的漏洞。
2025年12月15日 • inversive
本文介绍 Solaris,一个有状态、结构感知、sBPF 字节码覆盖引导的模糊测试器,专为 Solana 程序设计。通过跟踪跨交易序列的执行状态、理解程序结构以及使用字节码级别的覆盖反馈,Solaris 系统地探索目标程序状态空间。
Solaris 是一个有状态、结构感知、sBPF 字节码覆盖引导的 Solana 程序模糊测试器。 它维护跨交易序列的执行上下文,并使用 sBPF 字节码覆盖反馈来系统地探索程序的状态空间。这使得能够发现需要特定状态条件和指令顺序的漏洞。
Solana 程序遵循无状态架构,并且不在交易之间维护状态。每条指令都对其传递的账户进行操作,并且任何状态更改都必须显式保存到这些账户中。但是,为了正确测试这些程序,我们需要以有状态的方式对它们进行模糊测试,执行随着时间推移构建程序状态的指令序列。
根本的挑战是发现到达深层程序逻辑所需的特定状态条件和指令序列。一个幼稚的模糊测试器面临三个主要障碍:
Solaris 通过以下方式解决这些挑战:
通过将有效输入生成与有状态执行和相关覆盖反馈相结合,而不是在格式错误的交易或冗余执行路径上浪费周期,这种方法使 Solaris 能够有效地对 Solana 程序进行模糊测试。
Solaris 提供了一个命令行界面,用于配置和运行模糊测试活动:

主要特性包括:
语料库管理:Solaris 维护一个有趣的测试用例语料库,这些测试用例会触发新的覆盖或表现出独特的行为。随着模糊测试器发现到达新代码路径的输入,语料库会自动增长。语料库管理器还负责在模糊测试活动期间按一定间隔最小化语料库。
调度算法:多种调度策略决定接下来要突变哪些测试用例:
多工作进程支持:Solaris 可以生成多个模糊测试工作进程,这些工作进程共享一个语料库和覆盖图,从而能够并行探索状态空间。工作进程进行协调以避免冗余工作,同时最大限度地提高吞吐量。
测试用例可重现性:每个崩溃或不变性违规都包括重现它所需的指令序列。测试用例以 JSON 格式序列化,从而可以轻松地在调试期间重放失败或将发现结果集成到回归测试套件中。
Solaris 架构协调多个组件来生成、突变、执行和评估测试用例:

测试用例使用 Google Protocol Buffers (protobuf) 定义,这是一种由 Google 开发的语言中立、平台中立的序列化格式。与将输入视为原始字节数组不同,protobuf 提供了一种结构化的、模式驱动的表示,其中数据被组织成 .proto 文件中定义的类型化字段(整数、字符串、嵌套消息)。这种结构化格式为模糊测试提供了两个主要优势:
u64、用于某些数据参数的 bytes 字段等),从而防止了不合理的突变,例如将某些数据字段视为整数在 Solaris 中,protobuf 模式捕获了 Solana 交易序列的完整结构。这使得 mutator 能够生成语义上有效的测试用例,这些测试用例尊重交易需求,同时仍探索输入空间。
语料库管理器维护在模糊测试期间发现的有趣的测试用例池。它跟踪哪些输入达到了新的覆盖率,按一定间隔最小化语料库集,并为调度程序提供测试用例选择。
调度器智能地从语料库中选择测试用例进行突变。不同的策略包括:
突变器组件将转换应用于选定的测试用例。它在两个级别上运行:
arbitrary.rs 文件中通过 Arbitrary 特征实现的约束的特定于域的生成逻辑。这些突变理解 Solana 语义。例如,生成有效的账户类型,在预期范围内生成实际的指令参数范围等。这种双重方法可以通过通用突变实现广泛的探索,并通过自定义逻辑实现语义引导的生成。突变器会跟踪哪些策略成功发现新覆盖率,并相应地自适应地加权未来的突变选择。
模糊测试协调器协调主要的模糊测试循环。将语料库条目选择委派给调度器,将突变应用于突变器,通过执行器执行测试用例,并通过反馈管理器处理反馈。在多工作进程模式下,多个模糊测试实例并行运行,每个实例都有自己的执行器,同时通过共享内存映射共享覆盖信息以协调其探索工作。
执行器管理 harness 生命周期并协调测试用例执行。对于每个测试用例,都会发生以下情况:
Harness 是特定于程序的组件,它将 protobuf 消息转换为实际的 Solana 交易。此外,它还管理程序状态,并检查安全不变性以检测违规行为。
Harness 可以定义特定于正在执行的指令和正在测试的程序的自定义不变性(例如,“总token供应量永远不应超过上限”或“只有授权账户才能提款”)。
测试用例在 检测的 sBPF 虚拟机 中执行,该虚拟机在字节码级别跟踪执行。Solaris 使用 solana-sbpf 的修改版本,其中解释器在每次指令执行时调用检测Hook。这些Hook捕获:
这种字节码级别的反馈使模糊测试器能够区分执行不同代码路径的测试用例与遵循相同执行跟踪的测试用例。
反馈管理器接收来自检测的 VM 的执行结果,并评估每个测试用例的“有趣”程度。
当找到新边缘时,会将测试用例及其覆盖率添加到语料库中,并根据新边缘的数量、执行成功和其他指标分配能量分数。
反馈管理器还跟踪突变有效性。从本质上讲,哪些策略成功地发现了新的覆盖范围,从而使突变器能够自适应地加权未来的突变选择。
模糊测试有状态程序的一个关键挑战是以一种结构化的方式建模可能的指令序列的决策树,这种方式足以生成有效的输入,但又足够灵活,可以让模糊测试器通过覆盖反馈有机地发现正确的排序。Solaris 使用 Protocol Buffers 的分层消息结构来解决这个问题。
下图显示了一个典型的 solaris 工作文件系统:

Solaris 模糊测试目标实现通常包含以下内容:
artifacts/:用于测试的已编译程序二进制文件
handlers/:将 protobuf 消息转换为 Solana 交易的指令执行函数。
invariants/:验证状态完整性的安全属性检查器(例如,mint.rs 验证token供应量不变性,state_machine.rs 检查状态转换有效性,withdraw.rs 验证余额约束)
proto/:Protobuf 模式定义(.proto 文件)和生成的 Rust 代码
arbitrary.rs:用于领域特定突变的自定义 Arbitrary 特征实现
harness.rs:协调执行并委派给处理程序的主模糊测试 harness
state.rs:跨交易的持久状态跟踪(例如,创建的账户、密钥对、PDA 等)
每个处理程序构建适当的指令账户,编码指令数据,构建签名的交易,并在 SVM 上执行它们。
处理程序包括目标程序指令和外部程序指令(例如,SPL Token 操作)。
这种分离对于发现当外部程序修改目标程序假定它独占控制的状态时出现的漏洞至关重要。这些场景可以轻松集成到 Solaris 中。
这种模块化结构分离了关注点。protobuf 模式定义了输入空间,处理程序执行指令,不变性检查正确性,而 harness 充当主协调器。
为了说明 Solaris 如何建模有状态程序,请考虑一个简单的 Solana vault 程序,该程序具有三个指令,必须按特定顺序执行才能实现其正确的操作:
##[program]
pub mod example2 {
pub fn initialize(ctx: Context<Initialize>, initial_balance: u64) -> Result<()> {
let vault = &mut ctx.accounts.vault;
vault.owner = ctx.accounts.authority.key();
vault.balance = initial_balance;
vault.access_code = 0;
Ok(())
}
pub fn set_access_code(ctx: Context<UpdateVault>, code: u32) -> Result<()> {
let vault = &mut ctx.accounts.vault;
require!(
vault.owner == ctx.accounts.authority.key(),
ErrorCode::Unauthorized
);
vault.access_code = code;
Ok(())
}
pub fn withdraw(ctx: Context<UpdateVault>, amount: u64, provided_code: u32) -> Result<()> {
let vault = &mut ctx.accounts.vault;
require!(vault.access_code == provided_code, ErrorCode::InvalidAccessCode);
require!(amount <= vault.balance, ErrorCode::InsufficientFunds);
let max_withdrawal = vault.balance / 2;
require!(amount > 100 && amount <= max_withdrawal, ErrorCode::InvalidAmount);
if amount > 500 && amount < 600 {
return Err(ErrorCode::ForbiddenAmount.into());
}
vault.balance = vault.balance.checked_sub(amount).unwrap();
Ok(())
}
}
##[derive(Accounts)]
pub struct Initialize<'info> {
#[account(mut)]
pub authority: Signer<'info>,
#[account(\
init,\
payer = authority,\
space = 8 + 32 + 8 + 4,\
seeds = [b"vault", authority.key().as_ref()],\
bump\
)]
pub vault: Account<'info, Vault>,
pub system_program: Program<'info, System>,
}
##[derive(Accounts)]
pub struct UpdateVault<'info> {
#[account(mut)]
pub authority: Signer<'info>,
#[account(\
mut,\
seeds = [b"vault", authority.key().as_ref()],\
bump\
)]
pub vault: Account<'info, Vault>,
}
##[account]
pub struct Vault {
pub owner: Pubkey,
pub balance: u64,
pub access_code: u32,
}
下图显示了左侧的三个程序指令,以及右侧的相应 protobuf 消息定义:

状态依赖:
该程序表现出状态依赖,这使得模糊测试变得不那么简单:
access_code 设置为 0set_access_code:设置一个数字访问代码
withdraw:提取资金
amount > 100、amount <= balance/2,且不在禁止范围 [500, 600] 内一个幼稚的模糊测试器会因以下原因而挣扎:
为了模糊测试这个程序,我们定义了一个 protobuf 模式,该模式捕获了指令序列结构。该架构组织成三个层次:
message Example2Chain {
Example2InitializationMessages init_messages = 1;
repeated Example2AuxiliaryMessages auxiliary_messages = 2;
}
此顶级消息表示一个完整的测试用例。它强制每次执行都以初始化开始,然后是可变长度的辅助操作序列。我们可以根据被模糊测试的目标程序,根据具体情况自行方便地定义此层次结构。
初始化阶段(始终首先执行):
message Example2InitializationMessages {
Example2Initialize initialize = 1;
}
这确保初始化在每个测试用例的开头只发生一次。
辅助阶段(模糊测试器控制的序列):
message Example2AuxiliaryMessages {
oneof message {
Example2SetAccessCode set_access_code = 1;
Example2Withdraw withdraw = 2;
}
}
oneof 关键字创建一个选择点:每个辅助消息可以是 SetAccessCode 或 Withdraw。链结构中的 repeated 修饰符允许任意数量的这些选择,从而使模糊测试器能够生成诸如 [SetAccessCode, Withdraw, SetAccessCode, Withdraw, ...] 之类的序列。
Initialize 指令:
message Example2Initialize {
uint64 initial_balance = 1;
uint32 authority_index = 2;
}
SetAccessCode 指令:
message Example2SetAccessCode {
uint32 access_code = 1;
uint32 authority_index = 2;
uint32 vault_index = 3;
}
Withdraw 指令:
message Example2Withdraw {
uint64 amount = 1;
uint32 provided_code = 2;
uint32 authority_index = 3;
uint32 vault_index = 4;
}
每个指令消息都包含直接映射到 Rust 函数参数的类型化字段,从而为模糊测试器提供了结构感知的突变目标。
这种三层层次结构为有效的模糊测试提供了关键特性:
init_messages 始终首先执行,从而确保 vault 在任何操作之前都存在repeated auxiliary_messages 允许以任何顺序执行任意数量的 set_access_code 和 withdraw 指令uint64 initial_balance、uint32 access_code),可防止无意义的突变重要的是要注意,此模式并非旨在硬编码指令的“正确”序列。它只确保结构有效性。模糊测试器仍然必须通过覆盖反馈发现:
protobuf 模式隐式地定义了可能的指令序列的决策树。下图显示了该模式如何创建一个分支结构,其中 initialize 指令始终首先执行,然后是可变长度的辅助消息序列:

树结构如下:
1:N 辅助消息(可变计数和排序由模糊测试器确定)
oneof 构造在辅助序列中的每个步骤创建分支,从而使模糊测试器可以选择要执行的指令类型。repeated 关键字允许无限重复,从而使模糊测试器可以发现需要多个辅助消息才能到达更深层的程序状态。
这种设计足够宽松,允许无效序列,但结构足够好,可以确保所有生成的序列都是语法上有效的 Solana 交易。模糊测试器通过覆盖反馈而不是硬编码的规则来学习有效的指令排序。
随着模糊测试的进行,语料库有机地演变以发现更深层的程序状态。下图显示了来自模糊测试不同阶段的四个实际测试用例,说明了模糊测试器如何随着时间的推移学习构建更复杂的指令序列:

从左到右读取,每个面板显示了模糊测试活动的在不同阶段的 JSON 序列化的 protobuf 测试用例。早期面板中的红色框消息和后期面板中的绿色框消息高亮显示了推动覆盖增益的关键突变。
面板 1 - 早期阶段: 语料库从简单开始,辅助数组中只有一个 Withdraw 消息,实现的覆盖率最低。
面板 2 - 复杂性增长: mutator 插入第二个辅助消息。语料库现在包含 Withdraw 和 SetAccessCode,但顺序错误。withdraw 仍然失败,但是 SetAccessCode 的存在通过探索 set_access_code 指令路径来增加覆盖率。
面板 3 - 发现排序: 模糊测试器发现正确的序列:SetAccessCode 后跟 Withdraw。请注意,两个消息现在都用绿色框起来。正确的排序解锁了 withdraw 函数中的新分支,它在其中检查余额和金额约束。
面板 4 - 更丰富的测试用例: 随着模糊测试的继续,语料库朝着更复杂的测试用例演变,这些测试用例符合正确的指令排序。现在,多个 SetAccessCode 和 Withdraw 消息以正确的顺序出现,这表明模糊测试器已经学会始终如一地生成可最大化覆盖率的有效指令链。
请注意模糊测试器如何从面板 2(错误的顺序)发展到面板 3(正确的序列)。这种演变只有在 harness 为访问代码约束实现智能状态跟踪时才有可能。
虽然 Withdraw protobuf 消息包含 provided_code 字段,但 harness 实际上并不使用它。相反,在执行 withdraw 指令时,它会检索先前通过 SetAccessCode 设置的访问代码:
let access_code = if let Some(stored_code) = example2_state.vault_access_codes.get(&vault_pda.to_string()) {
*stored_code as u32 // 使用状态中的实际代码
} else {
msg.provided_code // 如果未设置代码,则回退到 protobuf 字段
};
这意味着一旦模糊测试器发现 SetAccessCode → Withdraw 序列(面板 3),后续的突变会自动使用状态中的正确访问代码。如果没有这个,模糊测试器会浪费周期随机突变 provided_code 试图猜测正确的值,永远无法到达更深层的 withdraw 逻辑。
provided_code 字段在技术上是不必要的。仅在此示例中包含它来演示 Solaris 如何处理有状态约束。实际上,你通常会省略此类字段,并且在很大程度上依赖状态跟踪。更多状态跟踪将在下面的 Harness 部分中展示。
总而言之,测试用例演变演示了覆盖引导的反馈如何驱动自动发现:
整个过程的进展都没有手动指定指令排序或参数约束。模糊测试器在运行时通过字节码覆盖反馈和智能 harness 设计来学习。
Harness 桥接了 protobuf 消息和 Solana 交易,从而将测试用例转换为程序交易。此转换层演示了 protobuf 字段突变如何通过账户派生和状态管理产生级联效应。

上图显示了 initialize 指令 harness(左侧绿色框中高亮显示)以及 protobuf 消息定义(红色框,右上角)。两个 protobuf 字段驱动着整个执行过程:
1. authority_index 字段:
Harness 使用 protobuf 字段作为索引从状态管理的池中检索 authority 密钥对:
let authority = self.state.get_or_insert_extension::<Example2State>()
.get_or_create_authority(msg.authority_index as usize);
let authority_pubkey = authority.pubkey();
这个简单的字段突变会导致多米诺骨牌效应:
authority_index 派生不同的 authority 密钥对vault PDA 根据 authority_pubkey 确定性地派生:
let (vault_pda, _bump) = Pubkey::find_program_address(
&[b"vault", authority_pubkey.as_ref()],
&self.program_id,
);
通过仅突变 authority_index,模糊测试器可以探索不同的账户状态,从而产生与账户所有权、PDA 冲突和跨账户交互相关的场景。
2. initial_balance 字段:
余额直接编码到指令数据中:
let mut instruction_data = vec![175, 175, 100, 31, 13, 152, 155, 237]; // 鉴别器
instruction_data.extend_from_slice(&msg.initial_balance.to_le_bytes());
突变 initial_balance 会探索不同的值范围,从而触发后续 withdraw 指令中的约束。
成功执行交易后,harness 将 vault-authority 对保存到内部状态(底部青色框中高亮显示):
// 仅在交易成功后才将 vault 信息存储在状态中
self.state.get_or_insert_extension::<Example2State>()
.add_vault(vault_pda, authority_pubkey);
这种状态持久性使后续指令可以引用同一个 vault。例如,set_access_code harness(青色框中显示,右下角)使用 protobuf vault_index 从内部状态检索 vault:
let example2_state = self.state.get_or_insert_extension::<Example2State>();
let authority = example2_state.get_authority(msg.authority_index as usize)?;
let vault_pda = example2_state.get_vault(msg.vault_index as usize)?;
这种状态感知的选择可确保模糊测试器可以:
vault_index 来探索多 vault 场景authority_index 值来测试 authority 不匹配这种 harness 架构展示了 protobuf 的结构化表示如何实现智能探索。字段突变通过账户派生、指令编码和跨指令状态引用进行传播,从而使模糊测试器可以系统地探索程序的状态空间。
覆盖反馈是有效模糊测试的基础。通过跟踪已执行的代码路径,模糊测试器可以识别探索新程序行为的测试用例,并优先考虑这些测试用例以进行进一步突变。但是,在 Solana 的执行环境中获得准确的覆盖率提出了独特的挑战,因为尽管每个程序都在具有自己的主机内存映射的隔离 VM 实例中执行,但 SVM 为所有程序使用固定的虚拟地址布局。
SVM 将 sBPF 程序映射到相同的基地址:
0x1000000000x0这为传统的边缘覆盖跟踪创建了一个根本问题。边缘覆盖使用程序计数器 (PC) 值来识别控制流转换。标准边缘计算为:
edge = (previous_pc >> 1) ^ current_pc
当多个程序共享相同的虚拟地址布局时,不同程序中相同的控制流转换(相同的 previous_pc 和 current_pc 值)会生成相同的边缘 ID,即使它们表示在不同程序中执行的完全不同的代码。
例如,目标程序中从 0x100000080 → 0x100000100 的转换与依赖项程序中相同的转换具有相同的边缘 ID,尽管在这些地址执行完全不同的指令。这会导致边缘覆盖污染,其中模糊测试器无法区分哪个程序生成了哪个覆盖,从而导致:
Solaris 通过自定义哈希实现解决了这个问题,该哈希将程序 ID 合并到边缘计算中,从而减轻了不同程序之间的冲突。下图说明了这一点:

该算法在两个并行路径中运行,这些路径在最后合并:
左侧路径(边缘计算):
0x100000000)0x0800000000x100000100) 进行异或运算0x180000100右侧路径(程序 ID 聚合):
7oRegZsrEfNzmiUY0s43tHjFkkhb236YfZb1UZrCMuXH)0xA1B2C3D4E5F67890组合:
× 31)0xEA3223D4E6036890通过将程序 ID 合并到哈希中,这种方法将边缘 ID 范围限定为它们的原始程序。当两个程序在相同的虚拟地址处具有相同的控制流时,程序哈希可确保它们的边缘映射到不同的 ID。虽然从理论上讲,冲突仍然是可能的,尤其是在许多程序同时执行的情况下,但在实践中,我们没有观察到在使用这种方法进行的模糊测试活动中,典型的 Solana 程序及其依赖项之间存在显着的边缘污染。
Solana 程序经常通过跨程序调用 (CPI) 调用其他程序。 当目标程序调用依赖项时,两个程序都会生成覆盖率。Solaris 维护除了全局覆盖率之外的每个程序的覆盖率跟踪,从而使模糊测试器 能够识别新边缘,同时还会在覆盖率报告中提供详细的每个程序的统计信息。
覆盖率报告界面提供详细的每个程序的统计信息和 CFG 检查,以进行覆盖率可视化:

Solaris 覆盖率报告显示:
模糊测试统计信息:
程序覆盖率摘要: 每行代表一个不同的程序(由其 Base58 程序 ID 标识):
详细的 CFG 检查: 除了聚合统计信息之外,Solaris 还为所有已参与程序中的所有函数提供详细的控制流图 (CFG) 可视化。
CFG 中的每个基本块都根据执行状态进行颜色编码:已执行的块以绿色高亮显示以显示覆盖的代码路径,而未执行的块以红色高亮显示,从而立即提供有关已探索哪些分支的视觉反馈。
这种每个函数 CFG 可视化使开发人员可以精确地在基本块级别或 IL 级别识别覆盖率缺口,从而可以轻松地准确查看当前语料库尚未达到的分支、错误处理程序或边缘情况。
这种精细的可见性使开发人员能够:
Solaris 提供了 TUI(终端用户界面)和 Web UI 仪表板,用于实时监控模糊测试活动。
该界面显示实时覆盖率指标、执行统计信息,并允许检查汇编和中间表示 (IL) 视图,就像前面介绍的静态覆盖率报告一样。
这使开发人员可以观察到随着模糊测试器探索程序,哪些基本块和指令正在被访问。

实时 TUI 和 Web UI 仪表板显示实时覆盖率跟踪、执行统计信息和字节码检查
与覆盖率报告一样,这种可视化有助于识别覆盖率平台期,并指导有关何时调整模糊测试策略或通过混合模糊测试技术引入手动指导的决策。
虽然覆盖率引导的模糊测试有效地探索了可访问的代码路径,但某些程序状态需要随机突变难以满足的特定约束。
Solaris 计划开源发布,并将与 Radiant 集成,Radiant 是我们用于 Solana 程序的符号执行引擎。
这种混合方法结合了:
我们的想法是,当 Solaris 遇到覆盖率平台期时,开发人员可以使用 Radiant 来符号化分析阻止进展的路径约束,生成满足这些约束的具体输入,并将它们聚合到 Solaris 的语料库中。
Solaris 通过使用 Protocol Buffers 的结构感知测试用例生成、跨交易序列的有状态执行跟踪,以及可在 CPI(跨程序调用)边界准确归属边缘 (edge) 的抗冲突字节码覆盖反馈,解决了有状态模糊测试 Solana 程序的核心挑战。
基于 Protocol Buffer 的方法允许 Solaris 生成语法上有效的交易,同时仍然探索完整的输入空间。
抗冲突哈希算法解决了 SVM(Solana 虚拟机)内存模型引起的边缘归属问题。
总之,这些技术能够在不硬编码特定指令序列或约束的情况下,系统地探索程序状态空间。
Solaris 计划开源发布,并将与 Radiant 集成,用于手动引导的混合模糊测试,以处理纯模糊测试难以达到的约束繁重的代码路径。
×
- 原文链接: inversive.xyz/blog/Solar...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!