一、从区块链合约的“坑”说起
你可以把区块链智能合约看作是自动执行的机器人,一旦写了bug就没人能中途叫停——这也是近年不少DeFi项目出问题的核心原因。之前有项目因为被攻击方绕过程序漏洞重复取钱,卷走了数千万美元,还有的合约因为乱碰不该碰的数据(内存),把用户的余额改得一塌糊涂,这些都是合约安全里最头疼的两类问题:内存安全和重入攻击。本文就结合Rust的特性,讲讲怎么用它来防御这两个常见的合约漏洞,适合不同基础的开发者,不用懂太复杂的专业术语。
1.1 先搞懂两个核心“敌人”
先说内存安全:你可以把合约的存储数据比作自己的私人抽屉,只有你能开抽屉放东西,要是有人随便撬抽屉改东西,就是内存安全出了问题——比如其他函数乱改余额数据,或者用了已经被删掉的旧数据(悬垂指针)。重入攻击呢?就像你开了个零食店,要求客人先付钱再拿零食,但反过来客人先拿了零食跑,还转身再来拿一次,因为你没记清楚他已经拿过一次,这就是攻击方在你合约执行时,调用你合约的其他函数,重复触发取钱、转账操作的典型场景。
二、Rust能防这俩坑吗?
Rust天生就是为解决内存安全问题设计的,它的所有权、借用规则,在编译阶段就会把内存漏洞拦住,比如不会让你用已经释放的变量,不会出现两个函数同时改同一份数据的情况——这比Solidity早期版本只能靠人工检查安全太多了。而且Rust的运行效率高,适合高并发的区块链场景,加上有Solang、Anchor这些专门针对智能合约的工具,用Rust写合约已经成了不少DeFi项目的选择。
2.1 Rust搞定内存安全的小例子
下面是一个用Rust写的简单代币合约,技术栈是Rust + Solang(用于编译成区块链能识别的WebAssembly),你可以看Rust怎么把内存安全的规则落地:
// 技术栈:Rust + Solang 智能合约编译工具
use solang::prelude::*;
use std::collections::BTreeMap;
// 定义转账事件,符合以太坊标准
#[derive(Default, SolidityEvent)]
struct Transfer {
from: Address,
to: Address,
value: u256,
}
// 代币合约结构体,用BTreeMap存余额,Rust的类型规则保证不会乱改
struct SafeToken {
balances: BTreeMap<Address, u256>,
total_supply: u256,
}
impl SafeToken {
// 部署合约时初始化总供给,Rust保证这个操作不会有内存泄露
fn new(total: u256) -> Self {
let mut balances = BTreeMap::new();
balances.insert(Self::deployer(), total);
SafeToken { balances, total_supply: total }
}
// 转账函数,用checked_sub防整数溢出,Rust的检查双重保险
fn transfer(&mut self, to: Address, value: u256) -> bool {
let caller = Self::caller();
let from_balance = self.balances.get(&caller).copied().unwrap_or(0);
// 先检查余额,不够就直接返回,不会往下走
if from_balance < value {
return false;
}
// 扣余额的操作,Rust保证这一步不会被其他线程打断(单合约场景也安全)
let new_from = from_balance.checked_sub(value).unwrap();
*self.balances.get_mut(&caller).unwrap() = new_from;
// 给接收方加余额
*self.balances.entry(to).or_insert(0) += value;
// 触发转账事件,链上可查
Self::emit(Transfer { from: caller, to, value });
true
}
}
这个例子里,Rust的BTreeMap和所有权规则,保证了余额数据不会被意外修改,也不会出现内存访问越界的问题——比如你没法随便把一个地址的余额改成负数,因为用了checked_sub方法,哪怕你写错代码,编译器也会提醒你。
三、重入攻击的防御:Rust专属实践
重入攻击的核心是“先和外部交互,再修改合约状态”,所以经典的防御模式叫Checks-Effects-Interactions,也就是先做检查、再修改状态、最后和外部交互,Rust的特性刚好能把这个模式落地得更稳。
3.1 两种提现代码的对比
先看有重入风险的代码,这是很多新手容易写的错误:
// 有重入风险的提现代码(错误示例)
fn withdraw_risky(&mut self, amount: u256) -> bool {
let caller = Self::caller();
let balance = self.balances.get(&caller).copied().unwrap_or(0);
// 错误1:先转钱给调用者(和外部交互),再扣余额(修改状态)
// 如果调用者是恶意合约,转钱的时候会回调这个withdraw函数,重复取钱
transfer(caller, amount);
self.balances.insert(caller, balance - amount);
true
}
恶意合约会在你转钱的时候,再次调用你的withdraw函数,这时候余额还没扣,所以能再次取钱,直到合约里的钱被拿空。再看符合防御模式的安全代码:
// 安全的提现代码(符合Checks-Effects-Interactions模式)
fn withdraw_safe(&mut self, amount: u256) -> bool {
let caller = Self::caller();
// Checks:先检查余额是否足够
let mut balance = self.balances.get(&caller).copied().unwrap_or(0);
if balance < amount {
return false;
}
// Effects:先修改合约状态(扣余额),这一步是在合约内部,不会被外部打断
balance = balance.checked_sub(amount).unwrap();
self.balances.insert(caller, balance);
// Interactions:最后才和外部交互(转钱),这时候余额已经扣了,没法重复调用
transfer(caller, amount);
true
}
这个顺序的关键是:修改状态的操作在和外部交互之前,哪怕调用的是恶意合约,这时候已经扣了余额,没法再重复触发取钱——Rust的内存安全特性,让这个状态修改操作不会被中间的回调打乱,比其他语言更容易实现这个防御模式。
3.2 Rust额外的防御技巧
除了这个经典模式,Rust还有专属的小技巧:比如用内部可变性的安全封装,不让外部随便修改你的合约状态;比如把对外的转账函数做成私有的,只有合约内部能调用,避免被外部恶意使用;还有就是用u256类型处理大额token,避免整数溢出的问题——Rust默认会检查溢出,所以不用像Solidity那样要手动加SafeMath库。
四、应用场景、优缺点和注意事项
4.1 适用场景
这种实践特别适合DeFi里的锁仓合约、借贷合约、质押合约,这些都是重入攻击的高发区,同时内存安全的要求也很高,比如Solana上的很多DeFi项目都是用Rust写的,就是因为这些场景的资金量大,出不起漏洞。还有NFT的交易合约,因为涉及到数字资产的转移,也要特别注意安全。
4.2 技术优点
Rust的编译期检查,把很多漏洞提前拦在部署之前——毕竟合约一旦部署就不能改,提前发现bug比事后补救重要太多。而且Rust的运行效率比Solidity高,适合处理高并发的交易请求,比如链上的每秒交易数量更高。还有就是Rust的社区支持好,有专门针对智能合约的安全库,不用自己从零写安全代码。
4.3 注意事项
不能完全依赖Rust的编译器,比如有些边界情况,比如调用外部合约的逻辑,编译器没法检查,所以还要做静态审计,比如用cargo audit检查依赖库的安全问题。还有就是要注意整数回绕的情况,哪怕用了checked方法,也要测试大额转账的场景,比如代币的总供给是1e18,会不会出现溢出的问题。另外,调用外部合约的时候,不要把整个合约的状态暴露给外部,只传必要的参数,避免被攻击方利用。
五、总结
Rust在智能合约安全上的优势,就是把内存安全的规则内置到语言里,加上合适的防御模式,就能写出更安全的合约。对于不同基础的开发者来说,不用懂太复杂的专业术语,只要理解Rust的基本所有权概念,就能避开很多常见的漏洞。本文讲的内存安全和重入攻击的防御实践,不仅适合区块链智能合约,也适合其他用Rust写的高安全要求的应用,希望能帮到你写出更可靠的代码。
Comments