Solana:lamport转移的隐藏危险

  • osecio
  • 发布于 7小时前
  • 阅读 56

本文深入探讨了Solana中lamport转移的潜在危险,通过一个“King of the SOL”的智能合约游戏案例,揭示了rent-exemption、可写账户的lamport转移失败以及write-demotion等问题可能导致程序出错甚至瘫痪。文章强调了在Solana上转移lamport并非总是直接的,需要考虑多种runtime-specific的特殊情况。

Solana: Lamport 转账的隐藏危险

Solana 的 lamport 转账逻辑隐藏着危险的极端情况 —— 从租金豁免怪癖到写入降级陷阱。我们剖析了一个看似简单的智能合约游戏,以揭示向任意账户转账如何悄无声息地失败、破坏你的程序或加冕一个永恒的国王。

Solana:Lamport 转账的隐藏危险的标题图片

简介

在 Solana 上,向任意地址转移 lamports 是否安全?答案可能会让你惊讶。

在这篇文章中,我们将探索一个受 以太之王 启发的看似简单的智能合约游戏。通过它,我们将强调 Solana 账户模型中可能破坏你的程序的微妙陷阱 —— 尤其是在转移 lamports 时。

游戏:SOL 之王

游戏是这样运作的:

  • 任何人都可以通过出价至少是前一个出价的 2 倍成为国王
  • 老国王会收到其出价 95% 的报销。
  • 剩余的 5% 进入奖金池。
  • 如果在位国王在没有被推翻的情况下存活 10 天,他们可以申领整个奖金池。

很简单,对吧?

这是核心逻辑:

#[derive(Accounts)]
pub struct ChangeKing<'info> {
    #[account(mut)]
    pub throne: Account<'info, Throne>,

    /// CHECK: old_king gets a 95% refund, so ensure its writable.
    // CHECK: old_king 获得 95% 的退款,因此请确保它是可写的。
    #[account(mut, constraint = old_king.key() == throne.king)]
    pub old_king: AccountInfo<'info>,

    /// CHECK: any writable account is allowed as a new king.
    // CHECK: 任何可写账户都可以作为新国王。
    #[account(mut)]
    pub new_king: AccountInfo<'info>,

    #[account(mut)]
    pub payer: Signer<'info>,
}

#[program]
pub mod king_of_the_sol {
    pub fn change_king(ctx: Context<ChangeKing>, bid_amount: u64) -> Result<()> {
        // Check that bid_amount is at least 2x last_bid_amount
        // 检查 bid_amount 至少是 last_bid_amount 的 2 倍
        assert!(bid_amount >= ctx.accounts.throne.last_bid_amount * 2);
        transfer_from_signer(
            &ctx.accounts.payer,
            &ctx.accounts.throne.to_account_info(),
            bid_amount,
        )?;

        // Reimburse 95% of the last bid to the old king
        // 向老国王报销上次出价的 95%
        let to_reimburse = (ctx.accounts.throne.last_bid_amount * 9500) / 10000;
        transfer_from_pda(
            &ctx.accounts.throne.to_account_info(),
            &ctx.accounts.old_king,
            to_reimburse,
        )?;

        // Set new king
        // 设置新国王
        ctx.accounts.throne.king = ctx.accounts.new_king.key();
        ctx.accounts.throne.last_bid_amount = bid_amount;
        ctx.accounts.throne.last_time = Clock::get()?.unix_timestamp as u64;

        Ok(())
    }
}

请注意此注释:

any writable account is allowed as a new king. 任何可写账户都可以作为新国王。

...我们的假设正确吗?

潜伏在下方的 Bug

Bug 1:租金豁免陷阱

在 Solana 上,所有账户必须维持一个 lamports 的最低余额,以保持租金豁免。具体来说,一个账户可以处于以下两种状态之一:

  • 未初始化lamports = 0
  • 已初始化lamports >= 租金豁免阈值

这种租金模型的存在是为了防止对验证者的低成本 DoS 攻击。关键的想法是,即使是没有数据的账户(即,零长度数据缓冲区)仍然会消耗链上资源;具体来说,是账户元数据,如其公钥、所有者或 lamport 余额。该元数据必须由验证者持久存储,而这种存储并非免费。

因此,Solana 上的“持久状态”不仅意味着你程序的数据 —— 它还包括基本账户结构本身。即使 data.len() == 0 的账户也必须满足最低租金阈值才能保持活跃,并避免被运行时垃圾回收。

这是在运行时级别强制执行的,相关的逻辑可以在这里找到。

    fn transition_allowed(&self, pre_rent_state: &RentState, post_rent_state: &RentState) -> bool {
        match post_rent_state {
            RentState::Uninitialized | RentState::RentExempt => true,
            RentState::RentPaying {
                data_size: post_data_size,
                lamports: post_lamports,
            } => {
                match pre_rent_state {
                    RentState::Uninitialized | RentState::RentExempt => false,
                    RentState::RentPaying {
                        data_size: pre_data_size,
                        lamports: pre_lamports,
                    } => {
                        // Cannot remain RentPaying if resized or credited.
                        // 如果调整大小或贷记,则不能保持 RentPaying 状态。
                        post_data_size == pre_data_size && post_lamports <= pre_lamports
                    }
                }
            }
        }
    }

你可以使用 CLI 检查零数据账户的租金豁免阈值:

solana rent 0
Rent-exempt minimum: 0.00089088 SOL
租金豁免最小值:0.00089088 SOL

修复 1:仅在租金豁免时报销

我们不想给不公平的国王捐任何东西!因此,让我们更新我们的程序,仅在老国王在转账后获得租金豁免时才进行报销:

let to_reimburse = (ctx.accounts.throne.last_bid_amount * 9500) / 10000;
+let rent = Rent::get()?;
+let balance_after = ctx.accounts.old_king.lamports() + to_reimburse;
+if rent.is_exempt(balance_after, ctx.accounts.old_king.data_len()) {
    transfer_from_pda(
        &ctx.accounts.throne.to_account_info(),
        &ctx.accounts.old_king,
        to_reimburse,
    )?;
+}

但是,租金豁免是导致 lamport 转账失败的唯一原因吗?不完全是。

Bug 2:可写但不可触碰 —— set_lamports 失败

让我们看一下 BorrowedAccount::set_lamports

/// Overwrites the number of lamports of this account (transaction wide)
// 覆盖此账户的 lamports 数量(整个事务范围内)
#[cfg(not(target_os = "solana"))]
pub fn set_lamports(&mut self, lamports: u64) -> Result<(), InstructionError> {
    // An account not owned by the program cannot have its balance decrease
    // 不属于程序的账户不能减少其余额
    if !self.is_owned_by_current_program() && lamports < self.get_lamports() {
        return Err(InstructionError::ExternalAccountLamportSpend);
    }
    // The balance of read-only may not change
    // 只读账户的余额可能不会更改
    if !self.is_writable() {
        return Err(InstructionError::ReadonlyLamportChange);
    }
    // The balance of executable accounts may not change
    // 可执行账户的余额可能不会更改
    if self.is_executable_internal() {
        return Err(InstructionError::ExecutableLamportChange);
    }
    // don't touch the account if the lamports do not change
    // 如果 lamports 没有更改,则不要动账户
    if self.get_lamports() == lamports {
        return Ok(());
    }
    self.touch()?;
    self.account.set_lamports(lamports);
    Ok(())
}

/// Feature gating to remove `is_executable` flag related checks
// 功能门控以删除与“is_executable”标志相关的检查
#[cfg(not(target_os = "solana"))]
#[inline]
fn is_executable_internal(&self) -> bool {
    !self
        .transaction_context
        .remove_accounts_executable_flag_checks
        && self.account.executable()
}

事实证明:即使是可写的、租金豁免的账户仍然会拒绝 lamport 转账。

具体来说,可执行账户无法接收或发送 lamports —— 运行时将其视为不可变的。

边栏:什么是可执行标志?

executable 标志是一种遗留机制,用于标记持有程序代码的账户。历史上,具有此标志的账户被假定为包含不可变的 BPF 字节码,或者是内置程序的代理,因此将其视为只读的以提高性能是有意义的。

随着 可升级 BPF 加载器的引入,此行为变得有问题。使用了一种解决方法来维持与现有运行时逻辑的兼容性。包含 bpf 字节码的程序数据被拆分为一个单独的账户 ProgramData,程序账户现在仅包含指向 ProgramData 账户的地址:

Program {
    /// Address of the ProgramData account.
    // ProgramData 账户的地址。
    programdata_address: Pubkey,
},
ProgramData {
    /// Slot that the program was last modified.
    // 程序上次修改的Slot。
    slot: u64,
    /// Address of the Program's upgrade authority.
    // 程序的升级权限的地址。
    upgrade_authority_address: Option<Pubkey>,
    // The raw program data follows this serialized structure in the
    // 账户数据中,原始程序数据遵循此序列化结构。
    account's data.
},

最终,可执行标志将按照 SIMD-0162 中的提议完全删除。原因很简单:账户的所有者及其内容足以确定它是否是有效的程序 —— 可执行标志是多余的。

此更改也是支持新的 loader-v4 的硬性要求。与依赖于单独的 ProgramData 代理账户的可升级加载器不同,loader-v4 将所有程序数据直接存储在程序账户本身中。

因此,在部署后无法修改账户的大小,或者在不违反 ExecutableLamportChange 限制的情况下,无法从可升级加载器迁移到 loader-v4。

修复 2:拒绝程序账户

为了避免这个陷阱,让我们明确跳过任何可执行账户:

pub fn can_transfer_lamports(account: &AccountInfo, lamports: u64) -> Result<bool> {
fn is_program(account: &AccountInfo) -> bool {
    account.executable
}
let rent = Rent::get()?;
let balance_after = account.lamports() + lamports;
Ok(account.is_writable
    && rent.is_exempt(balance_after, account.data_len())
    && !is_program(account))
}

现在我们安全了...对吧?

Bug 3:写入降级陷阱

在 Solana 上,在事务中作为可写传递的账户可以被静默降级为只读。此行为发生在消息清理期间 —— 甚至在你的程序运行之前。

让我们逐步了解旧消息的逻辑(注意:相同的规则适用于 MessageV0,但旧消息更容易理解):

// https://github.com/anza-xyz/solana-sdk/blob/master/message/src/sanitized.rs#L39-L55
impl LegacyMessage<'_> {
    pub fn new(message: legacy::Message, reserved_account_keys: &HashSet<Pubkey>) -> Self {
        let is_writable_account_cache = message
            .account_keys
            .iter()
            .enumerate()
            .map(|(i, _key)| {
                message.is_writable_index(i)
                    && !reserved_account_keys.contains(&message.account_keys[i])
                    && !message.demote_program_id(i)
            })
            .collect::<Vec<_>>();
        Self {
            message: Cow::Owned(message),
            is_writable_account_cache,
        }
    }
}

// https://github.com/anza-xyz/solana-sdk/blob/master/message/src/legacy.rs#L642-L644
pub fn demote_program_id(&self, i: usize) -> bool {
    self.is_key_called_as_program(i) && !self.is_upgradeable_loader_present()
}

如你所见,写入降级主要有两个原因:

  1. 该账户出现在保留账户列表
  2. 在事务中没有可升级加载器的情况下,该账户被作为程序调用。

第二种情况通常由先前实现的可执行检查覆盖。

然而,第一种情况更加危险 —— 它可能会在没有任何明显原因的情况下静默地破坏你的程序逻辑。让我们深入研究一下。

保留账户列表

Solana 运行时维护一个保留账户列表,其中包括具有特殊语义的地址 —— 例如内置程序、预编译程序和 sysvar。

这些账户最初可能表现得像普通账户。但是,一旦它们在功能门激活后变为保留账户,运行时将自动将它们降级为只读,即使事务将它们标记为可写。

// https://github.com/anza-xyz/agave/blob/0e6d9bf8c81cd94dfdedb500af4ac17328cf7a43/runtime/src/bank.rs#L6469-L6474
// Update active set of reserved account keys which are not allowed to be write locked
// 更新不允许被写锁定的保留账户密钥活动集
self.reserved_account_keys = {
    let mut reserved_keys = ReservedAccountKeys::clone(&self.reserved_account_keys);
    reserved_keys.update_active_set(&self.feature_set);
    Arc::new(reserved_keys)
};

后果:静默失败和损坏的程序

当约束程序为可写时,例如,使用 Anchor,此行为尤其危险,使用 account(mut) 约束非常常见:

#[derive(Accounts)]
pub struct ChangeKing<'info> {
    #[account(mut)]
    pub throne: Account<'info, Throne>,

    #[account(mut, constraint = old_king.key() == throne.king)]
    pub old_king: AccountInfo<'info>,

    #[account(mut)]
    pub new_king: AccountInfo<'info>,

    #[account(mut)]
    pub payer: Signer<'info>,
}

这工作正常 —— 直到有一天,old_king 被静默降级。突然,#[account(mut)] 约束失败,你的程序损坏。即使你在事务中传递一个可写账户,运行时也单方面决定覆盖它。

真实示例:使用 secp256r1_program 进行写入降级

这是一个在主网上发生的写入降级陷阱的具体示例 —— 涉及 secp256r1_program,这是一个在功能标志后面进行门控的预编译程序:

ReservedAccount::new_pending(
    secp256r1_program::id(),
    feature_set::enable_secp256r1_precompile::id(),
)

在激活 enable_secp256r1_precompile 功能之前,此账户的行为类似于任何普通账户。你可以将 secp256r1_program::id() 分配为合约中的国王。

但是,一旦该功能被打开,运行时会静默地将其标记为只读,从而阻止任何未来的写入。结果,secp256r1_program::id() 成为永恒的国王,没有人可以推翻它。

修复 3:防止写入降级陷阱

好的,让我们尝试修复这个又一个极端情况 —— 并希望结束这本书。

尝试 1:阻止已知的保留账户

一种幼稚的解决方案是拒绝任何已知的保留账户,例如:

    pub fn change_king(ctx: Context<ChangeKing>, bid_amount: u64) -> Result<()> {
+       assert!(ctx.accounts.new_king.key() != secp256r1_program::id());

这在短期内有效,但无法扩展 —— 你无法预测 ReservedAccount 列表的所有未来添加。一旦引入新的保留账户,你的程序将再次变得脆弱。

尝试 2:使用 PDA Vault

更具前瞻性的修复方法是完全避免向任意账户转移 lamports

一种干净的方法是将退款 lamports 存储在由你的程序拥有的 PDA vault 中。这可以防止你的逻辑依赖于你没有完全控制权的账户,并避免任何写入降级或未来账户限制的风险。

最后的想法

在 Solana 上转移 lamports 并非总是那么简单,并且存在潜在的风险。单独的账户约束不足以确保安全,尤其是在处理运行时特定的极端情况时。

在以下条件下,我们可以安全地将 lamports 转移到账户:

  • 它不可执行。
  • 转账后,其余额仍保持租金豁免。
  • 它不是保留账户。

此问题并非纯粹是理论上的;它已经影响了现实世界的程序。最近,Jito 通过错误赏金报告了一个重要案例,这可能导致不正确的提示付款。

  • 原文链接: osec.io/blog/2025-05-14-...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 1
收藏 1
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
osecio
osecio
Audits that protect blockchain ideas.