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.
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.
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.
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.
The contest is live. Earn rewards by submitting a finding.
Submissions are being reviewed by our AI judge. Results will be available in a few minutes.
View all submissionsThe contest is complete and the rewards are being distributed.