从以太坊到Solana:开发者的假设造成的严重安全漏洞

  • Dedaub
  • 发布于 5小时前
  • 阅读 90

本文探讨了以太坊开发者在Solana上开发时可能犯的常见错误,由于Solana和以太坊的安全模型差异很大,例如:账户验证不足、签名者账户转发等,这些错误可能导致严重的安全漏洞,并提供了防范这些风险的建议,强调了在Solana上进行安全开发需要对账户的所有权、类型检查、关系验证和签名者处理进行严格执行。

Solana上的以太坊开发者

Solana是目前最受欢迎的区块链之一,以其高吞吐量和可扩展性而闻名,这使它成为以太坊的一个有吸引力的替代品。 这些优点源于Solana独特的架构,这与以太坊的设计截然不同。 虽然这些架构上的差异构成了Solana的许多优势,但它们也引入了以太坊开发者在转型过程中可能不熟悉的独特风险。 在本文中,我们将探讨由于两个平台的安全模型截然不同,以太坊开发者在构建Solana程序时可能犯的一些常见错误。

正确的账户验证

以太坊中的状态与控制它的智能合约代码紧密相关。 以太坊上的每个合约都有一个唯一的存储空间,任何其他合约都不能写入。 Solana采用了一种截然不同的方法,将可执行代码(称为程序)与其他类型的账户分开。 这引入了一个额外的复杂性,以太坊开发者很容易忽略:账户验证。

在Solana上,用户必须提供程序运行的所有账户。 这意味着如果程序没有强制执行适当的约束和验证,恶意用户可能会注入意想不到的账户,这可能导致严重漏洞。 具体来说,应该检查所有账户是否具有正确的所有权、正确的类型、如果期望是特定账户则检查正确的地址,以及与程序期望的其他账户的正确关系。 使用Anchor框架可以简化所有这些验证。 然而,即使利用这些工具,仍然可能出现遗漏的检查和验证,尤其是在使用 remaining_accounts 时,Anchor对此不作任何检查。 例如,考虑以下来自简单借贷程序的代码片段:


pub fn liquidate_collateral(ctx: Context<LiquidateCollateral>) -> Result<()> {
    let borrower = &mut ctx.accounts.borrower;
    let collateral = &mut ctx.accounts.collateral;
    let liquidator = &mut ctx.accounts.liquidator;

    let collateral_in_usd = get_value_in_usd(collateral.amount, collateral.mint);
    let borrowed_amount_in_usd = get_value_in_usd(borrower.borrowed_amount, borrower.mint);

    if collateral_in_usd * 100 < borrowed_amount_in_usd * 150 {
        withdraw_from(liquidator, borrower.borrowed_amount);
        transfer_collateral_to_liquidator(ctx);
        let liquidated_amount = collateral.amount;

        borrower.borrowed_amount = 0;
        msg!(
            "Liquidated {} collateral tokens due to insufficient collateralisation.",
            liquidated_amount
        );
    } else {
        msg!("Collateralisation ratio is sufficient; no liquidation performed.");
    }
    Ok(())
}

#[derive(Accounts)]
pub struct LiquidateCollateral<'info> {
    #[account(mut)]
    pub borrower: Account<'info, BorrowerAccount>,

    #[account(mut)]
    pub collateral: Account<'info, TokenAccount>,

    #[account(mut)]
    pub liquidator: Account<'info, TokenAccount>,

    /// CHECK: signer PDA for collateral account
    pub collateral_signer: UncheckedAccount<'info>,

    pub token_program: Program<'info, Token>,
}

此函数仅检查贷款的抵押率,如果该比率低于1.5,则执行清算。 以太坊上的类似程序可能会将抵押数据存储在映射中,无论是在同一合约中还是在不同的合约中。 这将要求合约开发者显式指定映射的键。 但是,在Solana上,是由用户选择账户而不是开发者。

因此,虽然乍一看这对于来自以太坊的人来说似乎是安全的,但指令处理程序缺少一个关键的检查。 内置的Anchor检查确保所有账户都具有正确的类型并具有正确的所有者,但是,无法确保提供的抵押账户与提供的借款人相关联。 这意味着攻击者可以提供任意的借款人账户和不同借款人的抵押账户。 通过查找(或创建)一个刚好低于所需比率的借款人账户,这实际上允许攻击者清算任何抵押账户,而不管其抵押率如何。

这个例子说明了账户验证不足的危险,尤其是在从以太坊开发过渡时,在以太坊开发中不存在这种验证。 虽然以太坊的模型将状态与源代码紧密结合,限制了外部参与者的潜在干扰,但Solana的可执行程序和账户的分离要求开发者采取额外的预防措施。 在Solana上,必须仔细检查传递到程序中的每个账户,以确保其具有正确的所有权类型和预期的关系

签名者账户转发

在以太坊上,授权非常简单。 全局变量 msg.sender 可用于安全地确定函数的直接调用者,这通常足以授权特权操作。 在Solana上,可以采用类似的方法,利用签名者账户。

Solana中的签名者账户充当为交易提供有效签名的实体,确认其执行操作的意图和权限。 这些账户可以是传统的用户密钥对(其中私钥直接授权操作),也可以是程序派生地址(PDA)。 PDA是从一组种子和一个程序ID确定性生成的账户地址。 与密钥对不同,PDA没有私钥。 只有从中定义PDA的程序才能使用 invoke_signed 函数将PDA标记为签名者账户。

msg.sender不同,签名者账户不能安全地确定直接调用者。 Solana中的程序允许调用其他程序,这些程序具有与它们自己被调用的相同的签名者账户,从而有效地转发签名者账户。

Solana程序可以通过CPI(跨程序调用)调用其他程序。 有两种执行CPI的方法:invokeinvoke_signed。 如前所述,invoke_signed用于将PDA账户(必须从调用程序派生)标记为CPI的签名者。 另一方面,invoke 函数不添加任何签名者。 这两个函数都可以转发已经标记为签名者的签名者账户。

因此,当用户或程序提供签名者账户时,他们实际上是将一块经过验证的权限委托给下游程序。 当这种信任被放错地方时,就会出现漏洞。 如果使用具有敏感特权的签名者账户调用不受信任的程序,则它可以转发此签名者并带有任意参数,以利用这些特权。 例如,攻击者可能会利用这一漏洞来代表不知情的用户执行操作。

当对可以由用户确定或影响的程序执行签名CPI时,程序尤其面临风险。 恶意用户可能会故意将CPI定向到恶意程序,从而有效地劫持签名者账户以冒充易受攻击的程序。 如果CPI允许用户指定remaining_accounts以提高调用的灵活性,则问题的严重性可能会进一步提高。 虽然这显着提高了合法用户的Solana程序的灵活性和可组合性,但也带来了额外的风险。 利用不安全的签名处理的攻击者可能能够利用这些remaining_accounts来包括进行特权调用所需的任何必需的其他账户。

考虑以下时间锁程序:

/// 使用指定的延迟对任意任务进行排队。
/// 调用者提供目标程序、指令数据 (task_data)
/// 和一个延迟(以秒为单位),它决定了何时可以执行该任务。

pub fn queue_task(
    ctx: Context<QueueTask>,
    task_data: Vec<u8>,
    target_program: Pubkey,
    delay: i64
) -> ProgramResult {

    let task = &mut ctx.accounts.task;

    // 获取当前的unix时间戳
    let clock = Clock::get()?;

    task.release_time = clock.unix_timestamp + delay;  // 将执行时间设置为现在 + 延迟
    task.target_program = target_program; // 要在执行时调用的目标程序
    task.authority = *ctx.accounts.authority.key; // 存储的任务创建者,用于授权

    task.task_data = task_data; // 任意指令数据

    Ok(())
}

#[derive(Accounts)]
pub struct QueueTask<'info> {
    #[account(\
        init,\
        payer = authority,\
        space = 8 + Task::LEN,\
    )]

    pub task: Account<'info, Task>,

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

    pub system_program: Program<'info, System>,
}

该程序允许任何人对具有任意延迟的任务进行排队,存储任务的创建者以进行授权。 程序和参数由创建者控制。 现在考虑一下这个程序的执行函数:

/// 执行排队的任务。
/// 任何人都可以调用此指令,但只有在时间锁过期后才会执行该任务。

pub fn execute_task(ctx: Context<ExecuteTask>) -> ProgramResult {
    let task = &ctx.accounts.task;

    // 确保时间锁已过
    let clock = Clock::get()?;
    if clock.unix_timestamp < task.release_time {
        return Err(ErrorCode::TimelockNotExpired.into());
    }

    let cpi_accounts: Vec<AccountMeta> =
        std::iter::once(&ctx.accounts.task_authority).chain(
        ctx
        .remaining_accounts
        .iter()
        ).map(|acc| AccountMeta {
            pubkey: *acc.key,
            is_signer: acc.is_signer,
            is_writable: acc.is_writable,
        })
        .collect();

    let ix = Instruction {
        program_id: task.target_program,
        accounts: cpi_accounts,
        data: task.task_data.clone(),
    };

    invoke_signed(&ix, ctx.remaining_accounts, &[&[TIMELOCK_SIGNER]])?;
    Ok(())
}

#[derive(Accounts)]
pub struct ExecuteTask<'info> {
    #[account(mut, close = authority)]
    pub task: Account<'info, Task>,

    #[account(address = task.authority)]
    pub task_authority: AccountInfo<'info>,

    /// 只有这样才能从正在关闭的账户收到lamports。
    #[account(mut)]
    pub authority: Signer<'info>,

    #[account(\
        seeds = [TIMELOCK_SIGNER],\
        bump\
    )]
    pub timelock_signer: UncheckedAccount<'info>,

    pub system_program: Program<'info, System>,
}

这个执行函数允许任何人在时间过去后执行任务,原始任务创建者被预先添加到账户列表中以进行授权。 对于以太坊开发者来说,这可能看起来是安全的。 但是,根据Solana的安全模型,该程序包含一个严重错误。

execute_task 函数中的CPI对所有任务使用相同的签名者PDA。 这意味着恶意任务可能会滥用签名者来冒充时间锁程序。 假设攻击者要创建以下程序:

#[program]
pub mod malicious_program {
    use super::*;
    // 此指令通过 CPI 将签名者账户转发到易受攻击的程序。
    // 然后,易受攻击的程序认为转发的账户已合法签名。
    pub fn forward_signer(ctx: Context<ForwardSigner>) -> Result<()> {
        let accounts = vec![AccountMeta::new(ctx.accounts.user.key(), true)];
        let instruction_data: Vec<u8> = vec![]; // 攻击者控制的数据
        let instruction = Instruction {
            program_id: ctx.accounts.target_program.key(),
            accounts,
            data: instruction_data,
        };

        invoke(&instruction, &[ctx.remaining_accounts])?;
        Ok(())
    }
}

#[derive(Accounts)]
pub struct ForwardSigner<'info> {
    /// CHECK: 这是攻击者的密钥,因为他们创建了恶意任务
    pub ignored_task_creator: UncheckedAccount<'info>,
    /// CHECK: 这是目标程序的 ID
    pub target_program: UncheckedAccount<'info>,
}

该程序旨在接收来自时间锁程序的CPI,剥离用于重要安全检查的任务创建者账户,并将调用(时间锁签名完整)重定向到其他程序。 如果不知情的程序向时间锁公开了一个特权函数,并使用第一个账户作为授权,则攻击者可以利用它。 首先,只需对这个恶意程序排队一个延迟最短的任务,然后执行该任务,并提供目标程序,然后是目标调用所需的账户列表。 这种CPI与来自时间锁的合法CPI无法区分。 因此,攻击者可以绕过时间锁中任何现有任务的延迟,并可能执行他们未被授权执行的函数。

这个例子说明了误解Solana安全模型的危险。 本质上,错误处理签名者账户可以将有用的委托机制转变为可利用的后门,攻击者可以通过链接CPI来绕过关键的授权检查。 应该仔细考虑授予签名者账户的权限,并且不应使用单个签名者账户来授权多个操作。

Solana上的以太坊开发者:结论

从以太坊到Solana的过渡需要重新考虑某些安全假设。 不充分的账户验证和未经检查的签名者账户转发可能会为利用打开大门。 开发者必须在账户之间强制执行严格的所有权类型检查关系验证签名者处理,以降低风险。 拥抱Solana独特的模型需要一种谨慎和更新的程序设计方法,以确保针对其架构中固有的漏洞提供强大的保护。


由Dedaub出品,最好的EVM bytecode decompiler的家。

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

0 条评论

请先 登录 后评论
Dedaub
Dedaub
Security audits, static analysis, realtime threat monitoring