Santa's List

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

tokenURI is overridden as pure and drops OpenZeppelin's _requireOwned check, so it returns Santa NFT metadata for token ids that do not exist (ERC-721 Metadata non-compliance)

Root + Impact

Description

  • The EIP-721 Metadata extension says tokenURI "Throws if _tokenId is not a valid NFT". The OpenZeppelin 5.0.0 base implementation starts with _requireOwned(tokenId). SantasList advertises IERC721Metadata through supportsInterface(0x5b5e139f).

  • SantasList overrides tokenURI as pure, comments out tokenId and returns the constant TOKEN_URI. A pure function cannot read ownership state, so the existence check is gone. tokenURI returns full metadata for ids that were never minted, while ownerOf correctly reverts for the same ids.

// src/SantasList.sol, lines 187-189
@> function tokenURI(uint256 /* tokenId */ ) public pure override returns (string memory) {
return TOKEN_URI;
}
​
// lib/openzeppelin-contracts/contracts/token/ERC721/ERC721.sol (v5.0.0), lines 88-89: the check the override removes
function tokenURI(uint256 tokenId) public view virtual returns (string memory) {
@> _requireOwned(tokenId);

Risk

Likelihood:

  • It occurs whenever a wallet, marketplace or indexer calls tokenURI for an id that has not been minted yet or for a guessed id.

  • Harm requires an off-chain consumer that treats a tokenURI revert as its signal that a token does not exist. Most consumers rely on ownerOf or Transfer events instead, and those still behave correctly, so likelihood is Low.

Impact:

  • The contract breaks the ERC-721 Metadata standard it advertises. In the PoC, three calls each return the same 51,373-byte metadata as a real token: tokenURI(0) before any mint, tokenURI(1_000_000), and tokenURI(type(uint256).max).

  • Off-chain consumers that rely on the revert can show nonexistent ids as genuine Santa NFTs. No on-chain funds or state are affected.

Proof of Concept

  • test_baseline_StockOZTokenURIRevertsForNonexistentId: an unmodified OpenZeppelin 5.0.0 ERC721, defined inside the PoC, reverts tokenURI(1_000_000) with ERC721NonexistentToken. This is the behaviour the override removes.

  • test_SantasListTokenURIReturnsMetadataForNonexistentIds: SantasList reports IERC721Metadata support. Before any mint, ownerOf(0) reverts but tokenURI(0) returns TOKEN_URI. After one real mint, ownerOf(1_000_000) reverts, but tokenURI(1_000_000) and tokenURI(type(uint256).max) return the same metadata as token 0.

File test/PoC_SL-09.t.sol:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.22;
​
import {Test, console2} from "forge-std/Test.sol";
import {SantasList} from "../src/SantasList.sol";
import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import {IERC721Errors} from "@openzeppelin/contracts/interfaces/draft-IERC6093.sol";
​
/// Reference: an unmodified OZ 5.0.0 ERC721 (inherits the stock tokenURI with _requireOwned).
contract ReferenceERC721 is ERC721 {
constructor() ERC721("Reference", "REF") {}
​
function mint(address to, uint256 id) external {
_mint(to, id);
}
}
​
/// SL-09: SantasList.tokenURI is overridden as `pure` (L187-189), ignores tokenId and drops
/// OZ's _requireOwned check, so it returns Santa NFT metadata for ids that do not exist.
contract PoC_SL09 is Test {
SantasList internal santasList;
ReferenceERC721 internal ref;
​
address internal santa = makeAddr("santa");
address internal alice = makeAddr("alice"); // NICE
​
uint256 internal constant CHRISTMAS = 1_703_480_381;
uint256 internal constant GHOST_ID = 1_000_000;
bytes4 internal constant IERC721_METADATA_ID = 0x5b5e139f;
​
function setUp() public {
vm.prank(santa);
santasList = new SantasList();
ref = new ReferenceERC721();
​
vm.startPrank(santa);
santasList.checkList(alice, SantasList.Status.NICE);
santasList.checkTwice(alice, SantasList.Status.NICE);
vm.stopPrank();
}
​
/// Baseline: stock OZ 5.0.0 ERC721 reverts tokenURI for a nonexistent id (EIP-721 Metadata behaviour).
function test_baseline_StockOZTokenURIRevertsForNonexistentId() public {
ref.mint(alice, 0);
// existing id works
ref.tokenURI(0);
// nonexistent id reverts with ERC721NonexistentToken
vm.expectRevert(abi.encodeWithSelector(IERC721Errors.ERC721NonexistentToken.selector, GHOST_ID));
ref.tokenURI(GHOST_ID);
}
​
/// Break: SantasList returns full Santa NFT metadata for ids that were never minted,
/// while ownerOf correctly reports them as nonexistent.
function test_SantasListTokenURIReturnsMetadataForNonexistentIds() public {
// The contract advertises ERC-721 Metadata support.
assertTrue(santasList.supportsInterface(IERC721_METADATA_ID), "advertises IERC721Metadata");
​
// Before any mint: no token exists at all.
vm.expectRevert(abi.encodeWithSelector(IERC721Errors.ERC721NonexistentToken.selector, 0));
santasList.ownerOf(0);
string memory uriBeforeAnyMint = santasList.tokenURI(0);
assertEq(uriBeforeAnyMint, santasList.TOKEN_URI(), "tokenURI(0) returns metadata before token 0 exists");
​
// Mint exactly one real token (id 0) to alice.
vm.warp(CHRISTMAS + 1);
vm.prank(alice);
santasList.collectPresent();
assertEq(santasList.ownerOf(0), alice);
string memory realUri = santasList.tokenURI(0);
​
// ownerOf(GHOST_ID) reverts: the token does not exist ...
vm.expectRevert(abi.encodeWithSelector(IERC721Errors.ERC721NonexistentToken.selector, GHOST_ID));
santasList.ownerOf(GHOST_ID);
​
// ... but tokenURI(GHOST_ID) does NOT revert and returns the same metadata as the real token.
string memory ghostUri = santasList.tokenURI(GHOST_ID);
assertEq(ghostUri, realUri, "nonexistent id gets identical metadata to the real token");
assertGt(bytes(ghostUri).length, 0, "non-empty metadata returned");
​
// Same for an arbitrary extreme id.
assertEq(santasList.tokenURI(type(uint256).max), realUri, "tokenURI(max uint) returns metadata");
​
console2.log("tokenURI(1_000_000) length (bytes):", bytes(ghostUri).length);
console2.log("ownerOf(1_000_000) reverted with ERC721NonexistentToken; tokenURI did not");
}
}

Run from the repository root:

FOUNDRY_FFI=false FOUNDRY_DISABLE_NIGHTLY_WARNING=1 forge test --match-path test/PoC_SL-09.t.sol -vv

Result:

Ran 2 tests for test/PoC_SL-09.t.sol:PoC_SL09
[PASS] test_SantasListTokenURIReturnsMetadataForNonexistentIds() (gas: 3727895)
Logs:
tokenURI(1_000_000) length (bytes): 51373
ownerOf(1_000_000) reverted with ERC721NonexistentToken; tokenURI did not
​
[PASS] test_baseline_StockOZTokenURIRevertsForNonexistentId() (gas: 67969)
Suite result: ok. 2 passed; 0 failed; 0 skipped

Recommended Mitigation

Restore the existence check. The function must become view so that it can read ownership.

- function tokenURI(uint256 /* tokenId */ ) public pure override returns (string memory) {
+ function tokenURI(uint256 tokenId) public view override returns (string memory) {
+ _requireOwned(tokenId);
return TOKEN_URI;
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 1 hour 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!