Snowman Merkle Airdrop

First Flight #42
Beginner FriendlyFoundrySolidityNFT
100 EXP
View results
Submission Details
Severity: high
Valid

[H-3] Unrestricted Public Minting of Snowman NFTs Allows Arbitrary Token Creation

[H-3] Unrestricted Public Minting of Snowman NFTs Allows Arbitrary Token Creation

Description

  • Normal Protocol Behavior: The Snowman.sol contract (ERC721) NFTs are intended to be distributed via the SnowmanAirdrop.sol contract. This airdrop contract uses a Merkle tree system. Recipients prove their eligibility, stake their Snow tokens into the SnowmanAirdrop.sol contract, and in return, the SnowmanAirdrop.sol contract is responsible for ensuring they receive Snowman NFTs equal to their staked Snow balance. This controlled mechanism is crucial for the NFT's intended distribution and value.

  • Specific Vulnerability: The mintSnowman(address receiver, uint256 amount) function in Snowman.sol is declared external and lacks any access control. This allows any external account to call it directly and mint an arbitrary number of Snowman NFTs to any address, completely bypassing the Snow token staking and Merkle verification process managed by SnowmanAirdrop.sol.

// Snowman.sol
contract Snowman is ERC721, Ownable {
// ...
function mintSnowman(address receiver, uint256 amount) external { // @> VULNERABILITY: No access control
for (uint256 i = 0; i < amount; i++) {
_safeMint(receiver, s_TokenCounter);
emit SnowmanMinted(receiver, s_TokenCounter);
s_TokenCounter++;
}
}
// ...
}

Risk

Likelihood: High

  • An attacker (any external account) can easily discover and call the public mintSnowman function.

Impact: High

  • Unlimited NFT Supply & Devaluation: Attackers can mint an unlimited number of Snowman NFTs, destroying their scarcity and value.

  • Circumvention of Airdrop Mechanism: The intended distribution via SnowmanAirdrop.sol (based on Merkle proofs and Snow token staking) is rendered ineffective.

  • Unfair Advantage and Market Manipulation: Attackers can pre-mint NFTs.

  • Loss of User Trust and Project Credibility: This fundamental flaw damages the project's reputation.

Proof of Concept

The following Foundry test, testVulnerability_AttackerCanMintSnowman from test/Snowman.t.sol, demonstrates that an arbitrary attacker address can successfully call mintSnowman.

// test/Snowman.t.sol
contract TestSnowman is Test {
Snowman nft;
// ... other setup variables ...
address contractOwner;
address attacker;
function setUp() public {
// ... (deployment of Snowman contract and nft initialization) ...
// Example:
DeploySnowman deployer = new DeploySnowman();
nft = deployer.run();
contractOwner = nft.owner(); // Assuming nft is initialized in setup
attacker = makeAddr("attacker"); // Creates a distinct address
assertTrue(attacker != contractOwner, "Attacker should not be the contract owner for this PoC.");
}
function testVulnerability_AttackerCanMintSnowman() public {
uint256 mintAmountByAttacker = 5;
address receiverForAttackersMint = makeAddr("receiver_for_attackers_mint");
uint256 initialTotalSupply = nft.getTokenCounter();
uint256 initialReceiverBalance = nft.balanceOf(receiverForAttackersMint);
// THE ATTACK: Attacker (who is NOT the owner) calls mintSnowman
vm.startPrank(attacker);
nft.mintSnowman(receiverForAttackersMint, mintAmountByAttacker);
vm.stopPrank();
uint256 finalTotalSupply = nft.getTokenCounter();
uint256 finalReceiverBalance = nft.balanceOf(receiverForAttackersMint);
// Assertions to prove the vulnerability
assertEq(finalTotalSupply, initialTotalSupply + mintAmountByAttacker, "VULNERABILITY: Total supply should increase by attacker's mintAmount.");
assertEq(finalReceiverBalance, initialReceiverBalance + mintAmountByAttacker, "VULNERABILITY: Receiver's balance should increase by attacker's mintAmount.");
uint256 firstTokenIdMintedByAttacker = initialTotalSupply;
assertEq(nft.ownerOf(firstTokenIdMintedByAttacker), receiverForAttackersMint, "VULNERABILITY: receiverForAttackersMint should own the token minted by the attacker.");
}
}

Recommended Mitigation

To align with the protocol's described invariants, where SnowmanAirdrop.sol manages the eligibility and distribution of Snowman NFTs based on Snow token staking and Merkle proofs, the mintSnowman function in Snowman.sol must be restricted.

The most direct and secure approach is to designate SnowmanAirdrop.sol as the exclusive minter for Snowman.sol.

  1. Modify Snowman.sol to implement a "Minter Role":

    • Add a state variable to store the address of the authorized minterContract.

    • Add a modifier (onlyMinter) that restricts the execution of mintSnowman to this minterContract address.

    • Add an onlyOwner function (setMinter) to allow the owner of Snowman.sol to set the address of the SnowmanAirdrop.sol contract as the minterContract.

    // Snowman.sol
    contract Snowman is ERC721, Ownable {
    // ...
    + address public minterContract;
    // ...
    + event MinterSet(address indexed minter); // Optional: event for when minter is set
    + modifier onlyMinter() {
    + if (msg.sender != minterContract) {
    + revert SM__NotAllowed(); // Or a more specific error like "CallerIsNotMinter"
    + }
    + _;
    + }
    + // Function to set the Minter contract (callable by Snowman.sol's owner)
    + function setMinter(address _minter) external onlyOwner {
    + if (_minter == address(0)) {
    + revert SM__ZeroAddress(); // Or your preferred zero address error
    + }
    + minterContract = _minter;
    + emit MinterSet(_minter); // Optional
    + }
    - function mintSnowman(address receiver, uint256 amount) external {...}
    + function mintSnowman(address receiver, uint256 amount) external onlyMinter {...}
    // ...
    }
  2. Operational Flow:

    • After deploying both Snowman.sol and SnowmanAirdrop.sol, the owner of Snowman.sol calls setMinter(addressOfSnowmanAirdropContract) on Snowman.sol.

    • When a user (recipient) successfully calls claimSnowman (or claimSnowmanFor) in SnowmanAirdrop.sol:

      • SnowmanAirdrop.sol verifies the Merkle proof and signatures (if any).

      • SnowmanAirdrop.sol handles the staking of the user's Snow tokens.

      • SnowmanAirdrop.sol then calls Snowman.sol::mintSnowman(recipient, verifiedAmount), where verifiedAmount is the number of NFTs the recipient is entitled to, based on their Snow balance from the Merkle proof.

This mitigation ensures:

  • The mintSnowman function in Snowman.sol is no longer publicly callable.

  • Only SnowmanAirdrop.sol, after its internal verifications (Merkle proof, Snow staking), can trigger the minting of Snowman NFTs.

  • This directly implements the README's intent: "Recipients stake their Snow tokens and receive Snowman NFTS equal to their Snow balance in return" via the SnowmanAirdrop contract.

Updates

Lead Judging Commences

yeahchibyke Lead Judge 3 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Unrestricted NFT mint function

The mint function of the Snowman contract is unprotected. Hence, anyone can call it and mint NFTs without necessarily partaking in the airdrop.

Support

FAQs

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