第三部分:保护智能合约免受捐赠攻击:安全 ETH 处理指南

本文主要介绍了以太坊智能合约中一种隐蔽的攻击方式:捐赠攻击(Donation Attacks)。攻击者通过直接向合约地址发送以太币,绕过合约预期的入口点,导致合约内部状态变量与实际余额不一致,从而引发会计错误、拒绝服务等问题。文章分析了攻击原理、展示了易受攻击的合约代码,并提供了安全的合约实现方案,强调应明确追踪以太币流入、拒绝意外存款,以及使用熔断器等机制。

介绍:捐赠攻击的隐蔽威胁

想象一下,你正在以太坊上运行一个众筹 dApp。你的社区热闹非凡,支持者纷纷发送 ETH,你的活动也越来越受欢迎。一切似乎都很完美 — 直到攻击者在没有使用你的捐赠函数的情况下,将 ETH 偷偷地塞进你的合约。突然间,你的内部账目乱了,资金分配等关键功能失败,用户也被锁在了资金之外。对你平台的信任度下降,你的项目也陷入停顿。这就是捐赠攻击 — 一种微妙的漏洞利用方式,它不会窃取资金,而是通过注入不需要的 ETH 来扰乱你合约的逻辑。

欢迎来到智能合约安全:Solodit CheckList 系列的第三期,我们将探讨 Solodit CheckList 中定义的 SOL-AM-DonationAttack:意外的 ETH 存款。捐赠攻击利用了以太坊的无需许可的特性,允许任何人通过直接转账或 selfdestruct 函数向合约发送 ETH。这些攻击可能导致会计错误,触发拒绝服务 (DoS) 条件,或锁定资金,所有这些都在暗中进行。截至 2025 年 7 月,以太坊的区块 gas 限制约为 3000 万 gas,这些漏洞甚至可能削弱精心设计的合约。

在这个引人入胜、以开发者为中心的指南中,我们将探讨捐赠攻击如何运作,分析一个易受攻击的众筹合约,并提供一个具有强大保护的安全实现。我们将包括详细的代码片段、视觉上吸引人的工作流程图和最佳实践,以确保你的合约保持弹性。无论你是构建 DeFi 协议、众筹平台还是 NFT 市场,本文都将为你提供抵御这些沉默破坏者并保持你的 dApp 平稳运行的工具。

为什么捐赠攻击是一种沉默的威胁

智能合约通常依赖于内部状态变量(例如,totalRaised)来跟踪资金和执行逻辑。然而,以太坊的开放架构允许任何人将 ETH 发送到合约的地址,绕过预期的入口点。这会在合约的实际余额(address(this).balance)与其内部记录之间造成不匹配,从而导致:

  • 会计错误:未跟踪的 ETH 会使计算倾斜,导致多付或少付。
  • 拒绝服务:期望余额一致性的函数失败,导致提款或资金分配等操作停止。
  • 声誉损害:停滞的合约会让用户感到沮丧并破坏信任,正如 2017 年 Parity Multisig 漏洞中所见,其中处理不当的 ETH 导致 1.5 亿美元的资金被冻结。

Solodit CheckList 的 SOL-AM-DonationAttack 强调显式的 ETH 管理和拒绝意外存款。让我们深入了解这些攻击是如何展开的,以及如何阻止它们。

捐赠攻击如何运作

捐赠攻击利用了以太坊向任何地址(包括合约)发送 ETH 而不触发其逻辑的能力。攻击者使用两种主要方法:

  1. 直接转账:通过交易(例如,address(contract).transfer(amount))或没有数据的钱包转账发送 ETH,绕过像 contribute() 这样的 payable 函数。
  2. Selfdestruct 攻击:部署一个调用 selfdestruct(payable(contract)) 的合约,强制 ETH 进入目标合约。这种方法特别阴险,因为它绕过了 receive()fallback() 函数。

这些攻击会扰乱那些假设 address(this).balance 与内部状态变量匹配的合约。例如,如果未跟踪的 ETH 使其逻辑倾斜,众筹合约可能无法分配资金或允许不正确的支付,从而可能导致 DoS 或财务错误。

真实案例:2017 Parity Multisig 漏洞

2017 年,Parity Multisig Wallet 遭受了一次灾难性的漏洞利用,导致 1.5 亿美元的 ETH 被冻结。虽然主要是一个访问控制和重入问题,但该事件因对意外 ETH 流入处理不当而加剧。这凸显了智能合约中强大 ETH 管理的关键需求,这一教训在 2025 年仍然适用。

脆弱代码:一个有风险的众筹合约

让我们检查一个跟踪捐款但对捐赠攻击完全开放的脆弱众筹合约:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.10;

contract VulnerableCrowdfunding {
    mapping(address => uint256) public contributions;
    uint256 public totalRaised;
    // Users contribute ETH
    function contribute() external payable {
        contributions[msg.sender] += msg.value;
        totalRaised += msg.value;
    }
    // Distribute funds to project
    function distributeFunds(address payable recipient) external {
        require(address(this).balance >= totalRaised, "Insufficient funds");
        (bool success, ) = recipient.call{value: totalRaised}("");
        require(success, "Distribution failed");
    }
}

攻击场景

此合约假定 address(this).balancetotalRaised 匹配,因此容易受到以下攻击:

  1. 直接转账攻击
  • 攻击者将 ETH 直接发送到合约的地址(例如,address(contract).transfer(1 ether))。
  • 这会增加 address(this).balance,而不会更新 contributionstotalRaised
  • distributeFunds 函数通过 require 检查,但使用不正确的会计进行操作,可能会导致过度分配或逻辑错误。

2. Selfdestruct 攻击

  • 攻击者部署一个恶意合约:
contract MaliciousContract {
    constructor(address payable target) payable {
        selfdestruct(target); // Forces ETH into target contract
    }
}
  • 攻击者为此合约提供 1 ETH 的资金,并调用 selfdestruct(payable(vulnerableCrowdfunding)),将 1 ETH 发送到众筹合约。
  • 这会增加 address(this).balance,而不会更新 totalRaised,从而扰乱逻辑或在更严格的情况下导致 DoS。

为什么它很危险

该合约假定所有 ETH 都通过 contribute() 进入,因此它对外部存款毫无防御能力。这可能导致会计错误、DoS 条件或依赖余额一致性的函数中的意外行为。

了解捐赠攻击

捐赠攻击利用了以太坊的基本属性:任何人都可以将 ETH 发送到任何地址,包括智能合约。这种开放性创造了两个主要的攻击媒介:

  1. 直接转账:攻击者直接将 ETH 发送到合约的地址,绕过预期的入口点
  2. Selfdestruct 攻击:恶意合约使用 selfdestruct 强制将 ETH 送入目标合约

该图说明了捐赠攻击如何在合约的实际余额与其内部状态跟踪之间造成严重的不匹配。当攻击者直接发送 ETH 或通过 selfdestruct 强制发送 ETH 时,合约的余额会增加,而其状态变量保持不变。这种差异会导致关键功能失败,因为它们依赖于实际余额和跟踪金额之间的一致核算。

脆弱的实施

让我们检查一个易受攻击的众筹合约,该合约演示了捐赠攻击如何破坏功能:

安全实施图演示了正确的 ETH 处理如何防止捐赠攻击。实心箭头表示成功的操作,而虚线箭头表示恢复的尝试。当用户通过预期函数贡献时,所有状态变量都会一致地更新。即使攻击者尝试直接转账或 selfdestruct,合约也会通过依赖跟踪余额而不是实际余额来保持准确的会计。

安全代码:构建一个防捐赠合约

Solodit CheckList 建议拒绝意外的 ETH 存款并依赖状态变量进行会计处理。以下是众筹合约的安全版本:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.10;

contract FixedCrowdfunding {
    mapping(address => uint256) public contributions;
    mapping(address => uint256) public pendingWithdrawals;
    uint256 public totalRaised;
    address public admin;
    bool public paused;

    modifier onlyAdmin() {
        require(msg.sender == admin, "Not admin");
        _;
    }

    modifier whenNotPaused() {
        require(!paused, "Contract paused");
        _;
    }

    constructor() {
        admin = msg.sender;
    }

    // Users contribute ETH
    function contribute() external payable whenNotPaused {
        require(msg.value > 0, "No ETH sent");
        contributions[msg.sender] += msg.value;
        totalRaised += msg.value;
        pendingWithdrawals[msg.sender] += msg.value; // Track for refunds
        emit Contributed(msg.sender, msg.value);
    }

    // Reject unexpected ETH deposits
    receive() external payable {
        revert("Direct ETH deposits not allowed");
    }

    // Distribute funds to project
    function distributeFunds(address payable recipient) external onlyAdmin whenNotPaused {
        require(totalRaised > 0, "No funds to distribute");
        require(address(this).balance >= totalRaised, "Insufficient funds");
        uint256 amount = totalRaised;
        totalRaised = 0; // Prevent reentrancy
        (bool success, ) = recipient.call{value: amount}("");
        require(success, "Distribution failed");
        emit FundsDistributed(recipient, amount);
    }

    // Allow users to withdraw refunds (pull-over-push)
    function withdraw() external whenNotPaused {
        uint256 amount = pendingWithdrawals[msg.sender];
        require(amount > 0, "No funds to withdraw");
        pendingWithdrawals[msg.sender] = 0;
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Withdrawal failed");
        emit Withdrawn(msg.sender, amount);
    }

    // Pause contract in case of attack
    function pause() external onlyAdmin {
        paused = true;
        emit Paused();
    }

    // Unpause contract
    function unpause() external onlyAdmin {
        paused = false;
        emit Unpaused();
    }

    event Contributed(address indexed user, uint256 amount);
    event FundsDistributed(address indexed recipient, uint256 amount);
    event Withdrawn(address indexed user, uint256 amount);
    event Paused();
    event Unpaused();
}

它是如何工作的

  • 显式 ETH 跟踪contribute 函数更新 contributionstotalRaisedpendingWithdrawals,确保跟踪所有 ETH 流入。
  • 拒绝意外存款receive 函数恢复直接 ETH 转账,防止未跟踪的流入。虽然 selfdestruct 绕过了 receive,但合约的逻辑依赖于 totalRaised,而不是 address(this).balance
  • Pull-Over-Push 模式withdraw 函数允许用户声明退款,分配 gas 成本并隔离故障。
  • 断路器paused 标志和 whenNotPaused 修饰符在攻击期间停止操作。
  • 访问控制onlyAdmin 修饰符将敏感函数限制为管理员。
  • 重入保护:在 distributeFunds 中的外部调用之前重置 totalRaised 可以防止重入
  • 事件日志记录:高效的事件为捐款、分配、提款和暂停/取消暂停操作提供透明度。

安全的工作流程图

这是安全合约的工作流程,展示了它如何缓解捐赠攻击:

此图说明了安全合约如何拒绝直接转账,显式跟踪捐款,并忽略来自 selfdestruct 的未跟踪 ETH,从而确保稳健的操作。

安全优势

  • 准确的会计:所有 ETH 流入都通过 contribute 跟踪,防止不匹配。
  • DoS 预防:拒绝意外存款并依赖状态变量可以避免逻辑故障。
  • 隔离的故障:pull-over-push 模式确保一个用户的故障不会阻止其他用户。
  • 紧急控制:暂停机制允许快速响应攻击。
  • 稳健的设计:访问控制、重入保护和高效的事件增强了安全性。

其他捐赠攻击媒介和缓解措施

1. 通过挖矿强制 ETH

矿工可以在 coinbase 交易中包含 ETH 转账,从而强制将 ETH 送入合约,而无需触发 receive()

缓解措施

  • 依赖状态变量(totalRaised)进行逻辑,而不是 address(this).balance
  • 实施余额监控:
function checkBalanceConsistency() external view onlyAdmin returns (bool) {
    return address(this).balance >= totalRaised;
}

2. 多功能合约中的捐赠攻击

未跟踪的 ETH 可能会扰乱具有多个依赖于余额的函数(例如,支付、退款)的合约。

缓解措施

  • 对所有支付使用 pull-over-push:
function withdraw() external whenNotPaused {
    uint256 amount = pendingWithdrawals[msg.sender];
    require(amount > 0, "No funds to withdraw");
    pendingWithdrawals[msg.sender] = 0;
    (bool success, ) = msg.sender.call{value: amount}("");
    require(success, "Withdrawal failed");
    emit Withdrawn(msg.sender, amount);
}

3. 可升级合约中的 Selfdestruct

在基于代理的合约中,selfdestruct 可以将 ETH 存入代理中,从而扰乱依赖于余额的逻辑。

缓解措施

  • 避免在实现合约中使用 selfdestruct
  • 使用 OpenZeppelin 的 UUPS 或透明代理模式。
  • 监控代理余额:
function getProxyBalance() external view onlyAdmin returns (uint256) {
    return address(this).balance;
}

捐赠攻击预防的最佳实践

为了符合 Solodit 的 SOL-AM-DonationAttack 和 2025 行业标准:

  • 拒绝意外 ETH:实施一个 receive 函数,该函数恢复直接转账:
receive() external payable {
    revert("Direct ETH deposits not allowed");
}
  • 显式跟踪资金:使用状态变量(totalRaisedpendingWithdrawals)进行会计处理。
  • 使用 Pull-Over-Push:通过用户发起的提款来分配资金。
  • 实施断路器:包括暂停/取消暂停机制:
function pause() external onlyAdmin {
    paused = true;
    emit Paused();
}
  • 强制访问控制:使用 onlyAdmin 限制敏感函数。
  • 防止重入:在外部调用之前更新状态。
  • 监控余额:使用 Forta 或自定义视图函数来检测异常。
  • 审核 Selfdestruct:运行 slither --detect selfdestruct 以识别漏洞。
  • 全面测试
  • 使用 Hardhat/Foundry 模拟直接转账和 selfdestruct 攻击。
  • 使用 Echidna 进行模糊测试边缘情况。
  • Fork 主网以测试与余额相关的逻辑。

捐赠攻击预防的高级工具(2025 年更新)

截至 2025 年 7 月,这些工具增强了捐赠攻击预防:

  • Slither (0.10.x):检测 selfdestruct 和依赖于余额的逻辑(slither --detect selfdestruct)。
  • MythX:通过静态和动态分析识别 ETH 处理漏洞。
  • Foundry:支持主网 forking 和模糊测试以进行攻击模拟。
  • Forta:实时监控意外的余额增加。
  • Remix Gas Profiler:分析 gas 使用情况以实现高效的 ETH 处理。
  • OpenZeppelin Defender:自动监控和响应余额异常。

测试捐赠攻击弹性

通过以下方式确保你的合约能够承受攻击:

  • 单元测试
it("rejects direct ETH deposits", async () => {
    await expect(
        web3.eth.sendTransaction({
            from: accounts[0],
            to: crowdfunding.address,
            value: ethers.utils.parseEther("1"),
        })
    ).to.be.revertedWith("Direct ETH deposits not allowed");
});

it("handles selfdestruct attacks", async () => {
    const MaliciousContract = await ethers.getContractFactory("MaliciousContract");
    await MaliciousContract.deploy(crowdfunding.address, {
        value: ethers.utils.parseEther("1"),
    });
    expect(await ethers.provider.getBalance(crowdfunding.address)).to.equal(
        ethers.utils.parseEther("1")
    );
    await expect(crowdfunding.distributeFunds(accounts[1])).to.be.revertedWith("No funds to distribute");
});
  • 模糊测试:使用 Echidna 模拟随机 ETH 存款。
  • 主网 Forking:使用 Hardhat 测试与余额相关的逻辑。
  • 余额监控:使用 Forta 或脚本来检测意外增加。

真实案例研究:2023 年众筹漏洞利用

2023 年,一个 DeFi 众筹平台遭受了一次捐赠攻击,当时一名攻击者使用 selfdestruct 强制将 10 ETH 送入合约。该平台的退款逻辑依赖于 address(this).balance,由于未跟踪的 ETH 而失败,导致用户资金被锁定数周。该团队通过部署一个具有显式 ETH 跟踪和 pull-over-push 提款的新合约来缓解这种情况,但该事件导致了严重的用户流失和声誉损害。

与之前部分的整合

捐赠攻击可以放大其他漏洞:

  • 状态膨胀(第 2 部分):攻击者可以通过垃圾邮件贡献来膨胀映射,然后强制 ETH 来扰乱余额检查,从而将捐赠攻击与状态膨胀结合起来。
  • DoS 攻击(第 1 部分):未跟踪的 ETH 可能导致 require 检查在 gas 密集型循环中失败,从而加剧 DoS 条件。

为了对此进行反击,请结合:

  • Pull-Over-Push(第 1 部分)withdraw 函数通过分配 gas 成本来与 DoS 缓解措施保持一致。
  • 状态上限(第 2 部分):使用 MAX_CONTRIBUTIONS 常量限制贡献垃圾邮件。
  • 显式 ETH 跟踪(第 3 部分):使用状态变量并拒绝意外存款。

结论:加强你的智能合约

捐赠攻击是隐秘的破坏者,它们利用以太坊的开放性来破坏合约逻辑。通过遵循 Solodit 的 SOL-AM-DonationAttack 指南 — 拒绝意外 ETH、显式跟踪资金,以及使用稳健的pull-over-push 和断路器等模式 — 你可以构建防捐赠合约。像 Slither、MythX 和 Foundry 这样的工具,加上严格的测试,可确保在攻击下的弹性。

本文是智能合约安全:Solodit CheckList 系列的一部分。接下来,我们将探讨抢先交易攻击,剖析基于 mempool 的漏洞利用,并实施诸如提交-披露方案之类的保护措施。无论你是构建众筹平台、DeFi 协议还是 NFT 市场,掌握这些防御措施对于安全、可扩展和无需信任的系统至关重要。

行动号召:在评论中分享你的想法!你在你的项目中遇到过捐赠攻击吗?关注该系列以获取有关保护智能合约的更多见解,并查看 Foundry 和 Forta 等工具,以在漏洞之前保持领先地位。

  • 原文链接: medium.com/@ankitacode11...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
ankitacode11
ankitacode11
江湖只有他的大名,没有他的介绍。