Beginner FriendlyFoundryDeFiOracle
100 EXP
View results
Submission Details
Severity: medium
Valid

Loss of liquidity tokens when removing a token from the allowed token list

Summary

The protocol works through a list of ERC-20 tokens with which flashloans can perform transactions. The owner of the contract can add these ERC-20s through the setAllowedToken function. Each time a new underlying token is added to the list, ThunderLoan will deploy an LP token (ERC-20), which will save all the underlying tokens deposited in the ThunderLoan contract.

At the same time, the setAllowedToken function, when setting an underlying token as invalid, completely removes all links with the LP token linked to the underlying token. This means that if a user has deposited liquidity for an underlying token and later the contract owner removes the underlying from the list of valid tokens, the funds saved in the LP token become inaccessible. This causes liquidity providers to be unable to claim their underlying tokens.

Vulnerability Details

Every time an underlying token is added to the list of allowed tokens, a completely new LP token is deployed. However, when an underlying token is removed from the list, it completely eliminates any reference to the linked LP token. Therefore, when this underlying token is re-added to the list of allowed tokens, it will be linked to a new LP token, thus losing access to the previous LP token and all its funds.

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;
}
}
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);
}

Proof of Concept

Actors:

  • LiquidityProvider: User providing liquidity to the ThunderLoan contract for an underlying token.

  • TokenA: Underlying token used within flashloans.

  • Asset: LP token that is delivered to liquidity providers.

Write and run the following tests cases in the ThunderLoanTest.t.sol test file.

function test_SetAllowedTokens_deleteToken_liquidity_issue() public {
//Owner sets tokenA as allowed token
vm.prank(thunderLoan.owner());
thunderLoan.setAllowedToken(tokenA, true);
assertEq(thunderLoan.isAllowedToken(tokenA), true);
//Liquidty provider deposits tokenA
vm.startPrank(liquidityProvider);
tokenA.mint(liquidityProvider, DEPOSIT_AMOUNT);
tokenA.approve(address(thunderLoan), DEPOSIT_AMOUNT);
thunderLoan.deposit(tokenA, DEPOSIT_AMOUNT);
vm.stopPrank();
//Chaeck deposit balance
AssetToken asset = thunderLoan.getAssetFromToken(tokenA);
assertEq(tokenA.balanceOf(address(liquidityProvider)), 0);
assertEq(tokenA.balanceOf(address(asset)), DEPOSIT_AMOUNT);
assertEq(asset.balanceOf(liquidityProvider), DEPOSIT_AMOUNT);
//Owner sets tokenA as not allowed token
vm.prank(thunderLoan.owner());
thunderLoan.setAllowedToken(tokenA, false);
assertEq(thunderLoan.isAllowedToken(tokenA), false);
//Liquidty provider can't redeem tokenA
vm.startPrank(liquidityProvider);
vm.expectRevert(abi.encodeWithSelector(ThunderLoan.ThunderLoan__NotAllowedToken.selector, address(tokenA)));
thunderLoan.redeem(tokenA, DEPOSIT_AMOUNT);
vm.stopPrank();
}
function test_SetAllowedTokens_deleteToken_liquidity_issue_readdToken() public {
//Owner sets tokenA as allowed token
vm.prank(thunderLoan.owner());
thunderLoan.setAllowedToken(tokenA, true);
assertEq(thunderLoan.isAllowedToken(tokenA), true);
//Liquidty provider deposits tokenA
vm.startPrank(liquidityProvider);
tokenA.mint(liquidityProvider, DEPOSIT_AMOUNT);
tokenA.approve(address(thunderLoan), DEPOSIT_AMOUNT);
thunderLoan.deposit(tokenA, DEPOSIT_AMOUNT);
vm.stopPrank();
//Chaeck deposit balance
AssetToken asset1 = thunderLoan.getAssetFromToken(tokenA);
assertEq(tokenA.balanceOf(address(liquidityProvider)), 0);
assertEq(tokenA.balanceOf(address(asset1)), DEPOSIT_AMOUNT);
assertEq(asset1.balanceOf(liquidityProvider), DEPOSIT_AMOUNT);
//Owner sets tokenA as not allowed token
vm.prank(thunderLoan.owner());
thunderLoan.setAllowedToken(tokenA, false);
assertEq(thunderLoan.isAllowedToken(tokenA), false);
//Liquidty provider can't redeem tokenA
vm.startPrank(liquidityProvider);
assertEq(tokenA.balanceOf(address(liquidityProvider)), 0);
vm.expectRevert(abi.encodeWithSelector(ThunderLoan.ThunderLoan__NotAllowedToken.selector, address(tokenA)));
thunderLoan.redeem(tokenA, DEPOSIT_AMOUNT);
vm.stopPrank();
//Owner sets tokenA as allowed token again
vm.prank(thunderLoan.owner());
thunderLoan.setAllowedToken(tokenA, true);
assertEq(thunderLoan.isAllowedToken(tokenA), true);
//The AssetToken shouldn't have previous funds
AssetToken asset2 = thunderLoan.getAssetFromToken(tokenA);
assertEq(tokenA.balanceOf(address(asset2)), 0);
}

Impact

This vulnerability affects the operation and fund management of the protocol, as it allows users to lose funds invested within the protocol and also leads to the loss of underlying tokens used to carry out flash loans.

Tools Used

Foundry

Recommendations

To avoid disconnecting underlying assets from LP tokens with stored funds, it is recommended to analyze the current balance of the LP token in relation to the underlying asset to ensure that there is no blocking or loss of funds

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 currentBalance = IERC20(token).balanceOf(address(assetToken))
+ if (currentBalance > 0) {
+ revert ThunderLoan__RemoveAssetTokenWithFunds(assetToken, currentBalance);
+ }
delete s_tokenToAssetToken[token];
emit AllowedTokenSet(token, assetToken, allowed);
return assetToken;
}
}

If the intention is to pause the use of an underlying token, a struct (address assetToken, bool isEnabled) could be created to handle these scenarios.

Updates

Lead Judging Commences

0xnevi Lead Judge
over 1 year ago
0xnevi Lead Judge over 1 year ago
Submission Judgement Published
Invalidated
Reason: Admin Input/call validation
0xnevi Lead Judge over 1 year ago
Submission Judgement Published
Validated
Assigned finding tags:

centralized owners can brick redemptions by unallowing a token

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.