区块链安全—详谈合约攻击(五)
字数 958 2025-08-22 12:22:15
智能合约安全:重入攻击与防御机制详解
一、重入攻击概述
重入攻击(Reentrancy Attack)是以太坊智能合约中最经典的安全问题之一,其危害性极大且开发者容易忽视。攻击原理是当合约调用外部合约时,外部合约可以通过回调函数再次进入原合约,利用状态更新不及时的漏洞进行恶意操作。
基本攻击模式
mapping (address => uint) private userBalances;
function withdrawBalance() public {
uint amountToWithdraw = userBalances[msg.sender];
if (!(msg.sender.call.value(amountToWithdraw)())) {
throw;
}
userBalances[msg.sender] = 0;
}
漏洞分析:
- 合约先转账后更新余额状态
- 转账操作会触发接收者的fallback函数
- 攻击者在fallback函数中再次调用withdrawBalance()
- 由于余额尚未清零,攻击者可重复提取资金
修复方案
function withdrawBalance() public {
uint amountToWithdraw = userBalances[msg.sender];
userBalances[msg.sender] = 0; // 先更新状态
if (!(msg.sender.call.value(amountToWithdraw)())) {
throw;
}
}
关键原则:先完成所有内部状态变更,再执行外部调用。
二、复杂场景下的重入攻击
1. 多函数重入
mapping (address => uint) private userBalances;
function transfer(address to, uint amount) {
if (userBalances[msg.sender] >= amount) {
userBalances[to] += amount;
userBalances[msg.sender] -= amount;
}
}
function withdrawBalance() public {
uint amountToWithdraw = userBalances[msg.sender];
if (!(msg.sender.call.value(amountToWithdraw)())) {
throw;
}
userBalances[msg.sender] = 0;
}
攻击路径:
- 调用withdrawBalance()
- 在转账时触发攻击者的fallback函数
- fallback函数调用transfer()
- 由于余额未清零,攻击者可进行非法转账
2. 跨合约重入
mapping (address => uint) private rewardsForA;
mapping (address => bool) private claimedBonus;
function withdraw(address recipient) public {
uint amountToWithdraw = userBalances[recipient];
rewardsForA[recipient] = 0;
if (!(recipient.call.value(amountToWithdraw)())) {
throw;
}
}
function getFirstWithdrawalBonus(address recipient) public {
if (claimedBonus[recipient]) { throw; }
rewardsForA[recipient] += 100;
withdraw(recipient); // 外部调用点
claimedBonus[recipient] = true;
}
攻击路径:
- 调用getFirstWithdrawalBonus()
- 该函数调用withdraw()
- withdraw()中的转账触发攻击者合约
- 攻击者合约回调getFirstWithdrawalBonus()
- 由于claimedBonus未更新,可重复获取奖励
三、防御机制
1. 互斥锁模式
bool private lockBalances;
function withdraw(uint amount) public returns (bool) {
if (!lockBalances && amount > 0 && balances[msg.sender] >= amount) {
lockBalances = true;
if (msg.sender.call(amount)) {
balances[msg.sender] -= amount;
}
lockBalances = false;
return true;
}
throw;
}
注意事项:
- 确保锁能被正确释放
- 防止永久锁定(如攻击者获取锁后不释放)
- 避免在锁定状态下进行外部调用
2. 检查-生效-交互模式
推荐的安全开发模式:
- 检查所有前置条件
- 生效:更新合约内部状态
- 交互:最后执行外部调用
function safeBid() payable {
// 检查
require(msg.value >= highestBid);
// 生效
if (highestBidder != address(0)) {
pendingWithdrawals[highestBidder] += highestBid;
}
highestBidder = msg.sender;
highestBid = msg.value;
// 交互(分离到单独函数)
}
3. 提款分离模式
mapping(address => uint) private refunds;
function bid() payable external {
require(msg.value >= highestBid);
if (highestBidder != address(0)) {
refunds[highestBidder] += highestBid; // 记录退款
}
highestBidder = msg.sender;
highestBid = msg.value;
}
function withdrawRefund() external {
uint refund = refunds[msg.sender];
refunds[msg.sender] = 0;
msg.sender.transfer(refund); // 单独提款
}
四、其他安全注意事项
1. 循环操作风险
// 危险做法
function refundAll() public {
for(uint x; x < refundAddresses.length; x++) {
if(!refundAddresses[x].send(refunds[refundAddresses[x]])) {
throw; // 单个失败会阻塞全部
}
}
}
// 安全做法
uint256 nextPayeeIndex;
function payOut() {
uint256 i = nextPayeeIndex;
while (i < payees.length && msg.gas > 200000) {
payees[i].addr.send(payees[i].value);
i++;
}
nextPayeeIndex = i;
}
最佳实践:
- 限制单次操作处理的条目数
- 跟踪处理进度以便继续
- 检查剩余gas量
2. 失败处理策略
避免使用可能被阻塞的外部调用作为关键逻辑:
// 危险
if (!currentLeader.send(highestBid)) { throw; }
// 安全
pendingWithdrawals[currentLeader] += highestBid;
五、总结与最佳实践
- 状态变更优先:在调用外部合约前完成所有状态变更
- 使用提款模式:让用户主动提取资金而非自动发送
- 限制循环操作:分批处理大数据集,避免gas耗尽
- 互斥锁谨慎使用:确保锁能被正确释放
- 遵循检查-生效-交互模式:结构化编写合约函数
- 全面测试:特别测试重入场景和边界条件
通过遵循这些原则和模式,开发者可以显著降低智能合约遭受重入攻击的风险,提高合约的安全性。