该文章深入探讨了在Solana开发中,即便使用Anchor框架,仍然可能遇到的安全隐患和常见问题。文章通过多个实例,分析了种子碰撞、CPI风险、账户重载等潜在的安全漏洞,并提醒开发者在使用Anchor时需谨慎,以确保Solana程序的安全性。
Solana 开发容易出错。人们常常提到 Anchor 作为解决这个问题的方法:“用 Anchor 编写,你的 Solana 程序将会安全。”但不要着急!即使使用 Anchor,仍然有很多陷阱。让我们来探讨一些。
编写安全的 Solana 程序并不是一件容易的事情。在创建 Solana 程序时,开发者可能会遇到许多陷阱。为了在这个生态系统中安全操作,开发者需要对运行时保持的不变性、在什么条件下这些不变性不再有保证,以及一系列的账户混淆、混淆的副代理、整数溢出和不足等经典安全漏洞类别十分了解。
Anchor 的创建是因为裸用 Solana 极易出错。Anchor 是一个 Solana 框架,旨在使编写程序的过程比没有使用它时更简单更安全。Anchor 解决了编写 Solana 智能合约时你将面临的许多最常见和最重要的问题。以下是一些示例:
作为一家智能合约审计公司,我们审查了很多 Solana 程序,尤其是那些用 Anchor 编写的程序。尽管 Anchor 解决了 Solana 程序中许多主要问题,但仍然存在许多问题,无论如何都会出现。
程序派生地址 (PDA) 是一种以编程方式派生非曲线账户地址的方法。它们通常用于跨程序调用 (CPI),以及创建保存状态信息的账户。例如,流动性池程序可能创建一个 PDA 来跟踪分配给特定目的的资金,或使用 PDA 来保存协议的全局配置。
在 Anchor 中使用 PDA 非常简单——我们定义组成种子用于派生账户地址的元素,然后就可以开始了。以下是一个 示例↗:
#[derive(Accounts)]
pub struct ChangeUserName<'info> {
pub user: Signer<'info>,
#[account(
mut,
seeds = [b"user-stats", user.key().as_ref()],
bump = user_stats.bump
)]
pub user_stats: Account<'info, UserStats>, // <-- PDA 账户
}
在这里,我们使用一个静态字符串 user-stats 和来自用户账户的固定长度(32 字节)公钥地址来验证所提供的 UserStats 账户的地址。这是定义和使用 PDA 上种子的一种典型示例。
因为没有两个不同的用户共享一个公钥,所以他们的 UserStats 账户也将拥有唯一的地址。然而,对于 PDA,这不是总是如此!碰撞可能导致在最佳情况下出现拒绝服务漏洞,而在另一种情况下完全被攻陷。
以下是一个 CreateNewProduct 指令账户保护的示例,它根据产品名称和一个前导静态字符串创建一个产品 PDA。
#[derive(Accounts)]
#[instruction(product_name: String)]
pub struct CreateNewProduct<'info> {
#[account(mut)]
pub user: Signer<'info>,
#[account(
init,
payer = user,
space = 8 + Product::SIZE,
seeds = [b"product", product_name.as_ref()]
)]
pub product: Account<'info, Product>, // <-- PDA 账户
pub system_program: Program<'info, System>,
}
它看起来没有任何问题,但是我们在考虑程序中的其他 PDA 时开始看到问题。
#[instruction(product_name: String)]
pub struct CreateNewBid<'info> {
#[account(mut)]
pub user: Signer<'info>,
#[account(
mut,
seeds = [b"product", product_name.as_ref()],
bump = product.bump
)]
pub product: Account<'info, Product>, // <-- PDA 账户
#[account(
init,
payer = user,
space = 8 + Bid::SIZE,
seeds = [product_name.as_ref(), user.key().as_ref()]
)]
pub bid: Account<'info, Bid>, // <-- PDA 账户
pub system_program: Program<'info, System>,
}
在这里,我们添加了一个 Bid PDA 账户,用于保存用户对某一产品的竞标。该竞标的范围是针对一个产品和一个用户。虽然起初看起来是安全的,因为这个 Bid 和这个 Product 不能相互碰撞,但攻击者可以利用这一点来阻止用户提交竞标或产品被创建。
在某些情况下,攻击者可以创建一个产品,该产品生成与 Bid 账户相同的程序地址。这会阻止特定用户为该产品创建竞标。
这是因为种子数组中的元素实际上被视为一个大的字节集合,并以 32 字节块进行处理。因此,这个种子(被攻击者的 Product 账户使用)
[b”product”, b”youshouldbuythisproductfast”]
和这个种子(被受害者的 Bid 账户使用)
[b”pr”, 0x6f64756374796f7573686f756c646275797468697370726f6475637466617374]
生成相同的 PDA 公钥。在 solana-program crate 文档中关于 Pubkey 有一个令人失望的警告,提到了这种特定行为。
正如我们在上面的种子碰撞示例中的代码样本中看到的,Anchor 的指令账户保护定义了固定数量的账户及其之间的约束。这些约束对于维护协议的不变性是重要的,但使用固定数量的账户这一限制可能会有所限制。
许多情况下,需要向指令传递可变数量的账户进行处理。例如,在尝试兑付池中的参与者或动态调用另一个程序时。为此,Anchor 暴露了 ctx.remaining_accounts 以访问未包含在原始指令账户保护中的任何额外账户。
值得注意的是,ctx.remaining_accounts 中的账户完全没有 Anchor 通常提供的任何保护。这意味着,如果你打算将这些账户用于任何敏感操作,你需要执行以下一些检查:
使用 ctx.remaining_accounts 可能出错的地方很多。所有与 ctx.remaining_accounts 中的 AccountInfo 类型的交互都需要谨慎对待。
Solana 是一个程序的组合生态系统。从调用系统程序、创建新账户,到将流动性转发给 Solend 生成收益,跨程序调用是编写 Solana 程序的正常组成部分。
尽管 CPI 带来了良好的效果,但它们也隐藏了危险。当进行 CPI 时,转发的账户以及签署了交易的账户会携带它们的 is_signer 状态。这就意味着,如果程序 A 调用程序 B,并且某些账户已经签署了交易,程序 B 可以使用该账户作为签名者。这一点非常重要。这意味着程序 B 可以创建新账户、发送 lamports 和任何相关代币(只要相关代币账户也被传递),以及更多操作。
#[derive(Accounts)]
pub struct RiskyInstruction<'info> {
pub lending_program: AccountInfo<'info>, // <-- 无账户验证
#[account(
mut,
seeds = [ b"pool" ],
bump = pool.bump
)]
pub pool: Account<'info, Pool>
#[account]
pub caller: Signer<'info>,
pub system_program: Program<'info, System>,
}
pub fn risky_instruction(ctx: Context<RiskyInstruction>, amount: u64) -> Result<()> {
// ...
let account_infos = [
ctx.accounts.caller.to_account_info().clone(),
ctx.accounts.lending_program.to_account_info().clone(),
ctx.accounts.pool.to_account_info().clone(),
ctx.accounts.system_program.to_account_info().clone(),
];
let instruction = create_lending_instruction(
ctx.accounts.lending_program.key(), // <-- 我们所调用的程序
ctx.accounts.caller.key(),
ctx.accounts.pool.key(),
amount
);
// v----- 如果我们不完全信任 lending_program 账户,则是危险的
invoke_signed(
&instruction,
&account_infos,
&[&[b"pool"]]
)?;
// ...
}
这个问题与 invoke_signed 的问题相叠加,invoke_signed 通常在与 PDA 的交互中使用。PDA 账户经常被用作协议中的授权账户。如果对 invoke_signed 的调用传递了这个授权账户以及所需的签名种子,那么就引入了实质性的第三方风险。目标程序可能会被调用者动态传递,从而允许攻击者利用授权 PDA 调用他们的恶意程序。即便是静态定义的程序,如果能被升级,也会引入风险。
CPI 能够引入的问题并不止于此。虽然 Anchor 为你自动做了很多事情,但有一件事是它不做的,那就是在 CPI 后更新反序列化的账户。
例如,假设你有一个 Mint 账户,并且即将为调用者铸造一些代币,以便跟踪他们对流动性池的贡献。你对代币程序进行 CPI 铸造这些代币,然后从 Mint 账户读取当前供应量以进行后续运算。虽然直观上,你可能期望供应量是准确的,但 Anchor 中的账户在 CPI 之后不会更新它们的数据!
let authority_seeds = /* seeds */;
let mint_to = MintTo {
mint: self.liquidity_mint.to_account_info(),
to: self.user.to_account_info(),
authority: self.liquidity_mint_authority.to_account_info()
};
msg!("铸造前的供应量: {}", self.liquidity_mint.supply);
anchor_spl::token::mint_to(
CpiContext::new_with_signer(
self.token_program.to_account_info(),
mint_to,
authority_seeds
),
amount
)?;
msg!("铸造后的供应量: {}", self.liquidity_mint.supply); // 保持不变!
为了获得预期行为,确保在账户上调用 Anchor 的 reload 方法。这将刷新结构的字段,以获取当前底层数据。
Anchor 是提高你的 Solana 程序安全性的绝佳方法,但这并不是灵丹妙药。在基于 Anchor 的 Solana 程序中依然有几种方式可以出现漏洞。这些漏洞的影响范围可以从拒绝服务到攻击者窃取你的资金。
感谢 Hana↗ 帮助校对本文。
Zellic 专注于保护新兴技术。我们的安全研究人员在从财富 500 强到 DeFi 巨头的最有价值目标上发现了漏洞。
开发者、创始人和投资者信任我们的安全评估,以便快速、自信地发布,而没有重大漏洞。凭借我们在现实世界攻防安全研究方面的背景,我们能够发现他人忽视的问题。
联系我们↗ 进行更好的审计。真实的审计,而不是模糊的盖章。
- 原文链接: zellic.io/blog/the-vulne...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!