Snowman Merkle Airdrop

First Flight #42
Beginner FriendlyFoundrySolidityNFT
100 EXP
Submission Details
Impact: medium
Likelihood: medium

Non-deterministic JSON key ordering in `GenerateInput.s.sol` breaks Merkle root reproducibility and trust in off-chain claim validation

Author Revealed upon completion

Root + Impact

Description

Normal behavior:
This script prepares Merkle tree input data using address/amount pairs. The intent is for off-chain tools or test helpers to use the JSON output to build a Merkle tree that matches the Merkle root deployed on-chain.

Issue:
The script manually serializes JSON by string-concatenating object keys "0", "1", "2", ......, using dynamic formatting. This leads to a non-deterministic data layout, since JSON itself does not guarantee object key order.

If a Merkle tree is regenerated using the same data but a different key order (as is common in real-world JSON parsers), the final root hash will change, breaking all Merkle proofs and preventing legitimate airdrop claims.

string memory json = string.concat('{ "types": ["address", "uint256"], "count":', countString, ',"values": {');
json = string.concat(json, '"0": { "0": "', whitelist[0], '", "1": "', vm.toString(snowAmountAlice), '" },');
json = string.concat(json, '"1": { "0": "', whitelist[1], '", "1": "', vm.toString(snowAmountBob), '" },');
// @> Hardcoded keys (e.g., "0", "1") used as object keys in unordered JSON

Risk

Likelihood: Medium

  • Occurs when Merkle roots are rebuilt off-chain using tools that parse the generated JSON without preserving key order.

  • Common in multi-platform environments or team handoffs where JSON is used as a shared data layer.

Impact: Medium

  • Causes Merkle root mismatch and invalidates airdrop proofs.

  • Users will be unable to claim Snowman NFTs even with valid data.

  • Leads to user frustration, broken airdrop launches, and trust issues in the protocol's fairness.


Proof of Concept

This PoC simulates how changing the order of input entries ,as would happen if JSON keys are reordered during parsing results in a different Merkle root, even if the values are the same.

  • leaves1: original order (alice, then bob)

  • leaves2: reordered (bob, then alice)

    • Different leaf order → different Merkle root → broken airdrop proof

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
import {MerkleProof} from "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
contract MerkleMismatchTest is Test {
bytes32[] leaves1;
bytes32[] leaves2;
function setUp() public {
// Example user address + amount
address alice = address(0xA1);
address bob = address(0xB2);
// Create same leaves with different key order simulation
leaves1.push(keccak256(abi.encodePacked(alice, 100)));
leaves1.push(keccak256(abi.encodePacked(bob, 200)));
leaves2.push(keccak256(abi.encodePacked(bob, 200))); // ❗ Order flipped
leaves2.push(keccak256(abi.encodePacked(alice, 100)));
}
function testMerkleRootsMismatch() public {
// Build Merkle root from each leaf list
bytes32 root1 = keccak256(abi.encodePacked(leaves1[0], leaves1[1]));
bytes32 root2 = keccak256(abi.encodePacked(leaves2[0], leaves2[1]));
assertTrue(root1 != root2, "Merkle roots should mismatch due to order");
}
}

Recommended Mitigation

Do not manually serialize JSON with numeric keys using string.concat. Instead:

  • Use a structured encoding format ( ABI or RLP) where order is enforced.

  • Or, sort values explicitly before building the Merkle tree, and use arrays rather than object keys for ordered data.

Why it works

  • Switching from object-style keys ("0": {...}) to array-style entries enforces consistent order.

  • Prevents unintended key reordering during parsing.

  • Maintains deterministic Merkle tree generation across systems.

- string memory json = string.concat('{ "types": ["address", "uint256"], "count":', countString, ',"values": {');
+ string memory json = string.concat('{ "types": ["address", "uint256"], "count":', countString, ',"values": [');
- json = string.concat(json, '"0": { "0": "', whitelist[0], '", "1": "', vm.toString(snowAmountAlice), '" },');
+ json = string.concat(json, '{ "0": "', whitelist[0], '", "1": "', vm.toString(snowAmountAlice), '" },');
// repeat for others...
- json = string.concat(json, "} }");
+ json = string.concat(json, "] }");

Support

FAQs

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