本文深入探讨了从EVM到SVM的过渡中的关键概念,对于进行Solana安全评估至关重要。讨论了程序派生地址(PDA)及其生命周期、跨程序调用(CPI)、Solana程序库(SPL)Token、交易和指令,以及涉及Solana原生资产的操作,同时强调了这些概念在安全评估中的重要性。
Solana 是一种高性能区块链基础设施,它支持程序——类似于以太坊虚拟机 (EVM) 中的智能合约。大多数 Solana 程序是用 Rust 编写的,其中 Anchor 是一个流行的框架。
在本文中,我们将讨论对 Solana 程序进行安全评估至关重要的几个关键概念。这些概念包括程序派生地址 (PDA) 及其生命周期、跨程序调用 (CPI)、Solana 程序库 (SPL) token、交易和指令,以及涉及 Solana 原生资产的操作。请注意,本文的讨论并非详尽无遗。
我们假设读者已经熟悉 EVM 和一般的安全评估程序。
Solana 虚拟机 (SVM) 和 EVM 之间的主要区别之一是存储管理。在 EVM 中,存储是在合约中声明的,合约可能包含也可能不包含用户可调用的程序逻辑。但是,SVM 将程序逻辑与存储分开。SVM 将数据存储在账户中,特别是存储在程序派生地址 (PDA) 中。每个 PDA 代表独立的存储,通常定义为一个 struct
。它有一个类似于标准 Solana 公钥的地址。但是,与 Solana 密钥对不同,PDA 地址并非设计为位于 Ed25519 曲线上的。为了确保这一点,使用一个称为 bump
的额外组件来生成曲线外的地址。
以下是 Solana 文档 中的一个示例。
##[derive(Accounts)]
struct ExampleAccounts {
#[account(
seeds = [b"example_seed"],
bump
)]
pub pda_account: Account<'info, AccountType>,
}
具有存储的 PDA 必须初始化,此过程需要“租金费用”。需要指定存储大小,尽管以后可以根据需要调整大小(需要额外费用)。当 PDA 不再需要时,可以将其关闭,并且费用会退还。在计算存储空间时,重要的是为账户鉴别器添加额外的 8 个字节,而可变长度的存储(如向量(数组)和字符串)则需要额外的 4 个字节。
鉴于 PDA 的关键作用,实施访问控制至关重要,尤其是在关闭 PDA 时,因为调用者会收到退还的费用。Anchor 框架通过提供账户约束功能来支持账户的生命周期,从而在账户管理中发挥着至关重要的作用。例如,init
和 init_if_needed
用于账户初始化,realloc
用于重新分配存储大小,而 close
用于关闭账户。
在评估 PDA 的安全性时,请考虑以下事项:
Solana 程序通常由两部分组成:逻辑代码和上下文代码。逻辑代码中的每个函数都可以有自己的上下文。PDA 在上下文部分中指定。
以下示例说明了缺乏访问控制如何导致漏洞。请注意 authority
账户是如何没有被正确约束的,从而允许任何用户调用该函数并关闭 PDA,这可能导致资金损失。
pub fn append_data(
ctx: Context<AppendData>,
data: [u8; 32],
) -> Result<()> {
// 这里是一些逻辑
}
pub fn clear_data(
ctx: Context<ClearData>,
data: [u8; 32],
) -> Result<()> {
// 这里是一些逻辑
}
##[derive(Accounts)]
##[instruction(data: [u8; 32])]
pub struct AppendData<'info> {
#[account(
mut,
seeds = [DATA_SEED],
bump
)]
#[account(mut)]
pub authority: Signer<'info>,
}
##[derive(Accounts)]
##[instruction(data: [u8; 32])]
pub struct ClearData<'info> {
#[account(
mut,
seeds = [DATA_SEED],
bump,
close = authority // 这将关闭 PDA 并将租金费用发送给 authority
)]
// authority 账户未被约束,允许任何用户调用此函数
#[account(mut)]
pub authority: Signer<'info>,
}
SVM 允许一个程序调用另一个程序,这一过程称为跨程序调用 (CPI)。CPI 需要三个组件:程序地址、相关账户和指令数据——其中包括要调用的函数和所需的输入。CPI 可能还需要签名。如果需要,调用程序可以使用 PDA 对 CPI 进行签名。为了验证“签名”,被调用程序根据提供的 PDA 计算预期的 PDA 地址(使用相同的种子、bump 和调用者的程序 ID)。在这种情况下,签名不是密码学签名,而是在 Solana CPI 中被认为是有效的。仅用于签名的 PDA 不需要初始化。
在评估 CPI 的安全性时,验证 PDA 签名者非常重要。确保计算出的 PDA 地址使用正确的种子、bump 和程序 ID;否则,调用者可能会被冒充。
以下是一个检查调用者程序 ID 的函数示例。
##[program]
pub mod vault_program {
use super::*;
// 一个只能由交易程序调用的函数
pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
// 这里是一些逻辑
Ok(())
}
}
##[derive(Accounts)]
pub struct Withdraw<'info> {
#[account(mut)]
pub vault: SystemAccount<'info>,
#[account(mut)]
pub recipient: SystemAccount<'info>,
// 验证 authority 来自交易程序并使用正确的种子
#[account(
signer,
constraint = authority.owner == TRADING_PROGRAM_ID @ ErrorCode::UnauthorizedProgram,
seeds = [b"trading_authority"],
bump
)]
/// CHECK: 交易程序的 PDA,具有经过验证的所有权和种子
pub authority: UncheckedAccount<'info>,
pub system_program: Program<'info, System>,
}
Solana 程序库 (SPL) Token 是一个用于生成 token 的程序库。虽然 SPL Token 在概念上类似于以太坊的 ERC20 token,但由于 Solana 的存储管理总是在账户下,因此它们的生成和管理方式差异很大。当用户生成新的 token 时,他们实际上是创建了一个 Mint Account
。此账户存储关于 token 的一般信息,例如小数位数和 token 供应量。例如,从 USDC Mint Account 中,我们知道它有 6 个小数位,并且在撰写本文时,供应量为 9,722,573,572.759998 USDC。
Mint Account
不存储 token 持有者的余额。相反,余额存储在 Token Accounts
中,所有者必须先创建这些账户才能拥有 token。由于跟踪和管理所需的额外工作,管理 Token Accounts
可能会给所有者带来不便。因此,还有另一种账户类型称为 Associated Token Accounts
,这是一种特殊形式的 Token Accounts
,可以从钱包所有者的公钥和 token Mint Account
地址确定性地派生出来。
在安全评估期间,请确保提供的账户地址正确。
一个 Solana 交易可能包含多个指令。一个指令执行一个操作,而一个交易捆绑多个指令(可能具有不同的签名者)。交易中的所有指令都必须通过;如果任何指令失败,则所做的更改将被回滚并丢弃。Solana 交易的概念有点类似于以太坊的账户抽象。
一个 Solana 交易的最大大小为 1,232 字节。此限制源自 IPv6 最大传输单元 (MTU) 大小 1,280 字节,减去网络标头 48 字节。虽然没有明确的文档解释这个特定的大小,但这可能是为了减少网络开销而做出的设计决策,从而避免了在 TCP 层重新组装网络数据包的需要。
有限的交易大小阻止函数接受大型输入。因此,程序开发人员可能会使用一些函数,先在一个账户中收集输入,然后在准备就绪后进一步处理它们。这表明完成具有高复杂性的任务涉及多个操作。
在评估函数与 Solana 交易和指令相关的安全性时,请考虑以下事项:
考虑以下模拟示例,其中用户想要交换 token。应该在用户批准 token 转移后调用函数 swap
。
// 交易 1:用户批准 DEX 使用他们的 token
approve_token_transfer(token_a_account, dex_authority, amount);
// 与此同时,攻击者看到此交易并抢先交易:
// - 购买 Token B,提高其价格
// - 首先下订单
// 交易 2:用户执行交换(现在价格更差)
swap_tokens(token_a_account, token_b_account, amount, minimum_out);
// 用户获得的 token 比预期少
在检查代码时,似乎可能存在抢先交易攻击。但是,捆绑多个指令的交易可以防止此类攻击。例如,当用户想要交换 token 时,交易将包括交换指令和转移指令。如果交换失败,则不会执行转移,从而保持原子性并防止部分执行攻击。
// 单个交易,包含两个指令
let transaction = Transaction::new_with_payer(
&[
// 指令 1:批准 token 转移
approve_token_transfer(token_a_account, dex_authority, amount),
// 指令 2:执行交换,保证最小输出量
swap_tokens(token_a_account, token_b_account, amount, minimum_out)
],
Some(&user.pubkey()),
);
// 如果不满足 minimum_out 条件(价格不利变动),
// 则整个交易失败,并且不转移任何 token
Solana 的原生资产 SOL 存储在账户中。当用户调用涉及 SOL 转移的函数时,必须将该账户标记为可变的,允许程序修改账户的余额。与以太坊不同,在以太坊中,交易中涉及的原生资产数量由用户预先定义,而 Solana 程序可以自由访问用户拥有的任何余额。此特性要求用户在与管理原生资产的程序交互时要格外小心。防止不必要行为的一种建议方法是使用具有有限原生资产数量的中间账户。
在允许程序执行涉及 SOL 的任意指令时,应格外小心。确保程序具有适当的访问控制,并且所涉及的 SOL 数量受到限制。
理解 Solana 如何管理程序、存储和交易在安全评估期间至关重要。主要要点包括:
对于有兴趣更深入地了解 Solana 安全概念的读者,以下资源提供了全面的指南、攻击向量分析和安全最佳实践,这些资源可以补充本文中讨论的概念。
- 原文链接: blog.sigmaprime.io/trans...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!