General
程序安全

程序安全

PDA 共享

PDA 共享攻击利用了在多个用户或域之间使用相同 Program Derived Address (PDA) 的程序,从而使攻击者能够访问不属于他们的资金、数据或权限。虽然使用全局 PDA 进行程序范围的操作看起来很优雅,但它会导致危险的交叉污染,使一个用户的操作可能影响到另一个用户的资产。

漏洞的根源在于派生 PDA 时种子(seed)特异性不足。当多个账户共享相同的 PDA 权限时,程序就无法区分合法和非法的访问尝试。攻击者可以创建引用相同共享 PDA 的账户,然后利用该 PDA 的签名权限操控其他用户的资产。

在 DeFi 协议中,这种情况尤其具有破坏性,因为 PDA 控制着代币金库、用户余额或提款权限。共享 PDA 本质上创建了一把主密钥,可以解锁多个用户的资产,将单个用户的操作变成对整个协议的潜在攻击。

Anchor

以下是一个使用基于 mint 的 PDA 进行签名的易受攻击的提款系统:

rust
#[program]
pub mod insecure_withdraw{
    use super::*;
    //..
 
    pub fn withdraw(ctx: Context<WithdrawTokens>) -> Result<()> {
 
        //..
        // other conditions/actions...
        //..
 
        let amount = ctx.accounts.vault.amount;
        
        let seeds = &[
            ctx.accounts.pool.mint.as_ref(),
            &[ctx.accounts.pool.bump],
        ];
 
        transfer(
            CpiContext::new_with_signer(
                ctx.accounts.token_program.to_account_info(),
                Transfer {
                    from: ctx.accounts.vault.to_account_info(),
                    to: ctx.accounts.withdraw_destination.to_account_info(),
                    authority: ctx.accounts.pool.to_account_info(),
                },
            ),
            &amount,
            seeds,
        )?;
 
        Ok(())
    }
        
    //..
}
 
#[derive(Accounts)]
pub struct WithdrawTokens<'info> {
    #[account(
        seeds = [b"pool", pool.mint.as_ref()],
        bump = pool.bump,                                        
    )]
    pool: Account<'info, TokenPool>,
    vault: Account<'info, TokenAccount>,
    withdraw_destination: Account<'info, TokenAccount>,
    //..
    // other accounts..
    //..
    token_program: Program<'info, Token>,
}
 
#[account]
#[derive(InitSpace)]
pub struct TokenPool {
    pub mint: Pubkey,
    pub bump: u8,
}

此代码存在一个关键缺陷:PDA 仅使用 mint 地址派生。这意味着同一代币类型的所有池共享相同的签名权限,从而形成了一个危险的攻击向量。

攻击者可以通过以下方式利用这一点:

  • 为相同的 mint 创建他们自己的 TokenPool
  • 将他们自己的地址设置为 withdraw_destination
  • 使用共享的 PDA 权限从任何使用相同 mint 的金库中提取代币
  • 将其他用户的资金转移到他们自己的目的地

攻击之所以成功,是因为 PDA 权限没有区分不同的资金池实例,它只关注代币类型,而不是应该访问这些资金的特定用户或资金池。

第一个改进是使 PDA 特定于个人用户或目标,并使用 Anchor 的种子和 bump 约束来验证 PDA 的推导:

rust
#[derive(Accounts)]
pub struct WithdrawTokens<'info> {
    #[account(
        has_one = vault,
        has_one = withdraw_destination,
        seeds = [b"pool", vault.key().as_ref(), withdraw_destination.key().as_ref()],
        bump = pool.bump,                                        
    )]
    pool: Account<'info, TokenPool>, // Authority for the vault
    #[account(mut)]
    vault: Account<'info, TokenAccount>,
    #[account(mut)]
    withdraw_destination: Account<'info, TokenAccount>,
    //..
    // other accounts..
    //..
    token_program: Program<'info, Token>,
}
 
#[account]
#[derive(InitSpace)]
pub struct TokenPool{
    pub vault:Pubkey,
    pub withdraw_destination:Pubkey,
    pub bump:u8
}

对指令处理程序也进行了相同的更改,其中一个可能的情况是杠杆交易程序允许用户的交易在亏损达到一定金额时被清算。例如,用户自己设置止损,代码会检查是否达到该金额的条件,然后允许任何人停止交易并将剩余资金提取到目标账户,即提款账户。

一个单一的PDA(程序派生地址)控制特定代币的所有资金会导致一种情况:如果多个用户同时满足条件,例如许多用户接近止损/清算,那么任何单个用户都可以为所有这些用户提取资金,可能通过一个或多个包含针对不同用户的多条指令的交易来完成。

Blueshift © 2025Commit: 0ce3b0d
Blueshift | 程序安全 | PDA 共享