第八部分:保护以太坊智能合约免受重入攻击

本文深入探讨了以太坊智能合约中臭名昭著的可重入攻击漏洞,回顾了 2016 年 DAO 被黑事件等历史教训,详细解释了可重入攻击的原理、类型和攻击流程。文章还提供了使用 CEI 模式和重入锁的防御方法,以及利用 Slither、Foundry 和 Forta 等工具进行测试和监控的最佳实践。

介绍:撼动以太坊的 DeFi 陷阱

重入攻击是以太坊智能合约中最臭名昭著的漏洞之一,2016 年的 DAO 黑客事件(损失 6000 万美元)给开发者敲响了警钟。通过利用合约在更新其状态之前被重复调用的能力,攻击者可以耗尽资金或扰乱逻辑。本文是 智能合约安全:Solodit 检查清单系列 的一部分,探讨了 SOL-AM-ReentrancyAttack 漏洞,涵盖了两个关键问题:外部调用后的状态更改和返回过时值的 view 函数。

截至 2025 年 8 月,以太坊的权益证明系统,具有约 4500 万 gas 的区块限制和约 12 秒的区块时间,使得安全合约设计至关重要。我们将分解重入的工作原理,展示有漏洞和安全的代码,提供清晰的图表,并分享使用最新工具和标准的最佳实践,所有这些都是为开发者和审计员量身定制的,即使是那些刚接触 Solidity 的人也是如此。

为什么重入攻击很重要

重入攻击利用合约与外部合约或地址交互的方式,导致:

  • 资金耗尽:攻击者虹吸 ETH 或 token(例如,The DAO 损失 6000 万美元)。
  • 逻辑中断:重复调用会扰乱合约状态,破坏功能。
  • 信任丧失:像 The DAO 这样的漏洞破坏了早期 DeFi 的信心。
  • 连锁反应:一次黑客攻击可能会破坏相互连接的协议的稳定性。

The DAO 黑客事件 (2016):一个历史教训

  • 发生了什么:The DAO,一个去中心化投资基金,筹集了 354 万 ETH(1.5 亿美元)。其提款函数中的一个重入缺陷让攻击者在余额更新之前递归地提取 6000 万美元来耗尽资金。
  • 影响:引发了一次硬分叉,将以太坊分为以太坊(分叉,撤销黑客攻击)和以太坊经典(原始链)。这引发了关于区块链不变性的辩论。
  • 教训:强调了在调用之前进行状态更新和严格审计的必要性。

重入攻击如何工作

当恶意合约在状态更新之前(例如,发送 ETH)在外部调用期间回调到易受攻击的合约时,就会发生重入,从而利用未更改的状态。

关键机制

  • Fallback/Receive 函数:当发送 ETH 或调用不存在的函数时触发,这些函数可以运行任意代码。
  • 漏洞窗口:状态更新之前的外部调用(例如,调用以发送 ETH)允许重入。

攻击类型

  • 经典重入:通过递归提款耗尽资金。
  • 只读重入:View 函数在重入期间返回过时的数据,误导其他协议。

攻击流程图

这显示了攻击者如何利用易受攻击的合约:

SOL-AM-ReentrancyAttack-1:外部调用后的状态更改

问题

外部调用(例如,调用以发送 ETH)之前的状态更新会创建一个重入窗口,让攻击者回调并利用未更改的状态(例如,非零余额)。

有漏洞的代码示例

此 Bank 合约允许在更新余额之前进行多次提款:

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

contract VulnerableBank {
    mapping(address => uint256) public balances;

    // Deposit ETH
    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    // Vulnerable withdraw function
    function withdraw() external {
        uint256 amount = balances[msg.sender];
        require(amount > 0, "No balance");
        // Interaction: Send ETH before updating state
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");
        // Effect: Too late!
        balances[msg.sender] = 0;
    }

    function getBalance() external view returns (uint256) {
        return address(this).balance;
    }
}

contract Attacker {
    VulnerableBank public bank;

    constructor(address _bank) {
        bank = VulnerableBank(_bank);
    }

    function attack() external payable {
        require(msg.value >= 1 ether, "Need 1 ETH");
        bank.deposit{value: msg.value}();
        bank.withdraw();
    }

    // Fallback re-enters withdraw
    receive() external payable {
        if (address(bank).balance >= 1 ether) {
            bank.withdraw();
        }
    }
}

攻击场景

  1. 攻击者存入 1 ETH;balances[attacker] = 1 ETH。
  2. 调用 withdraw();检查余额 (1 ETH),发送 1 ETH,触发攻击者的 receive()。
  3. receive() 再次调用 withdraw();余额仍然是 1 ETH(未更新)。
  4. 重复直到达到 gas 限制或合约耗尽。
  5. 多次提款后,余额设置为 0。

修复

  1. Checks-Effects-Interactions (CEI):在外部调用之前更新状态。
  2. Reentrancy Guard:在执行期间锁定该函数。

使用 CEI 的安全代码

在发送 ETH 之前更新余额:

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

contract FixedBankCEI {
    mapping(address => uint256) public balances;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw() external {
        uint256 amount = balances[msg.sender];
        require(amount > 0, "No balance");
        // Effect: Update state first
        balances[msg.sender] = 0;
        // Interaction: Safe external call
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");
    }

    function getBalance() external view returns (uint256) {
        return address(this).balance;
    }
}

使用重入保护的安全代码

使用 OpenZeppelin 的 ReentrancyGuard:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.10;
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

contract FixedBankGuard is ReentrancyGuard {
    mapping(address => uint256) public balances;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw() external nonReentrant {
        uint256 amount = balances[msg.sender];
        require(amount > 0, "No balance");
        balances[msg.sender] = 0;
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");
    }

    function getBalance() external view returns (uint256) {
        return address(this).balance;
    }
}

安全工作流程图

这显示了 CEI 或保护如何阻止攻击:

SOL-AM-ReentrancyAttack-2: View 函数返回过时的值

问题

只读(view)函数可以在重入窗口期间返回过时的数据,从而误导其他合约(例如,使用价格数据的借贷协议)。

有漏洞的代码示例

此 Vault 合约在重入期间具有过时的 getSharePrice:

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

contract VulnerableVault {
    mapping(address => uint256) public shares;
    uint256 public totalShares;
    uint256 public totalBalance;

    function deposit() external payable {
        uint256 sharesToMint = msg.value;
        shares[msg.sender] += sharesToMint;
        totalShares += sharesToMint;
        totalBalance += msg.value;
    }

    function withdraw(uint256 shareAmount) external {
        require(shares[msg.sender] >= shareAmount, "Insufficient shares");
        uint256 ethAmount = (shareAmount * totalBalance) / totalShares;
        shares[msg.sender] -= shareAmount;
        totalShares -= shareAmount;
        // Vulnerable: Call before updating totalBalance
        (bool success, ) = msg.sender.call{value: ethAmount}("");
        require(success, "Transfer failed");
        totalBalance -= ethAmount;
    }

    // Stale during reentrancy
    function getSharePrice() public view returns (uint256) {
        return totalShares == 0 ? 1e18 : (totalBalance * 1e18) / totalShares;
    }
}

contract LendingProtocol {
    VulnerableVault public vault;
    mapping(address => uint256) public collateralShares;
    mapping(address => uint256) public debt;

    constructor(address _vault) {
        vault = VulnerableVault(_vault);
    }

    function depositCollateral(uint256 shareAmount) external {
        require(vault.shares(msg.sender) >= shareAmount, "Low shares");
        vault.transferFrom(msg.sender, address(this), shareAmount);
        collateralShares[msg.sender] += shareAmount;
    }

    function borrow() external {
        uint256 sharePrice = vault.getSharePrice(); // Stale during reentrancy
        uint256 collateralValue = (collateralShares[msg.sender] * sharePrice) / 1e18;
        uint256 maxBorrow = collateralValue * 99 / 100;
        require(maxBorrow > debt[msg.sender], "Low collateral");
        uint256 borrowAmount = maxBorrow - debt[msg.sender];
        debt[msg.sender] += borrowAmount;
        payable(msg.sender).transfer(borrowAmount);
    }
}

contract Attacker {
    VulnerableVault public vault;
    LendingProtocol public lending;
    bool private attacking;

    constructor(address _vault, address _lending) {
        vault = VulnerableVault(_vault);
        lending = LendingProtocol(_lending);
    }

    function exploit() external payable {
        vault.deposit{value: msg.value}();
        uint256 shareAmount = msg.value / 2;
        vault.approve(address(lending), shareAmount);
        lending.depositCollateral(shareAmount);
        attacking = true;
        vault.withdraw(msg.value - shareAmount);
    }

    receive() external payable {
        if (attacking) {
            lending.borrow(); // Uses stale price
        }
    }
}

攻击场景

  1. 攻击者存入 1 ETH,获得份额,将一半存入作为抵押品。
  2. 为剩余份额调用 withdraw();totalShares 更新,但 totalBalance 没有。
  3. 调用会触发攻击者的 receive(),后者调用 lending.borrow()。
  4. getSharePrice 返回虚高的价格(过时的 totalBalance)。
  5. 攻击者根据错误的价格借用过多的 ETH。
  6. totalBalance 更新得太晚了。

修复

  1. CEI 模式:在调用之前更新所有状态(totalBalance)。
  2. 保护 View 函数:检查 view 函数中的重入。

使用 CEI 和保护的安全代码

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

contract FixedVault is ReentrancyGuard {
    mapping(address => uint256) public shares;
    mapping(address => mapping(address => uint256)) public allowances;
    uint256 public totalShares;
    uint256 public totalBalance;

    function deposit() external payable nonReentrant {
        uint256 sharesToMint = msg.value;
        shares[msg.sender] += sharesToMint;
        totalShares += sharesToMint;
        totalBalance += msg.value;
    }

    function withdraw(uint256 shareAmount) external nonReentrant {
        require(shares[msg.sender] >= shareAmount, "Insufficient shares");
        uint256 ethAmount = (shareAmount * totalBalance) / totalShares;
        shares[msg.sender] -= shareAmount;
        totalShares -= shareAmount;
        totalBalance -= ethAmount; // Update first
        (bool success, ) = msg.sender.call{value: ethAmount}("");
        require(success, "Transfer failed");
    }

    function getSharePrice() public view returns (uint256) {
        if (_reentrancyGuardEntered()) revert("Reentrant call detected");
        return totalShares == 0 ? 1e18 : (totalBalance * 1e18) / totalShares;
    }

    function approve(address spender, uint256 amount) external {
        allowances[msg.sender][spender] = amount;
    }

    function transferFrom(address from, address to, uint256 amount) external {
        require(shares[from] >= amount, "Insufficient shares");
        if (from != msg.sender) {
            require(allowances[from][msg.sender] >= amount, "Low allowance");
            allowances[from][msg.sender] -= amount;
        }
        shares[from] -= amount;
        shares[to] += amount;
    }
}

对比表:有漏洞 vs. 安全

预防的最佳实践

  • Checks-Effects-Interactions (CEI):在外部调用之前更新状态。
  • 重入保护:使用 OpenZeppelin 的 ReentrancyGuard 或 ReentrancyGuardTransient(2024 年坎昆之后,通过瞬态存储节省 gas)。
  • Pull Over Push:让用户声明资金(例如,声明函数)以避免强制发送 ETH。
  • 最小化调用:如果安全,则使用 transfer()(2300 gas 限制)或 send(),但最好使用 CEI。
  • 保护 View 函数:添加 _reentrancyGuardEntered() 检查。
  • Oracle 集成:使用 Chainlink 获取可靠的数据,从而降低 view 函数的风险。
  • 测试:使用 Foundry 模拟攻击,使用 Echidna 进行模糊测试,使用 Slither 进行扫描。

测试和工具(2025 年更新)

  • 单元测试
it("prevents reentrancy", async () => {
    await attacker.attack({value: ethers.parseEther("1")});
    await expect(attacker.exploit()).to.be.revertedWith("Reentrant call");
});
  • Fuzzing:Echidna 用于递归调用场景。
  • 模拟:Foundry 用于攻击重放。
  • 扫描器:Slither 0.10.x、MythX 用于重入检测。
  • 监控:Forta 用于实时漏洞警报。
  • Forks:Hardhat 用于主网测试。
  • Gas 优化:使用瞬态存储(EIP-1153,2024 年坎昆)进行保护。

链接到其他漏洞

重入可以放大:

  • 价格操纵(第 7 部分):扭曲的价格 + 重入 = 更大的损失。
  • 抢跑交易(第 4 部分):重入调用与合法的交易竞争。
  • 恶意破坏(第 5 部分):重入延迟有效操作。
  • 捐赠攻击(第 3 部分):强制 ETH + 重入会膨胀余额。

结合防御措施:

  • Pull-Payments(第 1 部分):使用 claim 进行安全提款。
  • 状态上限(第 2 部分):限制递归循环。
  • ETH 处理(第 3 部分):拒绝意外的 ETH。
  • Commit-Reveal(第 4 部分):延迟操作以阻止竞争。

结论:构建防重入合约

从 The DAO 到现代 DeFi,重入攻击表明为什么安全设计是不容谈判的。使用 CEI、重入保护和 view 函数检查可以同时阻止经典重入和只读重入。借助 Slither、Foundry 和 Forta 等工具,以及彻底的测试,你可以锁定你的合约。接下来,我们将解决像 UUPS 代理的存储冲突这样的可升级性风险。首先考虑安全性进行编码,以确保 DeFi 的安全和可信!

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

0 条评论

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