Solodit清单详解:价格操纵攻击

  • cyfrin
  • 发布于 6天前
  • 阅读 23

本文深入探讨了DeFi领域中价格操纵攻击的威胁,详细解释了如何利用闪电贷和脆弱的预言机操纵价格,并分析了两种常见的价格操纵方式:直接使用合约内部token余额比例计算价格和使用DEX流动性池的现货价格。此外,文章还介绍了使用Chainlink预言机和TWAP(时间加权平均价格)等防御措施,帮助开发者构建更强大的防御体系,以应对日益复杂的DeFi安全挑战。

Hans

Solodit 清单解释 (7): 价格操纵攻击

学习 DeFi 价格操纵攻击如何利用闪电贷和薄弱的预言机进行攻击,包含 Solidity 示例、真实事件以及 Solodit 清单中的关键防御措施。

欢迎回到 “Solodit 清单解释” 系列。

今天,我们深入探讨 价格操纵攻击

这些攻击是去中心化金融(DeFi)中普遍存在的威胁,它们利用协议中的漏洞来人为地扭曲资产价格,以获取非法利润。仅在 2024 年,这些攻击就造成了超过 5200 万美元的损失,涉及 37 起事件,使其成为第二大最具破坏性的攻击手段。攻击者通常会 利用 闪电贷 或利用薄弱的 预言机 来制造价格差异,从而影响关键组件,如借贷平台、去中心化交易所(DEXs)和稳定币。

本文涵盖 Solodit 清单中的两个关键项,重点关注可能被利用的 脆弱的定价机制 以及如何构建抗操纵系统。

为了获得最佳体验,请打开一个包含 Solodit 清单 的标签页,以便在阅读时参考。注意:我们之前已经介绍过包括拒绝服务(第 1 部分第 2 部分)、捐赠攻击抢跑攻击恶意破坏攻击矿工攻击 在内的主题。请务必查看它们!

SOL-AM-PMA-1:价格是否通过代币余额的比率计算得出?

  • 描述:如果价格是根据代币余额的比率计算出来的,那么可以通过闪电贷或捐赠进行操纵。

  • 补救措施:使用 Chainlink 预言机 获取资产价格。

在单个合约中根据代币余额计算价格看起来很简单:池中 Token A 与 Token B 的比率决定了价格。然而,攻击者可以使用闪电贷或直接捐赠来暂时改变这些余额,从而导致剧烈的价格波动。我们之前讨论过攻击者如何通过捐赠攻击来操纵协议状态,这通常依赖于扭曲内部余额来影响诸如“股票价格”之类的数值。闪电贷利用了类似的原理,允许攻击者 暂时操纵用于单个交易中定价的底层代币余额

让我们来看看攻击者如何利用一个简单的 DEX,其中价格直接从代币余额中得出。

// Pool.sol (为便于说明而简化)
// 表示一个容易受到余额操纵的简单流动性池
contract Pool is FlashLoanProvider { // 假设 FlashLoanProvider 在其他地方实现
    IERC20 public immutable tokenA;
    IERC20 public immutable tokenB;

    constructor(IERC20 _tokenA, IERC20 _tokenB) {
        tokenA = _tokenA;
        tokenB = _tokenB;
    }

    // 简化的 swap 函数
    function swap(IERC20 tokenIn, uint256 amountIn) external returns (uint256 amountOut) {
        require(tokenIn == tokenA || tokenIn == tokenB, "invalid token");
        IERC20 tokenOut = tokenIn == tokenA ? tokenB : tokenA;

        // 转入代币
        require(tokenIn.transferFrom(msg.sender, address(this), amountIn), "transfer in failed");

        // 根据价格计算转出数量
        uint256 price = getPrice(tokenIn, tokenOut);
        amountOut = amountIn * price / 1e18;

        // 转出代币
        require(tokenOut.transfer(msg.sender, amountOut), "transfer out failed");

        return amountOut;
    }

    // 基于当前池余额的脆弱的价格计算
    function getPrice(IERC20 tokenIn, IERC20 tokenOut) public view returns (uint256) {
        uint256 balIn = tokenIn.balanceOf(address(this));
        uint256 balOut = tokenOut.balanceOf(address(this));

        if (balIn == 0 || balOut == 0) {
            return 1e18; // 1:1 初始价格
        }

        // 价格是输出代币与输入代币的比率
        return balOut * 1e18 / balIn;
    }

    // ... (假设 flashLoanExternal 和 FlashLoanProvider 逻辑已实现) ...
}

// Exploit.sol (为便于说明而简化)
// 用于执行闪电贷和利用漏洞的合约
contract Exploit is IFlashLoanReceiver {
    Pool public pool;
    IERC20 public tokenA;
    IERC20 public tokenB;
    address public attacker;

    constructor(
        Pool _pool,
        IERC20 _tokenA,
        IERC20 _tokenB,
        address _attacker
    ) {
        pool = _pool;
        tokenA = _tokenA;
        tokenB = _tokenB;
        attacker = _attacker;
    }

    function attack(uint256 loanAmount) external {
        // 获取 tokenA 的闪电贷
        pool.flashLoanExternal(address(this), tokenA, loanAmount);
    }

    function receiveFlashLoan(IERC20 token, uint256 loanAmount) external override {
        require(token == tokenA, "Expected tokenA flash loan");

        // 步骤 1:现在池中 tokenA 较少,因此 tokenA 的价格(以 tokenB 计价)更高

        // 步骤 2:以这个被操纵的价格将 tokenA 兑换为 tokenB
        uint256 swapAmount = 100 * 1e18;
        tokenA.transferFrom(attacker, address(this), swapAmount);
        tokenA.approve(address(pool), swapAmount);
        uint256 receivedB = pool.swap(tokenA, swapAmount);

        // 步骤 3:偿还闪电贷
        tokenA.transfer(address(pool), loanAmount);

        // 步骤 4:将利润发送给攻击者
        uint256 remainingBalanceA = tokenA.balanceOf(address(this));
        uint256 remainingBalanceB = tokenB.balanceOf(address(this));

        if (remainingBalanceA > 0) {
            tokenA.transfer(attacker, remainingBalanceA);
        }

        if (remainingBalanceB > 0) {
            tokenB.transfer(attacker, remainingBalanceB);
        }
    }
}

在这种情况下,Pool 合约的 getPrice 函数根据池持有的 tokenAtokenB 的当前余额计算汇率。这种对内部、易于更改的状态的依赖是核心漏洞。

攻击如何运作

  1. 设置:攻击者向 Exploit 合约提供少量 tokenAswapAmount),他们将使用这些 tokenA 进行利用性兑换。

  2. 闪电贷:攻击者在 Exploit 合约上调用 attack,从 Pool 合约本身发起大量 tokenA 的闪电贷。此贷款在 receiveFlashLoan 函数返回之前执行。

  3. 价格操纵(在 receiveFlashLoan 内部):当闪电贷处于活动状态时,PooltokenA 余额被人为地降低。当 Exploit 合约调用 pool.swap(tokenA, swapAmount) 时,PoolgetPrice 函数会根据这些被暂时扭曲的低 tokenA 储备来计算价格。这导致了一个被操纵的价格,其中 tokenA 看起来比正常情况下更有价值。

  4. 利用性兑换(在 receiveFlashLoan 内部):Exploit 合约使用 pool.swap 函数将少量预先注资的 tokenA 兑换为 tokenB。由于价格被操纵,这种兑换会产生不成比例的大量 tokenB

  5. 偿还贷款(在 receiveFlashLoan 内部):Exploit 合约将大量的 tokenA 闪电贷偿还给 Pool 合约。receiveFlashLoan 调用必须成功完成,这意味着贷款已偿还。

  6. 利润(在 receiveFlashLoan 返回后):Exploit 合约现在持有原始的 swapAmounttokenA(或略少,因为有费用,尽管在此简化示例中省略了费用),以及从被操纵的兑换中收到的多余的 tokenB。剩余的代币被发送回攻击者的 地址,从而以 tokenB 的形式实现利润。

补救措施:使用可靠的价格来源(预言机)

稳健的解决方案是停止依赖链上余额比率进行价格计算,并 集成外部价格预言机,例如 Chainlink。预言机提供 可靠的、防篡改的链下价格数据,从而显著增强合约的安全性,使其免受基于内部状态的操纵。

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

import {AggregatorV3Interface} from "@chainlink/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol";

// 示例价格使用者合约,说明预言机集成
contract DataConsumerV3 {
    AggregatorV3Interface internal dataFeed;

    // 价格 Feeds 的地址在部署期间传递
    constructor(AggregatorV3Interface _dataFeed) { // 更正了变量名称的拼写错误
        dataFeed = _dataFeed;
    }

    /**
     * 从 Chainlink Feeds 返回最新的答案。
     * 重要提示:这是一个简化的示例。实际使用中需要验证预言机响应(陈旧性、round ID 等)。
     */
    function getChainlinkDataFeedLatestAnswer() public view returns (int256) { // 为了清晰起见,将返回类型更改为 int256
        (
            /* uint80 roundId */,
            int256 answer,
            /*uint256 startedAt*/,
            /*uint256 updatedAt*/,
            /*uint80 answeredInRound*/
        ) = dataFeed.latestRoundData();

        // 此处需要额外的有效性检查以确保数据是可信的。
        // 例如,检查 updatedAt 是否是最近的,roundId 是否已弃用等。
        // 我们将在本清单系列的后续部分中介绍这些必要的检查。

        return answer;
    }
}

通过从 Chainlink Feeds 获取价格,合约将其价格逻辑与其内部的、可操纵的状态以及外部的、不稳定的现货市场分离,从而显著降低了攻击面。

请注意,必须对从预言机收到的数据进行额外的检查,以确保其有效性和新鲜度。忽略这些检查可能会导致另一种预言机漏洞。如代码注释中所述,我们将在本清单系列的后续部分中介绍这些必要的检查。

我的 GitHub 上提供了示例 here

SOL-AM-PMA-2:价格是否从 DEX 流动性池的现货价格计算得出?

  • 描述:直接从 DEX 流动性池获得的现货价格读数很容易受到通过闪电贷暂时耗尽池的操纵。

  • 补救措施:使用 TWAP(时间加权平均价格),并根据资产波动性和流动性设置适当的时间窗口,或使用可靠的预言机解决方案。

在前一节中,我们确定 仅依赖合约的内部代币余额比率来定价是不安全的。但是,从外部来源(例如大型 DEX 流动性池(例如 Uniswap、SushiSwap))获取价格又如何呢?尽管存在风险,DEX 现货价格仍然以其感知到的优势来吸引开发者:它们的去中心化性质、成本效益(避免预言机费用)、透明度和即时定价,尤其适用于新代币。与专用预言机网络不同,它们仅使用链上数据,似乎避免了外部依赖性。

但它们的数学简单性直接与不稳定的池储备 (x * y = k) 相关联,这使得它们成为通过战略交易进行操纵的目标,类似于我们讨论过的较小池示例。攻击者可以通过 执行大型交易(通常由闪电贷提供资金)来暂时扭曲池的余额来利用这一点,从而导致池报告的现货价格飙升或暴跌,具体取决于交易的方向。对于低流动性池,此风险尤其明显,即使适度规模的交易也可能导致显著的价格波动。

补救措施:时间加权平均价格 (TWAP)

为了缓解使用突然的现货价格变化进行的价格操纵,可以使用 TWAP。TWAP 不是获取瞬时快照(现货价格),而是 在定义的时间段内平均价格,通常范围从几分钟到几小时。

这显著 钝化了短期操纵的影响。TWAP 的工作原理是按特定时间间隔记录累积价格并计算两个时间戳之间的平均价格。

为了显著扭曲 TWAP 在其窗口内的价格,攻击者必须在该窗口持续时间内维持扭曲的价格。这比转瞬即逝的闪电贷操纵要昂贵得多。尤其是在高流动性池中,维持扭曲价格所需的交易会产生大量的滑点或需要大量的资本。

像 Uniswap 这样的协议提供直接构建到其合约中的本地 TWAP 预言机,并且在 他们的文章 中很好地解释了这个概念。

TWAP 方法 对于可以容忍一定价格延迟的应用特别有效,例如结算层、财务运营或缓慢移动的市场。但是,时间窗口是一个关键的设计参数:更长的时间窗口会增加抗操纵性,但会引入更多的价格更新延迟;更短的时间窗口更快,但更容易被操纵。必须根据资产的波动性、池流动性和使用价格 Feeds 的协议的特定要求仔细选择最佳窗口。

结论

我们已经探讨了 DeFi 协议中与价格操纵相关的关键漏洞。通过了解攻击者如何扭曲链上价格计算,无论是来自直接合约代币余额还是不稳定的 DEX 现货价格,开发者都可以构建更强大的防御措施。DeFi 日益提高的复杂性需要从头开始集成的强大安全措施。有关更多资源,请查看 Cyfrin 审计清单

主要收获:

  1. 代币余额比率是不安全的:永远不要仅从合约的内部代币余额中得出关键资产价格。

  2. DEX 现货价格是不稳定的:避免在重要的金融操作中依赖来自流动性池的原始的、瞬时的现货价格。

  3. 预言机提供弹性:像 Chainlink 这样可靠的预言机网络提供从外部采购的更值得信赖的、防操纵的价格数据。

  4. TWAP 是一种强大的工具(对于 DEX 来源):如果使用 DEX 数据,请在足够的时间窗口内实施 TWAP,以平滑价格变化并阻止闪电贷攻击。

将这些原则集成到你的开发中可以显著降低价格操纵攻击的风险,从而有助于建立一个更安全、更可靠的 DeFi 生态系统。

下一次,我们将继续剖析 Solodit 清单,从攻击者的角度探索 智能合约 安全的更多方面。保持警惕,认真编码,并始终像攻击者一样思考。

  • 原文链接: 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.