Thunder Loan

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

Whitelist Removal Traps and Permanently Freezes User Underlying Deposits

Whitelist Removal Traps and Permanently Freezes User Underlying Deposits

Description

  • When an asset is supported, users deposit tokens to mint AssetToken shares and expect to be able to redeem their assets at any time, even if new deposits or loans are halted.

  • In ThunderLoan::setAllowedToken(), when the owner sets isAllowed = false, the modifier revertIfNotAllowedToken blocks not only deposit() and flashloan(), but also redeem(). As a result, existing depositors are completely unable to withdraw their assets, locking their funds permanently inside the AssetToken vault.

// ThunderLoan.sol: redeem() enforces revertIfNotAllowedToken
function redeem(
IERC20 token,
uint256 amountOfAssetToken
@> ) external revertIfZero(amountOfAssetToken) revertIfNotAllowedToken(token) {
...
}

Risk

Likelihood:

  • Occurs whenever the contract owner removes a token from the whitelist via setAllowedToken(token, false) to deprecate an asset.

  • A standard administrative operation routinely performed during asset decommissioning.

Impact:

  • Permanent loss and freezing of 100% of deposited user funds for the delisted token.

  • Inability for depositors to exit their positions, resulting in catastrophic loss of trust.

Proof of Concept

The exploit operates through the following steps:

  1. Users deposit 100e18 of tokenA and receive corresponding AssetToken shares.

  2. The protocol owner decommissions tokenA and calls setAllowedToken(tokenA, false).

  3. The user calls thunderLoan.redeem(tokenA, shares) to withdraw their principal.

  4. The transaction unconditionally reverts with ThunderLoan__NotAllowedToken, trapping user funds forever.

function test_whitelistRemovalFreezesUserDeposits() public setAllowedToken hasDeposits {
thunderLoan.setAllowedToken(tokenA, false);
AssetToken assetToken = thunderLoan.getAssetFromToken(tokenA);
uint256 userShares = assetToken.balanceOf(user);
vm.startPrank(user);
assetToken.approve(address(thunderLoan), userShares);
vm.expectRevert(abi.encodeWithSelector(ThunderLoan.ThunderLoan__NotAllowedToken.selector, address(tokenA)));
thunderLoan.redeem(tokenA, userShares);
vm.stopPrank();
}

Recommended Mitigation

Remove the revertIfNotAllowedToken(token) modifier from redeem() so that depositors can always withdraw their funds even after an asset is delisted.

function redeem(
IERC20 token,
uint256 amountOfAssetToken
- ) external revertIfZero(amountOfAssetToken) revertIfNotAllowedToken(token) {
+ ) external revertIfZero(amountOfAssetToken) {
AssetToken assetToken = s_tokenToAssetToken[token];
+ if (address(assetToken) == address(0)) revert ThunderLoan__NotAllowedToken(address(token));
...
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 1 hour ago
Submission Judgement Published
Validated
Assigned finding tags:

[M-01] 'ThunderLoan::setAllowedToken' can permanently lock liquidity providers out from redeeming their tokens

## Description If the 'ThunderLoan::setAllowedToken' function is called with the intention of setting an allowed token to false and thus deleting the assetToken to token mapping; nobody would be able to redeem funds of that token in the 'ThunderLoan::redeem' function and thus have them locked away without access. ## Vulnerability Details If the owner sets an allowed token to false, this deletes the mapping of the asset token to that ERC20. If this is done, and a liquidity provider has already deposited ERC20 tokens of that type, then the liquidity provider will not be able to redeem them in the 'ThunderLoan::redeem' function. ```solidity function setAllowedToken(IERC20 token, bool allowed) external onlyOwner returns (AssetToken) { if (allowed) { if (address(s_tokenToAssetToken[token]) != address(0)) { revert ThunderLoan__AlreadyAllowed(); } string memory name = string.concat("ThunderLoan ", IERC20Metadata(address(token)).name()); string memory symbol = string.concat("tl", IERC20Metadata(address(token)).symbol()); AssetToken assetToken = new AssetToken(address(this), token, name, symbol); s_tokenToAssetToken[token] = assetToken; emit AllowedTokenSet(token, assetToken, allowed); return assetToken; } else { AssetToken assetToken = s_tokenToAssetToken[token]; @> delete s_tokenToAssetToken[token]; emit AllowedTokenSet(token, assetToken, allowed); return assetToken; } } ``` ```solidity function redeem( IERC20 token, uint256 amountOfAssetToken ) external revertIfZero(amountOfAssetToken) @> revertIfNotAllowedToken(token) { AssetToken assetToken = s_tokenToAssetToken[token]; uint256 exchangeRate = assetToken.getExchangeRate(); if (amountOfAssetToken == type(uint256).max) { amountOfAssetToken = assetToken.balanceOf(msg.sender); } uint256 amountUnderlying = (amountOfAssetToken * exchangeRate) / assetToken.EXCHANGE_RATE_PRECISION(); emit Redeemed(msg.sender, token, amountOfAssetToken, amountUnderlying); assetToken.burn(msg.sender, amountOfAssetToken); assetToken.transferUnderlyingTo(msg.sender, amountUnderlying); } ``` ## Impact The below test passes with a ThunderLoan\_\_NotAllowedToken error. Proving that a liquidity provider cannot redeem their deposited tokens if the setAllowedToken is set to false, Locking them out of their tokens. ```solidity function testCannotRedeemNonAllowedTokenAfterDepositingToken() public { vm.prank(thunderLoan.owner()); AssetToken assetToken = thunderLoan.setAllowedToken(tokenA, true); tokenA.mint(liquidityProvider, AMOUNT); vm.startPrank(liquidityProvider); tokenA.approve(address(thunderLoan), AMOUNT); thunderLoan.deposit(tokenA, AMOUNT); vm.stopPrank(); vm.prank(thunderLoan.owner()); thunderLoan.setAllowedToken(tokenA, false); vm.expectRevert(abi.encodeWithSelector(ThunderLoan.ThunderLoan__NotAllowedToken.selector, address(tokenA))); vm.startPrank(liquidityProvider); thunderLoan.redeem(tokenA, AMOUNT_LESS); vm.stopPrank(); } ``` ## Recommendations It would be suggested to add a check if that assetToken holds any balance of the ERC20, if so, then you cannot remove the mapping. ```diff function setAllowedToken(IERC20 token, bool allowed) external onlyOwner returns (AssetToken) { if (allowed) { if (address(s_tokenToAssetToken[token]) != address(0)) { revert ThunderLoan__AlreadyAllowed(); } string memory name = string.concat("ThunderLoan ", IERC20Metadata(address(token)).name()); string memory symbol = string.concat("tl", IERC20Metadata(address(token)).symbol()); AssetToken assetToken = new AssetToken(address(this), token, name, symbol); s_tokenToAssetToken[token] = assetToken; emit AllowedTokenSet(token, assetToken, allowed); return assetToken; } else { AssetToken assetToken = s_tokenToAssetToken[token]; + uint256 hasTokenBalance = IERC20(token).balanceOf(address(assetToken)); + if (hasTokenBalance == 0) { delete s_tokenToAssetToken[token]; emit AllowedTokenSet(token, assetToken, allowed); + } return assetToken; } } ```

Support

FAQs

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

Give us feedback!