重入攻击:智能合约的“幽灵漏洞”

重入攻击,说白了就是合约在调用外部合约时,被对方“反咬一口”。

我最早接触这个漏洞,是在2016年。那时候DAO事件刚发生,整个以太坊社区都炸了锅。360万以太币被盗,价值数亿美元。嗯,这个漏洞的破坏力,你想想看,直接导致了以太坊硬分叉。

为什么会这样?因为Solidity的调用机制有个特点:外部调用会把控制权交给对方。如果对方是个恶意合约,它可以在你的函数还没执行完时,再次调用你的函数。

攻击原理:递归调用如何掏空合约

重入攻击的核心逻辑其实很简单。我画个图你就明白了:

攻击者合约 fallback() 再次调用 withdraw() 受害者合约 withdraw() 先转账,后更新余额 ① 调用 withdraw() ② 发送 ETH ③ fallback() 再次调用 withdraw() ↻ 循环直到余额耗尽 核心问题:转账在前,余额更新在后 攻击者利用这个时间差,在余额未扣减前反复提取资金

你看这个流程:攻击者先调用 withdraw(),合约检查余额通过后,先转账。转账时触发攻击者的 fallback() 函数,这个函数里又调用了 withdraw()。此时余额还没更新,所以又能通过检查。如此循环,直到合约被掏空。

关键点:重入攻击能成功,是因为状态更新发生在外部调用之后。只要把顺序反过来,就能堵住这个漏洞。

经典DAO攻击案例:血的教训

2016年的DAO攻击,是区块链史上最著名的事件之一。我复盘一下当时的代码逻辑:

// 简化版DAO合约(有漏洞)
contract DAO {
    mapping(address => uint) public balances;
    
    function withdraw(uint _amount) public {
        // 检查余额
        require(balances[msg.sender] >= _amount);
        
        // 转账(外部调用)
        msg.sender.call{value: _amount}("");
        
        // 更新余额(在转账之后!)
        balances[msg.sender] -= _amount;
    }
}

攻击者部署了一个恶意合约:

contract AttackDAO {
    DAO public dao;
    
    constructor(address _dao) {
        dao = DAO(_dao);
    }
    
    function attack() public payable {
        // 先存入一些ETH
        dao.deposit{value: msg.value}();
        // 开始提取
        dao.withdraw(msg.value);
    }
    
    // fallback函数:收到ETH时再次调用withdraw
    fallback() external payable {
        if (address(dao).balance >= msg.value) {
            dao.withdraw(msg.value);
        }
    }
}

这个攻击有多狠?攻击者只存了一次钱,却可以反复提取。我记得当时有个团队,他们的合约里存了上千万美元,几分钟内就被掏空了。

⚠️ 避坑指南:我曾经审计过一个DeFi项目,他们的提现函数也是先转账后更新。我跟他们说了DAO攻击的故事,CTO还不信,说“我们用的是ERC20,不是ETH”。结果我写了个测试用例,用ERC20的transfer回调函数,一样能重入。记住:任何外部调用都可能被重入,不管你是转ETH还是调ERC20。

重入锁防护:最简单的防御方案

重入锁,说白了就是一个“正在执行”的标志位。函数执行时锁上,执行完再解锁。如果重入进来,发现锁着,直接revert。

// OpenZeppelin的ReentrancyGuard
contract ReentrancyGuard {
    uint256 private _status;
    
    // 0 = 未锁定, 1 = 已锁定
    constructor() {
        _status = 0;
    }
    
    modifier nonReentrant() {
        require(_status == 0, "ReentrancyGuard: reentrant call");
        _status = 1;
        _;
        _status = 0;
    }
}

// 使用重入锁
contract SafeDAO is ReentrancyGuard {
    mapping(address => uint) public balances;
    
    function withdraw(uint _amount) public nonReentrant {
        require(balances[msg.sender] >= _amount);
        
        // 先更新余额
        balances[msg.sender] -= _amount;
        
        // 再转账
        msg.sender.call{value: _amount}("");
    }
}

这个方案的好处是简单粗暴。但要注意:重入锁会消耗更多gas,而且不能嵌套使用。我见过有人在一个函数里调另一个带锁的函数,结果把自己锁死了。

💡 个人经验:我习惯在涉及外部调用的函数上都加nonReentrant。虽然gas成本高了点,但安全第一。你想想看,省那点gas,万一被盗了,得不偿失。

检查-生效-交互模式:更优雅的解决方案

其实重入锁是“治标”,检查-生效-交互模式才是“治本”。这个模式的核心思想就一句话:先检查条件,再更新状态,最后做外部调用

// 检查-生效-交互模式
contract CheckEffectInteract {
    mapping(address => uint) public balances;
    
    function withdraw(uint _amount) public {
        // 第一步:检查(Check)
        require(balances[msg.sender] >= _amount, "余额不足");
        require(_amount > 0, "金额必须大于0");
        
        // 第二步:生效(Effect)
        balances[msg.sender] -= _amount;
        
        // 第三步:交互(Interaction)
        (bool success, ) = msg.sender.call{value: _amount}("");
        require(success, "转账失败");
    }
}

你看这个顺序:先检查余额够不够,然后直接扣减余额,最后才转账。就算转账时触发重入,余额已经扣了,重入进来检查余额时就会失败。

方案 优点 缺点 适用场景
重入锁 简单、通用、不易出错 gas成本高、不能嵌套 复杂合约、多函数需要保护
检查-生效-交互 gas成本低、无嵌套问题 需要仔细设计状态更新顺序 简单提现、转账场景
两者结合 双重保险、最安全 gas成本最高 高价值合约、资金池

我的建议:对于新写的合约,优先用检查-生效-交互模式。如果合约逻辑复杂,或者有多个函数互相调用,再加个重入锁做双重保险。我自己的项目都是这么干的,目前还没出过问题。

嗯,重入攻击就讲到这里。记住一句话:外部调用永远不可信。你永远不知道对方会在回调函数里做什么。把状态更新放在外部调用之前,这个习惯养成了,能帮你避开90%的重入漏洞。


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