Thunder Loan

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

setAllowedToken(false) deletes reference — locks LP funds permanently

Title: setAllowedToken(false) deletes reference — locks LP funds permanently
Impact: Medium. All LP deposits for a disallowed token become unrecoverable without upgrade.
Likelihood: Medium. Requires owner action, but no balance check prevents accidental lock.
Reference Files: repos/src/protocol/ThunderLoan.sol:227-244,161-167,260-262, repos/src/protocol/AssetToken.sol:36-41,76-78

Description

setAllowedToken(token, false) unconditionally calls delete s_tokenToAssetToken[token], destroying the only on-chain reference to the AssetToken contract that holds LP deposits. The redeem() function is guarded by revertIfNotAllowedToken(token), which calls isAllowedToken(token) — and that returns false once the mapping entry is zeroed. The AssetToken's own transferUnderlyingTo function is protected by onlyThunderLoan, meaning only the ThunderLoan contract can initiate withdrawals — but ThunderLoan's redeem() is now blocked by its own guard modifier checking the same deleted mapping.

// Admin: deletes mapping with no balance check
function setAllowedToken(IERC20 token, bool allowed) external onlyOwner {
if (!allowed) {
delete s_tokenToAssetToken[token]; // reference destroyed unconditionally
}
}
// User: redeem blocked — guard checks deleted mapping
function redeem(IERC20 token, uint256 amount) external revertIfNotAllowedToken(token) {
// revertIfNotAllowedToken → isAllowedToken → mapping is zero → reverts
}

Re-enabling the token via setAllowedToken(token, true) creates a brand-new AssetToken contract — it does not restore the old reference or its trapped funds.

Risk

Impact: Medium. If the owner disallows USDC while $1M in LP deposits sit in its AssetToken, every depositor's funds become permanently locked. The AssetToken holds the underlying tokens but no function on ThunderLoan can trigger their withdrawal because redeem() is blocked by the deleted mapping. Only deploying a new implementation with a custom recovery function can retrieve the funds.
Likelihood: Medium. setAllowedToken has no guard — the owner can disallow any token regardless of active deposits, with no warning, no balance check, and no safeguard. The function does not even emit a warning when non-zero deposits exist.
This is the same class of bug as DatingDapp's blockProfile — an admin function that deletes state critical to user fund redemption, creating a permanent lock with no self-service recovery.

Proof of Concept

thunderLoan.deposit(token, 10e18);
vm.prank(owner);
thunderLoan.setAllowedToken(token, false);
vm.expectRevert(ThunderLoan.ThunderLoan__NotAllowedToken.selector);
thunderLoan.redeem(token, 1e18);

This PoC shows that a user with 10 ETH deposited can no longer redeem a single wei after the owner disallows the token, confirming the funds are permanently trapped.

Recommended Mitigation

uint256 balance = IERC20(token).balanceOf(address(assetToken));
if (balance > 0) revert("Cannot disallow token with active deposits");

Checking the AssetToken's underlying balance before deletion prevents disallow when LPs still have funds at risk, eliminating the permanent lock scenario.

Updates

Lead Judging Commences

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