智能合约重入攻击,听起来像黑客电影里的高深词,其实本质上就是“趁你还在核对账本的时候,偷偷再刷一笔钱”。在区块链的世界里,这类攻击已经让很多去中心化应用吃了大亏。今天咱们不聊空泛概念,就结合真实事件,把这个攻击掰开揉碎,看看它到底怎么发生,又该怎么防。

一、先从真实事件里看见它的獠牙

把时间拨回2016年,以太坊上有个叫The DAO的众筹项目,当年风头特别猛,募到了价值好几亿美元的以太坊。结果因为合约里藏着重入漏洞,黑客利用一个看似正常的“拆分”功能,在转账还没完成时反复调用取款逻辑,就像在一台自动吐钞机上,银行还没来得及扣账,你疯狂按钮,机器就一遍遍吐钱。最后巨额资金被卷走,社区为了救场,不得不硬分叉,才把部分资金追回来。这个事件让所有开发者明白了一件事:智能合约一旦上线就不可篡改,一个漏洞可能值亿万学费。

后来类似事件也没断过。2020年,去中心化借贷平台Lendf.Me被重入攻击洗劫了约2500万美元;同年bZx平台连续两次被攻击,其中一个原因就涉及重入和预言机价格操纵的组合拳。你会发现,重入攻击不是某一个人的疏忽,而是很多DApp在资金流转时共有的病。

二、重入攻击的本质:记账顺序错误

我们用最接地气的话解释一下。智能合约本质上是一台自动售货机:你给它钱,它给你货,然后它在自己的账本上记一笔“已发货”。但如果它先发货再记账,你就有机会在等待记账的间隙里,再次投币购买,而它仍然认为你没买过。这就是重入。

在以太坊上,合约之间的调用是通过消息调用完成的。当合约A调用合约B时,B执行完可以回过来再调用A的函数,这种你呼我应、嵌套执行的特点,让重入成为可能。很多合约之所以中招,就是因为在外部转账之后才更新内部状态,导致攻击者能够趁状态没变,反复进入提款流程。

2.1 一个带漏洞的银行合约

来看一个非常典型的取款合约。假设你做了一个银行DApp,大家可以把ETH存进来,随时取走。我用Solidity写了一个简化版,故意保留漏洞:

// 技术栈: Solidity 0.8.x
// 一个简单的银行合约,支持存钱和取钱
pragma solidity ^0.8.0;

contract VulnerableBank {
    // 记录每个地址的余额
    mapping(address => uint256) public balances;

    // 存钱函数,存入的ETH会累加到余额里
    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    // 取款函数 - 存在重入漏洞!
    function withdraw(uint256 _amount) external {
        // 先检查余额够不够
        require(balances[msg.sender] >= _amount, "余额不足");
        
        // 关键点:这里用call把ETH转给对方
        // call会触发对方合约的fallback或receive函数
        (bool success, ) = msg.sender.call{value: _amount}("");
        require(success, "转账失败");

        // 注意:转账之后才扣掉余额
        // 这导致在转账时,对方的余额还没被扣减
        balances[msg.sender] -= _amount;
    }
}

注意看,withdraw函数里,转账发生在余额更新之前。如果调用者是普通账户,没问题。可如果调用者是个攻击者合约,那它收到ETH时就会执行receive函数,在receive里又调用一次withdraw。因为此时balances[msg.sender]还没扣减,所以检查依然通过。这样就能无限循环,直到合约里的ETH被掏空。

2.2 攻击者合约是怎么操作的

下面是一个攻击者合约。它先存入1个ETH,然后发起取款,在receive里反复取款:

// 技术栈: Solidity 0.8.x
// 攻击者合约,利用 receive 函数循环调用银行合约的 withdraw
pragma solidity ^0.8.0;

// 一个简单的接口,用来调用银行合约
interface TVulnerableBank {
    function deposit() external payable;
    function withdraw(uint256 _amount) external;
}

contract Attack {
    // 保存银行合约的地址
    TVulnerableBank public bank;

    // 构造函数,传入银行合约地址
    constructor(address _bankAddress) {
        bank = TVulnerableBank(_bankAddress);
    }

    // fallback函数,当这个合约收到ETH时会自动执行
    receive() external payable {
        // 如果银行合约里还有至少1个ETH,就继续取
        if (address(bank).balance >= 1 ether) {
            bank.withdraw(1 ether);
        }
    }

    // 攻击入口
    function attack() external payable {
        require(msg.value >= 1 ether, "至少存1个ETH");
        // 先往银行存入1个ETH
        bank.deposit{value: 1 ether}();
        // 然后取款,触发重入
        bank.withdraw(1 ether);
    }

    // 查看攻击合约里有几个ETH
    function getBalance() external view returns (uint256) {
        return address(this).balance;
    }
}

攻击流程就是:调用attack,先存1 ETH,再取1 ETH。在第一次取款时,银行先把1 ETH转给攻击合约,此时receive被触发,它又调用bank.withdraw(1 ether)。由于第一次的余额还没扣掉,银行认为你还有1 ETH,所以又转1 ETH。这样反复循环,直到银行合约余额不足。最终攻击合约拿到了远超自己本金的ETH。注意,在实际攻击中,攻击者通常会用多个账户或在不同交易里分层操作,但核心套路就是这个“先转账后扣账”的错误顺序。

三、真实世界的重入攻击还有哪些花样

随着技术演进,重入攻击也长出了新面孔。除了上面那种最直接的“同函数重入”,还有下面这些变种。

3.1 跨函数重入

跨函数重入,指的是合约里有两个函数都修改同一个状态变量。攻击者在函数A执行期间,通过回调进入函数B,而函数B同样依赖这个未更新的状态变量,从而做出错误行为。比如一个协议有“存钱”和“借贷”两个功能,如果攻击者在转账环节调用了借贷函数,而借贷函数看到的还是旧余额,就可能借出超额的资产。这种攻击比单函数重入更隐蔽,因为开发者往往只盯着某个提款函数,忘了其他函数也在共享状态。

3.2 只读重入

只读重入则更狡猾。攻击者会在重入过程中改变某个合约的存储内容,但外部读取方(比如预言机、链上分析工具)看到的是不一致的数据,然后据此做出错误判断。最典型的场景是DEX:攻击者利用重入修改了流动性池的余额,再趁另一个合约读取这个池子价格时,完成低价买入或高价卖出套利。换句话说,重入不仅可以直接偷钱,还能通过“污染数据”来间接偷钱。

四、多层防御,层层堵死漏洞

防御不是选其中一招就完了,而是要像系安全带一样,把好几层保护都戴上。下面我们一项一项说,每一层该怎么用。

4.1 策略一:检查-效果-交互

这是最基础也最有效的习惯。简单说就是:先检查条件,再更新状态(在合约账本上改动),最后才跟外部合约交互。我们把漏洞合约改一下:

// 技术栈: Solidity 0.8.x
// 修复后的银行合约,采用“检查-效果-交互”模式
pragma solidity ^0.8.0;

contract SafeBank {
    mapping(address => uint256) public balances;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw(uint256 _amount) external {
        // 第一步:检查
        require(balances[msg.sender] >= _amount, "余额不足");
        
        // 第二步:先更新余额
        balances[msg.sender] -= _amount;

        // 第三步:再转账
        (bool success, ) = msg.sender.call{value: _amount}("");
        require(success, "转账失败");
        // 如果转账失败,会自动回滚之前的状态更新
    }
}

注意,如果转账失败,整个交易会回滚,所以余额更新不会丢失。这个模式的核心是:让外部调用变成“最后一步”,这样无论对方怎么回调,看到的都是已经更新后的状态。

4.2 策略二:加一把重入锁

重入锁的作用就像公共场所的旋转门,每次只能进一个人。当一个函数正在执行时,锁被锁上;执行完才解锁。这样即使攻击者想在转账时回调,也会发现锁是锁着的,直接报错。

// 技术栈: Solidity 0.8.x
// 使用重入锁防止重入
pragma solidity ^0.8.0;

contract LockedBank {
    mapping(address => uint256) public balances;
    // 定义一把锁,初始值为false(未上锁)
    bool private locked;

    // 重入锁修饰器
    modifier nonReentrant() {
        // 如果锁已被锁上,直接报错
        require(!locked, "禁止重入");
        // 锁上
        locked = true;
        // 执行原函数体
        _;
        // 执行完解锁
        locked = false;
    }

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw(uint256 _amount) external nonReentrant {
        require(balances[msg.sender] >= _amount, "余额不足");
        // 这里仍然先交互再更新,但因为锁了,重入会被拦截
        (bool success, ) = msg.sender.call{value: _amount}("");
        require(success, "转账失败");
        balances[msg.sender] -= _amount;
    }
}

加了锁之后,即便转账在状态更新之前,攻击者也无法重入。但为了最佳实践,还是建议先更新状态,把锁作为第二道防线。

4.3 策略三:限制转账Gas,断了攻击者的后路

我们知道,transfersend转发到外部地址时有2300 gas限制。而攻击合约在receive里要执行复杂的调用,2300 gas根本不够。所以早期很多教程推荐用transfer转账。我们看看代码:

// 技术栈: Solidity 0.8.x
// 使用 transfer 来转账,限制 gas 从而防止重入
contract TransferBank {
    mapping(address => uint256) public balances;

    function withdraw(uint256 _amount) external {
        require(balances[msg.sender] >= _amount, "余额不足");
        balances[msg.sender] -= _amount;
        // transfer 只给目标地址发送 2300 gas,攻击者无法做复杂操作
        payable(msg.sender).transfer(_amount);
    }
}

但这里必须说明,transfer并不是银弹。如果接收方是合约且其receive需要超过2300 gas(比如多重继承、复杂的存储写操作),转账就会失败,用户资金也可能卡在合约里。所以现在主流建议是:用call配合其他防御手段,而不是依赖transfer

4.4 策略四:直接使用经过审计的库

最省心的方法是直接用OpenZeppelin提供的ReentrancyGuard,它已经过大量实战检验。在合约里继承它,然后给敏感函数加上nonReentrant修饰器就行。

// 技术栈: Solidity 0.8.x
// 使用 OpenZeppelin 提供的重入保护库
pragma solidity ^0.8.0;

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

contract GuardBank is ReentrancyGuard {
    mapping(address => uint256) public balances;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

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

注意这里的import路径,用户需要安装OpenZeppelin合约包。实际上,OpenZeppelin的ReentrancyGuard实现了一个uint256类型的锁,还区分了_ENTERED_NOT_ENTERED,比简单的bool锁更安全,可防止同交易内的不同函数互相影响。

五、技术优缺点与开发注意事项

5.1 几种防御手段的优缺点

拿我们上面提到的几种方法来说,各有各的使用场景。

“检查-效果-交互”模式:优点是零成本、白送的安全感,从根本上消除了重入条件;缺点是需要开发者有意识地去遵守,如果碰到特别复杂的函数,可能会漏掉某一条更新路径。所以必须配合代码审查和测试。

重入锁:优点是简单粗暴,效果立竿见影;缺点是一旦在函数执行过程中出现revert,锁状态会被自动回滚,所以不会“死锁”,但如果忘记在函数末尾解锁(其实因为回滚机制,只要交易结束就会回到初始状态),所以真正需要注意的是锁的作用域:不要用同一个锁去保护需要递归调用的合法场景,比如某些合约间的回调业务,可能会主动限制功能。

限制Gas的transfer:优点是代码写起来简单,且老版本编译器都支持;缺点是2300 gas的限制太苛刻,如今很多合约的收币逻辑不只记账,还要做额外操作,直接用transfer容易导致用户资金无法提取。而且随着EIP-1884等升级,操作码的gas成本变化,transfer可能更不可靠。所以现在Solidity官方也不建议继续使用transfer直接抗重入。

OpenZeppelin库:优点是社区维护、审计充分,而且不断迭代;缺点是需要引入外部依赖,对一些追求极简的合约来说略显沉重,但这点成本换安全,非常划算。

5.2 特别提醒:别在防御上偷懒

很多人以为只要用了一种防御方式就万事大吉,其实不然。比如你用了重入锁,但只在withdraw函数上加了锁,另一个mint函数没加,攻击者依然可能通过跨函数重入利用未锁的函数。再比如你用了“检查-效果-交互”,但某个函数里故意绕过顺序,先把外部调用写在了前面,那就等于白搭。此外,合约升级时要特别注意存储布局,如果新版本调整了状态变量顺序,很容易破坏锁或余额记录,制造新的漏洞。

开发的时候,一定要把“攻击者会怎么想”写进测试用例。写一个攻击合约,模拟重入,看你的防御是否真的有效。很多项目被攻击后才发现,自己的安全测试只测了功能,没测对抗。

六、应用场景与开发建议

重入攻击主要影响那些跟资产流转强相关的DApp。常见的有:

  • 去中心化借贷平台:用户的抵押、借款、还款、清算,每一步都可能调用外部合约,稍不留神就会留出重入口子。
  • 去中心化交易所(DEX)与AMM:流动性池的存取款、兑换操作,尤其是通过内部余额记账的方式,非常容易中招。
  • 保险库与收益聚合器:这些合约会频繁调用不同的外部协议,复杂的调用链让重入风险成倍上升。
  • NFT市场:预售、拍卖、版税分账功能,如果先转移资产后记账,同样可能被恶意合约利用。
  • 跨链桥和多签钱包:这些合约资金量巨大,一旦重入漏洞被利用,后果不堪设想。

开发建议很简单:

  1. 从第一行代码就养成“先改状态再交互”的习惯。
  2. 对每一个calldelegatecall,问自己:如果它回调我,会发生什么?
  3. 把重入锁当成标配,而不是可选方案。
  4. 使用经过审计的标准库,例如OpenZeppelin。
  5. 上线前做多轮测试,并请第三方审计团队重点排查重入路径。

七、总结

重入攻击不是多么高深的手法,它恰恰抓住了开发者“事后再记账”的懒惰心理。从The DAO到今天,无数DApp吃了大亏,但好消息是,防御方法已经非常成熟。只要我们在写代码时时刻想着“转账是不可信的”,再加上状态更新顺序、重入锁、标准库这些层层防线,就能把这类漏洞堵死。记住,在去中心化的世界里,没人替你兜底,安全只能靠自己在每一行代码里守护。