Solana 生态系统充满活力,但许多程序尚未开源。Syndica在 2024 年 2 月编制的统计数据显示,按计算单元计算,Solana 排名前 100 的程序中,近 50% 发布了其接口定义语言 (IDL)。然而,对于排名前 1000 的程序,这一数字下降到只有 20%。此外,即使已发布的 IDL 也并不总是可靠的。发布的 IDL 过时且与部署的链上程序不匹配的情况并不少见。
作为审计人员和安全研究人员,当我们发现可能导致漏洞的有趣模式时,我们通常会在其他程序中寻找类似的弱点。然而,如果没有源代码或准确的 IDL,这个过程通常仅限于基本的 GitHub 搜索,经常会发现无人维护的项目。
大多数 Solana 程序都是用 Rust 编写的,并编译为 Solana 字节码格式 (sBPF),这是一种基于 eBPF 的格式。对编译后的 Rust 进行逆向工程具有挑战性,而与 sBPF 相关的逆向工程工具链仍在开发中。这种不透明性不仅阻碍了恶意行为者,而且还减缓了白帽黑客和安全研究人员识别和负责任地披露漏洞的工作。
要分析任何闭源 Solana 程序(无论是动态还是静态),基本前提是了解如何与其交互。这意味着了解它的指令、每条指令所需的账户以及这些账户的属性(如签名者或可写状态)。
为了应对这些挑战,我们的安全研究员齐秦领导了这项工作并开发了一个名为IDL Guesser的原型工具。该工具旨在直接从闭源 Solana 程序二进制文件中自动恢复指令定义、所需帐户(包括签名者/可写标志)和参数信息。
该博客概述了 IDL Guesser 背后的方法并讨论了未来改进的潜在领域。
利用锚点模式进行逆向工程
由于 Solana 开发的大部分内容都使用了 Anchor 框架(IDL 的概念也源于此),因此 IDL Guesser 目前专注于基于 Anchor 的程序。Anchor 通过为常见任务和检查提供宏和辅助函数,大大简化了开发。至关重要的是,这会导致编译输出中出现可预测的标准化代码结构,我们可以通过模式匹配将其用于分析。
为了进行调试,Anchor CLI 甚至提供了一个anchor expand
\命令,该命令会显示由其宏生成的代码。检查此扩展代码可以深入了解 Anchor 所采用的模式,从而指导我们的逆向工程工作。
入口点和调度逻辑
我们来看看一个典型的 Anchor 程序经过宏扩展后的入口点结构:
#[no_mangle]
pub unsafe extern "C" fn entrypoint(input: *mut u8) -> u64 {
let (program_id, accounts, instruction_data) = unsafe {
::solana_program::entrypoint::deserialize(input)
};
match entry(&program_id, &accounts, &instruction_data) {
Ok(()) => ::solana_program::entrypoint::SUCCESS,
Err(error) => error.into(),
}
}
pub fn entry<'info>(
program_id: &Pubkey,
accounts: &'info [AccountInfo<'info>],
data: &[u8],
) -> anchor_lang::solana_program::entrypoint::ProgramResult {
try_entry(program_id, accounts, data)
.map_err(|e| {
e.log();
e.into()
})
}
fn try_entry<'info>(
program_id: &Pubkey,
accounts: &'info [AccountInfo<'info>],
data: &[u8],
) -> anchor_lang::Result<()> {
if *program_id != ID {
return Err(anchor_lang::error::ErrorCode::DeclaredProgramIdMismatch.into());
}
if data.len() < 8 {
return Err(anchor_lang::error::ErrorCode::InstructionMissing.into());
}
dispatch(program_id, accounts, data)
}
fn dispatch<'info>(
program_id: &Pubkey,
accounts: &'info [AccountInfo<'info>],
data: &[u8],
) -> anchor_lang::Result<()> {
let mut ix_data: &[u8] = data;
let sighash: [u8; 8] = {
let mut sighash: [u8; 8] = [0; 8];
sighash.copy_from_slice(&ix_data[..8]);
ix_data = &ix_data[8..];
sighash
};
use anchor_lang::Discriminator;
match sighash {
instruction::InitializeConfig::DISCRIMINATOR => {
__private::__global::initialize_config(program_id, accounts, ix_data)
}
instruction::InitializePool::DISCRIMINATOR => {
__private::__global::initialize_pool(program_id, accounts, ix_data)
}
// ... other instructions
}
}
程序首先对原始输入进行反序列化。它在进入函数之前执行基本检查(例如验证program_id
\并确保instruction_data
\至少有 8 个字节长)dispatch
\。在 中dispatch
\, 的前 8 个字节instruction_data
\被解释为指令鉴别符。该鉴别符确定应执行哪个特定的指令处理程序函数。
根据 Anchor 的文档,这个 8 字节鉴别器是从指令的命名空间和名称(例如global:initialize_config
\)中派生出来的,方法是取其 SHA-256 哈希的前 8 个字节。IDL Guesser 不会尝试从编译后的代码中提取这些原始鉴别器字节(这可能很复杂),而是采用一种更简单的方法:它首先专注于提取指令名称,然后使用 Anchor 的标准哈希方法计算相应的鉴别器。
识别指令处理程序
我们如何找到指令名称及其对应的处理程序函数?Anchor 提供了另一种有用的模式。考虑一个典型指令处理程序的开头:
pub fn initialize_config<'info>( /* ... */ ) -> anchor_lang::Result<()> {
// Log the instruction name - a key pattern for us!
::solana_program::log::sol_log("Instruction: InitializeConfig");
// Deserialize instruction-specific parameters
let ix = instruction::InitializeConfig::deserialize(&mut &__ix_data[..])
.map_err(|_| /* ... */ )?;
let instruction::InitializeConfig { /* ... parameters ... */ } = ix;
// Process accounts via try_accounts
let mut __bumps = /* ... */;
let mut __reallocs = /* ... */;
let mut __remaining_accounts: &[AccountInfo] = __accounts;
let mut __accounts = InitializeConfig::try_accounts(
__program_id,
&mut __remaining_accounts,
__ix_data,
&mut __bumps,
&mut __reallocs,
)?;
// ...
}
Anchorsol_log
\在每个处理程序的开头插入一个调用,记录指令的名称(例如“Instruction: InitializeConfig”\)以供日志解析。此日志记录提供了我们可以在编译后的二进制文件中搜索的独特签名。
用于记录指令名称*InitializeConfig 的*****sol_log\系统调用序列。
在汇编级别(如上所示),此日志调用通常会转换为特定的lddw
\、mov64
\和call
\指令设置,sol_log
\以使用指令名称字符串调用系统调用。
通过识别这些模式,IDL Guesser 可以可靠地定位指令处理程序的入口点并提取其名称。
提取账户信息
在初始日志记录和参数反序列化(通常由编译器内联)之后,处理程序通常会调用相应的try_accounts
\函数。此函数负责解析和验证指令所需的帐户。
参数反序列化之后调用try_accounts\函数(此处为sub_662B0\)。
让我们检查一下Accounts
\结构和生成的try_accounts
\函数initialize_config
\:
// Account definition
#[derive(Accounts)]
pub struct InitializeConfig<'info> {
#[account(init, payer = funder, space = WhirlpoolsConfig::LEN)]
pub config: Account<'info, WhirlpoolsConfig>, // To be initialized
#[account(mut)]
pub funder: Signer<'info>, // Must be mutable and a signer
pub system_program: Program<'info, System>, // System program
}
// Generated try_accounts function (simplified)
fn try_accounts( /* ... */ ) -> anchor_lang::Result<Self> {
// Check if enough accounts provided
if __accounts.is_empty() {
// Error 3005
return Err(anchor_lang::error::ErrorCode::AccountNotEnoughKeys.into());
}
// Process 'config' account (index 0) - checks applied later
let config = &__accounts[0];
*__accounts = &__accounts[1..];
// Process 'funder' account using Signer's try_accounts
let funder: Signer = anchor_lang::Accounts::try_accounts(/* ... */)
.map_err(|e| e.with_account_name("funder"))?; // Adds "funder" to error message
// Process 'system_program' account
let system_program: Program<System> = anchor_lang::Accounts::try_accounts(/* ... */)
.map_err(|e| e.with_account_name("system_program"))?; // Adds "system_program"
// Apply constraints checks
if !config.is_writable { // Check defined indirectly via init
// Error 2000
return Err(anchor_lang::error::ErrorCode::ConstraintMut.into().with_account_name("config"));
}
if !config.is_signer { // Check defined indirectly via init
// Error 2002
return Err(anchor_lang::error::ErrorCode::ConstraintSigner.into().with_account_name("config"));
}
// ... other checks like rent exemption (Error 2005), owner, etc.
if !funder.is_writable { // Check defined by #[account(mut)]
// Error 2000
return Err(anchor_lang::error::ErrorCode::ConstraintMut.into().with_account_name("funder"));
}
// ... checks for funder.is_signer happen inside its own try_accounts
Ok(Self { config, funder, system_program })
}
该try_accounts
\函数执行几个关键操作:
- 它会遍历预期的帐户,并尝试根据其类型(
Account
\、Signer
\、等)对其进行解析。如果在嵌套调用(如 for )Program
\中解析失败,Anchor 会方便地将帐户名称(例如“funder”)附加到错误消息中。这使我们能够通过查找这些特定的错误处理模式来提取帐户名称。*try_accounts
*****funder
\ - 它根据结构中定义的属性应用约束检查(
mut
\、signer
\、has_one
\、seeds
\、 、租金豁免等) 。重要的是,每个约束违规通常映射到一个唯一的Anchor (例如,is 2000\、is 2002\)。*owner
**Accounts
**ErrorCode
**ConstraintMut
**ConstraintSigner
*
检查需要初始化的帐户是否有足够的帐户密钥,如果失败则分支到错误3005\。
约束检查条件跳转,导致特定的错误代码如2000\(ConstraintMut\)或2002\(ConstraintSigner\)。
通过分析函数的控制流图(CFG)try_accounts
\,具体来说是遵循“快乐路径”(成功执行),IDL Guesser 可以拼凑出所需的账户:
- 账户处理的顺序揭示了账户的预期顺序。
- 与错误消息相关的字符串文字(例如“funder”)显示帐户名称。
- 失败路径上出现的特定错误代码(如 2000、2002、2005)表示对每个帐户应用的约束(可变、签名者、可变等)。
提取参数
虽然提取指令名称和账户详细信息依赖于相对不同的模式(日志字符串、错误代码、特定函数调用),但恢复有关指令参数的信息更加困难。
Anchor 通常不会生成针对单个参数反序列化失败的详细错误消息。这意味着 Rust 源代码中定义的原始参数名称通常会在编译期间丢失。此外,负责从切片中顺序反序列化参数的代码ix_data
\通常会由编译器优化和内联,这使得可靠的汇编级模式匹配非常困难。
ix_data
\更有希望的未来方向可能涉及符号执行,通过分析如何使用来确定预期的字节长度以及可能确定每个参数的类型。
然而,在 IDL Guesser 原型中,采用了一种利用动态分析的替代且更简单的方法:由于交易大小限制,Solana 指令数据通常很短,因此我们可以迭代探测处理程序函数。通过稍微增加模拟输入数据的长度并观察执行跟踪中的变化(例如,通过之前失败的检查),我们可能会在新的反序列化步骤成功时推断出边界,并可能推断出参数的类型。
这种迭代反馈循环技术也用于重建账户的内部布局。具体细节这里就不展开了,感兴趣的读者可以去源代码中探索相关的实现。此外,它还可能用于验证或完善恢复的指令和账户信息。
实现细节
识别出这些模式后,实施过程涉及反汇编 sBPF 字节码并对指令序列和 CFG 执行模式匹配。我们以现有solana-sbpf
\项目(参见static_analysis.rs)为基础,为 Solana 程序分析奠定了基础。
我们对基本静态分析实现进行了一些修改。原始版本倾向于在每次函数调用后过度拆分基本块。我们对此进行了调整,以创建更大、更易于管理的块。此外,我们还为系统调用(如abort
\或panic
\)添加了特殊处理。这些更改使 CFG 更加精确,从而简化了模式匹配过程。
完整的实现是开源的,可以在IDLGuesser 存储库中获取。
当前代码处理许多常见场景,但也包括一些特殊情况的逻辑,例如UncheckedAccount
\和Sysvar
\帐户。
这些逻辑try_accounts
\通常由编译器内联,从而创建类似于init
帐户的模式。然而,挑战仍然存在,特别是对于多个连续UncheckedAccount
实例或更复杂的结构(如可选帐户和嵌套帐户上下文),此原型尚未完全处理。
结论
IDL Guesser 展示了一种可行的方法,利用框架的代码生成模式和简单的动态分析,从基于 Anchor 的闭源 Solana 程序中恢复基本结构信息(具有相应帐户和参数信息的指令)。虽然原型有局限性,在复杂情况下可能需要手动进行逆向工程并与链上数据进行交叉引用,但它成功地为大量程序恢复了类似 IDL 的信息。
我们发现此功能很有用,可以更广泛地分析交易数据,并有助于自动扫描与帐户限制相关的基本漏洞(例如缺少签名者检查)。通过揭示闭源程序的内部工作原理,我们希望 IDL Guesser 等工具能够为保护 Solana 生态系统做出贡献。
版权属于:区块链中文技术社区 / 转载原创者
本文链接:https://bcskill.com/index.php/archives/2369.html
相关技术文章仅限于相关区块链底层技术研究,禁止用于非法用途,后果自负!本站严格遵守一切相关法律政策!