审计 Solana 程序前,你需要了解这些

审计 Solana 程序前,你需要了解这些

介绍

一点故事

和大多数没有专业软件开发背景的 Web3 安全研究人员一样,我的旅程始于 EthereumEVMSolidity。自从我踏入这片无尽的 黑暗森林 已经过去了十六个月。

我最近决定看一看 Solana,以扩大我在可编程区块链方面的专业知识。并不是因为 EVM 无聊(我知道你要说什么)。而是由于 Solana 生态系统最近获得了越来越多的关注,我觉得能够满足这种新出现的需求是一个好主意。

由于我对 RustSolana 没有任何先前的了解,我花了大约 7 天时间来学习基础知识。我的学习经历得到了许多优秀资源的帮助,特别是来自 Rareskills 的这门很棒的课程:60 Days of Solana

我也试图找到一个指南,以帮助我快速掌握潜入代码库的最低要求,但并没有找到——这就是我写这本指南的原因。

你将学到什么

我的目标是帮助你轻松导航 Solana 项目。这不需要你具备 Solana 的先前知识,因为我会尽量解释跟随本文所需的重要概念。

虽然这将帮助你快速开始 Solana 的部分,但你仍然需要学习一点 Rust,以真正对代码库感到舒适,因为一些编程概念是非常特定于这个语言的。然而,不要感到压力去单独掌握 Rust——你可以在审计时学习。Solana 生态系统只使用了 Rust 功能的一小部分,借助 LLM 来帮助你理解特定代码段,学习过程应该是相当可管理的。

1. Solana

Solana 是一个高吞吐量区块链,在 TVL 方面排名第三(仅次于 Bitcoin 和 Ethereum),也是在 Ethereum 之后第二重要的可编程区块链。

可编程区块链可以被视为一个分布式世界计算机,存储数据,并为任何愿意支付的人执行命令。

Solana 非常有趣,因为它走了一条与 Ethereum 非常不同的道路,从基础开始创建自己的层。它的架构通过特定机制优先考虑可扩展性和速度。

但我们真正感兴趣的是 Solana 的执行层,也称为 Solana 虚拟机 (SVM)。该层负责接收交易并根据区块链的状态执行它们。

在计算机上,数据存储在文件中——在 Solana 上,数据存储在 账户 中。这些数据可以是信息数据或可执行数据,但归根结底,这些只不过是在“账户”中存储的一些零和一。

在我看来,账户类似于文件(具有特定属性),因为它们存在不同类型(文件扩展名),其中一些具有“本地扩展名”(数据账户、可执行账户),然后它们可以包含任何东西,从 DEX 程序到用户数据(例如,用于存储代币用户余额的“数据账户”)。
另外,像操作系统上的文件一样,账户有访问控制机制,确保只有授权地址可以对其执行操作。

2. 账户模型

账户模型 可能是我花费最多时间去理解的内容,可能是因为模型与如何在代码中反映的关系并不明显。
但实际上,这很简单理解。

来自我之前提到的 Rareskills 课程 的一个很好的类比将 Solana 与众所周知的 Linux 原则进行了比较:“一切都是文件。”

对于 Solana,我们可以说,“一切都是账户。”

账户实际上是在区块链上专门的“空间”(最多 10MB),可以包含不同种类的信息,并且可以通过一个代表其在区块链中的“位置”的地址进行访问。

EVM 上,账户可以同时保存逻辑和可变数据,而在 Solana 中,账户可以保存数据或可执行逻辑,但不能同时保存这两者。

如果 Solana 账户不能同时保存数据和逻辑,这意味着程序不能直接访问数据。那么程序如何在需要读取和写入数据以执行其逻辑时保持动态呢?

程序如何创建和维护有关其状态和用户的信息?

3. 从数据账户读取和写入数据

一个程序(一般意义上)通常需要维护状态变量,以存储其操作所需的基本信息。以自动售货机程序为例——它需要存储各种信息,例如:每种产品的剩余数量、价格、在网格上的位置、技术员的访问代码等。这被称为变量,因为显然,它们的值会随着时间而改变,以响应各种操作(接收钱、交付产品等)。

与大多数传统系统不同,Solana 中的 可执行账户程序 不能将数据与其可执行代码存储在一起。相反,它们必须利用替代存储解决方案。这就是 数据账户 作为主要存储机制发挥作用的地方。

以下是来自 Solana 文档的插图:表示了 2 个账户:一个程序和一个数据账户。程序账户拥有数据账户,并用它来存储与其程序相关的信息。

image.png Data and Program Accounts, source: https://solana.com/docs/core/accounts#data-account

对于程序所需的每个新变量,可以创建一个新的数据账户。一旦创建,要访问存储在新创建账户中的变量,程序只需记住其地址即可,无论是写入还是读取。

如你所想,为每个所需变量创建一个新的唯一账户将非常低效。相反,Solana 允许开发人员创建结构化存储解决方案,其中多个值可以存储在单个账户中。

image.png

为了以结构化的方式写入数据账户,程序使用 序列化程序,而要将其读取回来则需要 反序列化程序(在使用普通的 Solana 框架和库时,该过程是透明的)。但是这些数据账户是如何创建的呢?

账户(因此数据账户)都有自己在区块链上的地址,我们可以用它们来访问它们。虽然对于程序要求外部数据的地址以便读取是合理的,但相反,如果程序必须存储或请求用户输入其自己的状态变量的地址,这将是非常低效的。

幸运的是,情况并非如此,从程序(由程序创建)的数据账户具有一种称为 程序派生地址 (PDA) 的特殊类型地址。
PDA 是通过 哈希 程序自身的地址和任何可选的种子(字节值),以及一个增量(你可以稍后查找这个,但现在并不重要)以确定的方式生成的。

image.png

因为这个过程是确定性的,程序现在有办法创建新变量,并自动检索这些变量。再一次,得益于可用的 Solana 框架和库,整个过程都是透明的。

4. 程序和指令

Solana 程序 是执行区块链逻辑的基础。虽然 Solana 提供了几个基本的 本地程序(例如账户部署工具),但生态系统主要由用户部署的程序组成,以扩展区块链的功能。

EVM 智能合约不同,Solana 程序 是无状态的,不能存储可变数据(它们可以有常量,因为这嵌入在程序的字节码中)。

相反,程序在被调用时必须接收数据账户地址作为参数,从而允许它们访问和操作外部数据。

要访问一个程序并执行其逻辑,我们需要调用所谓的程序 指令

一个 指令 需要三个基本组件:

  1. 程序 ID:你想要执行的程序的地址
  2. 账户:指令需要与之交互的所有账户列表(读/写或通过外部调用,也称为 CPI)
  3. 指令数据:不需要专门账户的补充参数(转移金额、新值等)

为了演示这些概念,让我们来看一个简化的例子(注意,我已删除代码中的许多重要内容以便于理解——我们将在第 7 部分探索完整实现及所有必要组件):

pub fn deposit(ctx: Context<Deposit>, amount: u64) -> Result<()> {
    // 从 user_token_account 转移 `amount` 代币到 vault...
}

// 指令所需的上下文
pub struct Deposit<'info> {
    // 必须签名交易的用户钱包
    pub user: Signer<'info>,

    // 我们将从中提取的用户代币账户
    pub user_token_account: Account<'info, TokenAccount>,

    // 我们将把代币存入的金库
    pub vault: Account<'info, TokenAccount>,

    // 我们将用于转移代币的程序
    pub token_program: Program<'info, Token>,
}

上面是一个 deposit 指令,输入多个由 Deposit 结构描述的账户:

  • 第一个账户 user 是调用方账户(Signer 类型是特殊的,因为它会自动存储调用者信息)
  • user_token_accountvault,两个数据账户,代币将从中提取/存入。如我们在第 2 部分所说,数据通常组织为结构,对于这两个账户是 TokenAccount
  • token_program,一个程序账户,我们将调用它以执行转移

5. 跨程序调用(CPI)

跨程序调用 由从另一个程序执行的指令组成。要做到这一点,必须提供(1)程序的地址(2)程序指令所需的数据;我们称之为 CPI 上下文

这是我借用自这节课的一个示例:

    pub fn send_sol(ctx: Context<SendSol>, amount: u64) -> Result<()> {  
        let cpi_context = CpiContext::new(                   // 构建 CPI 上下文:
            ctx.accounts.system_program.to_account_info(),   //(1) 程序地址
            system_program::Transfer {                       //(2) 程序所需数据
                from: ctx.accounts.signer.to_account_info(), //(2)
                to: ctx.accounts.recipient.to_account_info(),//(2)
            }
        );

        // 用上下文(及额外参数 `amount`)调用程序
        let result = system_program::transfer(cpi_context, amount); 

        if result.is_ok() {
            return Ok(());
        } else {
            return err!(Errors::TransferFailed);
        }
    }

该指令将 SOL(Solana 的本地加密货币/代币)从签署者发送到接收者。

我们首先需要创建指令将要执行的上下文:

  1. 程序的地址,这里由 system_program.to_account_info 表示
  2. 指令所需的数据,这些数据捆绑在结构 Transfer 中,包含两个信息:from(发送 SOL 的地址)和 to(接收 SOL 的地址)。

然后,使用上下文(及额外参数 amount)执行 CPI。

在这个例子中,被调用的程序是 Solana 系统程序,但如果我们调用另一个用户部署的程序,它看起来会是这样:
external_program::cpi::instruction_name(cpi_ctx, additional_param1, ...)

6. Tokens

Solana 的代币生态系统与我们在 EVM 中所知的大相径庭。

在 EVM 中,每个代币都有自己的合约(即 Solana 中的程序),而在 Solana 中,有一个程序称为 SPL Token 程序,可以派生出 铸造账户

每个代币的实例将拥有其唯一的 铸造账户:例如 USDC 铸造账户,USDT 铸造账户,等等。

然后,从这些铸造账户中,用户可以创建 代币账户 —— 专门用于持有特定代币的账户。

考虑一个像 Alice 这样的用户,她持有多种代币(USDC、USDT 和 DEGEN)。她需要为每个资产创建一个单独的代币账户,所有这些账户都是从各自的铸造账户派生而来的。这种结构在 Solana 生态系统中的所有用户中均适用。

以下是一个示意图,显示了代币程序的组织方式:

SPL Token Program and related accounts

在这个架构中,唯一的程序账户是 SPL Token 程序。其他账户是可以由 SPL Token 程序修改的数据账户。

SPL Token 程序 是这些账户的所有者,这意味着只有该程序有权修改这些账户。这意味着要更新它们,我们需要执行一个 CPI 到 SPL Token 程序,并提供我们希望看到更新的账户(如上文第 4 部分所述)。

显然,SPL Token 程序实现了访问控制逻辑,以防止用户从他们不拥有的账户转移代币。

如果你还记得我们所说的 PDAs(程序派生账户),只有部署这些账户的程序才能修改其状态。

但这些数据账户(铸造账户和代币账户)有许多字段,其中一个称为 owner,不同于我们在第二段中提到的 程序所有者,后者不是数据的一部分,而是以更高层次执行的“Solana 元数据”。

SPL Token 程序检查这些账户内部的 owner 字段,并将其与调用者的签名进行比较(类似于 EVM 中的 tx.origin),以确保只有合法访问。

请参阅 Solana 文档 中的这一精心设计的图表: different type of account ownership, source: https://solana.com/docs/core/accounts#data-account

different type of account ownership

7. Solana 项目架构

如果你对 Solana 项目架构或 Rust 项目一般不太熟悉,结构一开始可能会显得有些压倒性。让我们先通过检查每个文件的目的,拆解这个简单的项目(在转向一个更复杂的项目之前):

# 1. 基本项目(无指令文件夹)
my-solana-project/
├── Anchor.toml                    # Anchor 配置文件
├── Cargo.toml                     # Rust 配置文件
├── programs/                      # 所有程序的文件夹
│   └── my-program/                # 每个程序将具有自己的文件夹
│       └── src/
│           ├── instructions/      # 包含由 lib.rs 调用的每个指令的文件夹
│           │   ├── mod.rs         # lib.rs 导入以导入所有指令
│           │   ├── initialize.rs  # 每个指令
│           │   ├── deposit.rs
│           │   └── withdraw.rs
│           ├── lib.rs             # 主要程序文件
│           ├── state.rs           # 程序状态结构
│           └── errors.rs          # 自定义错误
└── tests/
    └── my-program.ts

项目特定文件:

  • Anchor.toml:这是 Anchor 工作的配置文件,可以在 这里 查看可以设置的内容
  • Cargo.tomlRust 工作区配置,里面埋藏着一些非常重要的内容:默认情况下,Rust 不检查溢出,但可以在这个文件中通过设置 [profile.release] 部分下的键 overflow-checks = true 来配置 Cargo.toml 文件的例子 这里

程序特定文件:

  • programs/ : 此文件夹将存储所有程序
  • my-program/:这里应该是程序名称,具体取决于项目,在 programs 父文件夹中可能会有多个程序文件夹
  • instructions/:包含可以从 lib.rs 调用的所有指令的逻辑
  • lib.rs:程序的入口点,包含公共访问的函数(受任何已实现的授权逻辑限制)。它通常充当一个包装器,将调用路由到 instructions 文件夹中的适当指令处理器
  • state.rs:通常_ 用于存放将由你的指令使用的不同数据结构
  • error.rs:自定义错误代码定义的集中文件
  • tests/:测试目录。虽然你可以在 TypeScript 和 Rust 中编写测试,但由于 TypeScript 具有更全面的测试库支持,因此优先使用 TypeScript

现在让我们看看另一个项目(我只会讨论差异):

my-solana-project/
├── Anchor.toml
├── Cargo.toml
├── package.json
├── programs/
│   ├── my-program/           # 第一个程序
│   │   ├── Cargo.toml
│   │   └── src/
│   │       ├── lib.rs        # 每个程序都有自己的入口文件
│   │       ├── instructions/
│   │       │   ├── mod.rs
│   │       │   ├── deposit.rs
│   │       │   └── withdraw.rs
│   │       ├── state.rs
│   │       └── errors.rs
│   └── my-other-program/     # 第二个程序
│       ├── Cargo.toml
│       └── src/
│           ├── lib.rs          # 每个程序都有自己的入口文件
│           ├── instructions/   # 指令根据它们的共性被组织在不同的文件夹中
│           │   ├── mod.rs
│           │   ├── utils.rs    # 一个非指令文件,包含被指令使用的工具
│           │   ├── user_management/    # 用户相关的指令
│           │   │   ├── mod.rs
│           │   │   ├── create_user.rs
│           │   │   └── update_user.rs
│           │   └── token_management/   # 代币相关的指令
│           │       ├── mod.rs
│           │       ├── mint.rs
│           │       └── burn.rs
│           ├── state/       # 我们更改为一个包含多个状态文件的状态文件夹
│           │   ├── mod.rs
│           │   ├── user.rs  # 对于每个指令组
│           │   └── token.rs # 对于每个指令组
│           └── errors.rs
└── tests/

这里的主要区别是:

  • 该项目同时管理多个程序:每个程序将在区块链上单独和独立部署,每个程序都有自己的公共地址。
  • 由于 my-other-program/ 实现了更复杂的逻辑,开发人员决定根据指令的共同特性将指令分组到不同的文件夹中。
    • 一个独立的 utils.rs 文件位于指令组外部,包含跨多个指令使用的共享工具函数(例如数学运算)
    • 你会看到一个 mod.rs 文件位于 instructions/ 的根部以及每个指令组文件夹内:这用于简化 lib.rs 中的指令导入
    • 原来的单个 state.rs 文件演变为一个专门的 state/ 目录,具有分离的状态文件,与指令组结构相对应

这基本上就是你将遇到的大多数 Solana 项目的样子。

8. Anchor

Anchor 是一个 Rust (和 TypeScript 用于测试)框架,由 Coral 开发,提供帮助开发人员更快速编写安全程序的工具。

该框架包含许多 特性 (Rust 概念),实现了许多重要组件,例如我们在 第 2 章 中谈到的账户数据 定序器和反定序器,账户上的 自动访问控制检查,或者一个将指令调用路由到适当逻辑部分的 指令调度器

正如你所看到的,所有这些工具都非常重要,你可能不想从头开始重新开发这些组件。出于这个原因,你将遇到的大多数 Solana 程序都使用 Anchor,而你需要了解其基本知识。

主程序 (lib.rs)

让我们从你总会遇到的两个程序 declare_id!()#[program] 开始。

它们位于 lib.rs,可以视为程序的“主入口”,因为这是所有程序入口点的位置。

看看这个示例代码:

    use anchor_lang::prelude::*;

    declare_id!("ABC123xyz...");

    #[program]
    pub mod basic_program {
        use super::*;

        pub fn entrypoint_to_instruction_1(ctx: Context<InstructionOne>) -> Result<()> {
            instructions::instruction_1::handler(ctx)
        }

        pub fn entrypoint_to_instruction_2(ctx: Context<InstructionTwo>) -> Result<()> {
            instructions::instruction_2::handler(ctx)
        }
    }

declare_id!() 是一个 函数宏(基本上是一个在编译前注入新代码的强大工具),用于定义程序的链上地址。你不需要了解更多,因为在使用 Anchor CLI 创建程序时会自动填充该地址。

#[program] 是一个 属性宏(类似于其他宏,但应用于结构体),用于创建一个低级调度器,该调度器接受事务/CPI 并将其重定向到程序代码中执行请求的函数的正确位置。

调度器确保授权、输入验证以及许多其他 安全检查,这得益于我们将在接下来的章节中进一步看到的一些其他宏。

存款指令

你还记得我们在第 4 部分中看到的存款指令吗?

这是更完整的实现:

pub fn deposit(ctx: Context<Deposit>, amount: u64) -> Result<()> {
    // 从 user_token_account 转移 `amount` 代币到 vault...
}

#[derive(Accounts)]
pub struct Deposit<'info> {
    // 用户的钱包,必须签署交易
    #[account(mut)]
    pub user: Signer<'info>,

    // 用户的代币账户,我们将从中提取
    #[account(
        mut,
        constraint = user_token_account.owner == user.key()
    )]
    pub user_token_account: Account<'info, TokenAccount>,

    // 我们将存入代币的程序库
    #[account(
        mut,
        seeds = [b"vault"],
        bump
    )]
    pub vault: Account<'info, TokenAccount>,

    // 我们将使用的 SPL 代币程序
    pub token_program: Program<'info, Token>,
}

// 第一步观察的是指令的输入内容:

  • Context<Deposit> 是一个包装器,包含执行指令所需的所有账户
  • amount: u64 是要存入的代币数量

Deposit 结构在指令下方描述,我们可以看到涉及相当数量的宏:

  • 第一个是 #[derive(Accounts)] 宏,它在 Anchor 中用于定义和验证指令在执行过程中将要交互的账户,支持 Solana 的并行事务处理。该宏根据它们的 类型 (Account, Signer, UncheckedAccount, Program) 实现账户的验证检查。
  • 第二个是 #[account()] 宏:如你所见,可以向该宏提供不同的参数。这些参数称为 账户约束,因为它们将添加额外的检查,每当程序访问这些账户时都会进行验证。

我们来看看一些约束:

  • 例如,user 账户具有 mut 约束,指示程序该账户是可变的。如果没有此约束,程序在任何时间修改账户时都会回滚。
  • 我们可以在下面看到下一个:constraint = user_token_account.owner == user.key()。此约束确保 user_token_account 的所有者字段和 user 的公共地址具有相同的值。这实际上是一种访问控制,确保只有交易的签署者被授权调用具有此特定 user_token_account 的指令。
  • 接下来我们看到的是 seeds = [b"vault"]:这特定于 PDA(程序派生账户)。这添加了检查,以确保提供的 vault 账户是我们当前调用的程序创建的,并且是用以下种子派生的。
  • bump 只是派生 PDA 时的一个附加参数。

// 你可以在 他们的文档 或 Ackee Blockchain 的 这门课程 中找到所有 Anchor 约束。

9. 学习资源

感谢你花时间阅读本指南!

我希望它能为你提供一个扎实的基础,开始探索 Solana 项目。请记住,这只是开始,学习的最佳方式是深入实际代码库,并将我们在这里讨论的概念与真实实现相结合。

如果你发现本指南对你有帮助,请随时与其他研究者分享。我也非常感谢任何 反馈或建议 来改进,因为这有助于使资源对所有人都更好。

对于那些希望深入了解 Solana 审计的人,我收集了一些有趣的资源:

祝审计愉快!

我是 AI 翻译官,为大家转译优秀英文文章,如有翻译不通的地方,在这里修改,还请包涵~

点赞 2
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
infect3d
infect3d
https://www.infect3d.xyz/blog/