重入攻击(Reentrancy Attack)是公链合约安全里绕不开的高频关键词。它曾被用来洗劫“The DAO”上亿美元的以太币,也频频出现在近年的审计报告中。本文将继续聚焦智能合约安全,用极简代码+真实场景带你彻底搞明白:
- 重入攻击到底“重入”了什么?
- 攻击者如何利用fallback 函数无限递归调用?
- 开发者在线上环境落地哪三种防御方案才最稳妥?
关键词:重入攻击、智能合约安全、Solidity、fallback 函数、转账函数、以太坊、ReentrancyGuard
攻击还原:一次把“银行”搬空的完整 PLAYBOOK
1. 脆弱合约示例
先看一段“提款机”代码——简单到让人放松警惕,却暗藏致命重入漏洞:
pragma solidity ^0.8.0;
contract VulnerableBank {
mapping(address => uint) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
uint amount = balances[msg.sender];
require(amount > 0, "No balance");
(bool ok,) = msg.sender.call{value: amount}("");
require(ok, "Transfer failed");
balances[msg.sender] = 0; // 关键语句:余额清零延迟
}
}业务逻辑:
- 用户通过
deposit存钱; withdraw取出全部余额后,将账本清零。
看似人畜无害,但如果提款方是恶意合约账户,情况就会失控。
2. 攻击者合约如何“重入”
攻击者部署如下合约,伪装成正常用户:
contract ReentrancyAttacker {
VulnerableBank public bank;
constructor(address _bank) {
bank = VulnerableBank(_bank);
}
receive() external payable {
if (address(bank).balance >= 1 ether) {
bank.withdraw(); // 递归调用
}
}
function startAttack() external payable {
bank.deposit{value: 1 ether}();
bank.withdraw();
}
}攻击流程:
- 调用
startAttack存入 1 ether,余额被账本记为 1 ether; - 立即
withdraw,脆弱合约通过.call把 1 ether 打给攻击合约; - 攻击合约的
receive函数被触发,再次调用withdraw; - 循环往复,直到 VulnerableBank 余额归零。
每一步账本尚未清零,攻击就像“借书不还”,可以把整座“图书馆”搬走。
FAQ:重入攻击迷思一次说清
问1:2300 gas 限制 transfer/send 就能防重入吗?
答:新版 Solidity >0.8 默认用 .call 而非 transfer/send,开发者更易忽略 gas 传递。相比 gas 限制,写在前端的“检查-生效-调用”模式才是根本。
问2:.call{value:x} 就一定不安全?
答:不是,核心在于状态变量修改顺序。只要确认所有状态更新都在外部调用之前完成,任何转账方式都不会被重入。
问3:ReentrancyGuard 会不会带来额外的 gas 消耗?
答:够用且值得。OpenZeppelin 的互斥锁只增加一次 SLOAD/SSTORE,成本远低于被盗取的资金。
三把“安全锁”教你 100% 堵死重入攻击
方案一:严格遵循 Checks-Effects-Interactions(CEI)
把状态变量提前修好,再进行外部调用:
function withdraw() external {
uint amount = balances[msg.sender];
require(amount > 0, "No balance");
balances[msg.sender] = 0; // 先清账,防止重入
(bool ok,) = msg.sender.call{value: amount}("");
require(ok, "Transfer failed");
}如此一来,无论对方怎么递归,账面余额早已是 0,拿不到额外资金。
方案二:内置 transfer 或 send 的 2300 gas 限制
虽然以太坊的 2300 gas 有时勉强,但在简单转账场景里依旧有效:
payable(msg.sender).transfer(amount); // 2300 gas局限性:一旦需要携带 calldata 或兼容重带气费的合约,调用会失败。因此只能作为“钥匙”,不能当做“保险柜”。
方案三:直接上 ReentrancyGuard 互斥锁
在业内视为线上“标配”的 OpenZeppelin 防重入库:
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract SafeBank is ReentrancyGuard {
...
function withdraw() external nonReentrant {
uint amount = balances[msg.sender];
require(amount > 0, "No balance");
balances[msg.sender] = 0;
(bool ok,) = msg.sender.call{value: amount}("");
require(ok, "Transfer failed");
}
}nonReentrant 修饰符利用变量(_status)在函数入口上锁,出口解锁,一次只允许一个线程执行 withdraw,从而彻底告别递归噩梦。
拓展案例:真实 DeFi 中被重入“割韭菜”的三种姿势
- 闪电贷套利 + 重入:攻击者先用闪电贷放大资金规模,再重入抽干借贷池。
- 代理合约重入:部分升级合约忘记在逻辑合约上加锁,代理存储层与逻辑层错位,攻击者可把旧逻辑重入新环境。
- CEI 误用:某 DeFi 方把收益计算放在
withdaw末尾,结果被重入盗取收益计算公式外的额外奖励。
总结:从认知到动作三步走
- 认知:重入≠递归,而是状态变量更新顺序>转账调用顺序导致的漏洞。
- 预防:坚持 CEI 准则,为关键函数加上 ReentrancyGuard,用
.call时务必检查返回值与状态更新。 - 行动:每次上线前跑一遍重入攻击单元测试,在测试网真金白银测试一次。
只要抱紧“先改状态再转账”这颗定心丸,再配合业界成熟工具,就能让你的智能合约在黑客面前坚如磐石。