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 {
vm.prank(thunderLoan.owner());
thunderLoan.setAllowedToken(tokenA, true);
assertEq(thunderLoan.isAllowedToken(tokenA), true);
vm.startPrank(liquidityProvider);
tokenA.mint(liquidityProvider, DEPOSIT_AMOUNT);
tokenA.approve(address(thunderLoan), DEPOSIT_AMOUNT);
thunderLoan.deposit(tokenA, DEPOSIT_AMOUNT);
vm.stopPrank();
AssetToken asset = thunderLoan.getAssetFromToken(tokenA);
assertEq(tokenA.balanceOf(address(liquidityProvider)), 0);
assertEq(tokenA.balanceOf(address(asset)), DEPOSIT_AMOUNT);
assertEq(asset.balanceOf(liquidityProvider), DEPOSIT_AMOUNT);
vm.prank(thunderLoan.owner());
thunderLoan.setAllowedToken(tokenA, false);
assertEq(thunderLoan.isAllowedToken(tokenA), false);
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 {
vm.prank(thunderLoan.owner());
thunderLoan.setAllowedToken(tokenA, true);
assertEq(thunderLoan.isAllowedToken(tokenA), true);
vm.startPrank(liquidityProvider);
tokenA.mint(liquidityProvider, DEPOSIT_AMOUNT);
tokenA.approve(address(thunderLoan), DEPOSIT_AMOUNT);
thunderLoan.deposit(tokenA, DEPOSIT_AMOUNT);
vm.stopPrank();
AssetToken asset1 = thunderLoan.getAssetFromToken(tokenA);
assertEq(tokenA.balanceOf(address(liquidityProvider)), 0);
assertEq(tokenA.balanceOf(address(asset1)), DEPOSIT_AMOUNT);
assertEq(asset1.balanceOf(liquidityProvider), DEPOSIT_AMOUNT);
vm.prank(thunderLoan.owner());
thunderLoan.setAllowedToken(tokenA, false);
assertEq(thunderLoan.isAllowedToken(tokenA), false);
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();
vm.prank(thunderLoan.owner());
thunderLoan.setAllowedToken(tokenA, true);
assertEq(thunderLoan.isAllowedToken(tokenA), true);
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.