漏洞合约分析:提现函数中的状态更新顺序问题、余额检查与更新分离

好,咱们今天来聊一个老生常谈,但每次都能让项目方翻车的问题——提现函数里的状态更新顺序。

说白了,就是你先扣钱,还是先给钱?

这个顺序,决定了你的合约是铜墙铁壁,还是一张废纸。

一、问题本质:先更新状态,还是先转账?

我见过太多新手,甚至一些老手写的合约,提现函数长这样:

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

你看出问题了吗?

嗯,这就是典型的「余额检查与更新分离」。检查是一回事,更新是另一回事,中间隔了一个转账操作。这个缝隙,就是黑客的突破口。

二、为什么顺序这么重要?

我个人习惯把合约的状态更新想象成「记账本」。你先把账记好,再给钱,这叫「先记账后付款」。反过来,先给钱再记账,那叫「先上车后补票」——万一人家不补票呢?

在以太坊的世界里,call 调用会触发接收方的 fallbackreceive 函数。如果接收方是一个恶意合约,它可以在 fallback 里再次调用你的 withdraw 函数。

这时候,你的余额还没扣,检查 require(balances[msg.sender] >= _amount) 依然能通过。于是,黑客就可以反复提现,直到把你的合约掏空。

核心原则: 所有外部调用(转账、交互其他合约)都必须放在状态更新的后面。先改状态,再交互。

三、我在项目中遇到过的真实案例

我记得有一次审计一个 DeFi 项目,他们的提现函数就是这种「先转账后更新」的写法。我跟他们团队说:「你们这个有重入风险。」

对方还不信,说:「我们用了 require 检查余额啊,怎么会重入?」

我直接给他们演示了一遍:部署一个攻击合约,在 fallback 里递归调用 withdraw。结果,原本只能提 10 ETH 的账户,硬生生提走了 50 ETH。

嗯,从那以后,他们团队的所有合约都加上了「先更新后转账」的规范。

四、正确的写法:先更新,后转账

修复方案其实很简单,把顺序调换一下:

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

你想想看,现在余额先扣了,黑客再重入的时候,balances[msg.sender] 已经减少了,require 检查就会失败。重入攻击自然就失效了。

小技巧: 我建议你在写提现函数时,养成一个肌肉记忆——先写状态更新,再写外部调用。哪怕你写的是 transfersend,也保持这个顺序。虽然 transfersend 有 2300 gas 限制,理论上能防止重入,但谁知道未来会不会有变数?

五、更安全的做法:使用重入锁

光靠顺序调整,其实还不够保险。为什么?

因为你的合约里可能有多个函数,它们之间可能存在交叉重入的风险。比如,一个函数调用了另一个函数,而那个函数又调用了你的提现函数。这种间接重入,光靠顺序调整是防不住的。

所以,我个人的习惯是:在关键函数上加重入锁

OpenZeppelin 提供了一个现成的 ReentrancyGuard,用法很简单:

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

contract MyContract is ReentrancyGuard {
    mapping(address => uint) public balances;
    
    function withdraw(uint _amount) public nonReentrant {
        require(balances[msg.sender] >= _amount, "余额不足");
        
        balances[msg.sender] -= _amount;
        
        (bool success, ) = msg.sender.call{value: _amount}("");
        require(success, "转账失败");
    }
}

这个 nonReentrant 修饰符,说白了就是一个互斥锁。它用一个状态变量来标记当前是否正在执行某个函数。如果重入了,锁还没释放,就会直接 revert。

注意: 重入锁虽然好用,但也不是万能的。它会影响 gas 消耗,而且如果锁的粒度太粗,可能会导致某些正常调用被阻塞。我曾经见过一个项目,把 nonReentrant 加到了所有函数上,结果用户想同时调用两个不同的功能都不行。嗯,这就有点矫枉过正了。

六、状态更新顺序的完整检查清单

我整理了一个清单,每次写合约时我都会过一遍:

检查项 说明 优先级
先更新状态,后外部调用 所有涉及余额、权限、状态的修改,必须在外部调用之前完成 必须
使用重入锁 对提现、转账等关键函数添加 nonReentrant 修饰符 强烈建议
避免跨函数重入 检查合约中是否有多个函数可能形成交叉调用链 建议
限制外部调用 gas 如果使用 call,考虑限制 gas 或使用 transfer/send 可选

七、用 SVG 画一张流程图

为了让你更直观地理解,我画了一张流程图,展示「先更新后转账」和「先转账后更新」的区别:

提现函数状态更新顺序对比 ❌ 错误:先转账后更新 1. 检查余额 (require) 2. 转账 (call) 3. 更新余额 ⚠️ 重入攻击风险 ✅ 正确:先更新后转账 1. 检查余额 (require) 2. 更新余额 3. 转账 (call) ✅ 重入攻击被阻断

八、避坑指南

我曾经在审计一个跨链桥合约时,发现他们的提现函数虽然顺序对了,但用了 address.send() 而不是 call。他们以为 send 有 2300 gas 限制就安全了。

结果呢?

攻击者通过一个复杂的合约,在 fallback 里只做了一件事——写一个存储变量。2300 gas 够用了。然后递归调用提现函数,照样把合约掏空了。

所以,别迷信 sendtransfer 的 gas 限制。它们只能防住「写合约逻辑」这种高耗 gas 的操作,但防不住「改状态」这种低耗 gas 的操作。

最终建议: 状态更新顺序 + 重入锁,两者缺一不可。顺序是基础,锁是保险。别偷懒,两个都加上。

好了,关于提现函数的状态更新顺序问题,就聊到这里。记住一句话:先记账,后付款。这是区块链安全里最朴素的真理。

专注资料整理