漏洞合约分析:提现函数中的状态更新顺序问题、余额检查与更新分离
好,咱们今天来聊一个老生常谈,但每次都能让项目方翻车的问题——提现函数里的状态更新顺序。
说白了,就是你先扣钱,还是先给钱?
这个顺序,决定了你的合约是铜墙铁壁,还是一张废纸。
一、问题本质:先更新状态,还是先转账?
我见过太多新手,甚至一些老手写的合约,提现函数长这样:
function withdraw(uint _amount) public {
require(balances[msg.sender] >= _amount, "余额不足");
// 先转账
(bool success, ) = msg.sender.call{value: _amount}("");
require(success, "转账失败");
// 后更新余额
balances[msg.sender] -= _amount;
}
你看出问题了吗?
嗯,这就是典型的「余额检查与更新分离」。检查是一回事,更新是另一回事,中间隔了一个转账操作。这个缝隙,就是黑客的突破口。
二、为什么顺序这么重要?
我个人习惯把合约的状态更新想象成「记账本」。你先把账记好,再给钱,这叫「先记账后付款」。反过来,先给钱再记账,那叫「先上车后补票」——万一人家不补票呢?
在以太坊的世界里,call 调用会触发接收方的 fallback 或 receive 函数。如果接收方是一个恶意合约,它可以在 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 检查就会失败。重入攻击自然就失效了。
小技巧: 我建议你在写提现函数时,养成一个肌肉记忆——先写状态更新,再写外部调用。哪怕你写的是 transfer 或 send,也保持这个顺序。虽然 transfer 和 send 有 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 画一张流程图
为了让你更直观地理解,我画了一张流程图,展示「先更新后转账」和「先转账后更新」的区别:
八、避坑指南
我曾经在审计一个跨链桥合约时,发现他们的提现函数虽然顺序对了,但用了 address.send() 而不是 call。他们以为 send 有 2300 gas 限制就安全了。
结果呢?
攻击者通过一个复杂的合约,在 fallback 里只做了一件事——写一个存储变量。2300 gas 够用了。然后递归调用提现函数,照样把合约掏空了。
所以,别迷信 send 和 transfer 的 gas 限制。它们只能防住「写合约逻辑」这种高耗 gas 的操作,但防不住「改状态」这种低耗 gas 的操作。
最终建议: 状态更新顺序 + 重入锁,两者缺一不可。顺序是基础,锁是保险。别偷懒,两个都加上。
好了,关于提现函数的状态更新顺序问题,就聊到这里。记住一句话:先记账,后付款。这是区块链安全里最朴素的真理。