Thunder Loan

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

Flash loan fee derived from manipulable AMM spot price allows borrower to collapse the fee by 119x

Root + Impact

Description

OracleUpgradeable.getPriceInWeth reads the instantaneous reserve ratio of a TSwap pool, and ThunderLoan.getCalculatedFee prices every flash loan directly from it. There is no TWAP, no Chainlink feed, and no sanity bound, so the fee is whatever the pool's spot price says at that exact moment. A swap executed in the same transaction moves that ratio, so the borrower controls the fee he pays.

// src/protocol/OracleUpgradeable.sol
function getPriceInWeth(address token) public view returns (uint256) {
address swapPoolOfToken = IPoolFactory(s_poolFactory).getPool(token);
@> return ITSwapPool(swapPoolOfToken).getPriceOfOnePoolTokenInWeth(); // spot price, manipulable
}
// src/protocol/ThunderLoan.sol
function getCalculatedFee(IERC20 token, uint256 amount) public view returns (uint256 fee) {
@> uint256 valueOfBorrowedToken = (amount * getPriceInWeth(address(token))) / s_feePrecision;
fee = (valueOfBorrowedToken * s_flashLoanFee) / s_feePrecision;
}

Risk

Likelihood: High

An attacker needs no capital and no special privileges: the manipulating swap can itself be funded by the flash loan being taken. Any TSwap pool used by the oracle can be moved by anyone within a single transaction, and the attack is repeatable every block.

Impact: High

The flash loan fee is the only mechanism that raises the AssetToken exchange rate. Suppressing it steals yield directly from liquidity providers and drains protocol revenue indefinitely.

Proof of Concept

Seed a TSwap pool at 100 tokenA : 100 WETH, quote the fee, dump tokenA into the pool, quote again.

function test_Finding1_FeeCollapsesWhenSpotPriceIsManipulated() public {
_seedPool(); // 100e18 tokenA : 100e18 WETH
_seedProtocol(100e18);
​
uint256 loanAmount = 10e18;
uint256 feeBefore = thunderLoan.getCalculatedFee(IERC20(address(tokenA)), loanAmount);
​
// Dump tokenA into the pool: tokenA gets cheaper in WETH terms
tokenA.mint(user, 1000e18);
vm.startPrank(user);
tokenA.approve(address(pool), type(uint256).max);
pool.swapPoolTokenForWethBasedOnInputPoolToken(1000e18, 1, block.timestamp);
vm.stopPrank();
​
uint256 feeAfter = thunderLoan.getCalculatedFee(IERC20(address(tokenA)), loanAmount);
​
assertLt(feeAfter, feeBefore);
assertLt(feeAfter * 5, feeBefore); // severe, not a rounding artifact
}

Result:

fee before manipulation: 29614741031911838 (~0.0296 ETH)
fee after manipulation: 247641627681304 (~0.000247 ETH)
fee reduced by factor: 119

Recommended Mitigation

Replace the spot-price read with a manipulation-resistant source. A spot price reflects a single instant and can be moved by any swap in the same transaction, so it must never price a fee that the same transaction pays. A time-weighted average price (TWAP) accumulated over several blocks forces an attacker to hold the manipulated price across blocks, which exposes the position to arbitrage and makes the attack uneconomic. A Chainlink feed with a staleness check removes the dependency on pool reserves entirely and is preferable where a feed exists for the token.


If the protocol must keep reading from TSwap, the minimum safe form is to read both the spot price and a TWAP, then revert when they diverge by more than a configured tolerance. That bound makes a single-transaction swap unable to move the quoted fee, since any manipulation large enough to matter also breaks the deviation check. The fix below applies this approach.

- return ITSwapPool(swapPoolOfToken).getPriceOfOnePoolTokenInWeth();
+ // Use a time-weighted average price, or a Chainlink feed with a staleness check.
+ // At minimum, compare spot against a TWAP and revert on divergence beyond a tolerance:
+ uint256 spot = ITSwapPool(swapPoolOfToken).getPriceOfOnePoolTokenInWeth();
+ uint256 twap = ITSwapPool(swapPoolOfToken).getTwapPrice(TWAP_WINDOW);
+ if (_absDiffBps(spot, twap) > MAX_DEVIATION_BPS) revert ThunderLoan__PriceDeviation();
+ return twap;
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!