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

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

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

跨函数重入 是指攻击者通过一次外部调用,递归地重新进入同一个合约的另一个函数 ,利用多个函数间共享的状态尚未更新的窗口期,执行非预期的操作。

这一攻击类型的标志性事件 是 2016 年的 The DAO 攻击,导致约 360 万 ETH 被分叉回滚,直接促成了以太坊经典(ETC)的诞生。


1. 核心原理

跨函数重入的根本原因与单函数重入相同:状态更新滞后于外部调用

区别在于,重入点不再是当前函数本身,而是另一个也依赖同一状态变量的函数

典型脆弱模式:

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

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

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

solidity 复制代码
// 有漏洞的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");
    }
}

攻击者合约

solidity 复制代码
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) 跨函数重入开始
6 withdraw 检查 balances[attacker] ≥ 1 由于 splitDAO 尚未扣款,余额仍为 1 → 通过
7 withdraw 扣减余额 (1 → 0),并向攻击者发送 1 ETH 攻击者再得 1 ETH
8 withdraw 执行完毕,返回 splitDAO 继续执行原 splitDAO
9 splitDAO 执行 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)

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

solidity 复制代码
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 修饰符,确保在函数执行期间整个合约不可重入。注意:重入锁是全局性的,可以同时防御单函数和跨函数重入。

solidity 复制代码
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 模式并配合重入锁即可一劳永逸地解决问题。然而,理解其与单函数重入的区别,有助于在审计中更敏锐地识别那些**"看似无关,实则共享状态"**的函数组合。

相关推荐
木西4 天前
揭秘 Web3 隐私社交标杆:CocoCat 的核心架构与智能合约实现
web3·智能合约·solidity
木西5 天前
深度拆解 Grass 模式:基于 EIP-712 与 DePIN 架构的奖励分发系统实现
web3·智能合约·solidity
Black_mario7 天前
Web3 时代的“伯克希尔”时刻:解析 Jason Hitchcock 与 Greenlane 的 Berachain 主权财库之路
web3
China_Yanhy7 天前
入职 Web3 运维日记 · 第 14 日:铸造无形钥匙 —— OIDC 与 CI/CD 施工实录
运维·web3
木西9 天前
深度解析|Form Network:BNX 迁移模块化 L2 的全流程技术实践
web3·智能合约·solidity
devmoon9 天前
区块链 Indexer 全解析:为什么 Web3 应用离不开数据索引器?(Polkadot / Ethereum / Solana 对比与未来展望)
rust·web3·区块链·以太坊·polkadot·solana·indexer
木西11 天前
STEPN相关内容延续篇:基于OpenZeppelinV5与Solidity0.8.24的创新点拆解
web3·智能合约·solidity
Lao乾妈官方认证唯一女友:D12 天前
wagmi使用方法
react.js·web3·wagmi
Lao乾妈官方认证唯一女友:D12 天前
Ethers.js使用方法
javascript·web3
木西12 天前
深度实战:用 Solidity 0.8.24 + OpenZeppelin V5 还原 STEPN 核心机制
web3·智能合约·solidity