Solana上的Rust内存安全:智能合约审计揭示了什么

本文主要讨论了即使使用内存安全的Rust语言,Solana智能合约仍然需要进行安全审计。

Rust 的内存安全并不足以确保区块链的安全。 了解为什么 Solana 程序仍然需要审计来发现逻辑错误和缺失的签名者检查。

Solana 上 Rust 内存安全:智能合约审计揭示的内容

Rust & Solana

介绍 – 指出误区

“Rust 是安全的,所以不需要审计。”

这种危险的观念可能会让你损失数百万美元的资金。内存安全并不能保护你免受可能损害协议安全性的逻辑缺陷的影响。

Rust 智能合约审计 使这些漏洞在主网之前可见。 它验证了 Solana 上的实际执行路径,因此问题不会隐藏在“内存安全”保证之后。

你被黑客攻击不是因为内存问题; 你被黑客攻击是因为你的逻辑存在缺陷。

在快速变化的 Solana 生态系统和各种基于 Rust 的生态系统中,开发人员常常过于信任 Rust 的安全功能。 虽然 Rust 的内存安全很强大,但它只是区块链协议复杂安全世界中的一层保护。 事实很清楚:尽管 Rust 具有安全功能,但 Solana 在各种协议中遭受了超过 10 亿美元的攻击,但没有一次是由于内存损坏造成的。

什么是 Rust 智能合约审计?

Rust 智能合约审计是对 Solana 程序进行的手动、运行时感知的审查,它验证逻辑、签名者/权限流程、跨程序调用 (CPI) Solana 模式和 PDA 派生错误, 此外还会检查 不安全 Rust 代码和资源上限,以便在主网之前发现漏洞。

Rust 审计在 Solana 上检查什么

跨程序调用 (CPI):

我们跟踪端到端的 CPI 链并测试最坏情况下的行为。 审查侧重于 CPI 注入风险、权限泄漏、重入式流程以及可写或签名过度暴露。

程序派生地址 (PDA):

我们验证规范种子编码和小数点处理。 模拟针对欺骗性地址、孤立帐户和特权混淆,这些混淆仅在对抗性输入下才会出现。

内存安全和不安全代码:

内存安全的 Rust 不会消除逻辑或经济错误。 我们审查算术和精度、零拷贝和序列化布局,以及任何可能启用别名或越界访问的不安全块,然后将发现结果映射到 Solana 的运行时约束。

资源限制和并行性:

我们检查计算单元预算、帐户锁定顺序和并行执行危害,这些危害可能导致拒绝服务、不一致状态或负载下的遗漏不变量。

Rust 实际上保护你免受什么侵害

Rust 的编译器是一项令人印象深刻的工程设计。 它强制执行严格的所有权模型,消除了困扰其他系统编程语言的整个类别的错误。 为了理解为什么 Rust 被广泛认为是“安全的”,让我们检查一下它阻止的具体内存安全问题,以及它与 C 和 C++ 等语言的比较。

Rust 与 C/C++ 中的内存安全保证

缓冲区溢出

当程序向缓冲区写入超过其容量的数据时,就会发生缓冲区溢出,这可能会覆盖相邻的内存。

C/C++(易受攻击):

image

#include <iostream>
#include <cstring>

int main() {
    char buffer[10];
    const char* long_string = "This is a very long string that exceeds the size of the buffer";

    // Vulnerability: strcpy does not perform bounds checking
    strcpy(buffer, long_string);

    std::cout << "Buffer contents: " << buffer << std::endl;
    return 0;
}

为什么这很危险: 在 C/C++ 中,写入数组时没有自动边界检查。 当 strcpy() 将长字符串复制到小缓冲区中时,它会写入超过分配的 10 个字节,从而破坏相邻的内存。 这可能会覆盖其他变量、返回地址或函数指针,从而可能允许攻击者执行任意代码。 程序可能会崩溃、产生不正确的结果或在内存已损坏的情况下继续运行,从而使行为不可预测且可利用。

Rust(安全):

image

fn main() {
    let mut buffer = [0u8; 10];
    let long_string = "This is a very long string that exceeds the size of the buffer";

    // Attempt to copy the long string into the buffer as bytes
    // Note: This will panic because the string is too long
    let result = buffer.copy_from_slice(long_string.as_bytes());

    match result {
        Ok(_) => {
            // Convert the buffer to a string (this is safe because the buffer is valid UTF-8)
            let s = String::from_utf8_lossy(&buffer);
            println!("Buffer contents: {}", s);
        }
        Err(_) => {
            println!("Error: The string is too long to fit in the buffer");
        }
    }
}

Rust 如何防止它: Rust 的标准库为所有数组访问实现边界检查。 copy_from_slice() 方法要求源切片和目标切片具有相同的长度,这在编译时尽可能强制执行,否则在运行时强制执行。 如果你尝试使用索引访问超出范围的数组,Rust 将会 panic(安全崩溃),而不是允许内存损坏。 这是由编译器和运行时强制执行的,因此无需使用 unsafe 代码即可实现缓冲区溢出。

释放后使用

当程序在内存释放后继续使用内存时,就会发生释放后使用,这会导致不可预测的行为。

C++(易受攻击):

image

#include <iostream>

int* create_and_return_dangling_pointer() {
    int local_var = 42;
    return &local_var; // Returning the address of a local variable
}

int main() {
    int* dangling_ptr = create_and_return_dangling_pointer();

    // Dangling pointer is now pointing to an invalid memory location
    // Dereferencing it can lead to undefined behavior
    std::cout << "Value at dangling pointer: " << *dangling_ptr << std::endl; // Possible crash or garbage value

    return 0;
}

为什么这很危险:create_and_return_dangling_pointer() 返回时,局部变量 local_var 超出范围,并且其在堆栈上的内存被回收。 但是,该函数返回指向此无效内存位置的指针。 当 main() 解引用此指针时,它访问的内存可能现在包含不同的数据或被另一个函数使用。 如果攻击者可以操纵最终存储在该内存位置的数据,则可能导致数据损坏、崩溃或安全漏洞。

Rust(安全):

image

fn create_and_return_dangling_reference<'a>() -> &'a i32 {
    let local_var = 42;
    &local_var // Returning a reference to a local variable
}

fn main() {
    let dangling_ref = create_and_return_dangling_reference();

    // Attempting to use the dangling reference would result in a compile-time error
    // println!("Value at dangling reference: {}", dangling_ref);
}

Rust 如何防止它: Rust 的借用检查器会跟踪每个引用的生命周期,以确保它永远不会超过它指向的数据。 编译器会分析代码并强制执行有关引用可以存在多长时间的规则。 在此示例中,尝试返回对局部变量的引用会导致编译时错误,因为该引用将超过变量的生命周期。 借用检查器会强制你返回拥有的值(转移所有权)或确保引用指向生命周期至少与引用本身一样长的数据。

数据竞争

当多个线程并发访问相同的内存位置时,并且至少有一个线程正在写入该内存位置,而没有适当的同步,就会发生数据竞争。

C++(易受攻击):

image

#include <iostream>
#include <thread>

volatile int shared_counter = 0;

void increment_counter(int num_iterations) {
    for (int i = 0; i < num_iterations; ++i) {
        shared_counter++; // Data race: Unsynchronized access to shared_counter
    }
}

int main() {
    std::thread t1(increment_counter, 50000);
    std::thread t2(increment_counter, 50000);

    t1.join();
    t2.join();

    std::cout << "Final counter value: " << shared_counter << std::endl;
    return 0;
}

为什么这很危险: shared_counter++ 操作不是原子的; 它涉及读取值、递增它,然后将其写回。 当两个线程并发执行此操作时,它们可能都会读取相同的初始值,独立地递增它,然后都写回相同的递增值,从而有效地丢失其中一个增量。 这会导致竞争条件,其中最终结果取决于线程执行的时序。 数据竞争会导致难以重现和调试的细微错误,从而可能导致数据损坏或安全漏洞。

Rust(安全):

image

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    // Use Arc to share ownership of the mutex-protected counter across threads.
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..2 {
        let counter = Arc::clone(&counter);
        let handle = thread::spawn(move || {
            for _ in 0..50000 {
                // Lock the mutex to safely increment the counter.
                let mut num = counter.lock().unwrap();
                *num += 1;
            }
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Final counter value: {}", *counter.lock().unwrap());
}

Rust 如何防止它: Rust 的所有权系统通过其所有权和借用规则在编译时防止数据竞争。 类型系统强制执行以下任一项:

  1. 多个线程可以拥有对相同数据的不可变(只读)引用,或者
  2. 单个线程可以拥有对该数据的单个可变引用

当需要在线程之间共享可变状态时,Rust 需要显式同步原语,例如 MutexRwLockArc(原子引用计数)类型安全地跨线程共享所有权,而 Mutex 确保一次只有一个线程可以访问数据。 编译器强制你必须在获得锁的情况下才能访问数据,从而无需使用 unsafe 代码即可实现数据竞争。

空指针解引用

当程序尝试通过空指针访问内存时,就会发生空指针解引用,通常会导致崩溃。

C++(易受攻击):

image

#include <iostream>

int main() {
    int* ptr = nullptr; // Null pointer

    // Attempting to dereference a null pointer leads to undefined behavior (crash)
    std::cout << "Value at pointer: " << *ptr << std::endl; // Crash!

    return 0;
}

为什么这很危险: 在 C/C++ 中,解引用空指针是未定义的行为,通常会导致程序崩溃(段错误)。 但是,在某些系统上或在某些上下文中,它可能不会立即崩溃,而是访问无效内存,从而可能导致数据损坏或安全漏洞。 空指针解引用是崩溃的常见来源,并且可能会被攻击者利用以导致拒绝服务,或者在某些情况下,执行任意代码。

Rust(安全):

image

fn main() {
    let optional_value: Option<i32> = None;

    // Attempting to unwrap a None value will cause a panic
    match optional_value {
        Some(value) => println!("Value: {}", value),
        None => println!("No value"),
    }

    // or use the `unwrap` method but be careful because it panics if the value is None
    // let value = optional_value.unwrap(); // Panic!
}

Rust 如何防止它: Rust 没有传统意义上的空指针。 而是使用 Option<T> 枚举来表示值的存在或不存在。 要访问 Option 中的值,你必须显式处理 Some 情况(存在值)和 None 情况(没有值)。 编译器强制执行此模式匹配,从而无法意外“解引用”None 值。 这消除了与空指针解引用相关的整个类别的错误和安全漏洞。

借用检查器:Rust 的秘密武器

Rust 内存安全的核心是借用检查器,它对如何使用引用强制执行严格的规则:

  1. 所有权:每个值都有一个所有者变量。
  2. 借用:对值的引用不得比所有者存在的时间更长。
  3. 可变性:你可以有一个可变引用或多个不可变引用,但不能同时拥有两者。

image

这些规则在编译时强制执行,从而在代码甚至运行之前就阻止了整个类别的内存安全错误。 借用检查器会分析整个程序中所有权和引用的流程,确保引用始终有效并且内存得到正确管理。

“Rust 的编译器可以帮助你编写内存安全的程序。但内存安全只是实际协议安全的一层。”

Solana 的安全漏洞:为什么内存安全是不够的

虽然 Rust 可以防止内存损坏,但 Solana 智能合约面临着完全不同类别的漏洞。 Solana 编程模型引入了独特的安全挑战,而 Rust 的内存安全功能根本没有解决这些挑战:

  1. 基于帐户的架构:Solana 的帐户模型需要显式验证帐户关系和权限
  2. 跨程序调用 (CPI):程序之间的交互会创建复杂的信任边界
  3. 程序派生地址 (PDA):PDA 的正确派生和验证对于安全至关重要
  4. 序列化/反序列化:正确处理帐户数据需要仔细验证
  5. 指令排序:多指令事务会创建复杂的状态转换
  6. Token 帐户:正确使用 Token 帐户和关联的 Token 帐户

Rust 的内存安全保证无法解决上述任何 Solana 特有的问题。 让我们检查一下导致实际攻击的最常见漏洞。

Rust 无法捕获的常见 Solana 错误

权限错误

智能合约通常实现特权功能,这些功能应该只能由授权用户访问。 Rust 的类型系统无法验证你是否已正确检查正确的权限是否已签署事务。

image

一个缺失的权限检查可能会导致整个协议的完全泄露,从而允许攻击者修改关键的协议参数。 这种漏洞已在实际中被反复利用,导致数百万美元的损失。

不安全的 CPI

跨程序调用 (CPI) 是 Solana 可组合性的基石。 但是,Rust 无法验证你是否在调用正确的程序或传递正确的帐户。

image

攻击者可以通过传递一个模拟预期行为但窃取资金的恶意程序来利用此漏洞。 这被称为“混淆副手”攻击,并且是 Solana 生态系统中多个备受瞩目的攻击事件的罪魁祸首。

处理不当的 Token 帐户权限

Solana 中的 Token 帐户需要仔细验证。 Rust 的类型系统不会验证 Token 帐户是否属于预期的 mint 或所有者。

image

如果没有正确的验证,攻击者可能会传递一个用于不同(可能毫无价值的)Token 的 Token 帐户,并欺骗你的程序将其视为预期的 Token。 这种确切的漏洞导致了 2022 年 3 月发生的 5200 万美元的 Cashio 攻击事件。

缺少签名者检查

Solana 程序必须显式验证关键操作是否已获得相应签名者的授权。 Rust 的编译器无法检测你何时忘记了此关键检查。

image

缺少签名者检查可能会导致未经授权的提款,从而可能耗尽协议中的所有资金。 这种漏洞非常常见,以至于它是 Solana 审计中要检查的首要事项之一。

例如,即使验证了管理密钥,但仍然缺少管理人员需要成为签名者的要求。 如果没有此要求,任何人都可以简单地使用管理人员的公钥并利用该系统。

指令之间的状态不同步

当程序通过 CPI 与其他程序交互时,帐户状态可能会更改。 Rust 不会在 CPI 之后自动重新加载帐户数据,从而导致状态不同步,即使使用 Anchor 也不会。

image

如果不重新加载,你的程序将在过时的数据上运行,从而可能会导致不正确的计算或安全绕过。

为什么会发生这种情况?

Solana 的帐户模型会在指令开始时获取内存中数据的快照。 如果另一个程序通过跨程序调用 (CPI) 更改此数据,则反序列化的结构不会自动更新。 因此,在 Anchor 中,你需要对 Account<T> 使用 reload() 函数来刷新存储中的 lamports、数据和所有者字段到内存副本。 这些结构使用 Rc<RefCell<&mut [u8]>>,但它们不会在 CPI 之后自动刷新,因此任何直接读取仍会显示原始快照。 如果不使用 reload(),读取 Token 余额或自定义状态将显示过时的信息,从而导致逻辑错误或在传输后计算余额时发生违规。 当你对 AccountInfoAccount<T> 调用 reload() 时,它会获取最新数据、重新序列化它,并更新 lamport 和所有者字段,从而确保未来的操作反映真实的链上状态。

帐户类型混淆

Solana 程序通常定义用于不同用途的多种帐户类型。 如果没有正确的类型检查,一种帐户类型可能会被替换为另一种。

image

如果没有正确的类型检查,攻击者可能会在需要一种帐户类型的地方传递另一种帐户类型,从而可能绕过安全检查。 Anchor 通过其帐户鉴别器自动处理此问题,但原生 Solana 程序必须手动实现这些检查。

PDA 验证失败

程序派生地址 (PDA) 是 Solana 中的一个基本概念,它允许程序确定性地控制帐户。 但是,Rust 不会验证 PDA 是否派生正确或验证是否正确。

image

如果没有正确的 PDA 验证,攻击者可能会传递一个与预期不同的帐户,从而可能获得对程序功能的未经授权的访问权限。 此漏洞已在多个 Solana 攻击事件中被利用。

不安全的帐户重新分配

重新分配帐户数据需要仔细的内存管理,以避免安全问题。

image

重新分配期间处理不当的内存会导致数据损坏、使用未初始化的内存或泄露先前内存使用情况中的敏感数据。

算术安全问题

Rust 的算术运算在release模式下可能会溢出或下溢,而 Solana 使用的是release模式。

image

整数溢出和下溢会带来安全漏洞,例如绕过余额检查或导致不正确的计算。 始终在 Solana 程序中使用经过检查的算术运算。

通过在 Cargo.toml 中包含属性 overflow-checks=true,使用最新版本的 Anchor 或使用新版本的 Anchor 创建新的 Solana 项目可以帮助防止这些问题。 但是,你需要确保此属性存在于代码库中。

Lamports 转移漏洞

从 PDA 转移 SOL (lamports) 时,必须确保帐户保持免租状态。

image

如果 PDA 低于免租金阈值,它可能会被运行时清除,从而导致程序状态丢失和潜在的安全问题。

指令排序漏洞

Solana 允许在单个事务中执行多个指令。 Rust 无法验证你的程序是否正确处理指令排序。

image

如果没有在每个指令中进行正确的验证,攻击者可能会以意想不到的方式链接指令,从而可能绕过安全检查。 此漏洞已在多个 Solana 攻击事件中被利用。

了解 Anchor:优势和局限性

什么是 Anchor?

Anchor 是用于 Solana 程序开发的框架,旨在简化流程并减少常见的安全漏洞。 它提供了一组宏、trait 和抽象,使编写 Solana 程序更加符合人体工程学且不易出错。

Anchor 的主要功能包括:

  1. 帐户验证:自动验证帐户约束
  2. 序列化/反序列化:简化了帐户数据的处理
  3. 错误处理:标准化的错误类型和处理
  4. 程序组织:结构化的程序架构方法
  5. 类型安全:增强了 Solana 特定概念的类型检查

Anchor 已成为 Solana 程序开发的实际标准,大多数新项目都使用它而不是直接编写原生 Solana 程序。

Anchor 帐户处理的技术细节

Anchor 的帐户处理是其最强大的功能之一。 让我们深入了解它的工作原理的技术细节:

帐户序列化和反序列化

在原生 Solana 程序中,你需要手动序列化和反序列化帐户数据。 Anchor 通过其 #[account] 宏自动执行此过程:

image

use anchor_lang::prelude::*;

#[account]
pub struct MyAccount {
    pub data: u32,
}

在底层,#[account] 宏为你的结构实现 AccountSerializeAccountDeserialize trait,它们处理帐户数据的序列化和反序列化。 它还在帐户数据的开头添加一个 8 字节的鉴别器来标识帐户类型。

帐户包装和解包

Anchor 的 Account<'info, T> 类型是 Solana 的 AccountInfo 的一个包装器,它提供对帐户数据的类型安全访问。 让我们看一下这个包装器是如何工作的:

image

use anchor_lang::prelude::*;

#[derive(Accounts)]
pub struct MyInstruction<'info> {
    #[account]
    pub my_account: Account<'info, MyAccount>,
}

#[account]
pub struct MyAccount {
    pub data: u32,
}

当你在程序中使用 Account<'info, T> 时,Anchor 会执行以下几项检查:

  1. 它验证该帐户是否归预期的程序所有
  2. 它检查帐户数据是否以正确的 8 字节鉴别器开头
  3. 它将帐户数据反序列化为指定的类型
  4. 它提供对帐户数据的类型安全访问
  5. 当指令完成时,它会自动将任何更改序列化回帐户

当你使用 Anchor 的 Context 类型(传递给你的指令处理程序)时,此包装和解包过程会自动发生。

帐户约束

Anchor 的 #[derive(Accounts)] 宏允许你指定对帐户的约束,这些约束会在你的指令处理程序被调用之前自动检查:

image

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>,
}

#[account]
pub struct MyAccount {
    pub data: u32,
    pub authority: Pubkey,
}

帐户约束

这些约束会扩展到在你的指令处理程序之抢跑的代码中:

image

// The expanded code will check:
// 1. The `my_account` is mutable
// 2. The `my_account.authority` matches the `authority` Pubkey

帐户约束

这种自动验证消除了 Solana 程序中许多常见的错误来源和安全漏洞。

为什么 Anchor 不能解决所有安全问题

虽然 Anchor 显着改善了开发体验并消除了许多常见的错误来源,但它并不是万能的安全解决方案。

误用的 #[account] 约束

Anchor 的帐户约束功能强大,但可能会被误用或误解:

image

误用的 #[account] 约束

缺少 mut 约束会在尝试修改帐户时导致事务在运行时失败,但编译时不会捕获此情况。

不完整的验证逻辑

Anchor 自动执行许多常见的验证,但它无法预测你的协议特有的所有业务逻辑:

image

不完整的验证逻辑

Anchor 约束处理基本验证,但你仍然需要在你的指令处理程序中实现全面的检查。

过度信任的 ctx.accounts.xyz 访问

开发人员通常假设如果一个帐户通过了 Anchor 的验证,那么它一定是安全的:

image

过度信任的ctx.accounts.xyz 访问

Anchor 的帐户约束是一个起点,而不是完整的安全解决方案。

剩余帐户验证

Anchor 的 ctx.remaining_accounts 功能允许将可变数量的帐户传递给指令,但这些帐户绕过了 Anchor 的自动验证:

image

剩余帐户验证

如果没有正确的验证,攻击者可能会通过 remaining_accounts 传递恶意帐户来绕过安全检查。

初始化和重新初始化漏洞

Anchor 的 init 约束有助于帐户初始化,但重新初始化攻击仍然是可能的:

image

初始化和重新初始化漏洞

如果未仔细验证,init_if_needed 约束可能会很危险,从而可能允许攻击者使用恶意数据重新初始化一个帐户。

审计的实际样子(对于 Solana)

一个全面的 Solana 程序审计远远超出了 Rust 编译器或 Anchor 框架可以验证的内容:

状态机验证

审计员会分析你的程序的状态转换,以确保它们是安全和一致的:

  • 识别无效的状态转换
  • 验证是否维护了状态不变性
  • 确保状态的正确初始化和最终确定
  • 识别每个指令的正确用法

指令排序逻辑

审计员检查你的指令如何组合或排序:

  • 测试指令序列以查找意外的后果
  • 识别事务排序漏洞
  • 检查重放攻击向量
  • 验证是否正确处理了跨链传输中的并发事务

许多攻击事件涉及以意想不到的顺序调用指令或以开发人员没有预料到的方式组合它们。 审计员模拟这些方案来识别潜在的漏洞。

CPI 链行为

审计员分析你的程序如何与其他程序交互:

  • 验证 CPI 中的程序 ID
  • 检查混淆副手漏洞
  • 在 CPI 之前验证正确的帐户验证
  • 确保在 CPI 之后正确重新加载状态

跨程序调用是 Solana 程序中常见的漏洞来源。 审计员跟踪 CPI 链以识别潜在的攻击向量。 当涉及第三方库或集成时,专门的 SDK 审计 有助于发现隐藏的风险。

用于多步流的模拟和模糊测试

审计员使用高级测试技术来查找边缘情况:

  • 模糊测试指令参数以查找意外行为
  • 模拟复杂的事务序列
  • 测试边界条件和错误路径
  • 识别经济攻击向量

这些技术可以发现难以通过手动代码审查找到的漏洞,例如导致 Mango Markets 遭到攻击的复杂价格操纵攻击。

案例研究:真实的 Solana 攻击事件

Loopscale 黑客攻击(2025 年 4 月)

2025 年 4 月,由于协议在计算 RateX PT Token 的价值方面存在严重缺陷,Loopscale 协议遭到攻击,损失了 580 万美元。 此次攻击表明,即使到 2025 年,智能合约中的逻辑漏洞仍然困扰着 Solana 程序,尽管 Rust 提供了内存安全保证。

Loopscale 是 Solana 上的 DeFi 借贷协议,旨在通过订单簿模型直接匹配贷方和借方,从而提高资本效率。 该协议支持专门的借贷市场,包括结构性信贷和抵押不足的借贷。

该漏洞源于协议价格预言机实现中的一个基本错误。 让我们检查一下存在漏洞的代码:

image

fn calculate_token_value(
    token_type: TokenType,
    amount: u64,
    oracle_price: u64,
) -> Result<u64> {
    // Vulnerability: Missing validation of the price oracle
    let value = amount * oracle_price;
    Ok(value)
}

Loopscale 黑客攻击(2025 年 4 月)

关键的漏洞在于 calculate_token_value 函数,该函数未能正确验证以下内容:

  1. 价格预言机对于特定的 Token 类型是否正确
  2. 价格计算没有考虑 RateX PT Token 的独特特征
  3. 没有针对价格过时或操纵抵抗的验证

攻击者通过以下方式利用此漏洞:

  1. 创建以 RateX PT Token 作为抵押品的位置
  2. 由于不正确的价格计算,操纵这些 Token 的感知价值
  3. 取出价值超过抵押品实际价值的抵押不足贷款
  4. 从协议的 Genesis Vaults 中提取约 570 万美元的 USDC 和 1,200 SOL

以下是更安全的实现方式:

image

fn calculate_token_value(
    token_type: TokenType,
    amount: u64,
    oracle_price: u64,
) -> Result<u64> {
    // Secure implementation: Validate the price oracle and token type
    if token_type == TokenType::RateXPT {
        // Apply a discount factor to the oracle price
        let discounted_price = oracle_price * 90 / 100;
        let value = amount * discounted_price;
        Ok(value)
    } else {
        // Use the oracle price directly for other token types
        let value = amount * oracle_price;
        Ok(value)
    }
}

Loopscale 黑客攻击(2025 年 4 月)- 更安全的实现

这次攻击突出了 Solana 开发人员的几个关键教训:

  1. 正确的价格预言机验证:始终验证价格预言机是否适合特定的 Token 类型,并实施针对操纵的保护措施。
  2. 特定于 Token 的估值逻辑:不同的 Token 类型可能需要专门的估值公式,特别是对于 RateX PT 等衍生 Token。
  3. 全面的帐户验证:确保传递给你的程序的所有帐户都经过验证,以确保其正确性和所有权。
  4. 安全边际:在计算抵押品价值时实施保守的折扣和安全边际,以考虑市场波动和潜在的预言机不准确性。
  5. 对经济假设进行彻底测试:在各种市场条件和边缘情况下测试你的协议的经济模型,以识别潜在的漏洞。

在检测到攻击后,Loopscale 暂时停止了借贷市场和提款。 该团队向攻击者发送了链上消息,提出以 10% 的漏洞赏金换取免受起诉。 值得注意的是,攻击者接受了该要约,并将被盗资金归还给了协议,Loopscale 用户因此没有遭受永久性损失。

此事件表明,即使到 2025 年,生态系统已经成熟多年,智能合约中的逻辑漏洞仍然是一个重大威胁。 Rust 的内存安全功能无法阻止此攻击,因为这是一个业务逻辑中的缺陷,而不是内存损坏问题。

Wormhole 网桥攻击(2022 年 2 月)

Wormhole 网桥攻击(2022 年 2 月)

2022 年 2 月 2 日,Solana 上的 Wormhole 网桥被攻击,损失了 120,000 ETH(当时价值约 3.2 亿美元),成为历史上最大的 DeFi 攻击事件之一。 该漏洞源于签名验证过程中开发人员的一个严重错误。 像这样的事件强调需要重点关注网桥/跨链应用审计

根本原因是逻辑缺陷:该合约使用了 load_instruction_at(一种已弃用的助手,不验证帐户的程序 ID)来检查是否调用了该函数未能验证 bank 账户的真实性,导致攻击者可以提供与假冒 token 关联的伪造银行。

此函数负责验证 Saber swap 账户。

image

Cashio 漏洞利用 (2022 年 3 月)

该函数未验证 saber_swap.mint 是否与预期的 mint 匹配,从而使攻击者可以使用带有假冒 mint 的伪造 Saber swap 账户。

Cashio 的漏洞利用表明,抵押品处理中缺少验证检查可能会导致 DeFi 协议的灾难性损失。此漏洞与 Rust 的内存安全特性无关,这是协议安全模型中的一个逻辑缺陷。

Solend 漏洞利用 (2022 年 11 月 2 日)

2022 年 11 月 2 日,Solend 借贷协议因其价格预言机实现中的漏洞而遭受了 126 万美元的漏洞利用。攻击者操纵了 USDH 稳定币的价格,从而能够根据虚高的抵押品价值借入资产。

漏洞的技术细节

Solend 的 USDH 价格预言机仅依赖于来自 Saber 去中心化交易所 (DEX) 的数据。这种单一来源的依赖性造成了一个漏洞,因为它允许攻击者在不受其他市场数据源干扰的情况下操纵 USDH 的价格。

执行步骤

  1. 初步尝试 (2022 年 10 月 28 日): 攻击者向 Saber 池注入了 200,000 USDC 以抬高 USDH 的价格。然而,套利者在同一插槽内纠正了价格,使尝试无效。
  2. 成功的漏洞利用 (2022 年 11 月 2 日):
    • 价格抬高: 攻击者使用 100,000 USDC 来抬高 Saber 上 USDH 的价格。
    • 插槽垃圾信息: 他们用交易淹没了 Saber 账户,阻止套利者在同一插槽内纠正价格。
    • 预言机更新: 被操纵的价格被 Solend 的预言机提供商 Switchboard 捕获。
    • 资产借贷: 攻击者使用虚高的 USDH 作为抵押品,从 Solend 的 Stable、Coin98 和 Kamino 池中借入资产。

image

Solend 漏洞利用 (2022 年 11 月 2 日)

更安全的实现方式本应包括多个价格来源和防止操纵的保障措施:

image

Solend 漏洞利用 (2022 年 11 月 2 日)

开发者错误和经验教训

Solend 的漏洞利用突显了 Solana 开发者需要汲取的几个关键教训:

  1. 永远不要依赖单一的价格来源: 主要错误是仅依赖 Saber 的池进行 USDH 定价。始终使用多个独立的价格来源,并实施诸如中位数定价之类的机制来抵抗操纵,或者使用 Pyth 之类的预言机。
  2. 为稳定币实施价格范围: 稳定币应具有严格的价格范围 (例如,0.95 美元到 1.05 美元),以防止协议接受极端的价格波动。

Solend 的漏洞利用表明,即使是复杂的 DeFi 协议,如果没有实施适当的保障措施,也可能容易受到预言机操纵的影响,而彻底的机制设计审查旨在发现此问题。此漏洞与 Rust 的内存安全特性无关,这是协议预言机实现中的一个设计缺陷。

如果没有彻底的验证,Solana 的速度和 Rust 的安全性是不够的。我们的 Rust 智能合约审计会在不安全的内存访问、账户管理不善和逻辑错误影响主网或你的用户之前检测到它们。

结论 – Rust 是一种工具,而不是盾牌

Rust 是一种用于区块链开发的优秀语言。它的内存安全保证消除了困扰其他系统编程语言的整类漏洞。但内存安全只是智能合约安全的一个方面。你仍然需要安全软件开发策略

Anchor 通过提供结构化的框架并自动执行常见的验证来进一步改善开发体验。但是,它无法预测你的独特协议的所有特定安全要求。

逻辑安全、权限设计和状态保证仍然需要人工审查。Solana 程序中最危险的漏洞不是内存损坏问题,而是协议设计和实现中的逻辑缺陷:

  • 缺少权限检查
  • 不正确的账户验证
  • 不安全的跨程序调用
  • 有缺陷的业务逻辑
  • 状态同步问题

即使对于使用安全设计语言构建的团队,创始人也需要尽早重视安全性,以避免架构债务和日后代价高昂的返工。这些漏洞只能通过由了解 Solana 编程模型和常见区块链安全模式的专家进行的全面安全审计来识别。

不要仅仅依靠 Rust 的编译器或 Anchor 的约束来保护你的协议。在部署你的 Solana 程序之前,请投资进行彻底的测试、代码审查和专业的安全审计。你用户的资金取决于它。有关实用清单,请参阅我们的 Web3 创始人安全 指南。

请记住:内存安全 ≠ 智能合约安全。最安全的 Solana 程序将 Rust 的强大安全功能与严格的安全审查、经济审计 和全面的测试相结合。

启动之前,安排一次 Rust 智能合约审计,以发现 CPI 权限问题、PDA 派生错误不安全的 Rust 代码。如果你要在 Solana 上发布,那么侧重于 CPI、PDA 和签名者验证的 Solana 智能合约审计 是安全发布的捷径。

常见问题解答

我们应该何时安排 Rust 审计?

在主网之前、重大升级之后,以及添加新指令、CPI 或治理权限时。在交易所上市或重大流动性事件之前重新访问。

开始时,我们需要向你提供什么?

固定的提交哈希、构建和测试步骤、程序 ID、示例交易、IDL、预期不变条件的列表以及部署计划。

审计能否在提高安全性的同时提高性能?

通常可以。审查会发现计算热点、不必要的写入或签名者标志,以及提高 CU 成本的低效 CPI 模式。

审计是否保证零错误?

不能。审计通过优先考虑高影响问题并验证修复来降低风险。任何审查都不能保证不存在漏洞。

报告之后会发生什么?

我们提供问答、审查补丁、重现发现结果并运行有记录的重新测试,以确认在实际限制下进行的更改。

如果需要,你们是否还会审查经济设计?

是的,根据要求,我们将代码审计与代币经济学和机制审查相结合,以用于经济状况影响安全的协议。

参考资料

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

0 条评论

请先 登录 后评论
Three Sigma
Three Sigma
Three Sigma is a blockchain engineering and auditing firm focused on improving Web3 by working closely with projects in the space.