文章强调了Rust的内存安全特性并不能完全保证Solana智能合约的安全性,Solana程序仍然需要进行安全审计,以发现逻辑错误、权限验证缺失、不安全的跨程序调用等问题。
Rust 的内存安全不足以保障区块链安全。了解为什么 Solana 程序仍然需要审计,以发现逻辑错误和缺失的签名者检查。
“Rust 是安全的,所以不需要审计。”
这种危险的信念可能会让你损失数百万美元的资金。内存安全无法保护你免受可能危及协议安全性的逻辑缺陷的影响。
你被黑客攻击不是因为内存问题,而是因为你的逻辑存在缺陷。
在快速变化的 Solana 生态系统和各种基于 rust 的生态系统中,开发人员常常过于信任 Rust 的安全功能。虽然 Rust 的内存安全性很强大,但它只是区块链协议复杂安全世界中的一层保护。事实很清楚:尽管 Rust 具有安全功能,但 Solana 已经在各种协议中经历了超过 10 亿美元的漏洞利用,而这些漏洞都不是由于内存损坏造成的。
Rust 的编译器是一项令人印象深刻的工程成就。它强制执行严格的所有权模型,从而消除了困扰其他系统编程语言的整类错误。要理解为什么 Rust 被广泛认为是“安全的”,让我们研究一下它可以防止哪些特定的内存安全问题,以及它与 C 和 C++ 等语言相比如何。
当程序向缓冲区写入的数据超过其容量时,就会发生缓冲区溢出,可能会覆盖相邻的内存。
C/C++(易受攻击):
#include <iostream>
#include <cstring>
int main() {
char buffer[10];
const char* long_string = "This is a long string";
strcpy(buffer, long_string); // No bounds checking
std::cout << buffer << std::endl;
return 0;
}
为什么这很危险: 在 C/C++ 中,写入数组时没有自动的边界检查。当 strcpy()
将长字符串复制到小缓冲区时,它会写入超过分配的 10 个字节,从而损坏相邻的内存。这可能会覆盖其他变量、返回地址或函数指针,从而可能允许攻击者执行任意代码。程序可能会崩溃,产生不正确的结果,或者在内存损坏的情况下继续运行,从而使行为不可预测且可利用。
Rust(安全):
fn main() {
let mut buffer = [0u8; 10];
let long_string = "This is a long string";
// Attempting to copy a string that's too long will cause a panic
buffer.copy_from_slice(long_string.as_bytes());
println!("{:?}", buffer);
}
Rust 如何防止它: Rust 的标准库为所有数组访问实现了边界检查。copy_from_slice()
方法要求源切片和目标切片具有相同的长度,这在编译时尽可能强制执行,否则在运行时强制执行。如果你尝试使用索引访问超出范围的数组,Rust 会发生 panic(安全崩溃),而不是允许内存损坏。这是由编译器和运行时强制执行的,使得在不使用 unsafe
代码的情况下不可能发生缓冲区溢出。
当程序在释放内存后继续使用内存时,会发生释放后使用,从而导致不可预测的行为。
C++(易受攻击):
#include <iostream>
int* create_and_return_dangling_pointer() {
int local_var = 42;
return &local_var; // Returning a pointer to a local variable
}
int main() {
int* dangling_ptr = create_and_return_dangling_pointer();
std::cout << *dangling_ptr << std::endl; // Dereferencing the dangling pointer
return 0;
}
为什么这很危险: 当 create_and_return_dangling_pointer()
返回时,局部变量 local_var
超出范围,并且它在堆栈上的内存被回收。但是,该函数返回指向此无效内存位置的指针。当 main()
对此指针进行解引用时,它正在访问可能现在包含不同数据或被另一个函数使用的内存。如果攻击者可以操纵最终出现在该内存位置中的数据,这可能会导致数据损坏、崩溃或安全漏洞。
Rust(安全):
fn create_and_return_dangling_reference() -> &i32 {
let local_var = 42;
&local_var // Returning a reference to a local variable
}
fn main() {
let dangling_ref = create_and_return_dangling_reference();
println!("{}", dangling_ref);
}
Rust 如何防止它: Rust 的借用检查器会跟踪每个引用的生命周期,以确保它永远不会超过它指向的数据的生命周期。编译器分析代码并强制执行有关引用可以存在多长时间的规则。在此示例中,尝试返回对局部变量的引用会导致编译时错误,因为引用将超过变量的生命周期。借用检查器会强制你返回拥有的值(转移所有权)或确保引用指向生命周期至少与引用本身一样长的数据。
当多个线程并发访问同一内存位置时(至少有一个线程正在写入),并且没有适当的同步,就会发生数据竞争。
C++(易受攻击):
#include <iostream>
#include <thread>
volatile int shared_counter = 0;
void increment_counter() {
for (int i = 0; i < 100000; ++i) {
shared_counter++; // Non-atomic increment
}
}
int main() {
std::thread t1(increment_counter);
std::thread t2(increment_counter);
t1.join();
t2.join();
std::cout << "Counter value: " << shared_counter << std::endl;
return 0;
}
为什么这很危险: 操作 shared_counter++
不是原子的;它涉及读取值、递增它和将其写回。当两个线程同时执行此操作时,它们可能会读取相同的初始值,独立地递增它,然后都写回相同的递增值,从而有效地丢失其中一个递增。这会导致竞争条件,其中最终结果取决于线程执行的时序。数据竞争会导致难以重现和调试的细微错误,从而可能导致数据损坏或安全漏洞。
Rust(安全):
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let shared_counter = Arc::new(Mutex::new(0));
let counter1 = Arc::clone(&shared_counter);
let counter2 = Arc::clone(&shared_counter);
let t1 = thread::spawn(move || {
for _ in 0..100000 {
let mut num = counter1.lock().unwrap();
*num += 1;
}
});
let t2 = thread::spawn(move || {
for _ in 0..100000 {
let mut num = counter2.lock().unwrap();
*num += 1;
}
});
t1.join().unwrap();
t2.join().unwrap();
println!("Counter value: {}", *shared_counter.lock().unwrap());
}
Rust 如何防止它: Rust 的所有权系统通过其所有权和借用规则在编译时防止数据竞争。类型系统强制执行以下任一项:
当需要在线程之间共享可变状态时,Rust 需要显式同步原语,例如 Mutex
或 RwLock
。Arc
(原子引用计数)类型安全地在线程之间共享所有权,而 Mutex
确保一次只有一个线程可以访问数据。编译器强制你在不获取锁的情况下无法访问数据,从而使得在不使用 unsafe
代码的情况下不可能发生数据竞争。
当程序尝试通过空指针访问内存时,通常会导致崩溃,就会发生空指针解引用。
C++(易受攻击):
#include <iostream>
int main() {
int* ptr = nullptr;
// Dereferencing a null pointer
std::cout << *ptr << std::endl;
return 0;
}
为什么这很危险: 在 C/C++ 中,解引用空指针是未定义的行为,通常会导致程序崩溃(段错误)。但是,在某些系统上或在某些上下文中,它可能不会立即崩溃,而是访问无效内存,从而可能导致数据损坏或安全漏洞。空指针解引用是崩溃的常见原因,并且可能会被攻击者利用来导致拒绝服务,或者在某些情况下,执行任意代码。
Rust(安全):
fn main() {
let optional_value: Option<i32> = None;
// Attempting to unwrap a None value will cause a panic
let value = optional_value.unwrap();
println!("{}", value);
}
Rust 如何防止它: Rust 在传统意义上没有空指针。相反,它使用 Option<T>
枚举来表示值的存在或不存在。要访问 Option
内部的值,你必须显式处理 Some
情况(值存在)和 None
情况(无值)。编译器强制执行这种模式匹配,使得不可能意外地“解引用” None
值。这消除了与空指针解引用相关的整类错误和安全漏洞。
Rust 内存安全的核心是借用检查器,它对引用的使用方式强制执行严格的规则:
这些规则在编译时强制执行,从而在代码运行之前防止了整类内存安全错误。借用检查器分析整个程序中所有权和引用的流动,确保引用始终有效并且内存得到正确管理。
“Rust 的编译器可以帮助你编写内存安全程序。但内存安全只是实际协议安全的一个层面。”
虽然 Rust 可以防止内存损坏,但 Solana 智能合约面临着完全不同的漏洞类别。Solana 编程模型引入了独特的安全挑战,而 Rust 的内存安全功能根本无法解决这些挑战:
Rust 的内存安全保证无法解决上述任何 Solana 特有的问题。让我们来看看导致实际漏洞的最常见漏洞。
智能合约通常实现特权功能,这些功能只能由授权用户访问。Rust 的类型系统无法验证你是否已正确检查正确的权限是否已签署事务。
缺少单个权限检查可能会导致完全的协议compromise,从而允许攻击者修改关键协议参数。此漏洞已在实际中被重复利用,导致数百万美元的损失。
跨程序调用 (CPI) 是 Solana 可组合性的一个基本组成部分。但是,Rust 无法验证你是否正在调用正确的程序或传递正确的帐户。
攻击者可以通过传递一个模仿预期行为但窃取资金的恶意程序来利用此漏洞。这被称为“混淆代理”攻击,并且是 Solana 生态系统中几个备受瞩目的漏洞的原因。
Solana 中的Token帐户需要仔细验证。Rust 的类型系统不会验证Token帐户是否属于预期的 mint 或所有者。
如果没有适当的验证,攻击者可能会传递一个用于不同(可能毫无价值的)Token的Token帐户,并欺骗你的程序将其视为预期的Token。2022 年 3 月发生的 5200 万美元的 Cashio 黑客攻击正是利用了这一漏洞。
Solana 程序必须显式验证关键操作是否已获得相应签名者的授权。Rust 的编译器无法检测到你何时忘记了此关键检查。
缺少签名者检查可能会允许未经授权的提款,从而可能耗尽协议中的所有资金。此漏洞非常常见,以至于它是 Solana 审计中首先要检查的事项之一。
例如,即使验证了管理员密钥,也缺少管理员需要成为签名者的要求。如果没有这个,任何人都可以简单地使用管理员的公钥并利用系统。
当程序通过 CPI 与其他程序交互时,帐户状态可能会更改。Rust 不会在 CPI 之后自动重新加载帐户数据,从而导致状态不同步,Anchor 的使用也不会这样做。
如果没有重新加载,你的程序将在过时的数据上运行,从而可能导致不正确的计算或安全绕过。
为什么会发生这种情况?
Solana 的帐户模型会在指令开始时拍摄内存中数据的快照。如果另一个程序通过跨程序调用 (CPI) 更改此数据,则反序列化的结构不会自动更新。因此,在 Anchor 中,你需要在 Account<T>
上使用 reload()
函数来刷新存储中的 lamports、数据和所有者字段到内存副本。这些结构使用 Rc<RefCell<&mut [u8]>>
,但它们在 CPI 后不会自动刷新,因此任何直接读取仍会显示原始快照。如果不使用 reload()
,读取Token余额或自定义状态将显示过时的信息,从而导致在传输后计算余额时的逻辑错误或违规。当你在 AccountInfo
或 Account<T>
上调用 reload()
时,它会获取最新数据,重新序列化它,并更新 lamport 和所有者字段,从而确保将来的操作反映真实的链上状态。
Solana 程序通常为不同的目的定义多种帐户类型。如果没有适当的类型检查,一个帐户类型可能会被替换为另一个帐户类型。
如果没有适当的类型检查,攻击者可能会传递一种类型的帐户,而在另一种类型被期望的情况下,可能会绕过安全检查。Anchor 使用其帐户鉴别符自动处理此问题,但本机 Solana 程序必须手动实现这些检查。
程序派生地址 (PDA) 是 Solana 中的一个基本概念,允许程序以确定性的方式控制帐户。但是,Rust 不会验证 PDA 是否已正确派生或已正确验证。
如果没有适当的 PDA 验证,攻击者可能会传递一个与预期不同的帐户,从而可能未经授权访问程序功能。此漏洞已在多个 Solana 黑客攻击中被利用。
重新分配帐户数据需要仔细的内存管理,以避免安全问题。
重新分配期间不正确的内存处理可能会导致数据损坏,使用未初始化的内存或泄漏先前内存使用情况中的敏感数据。
Rust 的算术运算可能会在发布模式下溢出或下溢,Solana 使用的就是发布模式。
整数溢出和下溢可能会导致安全漏洞,例如绕过余额检查或导致不正确的计算。始终在 Solana 程序中使用检查的算术运算。
通过在 Cargo.toml
中包含属性 overflow-checks=true
,使用最新版本的 Anchor 或使用新版本的创建新的 Solana 项目可以帮助防止这些问题。但是,你需要确保此属性存在于代码库中。
从 PDA 转移 SOL (lamports) 时,你必须确保帐户保持免租金。
如果 PDA 低于免租金阈值,则可能会被运行时清除,从而导致程序状态丢失和潜在的安全问题。
Solana 允许在单个事务中执行多个指令。Rust 无法验证你的程序是否正确处理指令排序。
如果没有在每个指令中进行适当的验证,攻击者可能会以意想不到的方式链接指令,从而可能绕过安全检查。此漏洞已在多个 Solana 黑客攻击中被利用。
Anchor 是一个用于 Solana 程序开发的框架,旨在简化流程并减少常见的安全漏洞。它提供了一组宏、trait 和抽象,使编写 Solana 程序更加符合人体工程学且更少出错。
Anchor 的主要功能包括:
Anchor 已成为 Solana 程序开发的实际标准,大多数新项目都使用它而不是直接编写本机 Solana 程序。
Anchor 的帐户处理是其最强大的功能之一。让我们深入了解它如何工作的技术细节:
在本机 Solana 程序中,你需要手动序列化和反序列化帐户数据。Anchor 通过其 #[account]
宏自动执行此过程:
use anchor_lang::prelude::*;
#[account]
pub struct MyAccount {
pub data: u32,
}
在底层,#[account]
宏为你的结构实现 AccountSerialize
和 AccountDeserialize
特性,这些特性处理帐户数据的序列化和反序列化。它还在帐户数据的开头添加了一个 8 字节的鉴别符来标识帐户类型。
Anchor 的 Account<'info, T>
类型是 Solana AccountInfo
的包装器,它提供对帐户数据的类型安全访问。让我们看看这个包装器是如何工作的:
use anchor_lang::prelude::*;
#[derive(Accounts)]
pub struct MyInstruction<'info> {
#[account]
pub my_account: Account<'info, MyAccount>,
}
当你在程序中使用 Account<'info, T>
时,Anchor 会执行几项检查:
当你使用 Anchor 的 Context
类型(传递给你的指令处理程序)时,此包装和解包过程会自动发生。
Anchor 的 #[derive(Accounts)]
宏允许你指定对帐户的约束,这些约束会在你的指令处理程序调用之前自动检查:
use anchor_lang::prelude::*;
#[derive(Accounts)]
pub struct MyInstruction<'info> {
#[account(mut, has_one = authority)]
pub my_account: Account<'info, MyAccount>,
pub authority: Signer<'info>,
}
这些约束扩展为在你的指令处理程序之抢跑的代码:
// Expanded code from Anchor
if my_account.authority != *authority.key {
return Err(ErrorCode::ConstraintHasOne.into());
}
这种自动验证消除了 Solana 程序中许多常见的错误来源和安全漏洞。
虽然 Anchor 显着改善了开发体验并消除了许多常见的错误来源,但它并非万能的安全解决方案。
Anchor 的帐户约束功能强大,但可能会被误用或误解:
use anchor_lang::prelude::*;
#[derive(Accounts)]
pub struct MyInstruction<'info> {
#[account(has_one = authority)]
pub my_account: Account<'info, MyAccount>,
pub authority: Signer<'info>,
}
#[account]
pub struct MyAccount {
pub data: u32,
pub authority: Pubkey,
}
pub fn my_instruction(ctx: Context<MyInstruction>) -> Result<()> {
ctx.accounts.my_account.data = 42; // Error: cannot borrow `ctx.accounts.my_account` as mutable, as it is not declared as mutable
Ok(())
}
缺少 mut
约束会导致事务在尝试修改帐户时在运行时失败,但这不是在编译时捕获的。
Anchor 自动执行许多常见的验证,但它无法预测特定于你的协议的所有业务逻辑:
use anchor_lang::prelude::*;
#[derive(Accounts)]
pub struct MyInstruction<'info> {
#[account(has_one = authority)]
pub my_account: Account<'info, MyAccount>,
pub authority: Signer<'info>,
}
#[account]
pub struct MyAccount {
pub data: u32,
pub authority: Pubkey,
pub max_value: u32,
}
pub fn my_instruction(ctx: Context<MyInstruction>, new_data: u32) -> Result<()> {
if new_data > ctx.accounts.my_account.max_value {
return Err(ErrorCode::ValueTooHigh.into());
}
ctx.accounts.my_account.data = new_data;
Ok(())
}
#[error_code]
pub enum ErrorCode {
#[msg("Value is too high")]
ValueTooHigh,
}
Anchor 约束处理基本验证,但你仍然需要在指令处理程序中实现全面的检查。
开发人员通常认为,如果帐户通过了 Anchor 的验证,则可以安全使用:
use anchor_lang::prelude::*;
#[derive(Accounts)]
pub struct MyInstruction<'info> {
#[account(has_one = authority)]
pub my_account: Account<'info, MyAccount>,
pub authority: Signer<'info>,
}
#[account]
pub struct MyAccount {
pub data: u32,
pub authority: Pubkey,
}
pub fn my_instruction(ctx: Context<MyInstruction>, new_data: u32) -> Result<()> {
// Missing check: Is new_data a valid data for this account?
ctx.accounts.my_account.data = new_data;
Ok(())
}
Anchor 的帐户约束只是一个起点,而不是完整的安全解决方案。
Anchor 的 ctx.remaining_accounts
功能允许将可变数量的帐户传递给指令,但这些帐户绕过了 Anchor 的自动验证:
use anchor_lang::prelude::*;
#[derive(Accounts)]
pub struct MyInstruction<'info> {
pub my_account: AccountInfo<'info>,
}
pub fn my_instruction(ctx: Context<MyInstruction>) -> Result<()> {
for account in ctx.remaining_accounts.iter() {
// Missing validation: Is this account the type we expect?
// Missing validation: Does this account have the correct authority?
// Missing validation: Is this account initialized?
}
Ok(())
}
如果没有适当的验证,攻击者可能会通过 remaining_accounts
传递恶意帐户来绕过安全检查。
Anchor 的 init
约束有助于帐户初始化,但重新初始化攻击仍然是可能的:
use anchor_lang::prelude::*;
#[derive(Accounts)]
pub struct MyInstruction<'info> {
#[account(init, payer = payer, space = 8 + 4)]
pub my_account: Account<'info, MyAccount>,
pub payer: Signer<'info>,
pub system_program: Program<'info, System>,
}
#[account]
pub struct MyAccount {
pub data: u32,
}
pub fn my_instruction(ctx: Context<MyInstruction>, initial_data: u32) -> Result<()> {
// Potential reinitialization attack: Can this account be reinitialized with malicious data?
ctx.accounts.my_account.data = initial_data;
Ok(())
}
如果未仔细验证,init_if_needed
约束可能很危险,可能会允许攻击者使用恶意数据重新初始化帐户。
全面的 Solana 程序审计远远超出了 Rust 编译器或 Anchor 框架可以验证的内容:
审计员分析你的程序状态转换,以确保它们是安全且一致的:
审计员检查你的指令如何组合或排序:
许多漏洞涉及以意想不到的顺序调用指令或以开发人员未预料到的方式组合它们。审计员模拟这些场景以识别潜在的漏洞。
审计员分析你的程序如何与其他程序交互:
跨程序调用是 Solana 程序中漏洞的常见来源。审计员跟踪 CPI 链以识别潜在的攻击向量。
审计员使用高级测试技术来查找边缘情况:
这些技术可以发现通过手动代码审查难以找到的漏洞,例如导致 Mango Markets 漏洞的复杂价格操纵攻击。
2025 年 4 月,由于协议在计算 RateX PT Token价值的方式上存在严重缺陷,Loopscale 协议被利用价值 580 万美元。此漏洞表明,即使到 2025 年,智能合约中的逻辑漏洞仍然困扰着 Solana 程序,尽管 Rust 具有内存安全保证。
Loopscale 是 Solana 上的 DeFi 贷款协议,旨在通过订单簿模型直接匹配贷款人和借款人来提高资本效率。该协议支持专门的贷款市场,包括结构化信贷和抵押不足的贷款。
该漏洞源于协议价格预言机实施中的一个基本错误。让我们检查一下易受攻击的代码:
fn calculate_token_value(
token_type: TokenType,
oracle_data: &OracleData,
) -> Result<u64> {
match token_type {
TokenType::RateXPT => {
// Incorrect price calculation for RateX PT tokens
let price = oracle_data.price;
Ok(price)
}
// Other token types...
}
}
关键漏洞在于 calculate_token_value
函数,该函数未能正确验证:
攻击者通过以下方式利用此漏洞:
这是一个更安全的实现:
fn calculate_token_value(
token_type: TokenType,
oracle_data: &OracleData,
ratex_pt_data: &RateXPTData,
) -> Result<u64> {
match token_type {
TokenType::RateXPT => {
// Validate price oracle and apply specific logic for RateX PT
if oracle_data.oracle_type != OracleType::RateX {
return Err(ErrorCode::InvalidOracle.into());
}
let price = oracle_data.price * ratex_pt_data.multiplier;
if price > MAX_PRICE {
return Err(ErrorCode::PriceTooHigh.into());
}
Ok(price)
}
// Other token types...
}
}
此漏洞突出了 Solana 开发人员的几个关键教训:
检测到漏洞后,Loopscale 暂时停止了贷款市场和提款。该团队向利用方发送链上消息,提供 10% 的漏洞赏金以换取免于起诉。值得注意的是,攻击者接受了该提议并将被盗资金归还给了协议,从而没有给 Loopscale 用户造成永久性损失。
此事件表明,即使到 2025 年,随着生态系统的成熟,智能合约中的逻辑漏洞仍然是一个重大威胁。Rust 的内存安全功能无法阻止此漏洞,因为它是业务逻辑中的缺陷,而不是内存损坏问题。
- 原文链接: threesigma.xyz/blog/rust...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!