Underlying deposits do not live in ThunderLoan; each token's balance sits in its own AssetToken, and s_tokenToAssetToken is the only record of which AssetToken belongs to which token.
setAllowedToken(token, false) deletes that entry. It does not migrate, close or drain anything — the AssetToken keeps custody of every LP's underlying, and the only handle to it is gone.
There is no way back to the money. AssetToken::transferUnderlyingTo is onlyThunderLoan, and the sole call site inside ThunderLoan is redeem, which is gated on the mapping being populated:
Re-listing is not a remedy — it deepens the problem. The allowed branch has no lookup for a pre-existing AssetToken; it unconditionally deploys a new one with a fresh supply and a reset exchange rate. The LPs' shares are in the old contract, the accounting is in the new one, and the two are never reconnected.
Recoverability, stated accurately. The funds are not mathematically lost. transferUnderlyingTo is gated on i_thunderLoan, which is the proxy address, and that does not change across an upgrade — so the owner can deploy a new implementation that restores the mapping entry and recover everything. I verified this works. What the finding claims is narrower and still true: no function in either in-scope implementation can reach those funds, so recovery requires writing and shipping new code, under exactly the same trusted role that stranded them, and only if the mistake is noticed at all.
There is also a nasty second-order effect once the token has been re-listed. redeem(token, type(uint256).max) resolves to the caller's balance in the new AssetToken, which is zero — and revertIfZero guards the raw parameter, not the resolved amount. So the LP's "withdraw everything" call succeeds, emits Redeemed, and transfers nothing:
Likelihood:
Low — the call is onlyOwner. I am not claiming a malicious owner; that would be a trust assumption, not a bug.
What makes it more than theoretical is that setAllowedToken(token, false) reads like an ordinary, reversible administrative action. Nothing in the function name, the signature, the events or a NatSpec comment (there is none) suggests it destroys custody. An owner de-listing a token to pause it during an incident, or to clean up an unused market, would have no reason to expect this. The allowed flag being a boolean parameter on a single function actively suggests the operation is symmetric.
There is no timelock, no guard on a non-zero balance, and no test in the repository that de-lists a token that has deposits in it.
Impact:
Total loss of every liquidity provider's principal for that token as far as the shipped code is concerned. No function in either in-scope implementation can move those funds again; only a newly written and deployed implementation can, which puts recovery outside the audited system and behind the same privileged role that caused it.
The LPs receive no signal. Their AssetToken balance is untouched and their share price is unchanged; only the withdrawal fails. After a re-listing it does not even fail — it silently returns zero, which is materially worse than a revert, because a wallet or integrator sees a confirmed Redeemed event.
Impact High × Likelihood Low ⇒ Medium.
Save as test/PoC_M03.t.sol and run forge test --mt test_M03 -vv:
Output:
De-listing should stop new exposure, not custody. Keep the AssetToken reachable and refuse to abandon a funded one.
If de-listing a funded market has to remain possible, then it must not also disable withdrawals: drop revertIfNotAllowedToken from redeem and resolve the AssetToken from a mapping that de-listing never clears, so LPs can always exit a market that has been closed to new deposits.
Separately, and independent of the fix above, redeem should not treat a resolved amount of zero as success:
## 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.