Tadle

Tadle
DeFiFoundry
27,750 USDC
View results
Submission Details
Severity: high
Valid

Users cannot withdraw their tokens once the tokens are deposited to the protocol

Summary

Users can deposit their tokens however they cannot take back them because even through the withdraw function exists in TokenManager contract, it will only transfer the wrapped native tokens and ERC20 tokens from CapitalPool contract to TokenManager contract. In case users want to get back their funds they won't be able to perform it because there is no any alternative withdrawing function in the protocol contracts.This means any deposited token will be locked in the protocol forever.

Vulnerability Details

As the following code snippet from TokenManager.sol::withdraw() method demonstrates withdraw function won't be useful in case any user wants to get back their tokens :

/**
* @notice Withdraw
* @dev Caller must be owner
* @param _tokenAddress Token address
* @param _tokenBalanceType Token balance type
*/
function withdraw(
address _tokenAddress,
TokenBalanceType _tokenBalanceType
) external whenNotPaused {
uint256 claimAbleAmount = userTokenBalanceMap[_msgSender()][
_tokenAddress
][_tokenBalanceType];
if (claimAbleAmount == 0) {
return;
}
address capitalPoolAddr = tadleFactory.relatedContracts(
RelatedContractLibraries.CAPITAL_POOL
);
if (_tokenAddress == wrappedNativeToken) {
/**
* @dev token is native token
* @dev transfer from capital pool to msg sender
* @dev withdraw native token to token manager contract
* @dev transfer native token to msg sender
*/
_transfer(
wrappedNativeToken,
capitalPoolAddr,
address(this),
claimAbleAmount,
capitalPoolAddr
);
IWrappedNativeToken(wrappedNativeToken).withdraw(claimAbleAmount);
payable(msg.sender).transfer(claimAbleAmount);
} else {
/**
* @dev token is ERC20 token
* @dev transfer from capital pool to msg sender
*/
_safe_transfer_from(
_tokenAddress,
capitalPoolAddr,
_msgSender(),
claimAbleAmount
);
}
emit Withdraw(
_msgSender(),
_tokenAddress,
_tokenBalanceType,
claimAbleAmount
);
}

As it can be clearly seen,native tokens will directly send to TokenManager and the case will be same for other tokens as well because _safe_transfer_from() will simply make a low level call to the transferFrom function of ERC20.This function requires preApproval for sending funds but core contracts have only one approve function in CapitalPool.sol and it approves all of the issued token to the TokenManager contract. Since there is no any alternative method in the protocol contracts, the users won't be able to withdraw their funds.
This issue can be reproduced by simply changing and calling test_ask_turbo_chain() function as below:

function test_ask_turbo_chain() public {
vm.startPrank(user);
uint256 userUSDTBalance0 = mockUSDCToken.balanceOf(user);
preMarktes.createOffer(
CreateOfferParams(
marketPlace,
address(mockUSDCToken),
1000,
0.01 * 1e18,
12000,
300,
OfferType.Ask,
OfferSettleType.Turbo
)
);
uint256 userUSDTBalance1 = mockUSDCToken.balanceOf(user);
assertEq(userUSDTBalance1, userUSDTBalance0 - 0.012 * 1e18);
address offerAddr = GenerateAddress.generateOfferAddress(0);
vm.stopPrank();
vm.startPrank(user1);
uint256 user1USDTBalance0 = mockUSDCToken.balanceOf(user1);
uint256 userTaxIncomeBalance0 = tokenManager.userTokenBalanceMap(
address(user),
address(mockUSDCToken),
TokenBalanceType.TaxIncome
);
uint256 userSalesRevenue0 = tokenManager.userTokenBalanceMap(
address(user),
address(mockUSDCToken),
TokenBalanceType.SalesRevenue
);
mockUSDCToken.approve(address(tokenManager), type(uint256).max);
preMarktes.createTaker(offerAddr, 300);
vm.stopPrank();
uint256 user1USDTBalance1 = mockUSDCToken.balanceOf(user1);
assertEq(
user1USDTBalance1,
user1USDTBalance0 - ((0.01 * 300) / 1000) * 1.035 * 1e18
);
uint256 userTaxIncomeBalance1 = tokenManager.userTokenBalanceMap(
address(user),
address(mockUSDCToken),
TokenBalanceType.TaxIncome
);
assertEq(
userTaxIncomeBalance1,
userTaxIncomeBalance0 + ((0.01 * 300) / 1000) * 0.03 * 1e18
);
uint256 userSalesRevenue1 = tokenManager.userTokenBalanceMap(
address(user),
address(mockUSDCToken),
TokenBalanceType.SalesRevenue
);
assertEq(
userSalesRevenue1,
userSalesRevenue0 + ((0.01 * 300) / 1000) * 1e18
);
vm.startPrank(user2);
mockUSDCToken.approve(address(tokenManager), type(uint256).max);
preMarktes.createTaker(offerAddr, 500);
address stock2Addr = GenerateAddress.generateStockAddress(2);
preMarktes.listOffer(stock2Addr, 0.006 * 1e18, 12000);
vm.stopPrank();
address offer2Addr = GenerateAddress.generateOfferAddress(2);
vm.startPrank(user3);
mockUSDCToken.approve(address(tokenManager), type(uint256).max);
preMarktes.createTaker(offer2Addr, 200);
vm.stopPrank();
vm.startPrank(user);
address originStock = GenerateAddress.generateStockAddress(0);
address originOffer = GenerateAddress.generateOfferAddress(0);
preMarktes.closeOffer(originStock, originOffer);
preMarktes.relistOffer(originStock, originOffer);
vm.stopPrank();
vm.prank(user1);
systemConfig.updateMarket(
"Backpack",
address(mockPointToken),
0.01 * 1e18,
block.timestamp - 1,
3600
);
vm.startPrank(user);
mockPointToken.approve(address(tokenManager), 10000 * 10 ** 18);
deliveryPlace.settleAskMaker(offerAddr, 800);
vm.stopPrank();
vm.prank(user1);
address stock1Addr = GenerateAddress.generateStockAddress(1);
deliveryPlace.closeBidTaker(stock1Addr);
vm.prank(user2);
deliveryPlace.closeBidTaker(stock2Addr);
vm.prank(user3);
address stock3Addr = GenerateAddress.generateStockAddress(3);
deliveryPlace.closeBidTaker(stock3Addr);
//let's assume user wants to withdraw his sales revenue
userSalesRevenue1 = tokenManager.userTokenBalanceMap(
address(user),
address(mockUSDCToken),
TokenBalanceType.SalesRevenue
); //get latest sales revenue of user
assert(userSalesRevenue1 > 0); //make sure it is more than 0
vm.prank(user);//impersonate user
vm.expectRevert();//user cannot withdraw his funds
tokenManager.withdraw(address(mockUSDCToken),TokenBalanceType.SalesRevenue);
vm.prank(address(tokenManager));//imporsonate tokenManager
tokenManager.withdraw(address(mockUSDCToken),TokenBalanceType.SalesRevenue);//tokenmanager can withdraw user's funds
}

Impact

Users won't be able to withdraw their tokens once the the tokens were deposited to the protocol which means their tokens will be locked there forever and only available to them inside the protocol. Since they cannot transfer their deposited tokens back to their eoa addresses, it will make the same effect with loosing their funds for the users in case they need to spent it in another protocol or simply convert it to a real world fiat currency.

Tools Used

Manual Analysis and Foundry

Recommendations

  1. Adding a mapping objects which tracks user balances per capital and a withdraw function in the CapitalPool contract can fix this issue.

  2. Mapping object can be like the following example mapping(address => mapping(address=>uint256)) public balanceOfUser`

  3. The withdraw function can include following check `if(balanceOfUser[msg.sender][token]==0){revert("User didn't deposit any funds")}` with a reentrancy guard modifier or well implemented cei pattern as well. This way protocol can be saved against both attacking methods :

    1. Stealing funds with reentrant call on withdraw function

    2. Stealing funds as simply calling withdraw function without depositing the issued token.

    This approach can ensure and enforce both security and user experience in the same time.

Updates

Lead Judging Commences

0xnevi Lead Judge about 1 year ago
Submission Judgement Published
Validated
Assigned finding tags:

finding-TokenManager-safeTransferFrom-withdraw-missing-approve

This issue's severity has similar reasonings to #252, whereby If we consider the correct permissioned implementation for the `approve()` function within `CapitalPool.sol`, this would be a critical severity issue, because the withdrawal of funds will be permanently blocked and must be rescued by the admin via the `Rescuable.sol` contract, given it will always revert [here](https://github.com/Cyfrin/2024-08-tadle/blob/04fd8634701697184a3f3a5558b41c109866e5f8/src/core/CapitalPool.sol#L36-L38) when attempting to call a non-existent function selector `approve` within the TokenManager contract. Similarly, the argument here is the approval function `approve` was made permisionless, so if somebody beforehand calls approval for the TokenManager for the required token, the transfer will infact not revert when a withdrawal is invoked. I will leave open for escalation discussions, but based on my first point, I believe high severity is appropriate. It also has a slightly different root cause and fix whereby an explicit approval needs to be provided before a call to `_safe_transfer_from()`, if not, the alternative `_transfer()` function should be used to provide an approval, assuming a fix was implemented for issue #252

Support

FAQs

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