Skip to content

Latest commit

 

History

History

EarningFarm

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 

Earning Farm

Step-by-step

  1. Deposit a small amount into the vulnerable contract
  2. Request a Flash Loan from Balance for the contract
  3. Withdraw your small amount and get a lot more

Detailed Description

The Earning Farm contract has a withdraw method that uses flash loans to pay debt accrued in the AAVE protocol.

We can start the flow by looking at the public withdraw method and noting where it makes the call to flashLoan.

 function withdraw(uint256 _amount) public nonReentrant{
    require(IERC20(ef_token).balanceOf(msg.sender) >= _amount, "not enough balance");
    if (is_paused){
      uint256 to_send = address(this).balance.safeMul(_amount).safeDiv(IERC20(ef_token).totalSupply());
      (bool status, ) = msg.sender.call.value(to_send)("");
      require(status, "transfer eth failed");
      TokenInterfaceERC20(ef_token).destroyTokens(msg.sender, _amount);
      return;
    }

    _earnReward();

    uint256 loan_amount = getDebt().safeMul(_amount).safeDiv(IERC20(ef_token).totalSupply());
    
    address[] memory tokens = new address[](1);
    uint256[] memory amounts = new uint256[](1);
    bytes memory userData = "0x2";
    tokens[0] = weth;
    amounts[0] = loan_amount;
    //uint256 user_eth_before = msg.sender.balance;
    IBalancer(balancer).flashLoan(address(this), tokens, amounts, userData);

    uint256 to_send = address(this).balance;
    (bool status, ) = msg.sender.call.value(to_send)("");
    require(status, "transfer eth failed");

    TokenInterfaceERC20(ef_token).destroyTokens(msg.sender, _amount);
    emit CFFWithdraw(msg.sender, to_send, _amount, getVirtualPrice());
  }

See that 0x02 is sent as userData, which makes the callback receiveFlashLoan (which will be called by the loaner) go to the _withdraw method.

    function receiveFlashLoan(
        IERC20[] memory tokens,
        uint256[] memory amounts,
        uint256[] memory feeAmounts,
        bytes memory userData
    ) public payable {
        require(msg.sender == balancer, "only flashloan vault"); 
        uint256 loan_amount = amounts[0];
        uint256 fee_amount = feeAmounts[0];

        if (keccak256(userData) == keccak256("0x1")){
          _deposit(loan_amount, fee_amount);
        }
        if (keccak256(userData) == keccak256("0x2")){
          _withdraw(loan_amount, fee_amount);
        }
    }

In the _withdraw method, it repays some of the debt to AAVE and repays the flashloaner (see: weth.call.value...).

  //1. rapay aave with flashloaned amount,    mx
  //2. withdraw steth with current ltv,  x
  //3. change all steths to eths,    x         
  //4. repay flashloan.   pay amx, left x-amx eth
  function _withdraw(uint256 amount, uint256 fee_amount) internal{
    uint256 steth_amount = amount.safeMul(IERC20(asteth).balanceOf(address(this))).safeDiv(getDebt());
    if (IERC20(weth).allowance(address(this), aave) != 0) {IERC20(weth).safeApprove(aave, 0);}
    IERC20(weth).safeApprove(aave, amount);

    IAAVE(aave).repay(weth, amount, 2, address(this));
    IAAVE(aave).withdraw(lido, steth_amount, address(this));

    if (IERC20(lido).allowance(address(this), curve_pool) != 0) {IERC20(lido).safeApprove(curve_pool, 0);}
    IERC20(lido).safeApprove(curve_pool, steth_amount);
    ICurve(curve_pool).exchange(1, 0, steth_amount, 0);

    (bool status, ) = weth.call.value(amount.safeAdd(fee_amount))("");
    require(status, "transfer eth failed");
    IERC20(weth).safeTransfer(balancer, amount.safeAdd(fee_amount));
  }

When it finishes this call, it goes back to the withdraw() method, which transfer all the balance in the contract to the sender.

So, conceptually:

  1. User requests a withdrawal backed by EF Token
  2. Contract takes a flash loan of debt*(withdrawal_amount / total_supply)
  3. Contract uses flashloan from Balancer to pay AAVE in weth
  4. Contract withdraws steth from AAVE (it borrowed weth and now has paid it back, so it can withdraw a portion of its collateral)
  5. Contract exchanges steth for eth using curve_pool
  6. Contract pays back flashloan
  7. Contract pays users its whole balance in ETH (note: it assuments contract holds exactly steth_amount in ETH)

This scheme works as long as the initial withdrawal is checked against the EF Token, which is the first check in the withdraw() method.

    require(IERC20(ef_token).balanceOf(msg.sender) >= _amount, "not enough balance");

This limits the _amount the sender can withdraw, which transitively limits the steth_amount the contract will exchange for ETH in the call to the curve_pool.

But what if an attacker totally bypasses this check and is able to arbitrarily add balance to the pool?

To do this, an attacker requests a flash loan from Balancer specifying the contract as the receiver? In that case, our call starts from the receiveFlashLoan method, which will call withdrawal if passed 0x02 as the user data. Note that amount is now unchecked, and the contract will performs the swap with this arbitrary amount, receiving finally an arbitrary amount of ETH. This amount is only limited by the contract's actual reserves, denominated in stETH.

So an attacker:

  1. Deposits some amount into the contract to pass this require:
    require(IERC20(ef_token).balanceOf(msg.sender) >= _amount, "not enough balance");
  1. Triggers a Flashloan from Balance directly into the contract, which will repay the debt, recover the collateral and exchange it for ETH into the contract
  2. Call withdraw with the amount they deposited (or less), which will transfer all of the balance from the contract into the attackers address.

Diagrams and graphs

Class

class

Possible mitigations

There are several things that could be improved here.

  1. Balance's Flashloan do not include the msg.sender in their callbacks, so it was impossible for Earning Farm to check that they had initiated the call. This is a problem with Balance itself as it does not conform to EIP-3156
  2. To migitate this risk, Earning Farm could the balances before and after the flashloan in withdraw so they correlate with the amount of tokens the user has. Curiously, there's a commented line in the withdraw() method which hints that something like this was being implemented or at least thought of, although this is speculation.
    //uint256 user_eth_before = msg.sender.balance;
  1. (2) does not completely solve the problem, but at least makes it non-profitable to an attacker. It still is possible for an attacker to force the contract to recover collateral.

Sources and references