欢迎订阅专栏: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) | 跨函数重入开始 |
| 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)
任何可能被外部重入的函数,都必须在外部调用之前完成所有内部状态更新。
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 模式并配合重入锁即可一劳永逸地解决问题。然而,理解其与单函数重入的区别,有助于在审计中更敏锐地识别那些**“看似无关,实则共享状态”**的函数组合。