The owner lists and delists tokens with setAllowedToken, and liquidity providers exchange their AssetTokens for the underlying plus accrued fees through redeem(). Delisting a token should stop new use of that token without taking away existing LPs' ability to withdraw what they deposited.
The delist branch of setAllowedToken only deletes s_tokenToAssetToken[token]. It moves no funds and keeps no record of the old AssetToken. redeem() is gated by revertIfNotAllowedToken, which reads that same mapping, and it also finds the AssetToken through that mapping, so after a delist every redeem reverts and the underlying stays in an AssetToken whose transferUnderlyingTo only ThunderLoan may call. Re-listing deploys a new AssetToken with no link to the old one, so it does not restore access.
Likelihood:
Only the owner can trigger it. setAllowedToken is onlyOwner and the README treats the owner as a trusted role, so no unprivileged user or outside attacker can cause the freeze.
It occurs when the owner delists a token whose AssetToken still has outstanding supply. Delisting is an intended feature (for example, a response to a depegged or compromised token), and neither the code nor the README warns that it blocks withdrawals, so a routine, non-malicious use of the feature is enough.
Re-listing the same token afterwards, a natural reaction to an unintended delist, does not undo it: it replaces the AssetToken in the mapping and leaves the old one unreachable.
Impact:
All LP principal and accrued fees for the delisted token become unwithdrawable. redeem reverts with ThunderLoan__NotAllowedToken for every holder and every amount, including type(uint256).max, and ThunderLoanUpgraded behaves the same way. In the PoC, 1,000,300e18 (1,000,000e18 of deposits plus a 300e18 flash-loan fee) stays in the old AssetToken and every LP share is unchanged.
The frozen liquidity also stops earning, because flashloan for the token reverts with ThunderLoan__NotEnoughTokenBalance(0, amount). Re-listing does not help. It deploys a new AssetToken with zero supply. An old holder's redeem of an explicit amount then reverts with "ERC20: burn amount exceeds balance", and redeem(type(uint256).max) succeeds but pays 0.
The funds are frozen, not lost, and no attacker gains anything. The owner can recover them only by writing a custom implementation, upgrading the proxy through UUPS, calling the old AssetToken's transferUnderlyingTo from the proxy, and distributing the funds pro rata outside the protocol, as the PoC does. A temporary freeze of LP funds that only the owner can lift fits Medium, not High.
The PoC test/PoC_TL-05.t.sol deploys ThunderLoan and ThunderLoanUpgraded behind ERC1967Proxy. Two LPs deposit 1,000,000e18 and one honest flash loan runs, so the pool holds 1,000,300e18 including fees. The tests assert the following after setAllowedToken(token, false): every redeem, including type(uint256).max, reverts with ThunderLoan__NotAllowedToken; neither the LPs nor the owner can call transferUnderlyingTo on the old AssetToken; flashloan reverts with NotEnoughTokenBalance(0, amount); re-listing deploys a new, empty AssetToken while the old one keeps all funds; and the funds come back only through a custom UUPS rescue implementation that is not part of the protocol. Run it with forge test --match-path test/PoC_TL-05.t.sol -vv; a baseline test also confirms that redeem works while the token is listed.
File test/PoC_TL-05.t.sol; run FOUNDRY_FFI=false forge test --match-path test/PoC_TL-05.t.sol -vv.
## 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; } } ```
The contest is live. Earn rewards by submitting a finding.
Submissions are being reviewed by our AI judge. Results will be available in a few minutes.
View all submissionsThe contest is complete and the rewards are being distributed.