4、常见漏洞模式(上):重入攻击、整数溢出、访问控制问题、未检查的外部调用
各位同学,今天我们来啃几块硬骨头。
智能合约的漏洞模式,说白了就是黑客最常下刀子的地方。我这些年审计过的合约,十有八九都栽在这几个坑里。咱们一个一个过,争取今天就把它们彻底搞明白。
4.1 重入攻击:最经典的"借刀杀人"
重入攻击,以太坊历史上最臭名昭著的漏洞。2016年的The DAO事件,直接导致以太坊硬分叉,损失超过6000万美元。
它的原理其实很简单:攻击者在被攻击合约执行回调函数时,再次调用攻击合约,形成递归调用。就像你借钱给别人,别人拿着你的借条又来借,循环往复,直到把你掏空。
核心问题:合约在更新状态之前,先执行了外部调用。
来看一个典型的漏洞合约:
// 漏洞合约
contract VulnerableBank {
mapping(address => uint) public balances;
function withdraw(uint _amount) public {
require(balances[msg.sender] >= _amount);
// 先转账,后更新余额 —— 这就是漏洞
(bool success, ) = msg.sender.call{value: _amount}("");
require(success);
balances[msg.sender] -= _amount;
}
}
攻击者合约长这样:
contract Attacker {
VulnerableBank public bank;
constructor(address _bank) {
bank = VulnerableBank(_bank);
}
// fallback函数,每次收到ETH都会触发
receive() external payable {
if (address(bank).balance >= 1 ether) {
bank.withdraw(1 ether);
}
}
function attack() external payable {
bank.withdraw(1 ether);
}
}
为什么会这样?
攻击者调用withdraw,合约先转ETH,触发攻击者的receive()。在receive()里,攻击者再次调用withdraw。此时余额还没扣减,所以又能通过检查。如此循环,直到合约被掏空。
我的经验:我个人习惯在写提现函数时,永远遵循"先检查-再更新-后交互"的顺序。这个顺序救过我很多次。
修复方案:
- 方法一:检查-生效-交互模式(Checks-Effects-Interactions)
- 方法二:使用重入锁(如OpenZeppelin的ReentrancyGuard)
// 修复后的合约
contract SafeBank {
mapping(address => uint) public balances;
bool private locked;
modifier noReentrant() {
require(!locked, "No reentrancy");
locked = true;
_;
locked = false;
}
function withdraw(uint _amount) public noReentrant {
require(balances[msg.sender] >= _amount);
balances[msg.sender] -= _amount; // 先更新状态
(bool success, ) = msg.sender.call{value: _amount}("");
require(success);
}
}
注意:重入攻击不限于ETH转账。任何外部调用都可能成为攻击入口,包括ERC20的transfer、NFT的safeTransferFrom等。
4.2 整数溢出:被低估的算术陷阱
整数溢出,说白了就是数字太大或太小,超出了变量能表示的范围。在Solidity 0.8.0之前,这个问题非常普遍。
举个例子:
uint8的范围是0到255。如果你给255加1,结果会变成0。这就是上溢。
如果你给0减1,结果会变成255。这就是下溢。
我记得有一次审计一个代币合约,项目方为了省gas,用了uint8来记录用户余额。结果一个用户通过下溢,凭空给自己转了255个代币。嗯,那场面挺尴尬的。
// 漏洞合约(Solidity 0.7.x)
contract Token {
mapping(address => uint8) public balances;
function transfer(address to, uint8 amount) public {
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount; // 如果amount > 余额,这里会下溢
balances[to] += amount;
}
}
修复方案:
- 使用Solidity 0.8.0+:内置了溢出检查,溢出会自动revert
- 使用SafeMath库:OpenZeppelin的SafeMath,适用于旧版本
- 使用更宽的数据类型:比如用
uint256代替uint8
避坑指南:我曾经见过一个项目,明明用了Solidity 0.8.x,但在unchecked块里手动做了减法,结果还是出了下溢。记住,unchecked会关闭溢出检查,用的时候千万小心。
4.3 访问控制问题:谁动了我的合约?
访问控制,说白了就是"谁能干什么"。这个问题看似简单,但出事的频率高得吓人。
常见的访问控制漏洞包括:
- 未初始化owner:构造函数没写,任何人都能成为owner
- 错误的修饰符:把
onlyOwner写成了public - 权限提升:普通用户能调用管理员函数
- tx.origin滥用:用
tx.origin做身份验证
// 漏洞示例1:未初始化owner
contract NoOwner {
address public owner;
// 构造函数忘记写owner初始化
// 任何人都能调用setOwner
function setOwner(address _owner) public {
owner = _owner;
}
function withdraw() public {
require(msg.sender == owner);
// 提现逻辑
}
}
// 漏洞示例2:tx.origin滥用
contract Phishable {
address public owner;
constructor() {
owner = msg.sender;
}
function withdrawAll() public {
// 使用tx.origin,容易被钓鱼攻击
require(tx.origin == owner);
payable(msg.sender).transfer(address(this).balance);
}
}
为什么tx.origin危险?
因为tx.origin是整个交易链的原始发起者,而msg.sender是直接调用者。如果用户A调用了攻击合约,攻击合约再调用你的合约,tx.origin还是A,但msg.sender是攻击合约。用tx.origin做验证,等于把钥匙交给了别人。
最佳实践:
- 始终使用
msg.sender进行身份验证 - 使用OpenZeppelin的
Ownable或AccessControl库 - 构造函数中初始化owner
- 敏感函数添加
onlyOwner修饰符
4.4 未检查的外部调用:无声的炸弹
未检查的外部调用,指的是合约调用了其他合约,但没有检查调用是否成功。在Solidity中,call、delegatecall、staticcall返回一个布尔值,但很多开发者会忽略它。
你想想看,如果外部调用失败了,你的合约会怎样?
—— 它会继续执行,就像什么都没发生一样。这可能导致状态不一致,甚至资金损失。
// 漏洞示例
contract UncheckedCall {
function withdraw(address to, uint amount) public {
// 没有检查call的返回值
to.call{value: amount}("");
// 即使转账失败,下面的代码也会执行
emit Withdrawn(to, amount);
}
}
如果to是一个合约地址,且它的receive()函数revert了,call会返回false,但不会revert整个交易。结果就是:事件被触发了,但钱没转出去。
我的建议:我个人习惯对所有外部调用都做检查。要么用require(success),要么用Address.sendValue()这样的封装函数。
修复方案:
// 方案1:使用require检查
function safeWithdraw(address to, uint amount) public {
(bool success, ) = to.call{value: amount}("");
require(success, "Transfer failed");
emit Withdrawn(to, amount);
}
// 方案2:使用OpenZeppelin的Address库
import "@openzeppelin/contracts/utils/Address.sol";
function safeWithdrawV2(address to, uint amount) public {
Address.sendValue(payable(to), amount);
emit Withdrawn(to, amount);
}
特别提醒:transfer和send虽然会自动检查,但它们有gas限制(2300 gas),在合约调用时可能不够用。我个人更推荐使用call + require的组合。
知识体系总览
下面这张图,是我梳理的这四种漏洞的核心逻辑。你可以把它当作一个快速检查清单:
这四种漏洞,每一个都值得你花时间吃透。我在实际审计中,经常看到它们组合出现——比如一个重入攻击加上未检查的外部调用,那简直就是灾难。
好了,今天的内容就到这。记住一句话:写合约的时候,永远假设攻击者就在你身后看着你的代码。下一章我们继续聊剩下的几种漏洞模式,到时候见。