Thunder Loan

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

`delete s_tokenToAssetToken[token]` makes `redeem()` revert for all LPs, stranding funds in the orphaned AssetToken

delete s_tokenToAssetToken[token] makes redeem() revert for all LPs, stranding funds in the orphaned AssetToken

Description

When the owner calls setAllowedToken(token, false), the mapping entry is deleted. But redeem() has revertIfNotAllowedToken, which checks this mapping. All LP redemptions revert. The AssetToken still holds underlying tokens with no retrieval path. Re-allowing the token creates a new AssetToken, orphaning the old one.

// src/protocol/ThunderLoan.sol:238-243
} else {
AssetToken assetToken = s_tokenToAssetToken[token];
delete s_tokenToAssetToken[token]; // @> deletes mapping, but AssetToken still holds funds
emit AllowedTokenSet(token, assetToken, allowed);
return assetToken;
}

Risk

Likelihood: Owner can call setAllowedToken(false) at any time. No timelock, grace period, or check for outstanding positions.

Impact: Permanent loss of all LP funds for the disallowed token. No recovery mechanism exists.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import { Test, console } from "forge-std/Test.sol";
import { ThunderLoan } from "../../src/protocol/ThunderLoan.sol";
import { AssetToken } from "../../src/protocol/AssetToken.sol";
import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MockTokenD is ERC20 {
constructor() ERC20("MockToken", "MTK") {}
function mint(address to, uint256 amount) external { _mint(to, amount); }
}
contract MockPoolFactory4 {
mapping(address => address) private s_pools;
function createPool(address token) external returns (address) {
MockTSwapPool4 pool = new MockTSwapPool4();
s_pools[token] = address(pool);
return address(pool);
}
function getPool(address token) external view returns (address) { return s_pools[token]; }
}
contract MockTSwapPool4 {
function getPriceOfOnePoolTokenInWeth() external pure returns (uint256) { return 1e18; }
}
contract Exploit_H05 is Test {
ThunderLoan thunderLoan;
MockTokenD tokenA;
AssetToken assetToken;
function setUp() public {
tokenA = new MockTokenD();
MockPoolFactory4 pf = new MockPoolFactory4();
pf.createPool(address(tokenA));
ThunderLoan impl = new ThunderLoan();
ERC1967Proxy proxy = new ERC1967Proxy(address(impl), "");
thunderLoan = ThunderLoan(address(proxy));
thunderLoan.initialize(address(pf));
thunderLoan.setAllowedToken(IERC20(address(tokenA)), true);
assetToken = thunderLoan.getAssetFromToken(IERC20(address(tokenA)));
tokenA.mint(address(0x1), 1000e18);
vm.startPrank(address(0x1));
tokenA.approve(address(thunderLoan), 1000e18);
thunderLoan.deposit(IERC20(address(tokenA)), 1000e18);
vm.stopPrank();
}
function testExploit_LPFundsStranded() public {
uint256 lpBal = assetToken.balanceOf(address(0x1));
thunderLoan.setAllowedToken(IERC20(address(tokenA)), false);
vm.prank(address(0x1));
vm.expectRevert(abi.encodeWithSelector(ThunderLoan.ThunderLoan__NotAllowedToken.selector, address(tokenA)));
thunderLoan.redeem(IERC20(address(tokenA)), lpBal);
assertEq(tokenA.balanceOf(address(assetToken)), 1000e18); // funds stuck
}
}

Output: redeem() reverts with ThunderLoan__NotAllowedToken. 1000e18 tokens permanently stranded.

Recommended Mitigation

Require zero outstanding supply before disallowing:

} else {
AssetToken assetToken = s_tokenToAssetToken[token];
+ require(assetToken.totalSupply() == 0, "LPs still have funds deposited");
delete s_tokenToAssetToken[token];
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 3 hours 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!