重入攻击:单函数重入、跨函数重入、跨合约重入、重入防护机制

重入攻击,在DeFi安全领域里,算是最经典、也最容易被新手忽视的漏洞之一。我最早接触这个漏洞,是在一次审计一个借贷协议的时候。当时看到代码里有个提款函数,逻辑很简单:先转账,再更新余额。我当时就觉得不对劲——这不就是教科书式的重入漏洞吗?结果一查,果然,攻击者可以反复调用提款,把池子掏空。

说白了,重入攻击的核心就一句话:外部调用(转账、回调)被恶意利用,在状态更新前再次进入原函数。你想想看,合约执行到一半,状态还没改,攻击者又调了一次,那不就等于白嫖吗?

单函数重入

这是最基础的重入形式。攻击者利用同一个函数内的外部调用,在状态更新前再次调用自身。

看个典型例子:

// 漏洞合约
contract VulnerableBank {
    mapping(address => uint) public balances;

    function withdraw(uint _amount) public {
        require(balances[msg.sender] >= _amount, "余额不足");
        
        // 先转账
        (bool success, ) = msg.sender.call{value: _amount}("");
        require(success, "转账失败");
        
        // 后更新状态
        balances[msg.sender] -= _amount;
    }
}

攻击者合约的fallback函数里,直接再次调用withdraw。因为余额还没扣,所以又能通过检查。循环下去,直到把合约掏空。

关键点:状态更新必须在外部调用之前完成。这是铁律。

我个人习惯,写提款函数时,永远先改状态,再转账。这个顺序救过我很多次。

跨函数重入

单函数重入好理解,但跨函数重入就有点绕了。攻击者不是重复调用同一个函数,而是利用外部调用,跳转到合约的另一个函数里,而这个函数恰好也依赖同一个状态变量。

举个例子:

contract CrossFunctionVulnerable {
    mapping(address => uint) public balances;
    bool public flag;

    function withdraw() public {
        require(balances[msg.sender] > 0);
        (bool success, ) = msg.sender.call{value: balances[msg.sender]}("");
        require(success);
        balances[msg.sender] = 0;
    }

    function setFlag(bool _flag) public {
        require(balances[msg.sender] > 0);
        flag = _flag;
    }
}

攻击者在withdraw的转账回调里,调用setFlag。虽然setFlag不涉及转账,但它依赖balances[msg.sender] > 0这个条件。因为withdraw还没更新余额,所以setFlag也能通过。这就是跨函数重入——攻击者利用同一个未更新的状态,在不同函数间跳转。

避坑指南:我曾经审计过一个项目,开发者觉得只要不在同一个函数里重复调用就安全。结果攻击者利用跨函数重入,绕过了权限检查。所以,任何外部调用后,所有共享状态都可能被篡改

跨合约重入

这个更高级。攻击者不只在当前合约里跳转,而是通过外部调用,进入另一个合约,再通过回调回到原合约。说白了,就是利用合约间的信任关系。

典型的场景是闪电贷。攻击者借出资金,在回调函数里操作,然后利用重入漏洞反复借贷。我记得有个经典案例,攻击者通过跨合约重入,在一个借贷协议里循环借出资产,最后用极少的本金撬动了巨额资金。

跨合约重入的难点在于:你很难在单个合约的审计中发现问题。因为漏洞的触发点分散在多个合约里,需要全局视角。

重入防护机制

说了这么多漏洞,该聊聊怎么防了。我总结了三种主流方案:

1. 检查-生效-交互模式(Checks-Effects-Interactions)

这是最基础、最推荐的模式。顺序是:先检查条件,再更新状态,最后做外部交互。只要严格遵守这个顺序,单函数重入基本就防住了。

function safeWithdraw(uint _amount) public {
    // 检查
    require(balances[msg.sender] >= _amount);
    
    // 生效(更新状态)
    balances[msg.sender] -= _amount;
    
    // 交互(外部调用)
    (bool success, ) = msg.sender.call{value: _amount}("");
    require(success);
}

2. 重入锁(Reentrancy Guard)

OpenZeppelin提供了ReentrancyGuard,原理很简单:用一个状态变量标记是否正在执行。如果重入,直接revert。

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

contract SafeContract is ReentrancyGuard {
    function withdraw() public nonReentrant {
        // 业务逻辑
    }
}

我个人建议,所有涉及外部调用的函数,都加上nonReentrant修饰符。虽然会多花一点gas,但安全第一。

注意:重入锁不能防跨合约重入。因为锁是合约级别的,不同合约之间不共享锁状态。跨合约场景需要更复杂的方案,比如全局锁或时间戳校验。

3. 转账方式的选择

用transfer代替call,因为transfer只转发2300 gas,不够攻击者做复杂的重入操作。但这个方法现在不推荐了,因为gas成本变化可能导致转账失败。

更好的做法是:使用pull-over-push模式。用户自己来取款,而不是合约主动转账。这样外部调用完全由用户触发,合约本身不承担重入风险。

知识体系总览

下面这张图,是我梳理的重入攻击知识体系。你可以看到,从单函数到跨合约,攻击面在扩大,防护手段也在升级。

重入攻击知识体系 攻击类型 单函数重入 跨函数重入 跨合约重入 核心原理 外部调用 → 状态未更新 → 再次进入原函数 防护机制 检查-生效-交互 重入锁 Pull-over-Push 关键原则 1. 状态更新必须在外部调用之前完成 2. 所有涉及外部调用的函数都应考虑重入风险 3. 跨合约场景需要全局视角的审计 4. 不要依赖单一防护手段,多层防御更可靠

嗯,到这里,重入攻击的几种形态和防护手段就讲完了。你想想看,其实核心就一点:外部调用不可信,状态更新要先行。这个原则,在DeFi安全里,怎么强调都不过分。

最后说一句:我见过太多项目,因为觉得“就一个简单的转账,不会出事”,结果被重入攻击搞得血本无归。安全无小事,尤其是涉及资金流动的函数,多花五分钟检查,可能就省下几百万的损失。


公众号:蓝海资料掘金营,微信deep3321