审计 Solana 程序前,你需要了解这些
- 原文链接:www.infect3d.xyz/blog...
- 译者:AI翻译官,校对:翻译小组
- 本文链接:learnblockchain.cn/article…
和大多数没有专业软件开发背景的 Web3 安全研究人员一样,我的旅程始于 Ethereum、EVM 和 Solidity。自从我踏入这片无尽的 黑暗森林 已经过去了十六个月。
我最近决定看一看 Solana,以扩大我在可编程区块链方面的专业知识。并不是因为 EVM 无聊(我知道你要说什么)。而是由于 Solana 生态系统最近获得了越来越多的关注,我觉得能够满足这种新出现的需求是一个好主意。
由于我对 Rust 和 Solana 没有任何先前的了解,我花了大约 7 天时间来学习基础知识。我的学习经历得到了许多优秀资源的帮助,特别是来自 Rareskills 的这门很棒的课程:60 Days of Solana。
我也试图找到一个指南,以帮助我快速掌握潜入代码库的最低要求,但并没有找到——这就是我写这本指南的原因。
我的目标是帮助你轻松导航 Solana 项目。这不需要你具备 Solana 的先前知识,因为我会尽量解释跟随本文所需的重要概念。
虽然这将帮助你快速开始 Solana 的部分,但你仍然需要学习一点 Rust,以真正对代码库感到舒适,因为一些编程概念是非常特定于这个语言的。然而,不要感到压力去单独掌握 Rust——你可以在审计时学习。Solana 生态系统只使用了 Rust 功能的一小部分,借助 LLM 来帮助你理解特定代码段,学习过程应该是相当可管理的。
Solana 是一个高吞吐量区块链,在 TVL 方面排名第三(仅次于 Bitcoin 和 Ethereum),也是在 Ethereum 之后第二重要的可编程区块链。
可编程区块链可以被视为一个分布式世界计算机,存储数据,并为任何愿意支付的人执行命令。
Solana 非常有趣,因为它走了一条与 Ethereum 非常不同的道路,从基础开始创建自己的层。它的架构通过特定机制优先考虑可扩展性和速度。
但我们真正感兴趣的是 Solana 的执行层,也称为 Solana 虚拟机 (SVM)。该层负责接收交易并根据区块链的状态执行它们。
在计算机上,数据存储在文件中——在 Solana 上,数据存储在 账户 中。这些数据可以是信息数据或可执行数据,但归根结底,这些只不过是在“账户”中存储的一些零和一。
在我看来,账户类似于文件(具有特定属性),因为它们存在不同类型(文件扩展名),其中一些具有“本地扩展名”(数据账户、可执行账户),然后它们可以包含任何东西,从 DEX 程序到用户数据(例如,用于存储代币用户余额的“数据账户”)。
另外,像操作系统上的文件一样,账户有访问控制机制,确保只有授权地址可以对其执行操作。
账户模型 可能是我花费最多时间去理解的内容,可能是因为模型与如何在代码中反映的关系并不明显。
但实际上,这很简单理解。
来自我之前提到的 Rareskills 课程 的一个很好的类比将 Solana 与众所周知的 Linux 原则进行了比较:“一切都是文件。”
对于 Solana,我们可以说,“一切都是账户。”
账户实际上是在区块链上专门的“空间”(最多 10MB),可以包含不同种类的信息,并且可以通过一个代表其在区块链中的“位置”的地址进行访问。
在 EVM 上,账户可以同时保存逻辑和可变数据,而在 Solana 中,账户可以保存数据或可执行逻辑,但不能同时保存这两者。
如果 Solana 账户不能同时保存数据和逻辑,这意味着程序不能直接访问数据。那么程序如何在需要读取和写入数据以执行其逻辑时保持动态呢?
程序如何创建和维护有关其状态和用户的信息?
一个程序(一般意义上)通常需要维护状态变量,以存储其操作所需的基本信息。以自动售货机程序为例——它需要存储各种信息,例如:每种产品的剩余数量、价格、在网格上的位置、技术员的访问代码等。这被称为变量,因为显然,它们的值会随着时间而改变,以响应各种操作(接收钱、交付产品等)。
与大多数传统系统不同,Solana 中的 可执行账户程序 不能将数据与其可执行代码存储在一起。相反,它们必须利用替代存储解决方案。这就是 数据账户 作为主要存储机制发挥作用的地方。
以下是来自 Solana 文档的插图:表示了 2 个账户:一个程序和一个数据账户。程序账户拥有数据账户,并用它来存储与其程序相关的信息。
对于程序所需的每个新变量,可以创建一个新的数据账户。一旦创建,要访问存储在新创建账户中的变量,程序只需记住其地址即可,无论是写入还是读取。
如你所想,为每个所需变量创建一个新的唯一账户将非常低效。相反,Solana 允许开发人员创建结构化存储解决方案,其中多个值可以存储在单个账户中。
为了以结构化的方式写入数据账户,程序使用 序列化程序,而要将其读取回来则需要 反序列化程序(在使用普通的 Solana 框架和库时,该过程是透明的)。但是这些数据账户是如何创建的呢?
账户(因此数据账户)都有自己在区块链上的地址,我们可以用它们来访问它们。虽然对于程序要求外部数据的地址以便读取是合理的,但相反,如果程序必须存储或请求用户输入其自己的状态变量的地址,这将是非常低效的。
幸运的是,情况并非如此,从程序(由程序创建)的数据账户具有一种称为 程序派生地址 (PDA) 的特殊类型地址。
PDA 是通过 哈希 程序自身的地址和任何可选的种子(字节值),以及一个增量(你可以稍后查找这个,但现在并不重要)以确定的方式生成的。
因为这个过程是确定性的,程序现在有办法创建新变量,并自动检索这些变量。再一次,得益于可用的 Solana 框架和库,整个过程都是透明的。
Solana 程序 是执行区块链逻辑的基础。虽然 Solana 提供了几个基本的 本地程序(例如账户部署工具),但生态系统主要由用户部署的程序组成,以扩展区块链的功能。
与 EVM 智能合约不同,Solana 程序 是无状态的,不能存储可变数据(它们可以有常量,因为这嵌入在程序的字节码中)。
相反,程序在被调用时必须接收数据账户地址作为参数,从而允许它们访问和操作外部数据。
要访问一个程序并执行其逻辑,我们需要调用所谓的程序 指令。
一个 指令 需要三个基本组件:
为了演示这些概念,让我们来看一个简化的例子(注意,我已删除代码中的许多重要内容以便于理解——我们将在第 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_account
和 vault
,两个数据账户,代币将从中提取/存入。如我们在第 2 部分所说,数据通常组织为结构,对于这两个账户是 TokenAccount
token_program
,一个程序账户,我们将调用它以执行转移跨程序调用 由从另一个程序执行的指令组成。要做到这一点,必须提供(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 的本地加密货币/代币)从签署者发送到接收者。
我们首先需要创建指令将要执行的上下文:
system_program.to_account_info
表示Transfer
中,包含两个信息:from
(发送 SOL 的地址)和 to
(接收 SOL 的地址)。然后,使用上下文(及额外参数 amount
)执行 CPI。
在这个例子中,被调用的程序是 Solana 系统程序,但如果我们调用另一个用户部署的程序,它看起来会是这样:
external_program::cpi::instruction_name(cpi_ctx, additional_param1, ...)
Solana 的代币生态系统与我们在 EVM 中所知的大相径庭。
在 EVM 中,每个代币都有自己的合约(即 Solana 中的程序),而在 Solana 中,有一个程序称为 SPL Token 程序,可以派生出 铸造账户。
每个代币的实例将拥有其唯一的 铸造账户:例如 USDC 铸造账户,USDT 铸造账户,等等。
然后,从这些铸造账户中,用户可以创建 代币账户 —— 专门用于持有特定代币的账户。
考虑一个像 Alice 这样的用户,她持有多种代币(USDC、USDT 和 DEGEN)。她需要为每个资产创建一个单独的代币账户,所有这些账户都是从各自的铸造账户派生而来的。这种结构在 Solana 生态系统中的所有用户中均适用。
以下是一个示意图,显示了代币程序的组织方式:
在这个架构中,唯一的程序账户是 SPL Token 程序。其他账户是可以由 SPL Token 程序修改的数据账户。
SPL Token 程序 是这些账户的所有者,这意味着只有该程序有权修改这些账户。这意味着要更新它们,我们需要执行一个 CPI 到 SPL Token 程序,并提供我们希望看到更新的账户(如上文第 4 部分所述)。
显然,SPL Token 程序实现了访问控制逻辑,以防止用户从他们不拥有的账户转移代币。
如果你还记得我们所说的 PDAs(程序派生账户),只有部署这些账户的程序才能修改其状态。
但这些数据账户(铸造账户和代币账户)有许多字段,其中一个称为 owner
,不同于我们在第二段中提到的 程序所有者,后者不是数据的一部分,而是以更高层次执行的“Solana 元数据”。
SPL Token 程序检查这些账户内部的 owner
字段,并将其与调用者的签名进行比较(类似于 EVM 中的 tx.origin),以确保只有合法访问。
请参阅 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.toml
:Rust 工作区配置,里面埋藏着一些非常重要的内容:默认情况下,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 项目的样子。
Anchor 是一个 Rust (和 TypeScript 用于测试)框架,由 Coral 开发,提供帮助开发人员更快速编写安全程序的工具。
该框架包含许多 宏 和 特性 (Rust 概念),实现了许多重要组件,例如我们在 第 2 章 中谈到的账户数据 定序器和反定序器,账户上的 自动访问控制检查,或者一个将指令调用路由到适当逻辑部分的 指令调度器。
正如你所看到的,所有这些工具都非常重要,你可能不想从头开始重新开发这些组件。出于这个原因,你将遇到的大多数 Solana 程序都使用 Anchor,而你需要了解其基本知识。
让我们从你总会遇到的两个程序 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 约束。
感谢你花时间阅读本指南!
我希望它能为你提供一个扎实的基础,开始探索 Solana 项目。请记住,这只是开始,学习的最佳方式是深入实际代码库,并将我们在这里讨论的概念与真实实现相结合。
如果你发现本指南对你有帮助,请随时与其他研究者分享。我也非常感谢任何 反馈或建议 来改进,因为这有助于使资源对所有人都更好。
对于那些希望深入了解 Solana 审计的人,我收集了一些有趣的资源:
祝审计愉快!
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!