AirDropper

AI First Flight #5
Beginner FriendlyDeFiFoundry
EXP
View results
Submission Details
Impact: medium
Likelihood: medium
Invalid

Merkle leaf encodes only (account, amount) with no chainId or contract address, allowing a valid proof from one deployment to replay on any other deployment sharing the same root

Root + Impact

Description

  • MerkleAirdrop.claim() verifies callers against a Merkle tree whose leaves are computed as keccak256(bytes.concat(keccak256(abi.encode(account, amount)))). The leaf contains only the recipient address and claim amount — no chain ID, no contract address.

  • A proof generated for a given (account, amount) pair is valid on any deployment of MerkleAirdrop that uses the same Merkle root, regardless of chain or contract address. If the deployer runs a testnet deployment with the same root (common practice), every legitimate recipient's proof is immediately usable to drain the testnet (or mainnet) contract. Any future multi-chain airdrop using the same root creates the same exposure.

// src/MerkleAirdrop.sol — claim()
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(account, amount))));
// @> no address(this) and no block.chainid bound to the leaf
if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) {
revert MerkleAirdrop__InvalidProof();
}

Risk

Likelihood:

  • Deploying the same Merkle root on testnet and mainnet for testing is standard practice. Any proof from a testnet claim is immediately replayable on mainnet.

  • If the protocol ever expands to additional chains (Ethereum, Polygon, Arbitrum) using the same recipient list and root, every user who claimed on one chain can claim on all others.

Impact:

  • An attacker with access to a valid proof (obtained from testnet, a public claim tx, or the airdrop announcement) can drain the airdrop on any parallel deployment.

  • All unclaimed tokens on the second deployment can be stolen by replaying proofs observed on the first.

Proof of Concept

The test deploys two separate MerkleAirdrop contracts with identical roots (simulating testnet + mainnet with the same Merkle tree). Alice's proof from the first deployment is submitted directly to the second, draining her allocation from both contracts using one proof.

function testProofReplayableAcrossDeployments() public {
// Deploy a second contract with the same root (simulates mainnet after testnet)
MerkleAirdrop airdrop2 = new MerkleAirdrop(ROOT, IERC20(address(token)));
token.mint(address(airdrop2), ALICE_AMOUNT);
uint256 before1 = token.balanceOf(alice);
// Claim on first deployment
vm.deal(alice, FEE * 2);
vm.prank(alice);
airdrop.claim{value: FEE}(alice, ALICE_AMOUNT, aliceProof);
// Same proof replayable on second deployment — no chain/contract binding
vm.prank(alice);
airdrop2.claim{value: FEE}(alice, ALICE_AMOUNT, aliceProof);
uint256 after1 = token.balanceOf(alice);
// Alice received 2× her allocation using one proof across two deployments
assertEq(after1 - before1, ALICE_AMOUNT * 2);
}

Alice's single Merkle proof works against both deployments, receiving double her intended allocation — confirming the leaf is not bound to a specific contract or chain.

Recommended Mitigation

Bind the leaf to both the chain ID and the contract address:

bytes32 leaf = keccak256(bytes.concat(
- keccak256(abi.encode(account, amount))
+ keccak256(abi.encode(block.chainid, address(this), account, amount))
));

This ensures every proof is unique to the specific contract instance and chain, making cross-deployment and cross-chain replay impossible.

Updates

Lead Judging Commences

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