The expected behavior of collectUsdcFromSelling() is that a seller calls it once after their NFT is sold to receive their sale proceeds (minus fees) plus their minting collateral returned. After this single call the funds should be marked as disbursed.
What actually happens is that the function has no mechanism to prevent it from being called multiple times. The only guard is require(!listing.isActive), which is satisfied once a sale completes and never changes again. Critically, collateralForMinting[listing.tokenId] — the value re-sent on every call — is never zeroed inside this function. Every subsequent call sends the same listing.price - fees + collateral amount to the seller, draining funds that belong to other protocol users.
Note the additional bug on the safeTransfer(address(this), fees) line: this transfers from the contract to itself, which is a no-op in standard ERC20. The fees are already in the contract after buy() — this line achieves nothing and only adds confusion about fee accounting.
Likelihood:
Occurs any time a seller realises the function can be called more than once — requires no special access or tooling, just multiple transactions
The attack is more profitable as more users have locked collateral in the contract, which is the normal operating state of the protocol
Impact:
Complete drain of all USDC held by the contract, up to the full contract balance
Other users' minting collateral is stolen and permanently unrecoverable — victims cannot call cancelListing() (they never listed) and cannot call collectUsdcFromSelling() (they are not the seller of the attacker's listing)
totalFeesCollected is over-inflated on every call, so withdrawFees() could also attempt to send more than the remaining balance to the owner
Run with:
Expected console output:
Zero collateralForMinting[listing.tokenId] before the transfer and remove the no-op self-transfer. This follows the Checks-Effects-Interactions pattern — state is cleared before any external call, preventing any re-entry or repeated call from finding the collateral still set.
This ensures that on any second call, collateralToReturn will be 0 and amountToSeller will equal listing.price - fees — the sale proceeds that have already been transferred to the seller on the first call — causing the contract to revert due to insufficient balance, or at worst sending a second (incorrect) amount of just the sale price with no collateral. A cleaner guard is to add an explicit "already claimed" check:
The contest is live. Earn rewards by submitting a finding.
This is your time to appeal against judgements on your submissions.
Appeals are being carefully reviewed by our judges.
The contest is complete and the rewards are being distributed.