修复破坏不变性:Stake Pool 提现中的双重舍入

Adevar labs 发布于 2026-04-29 阅读 234

本文深入分析了 Solana 流动性质押协议审计中发现的一个复杂算术漏洞。最初问题在于用户提取质押时,无论谁支付 PDA 租金,协议都按包含租金的总额销毁 LP 代币,导致用户被过度扣费。开发团队在修复时尝试根据净值反向计算销毁量,却引入了“双重舍入”错误:两次连续的向下取整操作导致销毁的代币少于应有值,从而产生未抵押代币并稀释池值。最终通过由协议储备支付租金并维持原始销毁逻辑解决了问题。文章强调了在金融逻辑中保持舍入不变性的重要性,并建议使用模糊测试和数值模拟来捕捉此类细微的算术偏差。

我们审计了一个从知名 Solana stake pool 实现分叉而来的 liquid staking 程序。用户将 SOL 存入共享池,并收到代表其按比例所有权的 LP tokens(pool tokens)。存入的 SOL 会被委托给多个 validator。当用户想要退出时,他们会销毁自己的 LP tokens,并取回其质押 SOL 的份额,以及任何已累积的奖励。

初始流程

原始的提现流程如下:

  • 用户指定他们想要赎回多少 LP tokens(lp_tokens)。
fn process_withdraw_stake(
        ....
        lp_tokens: u64,
        ....
    ) -> Result {
  • 协议会从该数量中扣除提现手续费,得到 lp_tokens_to_burn,即将被销毁的实际 token 数量。
let lp_tokens_to_burn = lp_tokens
            .checked_sub(lp_tokens_fee)
            .ok_or(PoolError::CalculationFailure)?;
  • 接着,lp_tokens_to_burn 会通过 calc_lamports_for_withdrawal 转换为 withdraw_lamports,即按当前兑换率计算出的等值 SOL,并按对协议有利的方向取整,因此在边界情况下用户会收到略少一点的金额(目前这是合理的)。
let mut withdraw_lamports = pool
    .calc_lamports_for_withdrawal(lp_tokens_to_burn)
    .ok_or(PoolError::CalculationFailure)?;
  • 协议从池中以 stake 的形式提取对应数量的 SOL,并精确销毁 lp_tokens_to_burn。
invoke_signed(
        &burn(
            token_program_info.key,
            burn_from_pool_info.key,
            pool_mint_info.key,
            signer_or_session_info.key,
            Some(program_signer_info.key),
            lp_tokens_to_burn,
        )?,

最初的发现:用户在提现时多付了钱

在审计期间,我们在这个提现流程中新引入的逻辑中发现了一个问题。

当用户通过新路径提取 stake 时,协议会创建一个 PDA stake account,用来临时持有用户提取出的 stake。

在 Solana 上,每个 account 都必须持有最低余额才能存在(这称为 rent)。

因此,这个 PDA 需要注入 stake_rent lamports 才能维持存活。

问题是:谁来支付这笔 rent?

场景 A

项目方的 native paymaster 支付。由于项目方预先承担了 rent,用户收到的 lamports 会更少(split_lamports,而不是 withdraw_lamports)。

let split_lamports = withdraw_lamports.saturating_sub(stake_rent);

场景 B

用户支付。用户直接向 PDA 发送 stake_rent lamports。

问题在于,代码并没有区分这两种场景。

无论是谁支付 rent,协议都会基于 withdraw_lamports 来销毁 pool tokens,也就是基于包含 rent 的完整金额。

但用户实际收到的 lamports 只有 split_lamports,也就是 withdraw_lamports - stake_rent。

所以当用户自己支付 rent 时,他们两头都亏:他们直接为 rent 转出了 SOL,而协议又像这部分 rent 也是他们收到的 stake 一样,销毁了他们对应的 LP 份额。

我们的建议:

检查 payer 是否为用户。如果是,那么只销毁相当于 split_lamports 的 LP shares tokens。

如果是 paymaster 为 PDA 提供资金,那么销毁 withdraw_lamports,因为原本逻辑在这种情况下已经能正确工作。

团队实施了这个修复。

这个修复看起来很干净。

但魔鬼藏在数学里。

引入错误的修复

正如我们所说,在这两种场景下,用户实际上只收到相当于 split_lamports 的真实 stake,但协议销毁的却是完整的 lp_tokens_to_burn,而它是从 withdraw_lamports 推导出来的(这个值更大,因为包含 rent)。

当用户自己支付 rent 时,他们就被多收了。

如果用户是 payer(场景 B),就不应该基于 withdraw_lamports 来销毁,而应该基于 split_lamports,因为那才是他们实际收到的金额。

这意味着要把 split_lamports 反向转换成一个实际要销毁的 LP token 数量。

而这正是团队所做的。

他们取 split_lamports,并使用 calc_lp_tokens_for_deposit 将其反向转换为 LP tokens:

pub fn calc_lp_tokens_for_deposit(&self, stake_lamports: u64) -> Option<u64> {
        ...
        u64::try_from(
            (stake_lamports as u128)
                .checked_mul(self.lp_token_supply as u128)?
                .checked_div(self.total_lamports as u128)?,
        )
        .ok()
    }

这个逻辑读起来是正确的:如果用户支付了 rent,就只销毁对应 split_lamports 的 token。

如果是 paymaster 支付,就销毁 lp_tokens_to_burn。

但问题出在他们用于这次反向转换的函数上。

双重取整问题

这个修复引入了双重取整问题。让我们追踪一下取整发生的位置:

第一次取整: 协议使用 calc_lamports_for_withdrawal 将 lp_tokens_to_burn 转换为 withdraw_lamports。这一步向下取整,因此用户拿到的 lamports 会比数学上的精确值略少。

第二次取整: 修复逻辑随后使用 calc_lp_tokens_for_deposit 将 split_lamports 反向转换为 LP tokens。这一步同样向下取整,因此销毁数量会比数学上的精确值略少。

两个连续的 floor 操作会朝同一个方向叠加:用户收到的 lamports 已经被向下取整,而针对这些 lamports 所销毁的 token 又再次被向下取整。

最终结果是,销毁的 token 数量少于实际被移除的 lamports 所应对应的数量。每一次 session 提现都会在流通中留下少量没有抵押支持的 token 残余,从而稀释所有剩余存款人的价值。

模拟取整影响

我们构建了一个电子表格模拟,来理解这种取整误差在不同池子条件下会如何表现。

有人能从中获利吗?

理论上可以,攻击者可以在二级市场上做空该池子的 LP token,然后触发足够多的提现,来明显拉低兑换率。

但做到这一点并不现实。

SOL 有 9 位小数精度,因此即使池子里只有 1,000 SOL,攻击者也需要在大约 1,000 × 10⁹ 次的量级上触发这种取整误差,才能让价格产生有意义的变化。

真正的损害不是一次 exploit,而是缓慢、被动的稀释,它会随着时间推移损害每个存款人的兑换率。

损害只发生在池子本身:销毁的 token 比应销毁的更少,没有抵押支持的 token 在流通中累积,而兑换率会随着时间缓慢下降,影响每一个存款人。

当池子处于平衡状态时,比如 total_lamports = 10M 且 lp_token_supply = 10M,每次提现的取整误差最多为 1 lamport。

但如果一开始这个比例就是失衡的,那么每次提现的误差就会变大。

一般来说,误差上限为 lp_token_supply / total_lamports,比例越悬殊,每次提现的误差就越大。

而且这个问题会自我强化:每一次少销毁 token 的提现都会让失衡变得更严重。

更多没有抵押支持的 token 留在流通中,token 价格下跌,比例变得更悬殊,下一次提现时少销毁的数量又会更多。

移除双重取整误差的修复

团队没有再把 split_lamports 反向转换为 LP tokens,而是改了由谁来支付 PDA rent。

现在,stake account 的 rent 由池子的 reserve stake account 支付,而不是由用户提取出来的 lamports 支付。

这意味着 withdraw_lamports 和 split_lamports 现在是相同的值。

原来的 lp_tokens_to_burn 保持不变,不需要第二次转换,也就没有第二次取整。

结论

只有当你追踪完整的算术路径,并检查两次转换中的 floor 操作是否会叠加时,取整不匹配问题才会显现出来。

要形成的思维模式是:任何值只要先按一个方向转换,再反向转换,两次连续的 floor 总会偏向同一方。

仅靠人工审查并不能可靠地发现这一点。

有两种技术本可以暴露它:

  • Fuzzing, 定义不变量 tokens_burned × total_lamports >= lamports_withdrawn × token_supply 并进行测试。任何违背都意味着池子少销毁了 token。
  • 数值模拟, 我们构建了一个电子表格,在一系列池子比例下运行提现循环,并跟踪预期 token 供应量与实际 token 供应量之间的累积漂移。

每个转换函数都有一个隐含的取整方向,而每个取整方向都有一个受益方。

在审查金融逻辑的修复时,不要只问“这个修复是否解决了已报告的问题?”

还要问:“这个修复是否保持了池子的取整不变量?”

如果你正在构建或审计 staking 系统,或者任何具有双向转换的机制,请仔细审查你的取整边界。

如果你希望获得更深入的不变量测试和审计支持,请联系 Adevar Labs

安全发布。

  • 原文链接: adevarlabs.com/blog/when...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~

相关文章

0 条评论