Snowman Merkle Airdrop

First Flight #42
Beginner FriendlyFoundrySolidityNFT
100 EXP
View results
Submission Details
Impact: high
Likelihood: medium
Invalid

Inflated Claims Possible via Misconfigured Off-Chain Merkle Generation

Root + Impact

The on-chain contract correctly verifies that a claimant's leaf is part of the Merkle tree. However, it blindly trusts the amount contained within that leaf. The contract has no independent, on-chain knowledge of the correct allocation amount, making it vulnerable to flawed or manipulated data that originates from the off-chain Merkle tree generation scripts.

Description

  • Normal Behavior: The off-chain scripts generate a Merkle tree where each leaf is a hash of (address, correct_amount). A user provides their amount and a proof, and the contract verifies that the resulting leaf is in the tree before minting.

  • The Issue: The entire security of the distribution amounts rests on the off-chain generation script being bug-free and the input data being pristine. If a bug or a malicious edit introduces a leaf with an inflated amount (e.g., (attacker_address, 1,000,000e18)), the contract will successfully verify the proof for this leaf and mint the inflated amount, as it has no mechanism to know the amount is wrong.

function claim(bytes32[] calldata proof, uint256 amount) external {
// @> The leaf is constructed with an amount passed directly by the user.
bytes32 leaf = keccak256(abi.encodePacked(msg.sender, amount));
// @> The contract's only check is that this leaf is part of the Merkle tree.
// @> It has no way to know if `amount` is the correct allocation for this user.
require(MerkleProof.verify(proof, merkleRoot, leaf), "Invalid proof");
_mint(msg.sender, amount);
}

Risk

Likelihood:

  • When a bug exists in the off-chain tree generation scripts (GenerateInput.s.sol, SnowMerkle.s.sol).

  • When the input.json file is maliciously altered before the Merkle root is generated and set.

Impact:

  • An attacker or bugged address can drain a disproportionate share of the airdrop.

  • The total planned airdrop supply can be exceeded, breaking the tokenomics.

Proof of Concept

An attacker (or a script bug) modifies the input.json file to assign themselves a massive allocation. They then run the project's own GenerateInput.s.sol and SnowMerkle.s.sol scripts to generate a new Merkle root and a proof that corresponds to their malicious leaf. If the project owner sets this new, malicious root, the attacker can call claim with the inflated amount. The MerkleProof.verify call will return true, and the contract will proceed to mint an illegitimate number of tokens.

function test_PoC_InflatedClaim() public {
// 1. Attacker manipulates input data to generate a tree with an inflated amount
// for their address, resulting in a malicious root and a valid proof for it.
bytes32 maliciousRoot = 0x...; // Generated from manipulated input
bytes32[] attackerProofForInflatedAmount = [...];
uint256 inflatedAmount = 1_000_000e18;
// 2. The owner sets the malicious root.
airdrop.setMerkleRoot(maliciousRoot);
// 3. Attacker calls claim. The proof is valid for the malicious root, so the check passes.
vm.prank(attackerAddress);
airdrop.claim(attackerProofForInflatedAmount, inflatedAmount);
// 4. Attacker has successfully drained the contract of an enormous amount.
assertEq(snowToken.balanceOf(attackerAddress), inflatedAmount);
}

Recommended Mitigation

The contract's design must be hardened to not blindly trust data from off-chain processes. The best practice is to create an on-chain source of truth for allocations. An allocations mapping should be added to the contract where the owner sets the correct amount for each user. The claim function would still use the Merkle proof to verify eligibility, but it would use the on-chain allocations mapping to determine the amount to be minted.

// Add a mapping for on-chain allocation tracking.
mapping(address => uint256) public allocations;
// A function for the owner to set the true allocations on-chain.
function setAllocations(address[] calldata users, uint256[] calldata amounts) external onlyOwner {
for (uint i = 0; i < users.length; i++) {
allocations[users[i]] = amounts[i];
}
}
function claim(bytes32[] calldata proof, uint256 amount) external {
bytes32 leaf = keccak256(abi.encodePacked(msg.sender, amount));
require(MerkleProof.verify(proof, merkleRoot, leaf), "Invalid proof");
// Add a check against the on-chain allocation record.
uint256 allocation = allocations[msg.sender];
require(amount > 0 && amount == allocation, "Amount does not match allocation");
// Prevent replays by clearing the allocation.
allocations[msg.sender] = 0;
_mint(msg.sender, amount);
}
Updates

Lead Judging Commences

yeahchibyke Lead Judge 5 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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