- Type: Exploit
- Network: Ethereum
- Total lost: 200 ETH (750 ETH originally targetted, 550 returned by MEV frontrunning bot)
- Category: Flashloan attack
- Vulnerable contracts:
- Attack transactions:
- Attacker Addresses:
- Attack Block:: 15746342
- Date: Oct 14, 2022
- Reproduce:
forge test --match-contract Exploit_EarningFarm -vvv
- Deposit a small amount into the vulnerable contract
- Request a Flash Loan from Balance for the contract
- Withdraw your small amount and get a lot more
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:
- User requests a withdrawal backed by EF Token
- Contract takes a flash loan of
debt*(withdrawal_amount / total_supply)
- Contract uses flashloan from Balancer to pay AAVE in
weth
- Contract withdraws
steth
from AAVE (it borrowedweth
and now has paid it back, so it can withdraw a portion of its collateral) - Contract exchanges
steth
foreth
usingcurve_pool
- Contract pays back flashloan
- Contract pays users its whole
balance
in ETH (note: it assuments contract holds exactlysteth_amount
inETH
)
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:
- Deposits some amount into the contract to pass this require:
require(IERC20(ef_token).balanceOf(msg.sender) >= _amount, "not enough balance");
- 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
- Call
withdraw
with the amount they deposited (or less), which will transfer all of the balance from the contract into the attackers address.
There are several things that could be improved here.
- 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 - 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 thewithdraw()
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;
- (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.