setAllowedToken(token, false) simply does delete s_tokenToAssetToken[token]. redeem()'s revertIfNotAllowedToken modifier checks exactly this mapping via isAllowedToken(), so the moment an owner disables a token for any ordinary operational reason (e.g. its underlying TSwap pool needs to be temporarily taken offline), every LP who already deposited into that token is immediately unable to redeem() - AssetToken exposes no way to withdraw funds outside of ThunderLoan (transferUnderlyingTo/burn are onlyThunderLoan).
It gets worse on re-enable: because the mapping entry was deleted, calling setAllowedToken(token, true) again passes the AlreadyAllowed check and the protocol news a brand-new AssetToken instance, repointing the mapping to it. The old AssetToken still physically holds every previous LP's real underlying tokens and share balances, but from this point on every ThunderLoan function (deposit/redeem/flashloan/repay) only ever operates through the mapping, which now points exclusively at the new instance. The old instance and everything it holds becomes permanently unreachable through any ThunderLoan function - there is no migration path.
This is not an "admin fat-fingered a parameter" self-inflicted-harm case: the owner is using setAllowedToken exactly as designed (a normal disable/re-enable governance action), and the harmed party is a third-party LP, not the owner. src/upgradedProtocol/ThunderLoanUpgraded.sol (explicitly in scope as the upcoming upgrade) has the identical setAllowedToken logic with no fix and no migration/rescue function, so this is not a known, soon-to-be-patched issue - it carries forward into the next version.
Likelihood:
Reason 1 // Requires two conditions together: the owner role calling setAllowedToken(token, false), and that token already having LP deposits - a specific but realistic and protocol-anticipated operational scenario (e.g. temporarily pulling a token whose price source misbehaves), not an arbitrary-user-anytime trigger, hence Medium rather than High.
Reason 2 // No attacker or special timing/race condition is needed beyond the owner performing the disable/re-enable sequence the contract itself explicitly supports.
Impact:
Impact 1 // LPs' already-deposited real underlying assets become immediately unreachable through any ThunderLoan function the moment the token is disabled - direct fund freeze.
Impact 2 // Re-enabling the token does not restore access; it orphans the old AssetToken and all funds inside it permanently, with no migration or rescue path in either ThunderLoan.sol or the upcoming ThunderLoanUpgraded.sol.
Ran with forge test --match-path "test/PoC_2.t.sol" -vvv: [PASS] testDisablingTokenPermanentlyLocksExistingLPFunds() (gas: 4348221). Sequence: owner allow-lists tokenA, creating AssetToken V1; an honest LP deposits 1000e18 tokenA into V1 and receives V1 shares (asserted). Owner then calls setAllowedToken(tokenA, false) as an ordinary operation; the LP's redeem() call reverts with ThunderLoan__NotAllowedToken and the 1000e18 remains stuck in V1 (asserted). Owner re-enables tokenA: a brand-new AssetToken V2 is confirmed minted (V2 != V1), and getAssetFromToken(tokenA) now points only to V2 (asserted). V2 has zero record of the LP's deposit (balanceOf == 0, tokenA.balanceOf(V2) == 0), while the LP's V1 shares remain unchanged at 1000e18 - but redeem() now only operates on V2, so it reverts again, and the funds stay permanently stranded in the now-unreachable V1 (all asserted). Full regression suite: 19/19 passing, no regressions.
Separate the "is this token currently allowed for new deposits/flashloans" flag from the AssetToken instance's lifecycle: keep s_tokenToAssetToken[token] intact once created (never delete it), and gate deposit()/flashloan() on a dedicated s_tokenIsAllowed[token] bool via isAllowedToken(). redeem() should NOT be gated by this flag at all - existing LPs must always be able to redeem from whatever AssetToken they already hold shares in, regardless of whether the token is currently paused for new activity. This guarantees deposits made before a disable remain 100% redeemable afterward, and re-enabling never orphans a prior instance. Add a regression/invariant test asserting that funds deposited before a disable are still fully redeemable after any disable/re-enable sequence.
## 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.