← Back to Blog

Merkle Proof Vulnerabilities in Smart Contracts: Leaf Malleability, Proof Reuse, and Allowlist Bypass

2026-04-18 merkle-proof allowlist cryptography smart-contract-security solidity airdrop

Merkle trees are foundational infrastructure in modern DeFi: they power airdrop allowlists, NFT mint gates, whitelist checks, and claim systems across hundreds of protocols. Because they are cryptographic primitives, developers often trust them implicitly — and that trust is frequently misplaced. The implementation layer around Merkle proofs is riddled with subtle bugs that attackers exploit to forge valid membership proofs, drain airdrop pools, double-claim rewards, and bypass allowlists entirely.

The root cause in nearly every case is not a flaw in the Merkle tree data structure itself — the math is sound. The vulnerabilities live in the gap between what the proof library guarantees and what the surrounding contract code actually enforces. Libraries like OpenZeppelin's MerkleProof verify hash paths correctly, but they cannot enforce business-level invariants such as "this proof has not been used before" or "this leaf belongs to the caller."

This post walks through six distinct vulnerability classes in smart-contract Merkle proof implementations. Each section includes a realistic vulnerable snippet, an explanation of the attack vector, a corrected version, and detection tips you can apply in your own audits.


1. Leaf Malleability and the Second Preimage Attack

The most dangerous Merkle proof vulnerability is leaf malleability, also called a second preimage attack. It arises when a contract hashes leaf data using the same algorithm as the tree's internal nodes. An attacker can submit an internal node of the tree as a leaf and construct a proof that the verification logic accepts as valid.

Vulnerable code:

// VULNERABLE: leaf is hashed identically to internal nodes
function verify(
    bytes32[] calldata proof,
    bytes32 root,
    address account,
    uint256 amount
) public pure returns (bool) {
    bytes32 leaf = keccak256(abi.encodePacked(account, amount));
    return MerkleProof.verify(proof, root, leaf);
}

Here, the leaf hash is produced with a bare keccak256. Because the tree's internal nodes are also keccak256 hashes of their children, an attacker who knows any internal node value can present it as a leaf and craft a truncated proof path that verifies correctly.

Fix — double-hash leaves to create a distinct domain:

// FIXED: double-hash the leaf to separate it from internal nodes
function verify(
    bytes32[] calldata proof,
    bytes32 root,
    address account,
    uint256 amount
) public pure returns (bool) {
    // Double-hashing ensures leaves can never collide with internal nodes
    bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encodePacked(account, amount))));
    return MerkleProof.verify(proof, root, leaf);
}

OpenZeppelin's MerkleProof library uses this double-hash convention by default since v4.7. If you are using an older version or a custom verifier, apply the pattern explicitly.

Detection tips: Search for keccak256(abi.encodePacked(...)) used as a direct leaf argument to MerkleProof.verify. Flag any implementation that does not double-hash or use a leaf prefix byte that differs from internal node construction.


2. Proof Reuse Across Merkle Trees

When a protocol deploys multiple Merkle trees — for instance, a Season 1 airdrop and a Season 2 airdrop — and both share the same leaf structure, a proof generated for one tree is mathematically valid against the other if the roots are not scoped into verification.

Vulnerable code:

// VULNERABLE: root is a mutable state variable; proofs are not scoped to a specific tree
bytes32 public merkleRoot;

function claim(bytes32[] calldata proof, uint256 amount) external {
    bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encodePacked(msg.sender, amount))));
    require(MerkleProof.verify(proof, merkleRoot, leaf), "Invalid proof");
    _mint(msg.sender, amount);
}

If the owner updates merkleRoot to a new season tree and an attacker still holds a valid proof from the old tree, they can test that proof against the new root. If the leaf construction is identical and the attacker happens to be included in both trees with the same amount, the proof remains valid.

Fix — bind proof to an explicit tree identifier:

// FIXED: tree ID is embedded in the leaf, scoping each proof to exactly one tree
mapping(uint256 => bytes32) public merkleRoots;

function claim(uint256 treeId, bytes32[] calldata proof, uint256 amount) external {
    bytes32 root = merkleRoots[treeId];
    require(root != bytes32(0), "Unknown tree");
    bytes32 leaf = keccak256(bytes.concat(
        keccak256(abi.encodePacked(treeId, msg.sender, amount))
    ));
    require(MerkleProof.verify(proof, root, leaf), "Invalid proof");
    _mint(msg.sender, amount);
}

Detection tips: Check whether multiple roots exist or whether the root is updateable. Confirm that the leaf hash includes a tree-scoping field (season ID, round number, or the root itself). Absence of this field in a multi-tree deployment is a finding.


3. Missing msg.sender Binding in Allowlist Verification

An allowlist Merkle tree is only meaningful if each proof is cryptographically bound to the address that submits it. When the leaf does not include msg.sender, any participant who obtains another user's proof can claim on their behalf — or claim tokens meant for someone else entirely.

Vulnerable code:

// VULNERABLE: leaf contains only the amount; any caller with the proof can claim
function claimAirdrop(bytes32[] calldata proof, address recipient, uint256 amount) external {
    bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encodePacked(recipient, amount))));
    require(MerkleProof.verify(proof, merkleRoot, leaf), "Invalid proof");
    token.transfer(recipient, amount);
}

Although the contract transfers to recipient, anyone who front-runs or intercepts a pending claim transaction can replay the call, sending tokens to the original recipient but burning the claim slot — or, in designs where recipient is caller-controlled, redirecting funds entirely.

Fix — commit to msg.sender inside the leaf:

// FIXED: leaf is bound to msg.sender; proof is non-transferable
function claimAirdrop(bytes32[] calldata proof, uint256 amount) external {
    bytes32 leaf = keccak256(bytes.concat(
        keccak256(abi.encodePacked(msg.sender, amount))
    ));
    require(MerkleProof.verify(proof, merkleRoot, leaf), "Invalid proof");
    token.transfer(msg.sender, amount);
}

Detection tips: Whenever msg.sender and a recipient or account parameter appear together, verify that the leaf hash uses msg.sender — not the parameter. If the caller can supply the address encoded in the leaf, the allowlist provides no real access control.


4. Double Claim via Proof Reuse

A valid Merkle proof for a given leaf is valid forever unless the contract records that it has been used. Without a nullifier or a claimed-bitmap, every allowlisted address can call claim repeatedly and drain the contract.

Vulnerable code:

// VULNERABLE: no record of which proofs have been consumed
function claim(bytes32[] calldata proof, uint256 amount) external {
    bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encodePacked(msg.sender, amount))));
    require(MerkleProof.verify(proof, merkleRoot, leaf), "Invalid proof");
    token.transfer(msg.sender, amount);
}

Fix — store used leaf hashes in a nullifier set:

// FIXED: each leaf hash can only be consumed once
mapping(bytes32 => bool) public claimed;

function claim(bytes32[] calldata proof, uint256 amount) external {
    bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encodePacked(msg.sender, amount))));
    require(!claimed[leaf], "Already claimed");
    require(MerkleProof.verify(proof, merkleRoot, leaf), "Invalid proof");
    claimed[leaf] = true;
    token.transfer(msg.sender, amount);
}

For large-scale airdrops where gas efficiency matters, replace the mapping(bytes32 => bool) with a packed bitmap indexed by a sequential leaf index stored in the tree off-chain.

Detection tips: Look for any MerkleProof.verify call site that is not preceded by a nullifier check. Also check whether a claimed mapping exists but is only set after a state-changing call — reentrancy combined with missing nullifiers is a compounding risk.


5. Off-Chain Root Manipulation via Updatable Root

Many airdrop contracts store the Merkle root in a mutable state variable controlled by an owner or admin. While this provides operational flexibility, it also lets a privileged actor silently swap the tree after users have verified their inclusion off-chain. Claims that were valid one block ago become invalid, and a malicious or compromised owner can replace the tree with one that allocates all tokens to attacker-controlled addresses.

Vulnerable code:

// VULNERABLE: owner can replace the root at any time with no delay
bytes32 public merkleRoot;
address public owner;

function setMerkleRoot(bytes32 newRoot) external {
    require(msg.sender == owner, "Not owner");
    merkleRoot = newRoot;
}

function claim(bytes32[] calldata proof, uint256 amount) external {
    bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encodePacked(msg.sender, amount))));
    require(MerkleProof.verify(proof, merkleRoot, leaf), "Invalid proof");
    token.transfer(msg.sender, amount);
}

Fix — use an immutable root, or gate updates behind a timelock:

// FIXED: root is set once at construction and can never change
bytes32 public immutable merkleRoot;

constructor(bytes32 _merkleRoot) {
    merkleRoot = _merkleRoot;
}

function claim(bytes32[] calldata proof, uint256 amount) external {
    bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encodePacked(msg.sender, amount))));
    require(MerkleProof.verify(proof, merkleRoot, leaf), "Invalid proof");
    token.transfer(msg.sender, amount);
}

When the root genuinely must be updatable (e.g., rolling reward epochs), enforce a minimum timelock — typically 48 to 72 hours — so that users and monitoring systems can detect and react to unexpected changes before they take effect.

Detection tips: Flag any setMerkleRoot or equivalent function lacking a timelock. Check whether the root setter is protected by a multisig with an appropriate threshold. A root that can change in a single EOA transaction is a centralization risk regardless of the stated intent.


6. Empty Proof Bypass

OpenZeppelin's MerkleProof.verify returns true when an empty proof array is passed and the leaf being verified equals the root. This is mathematically correct — a single-element tree's root is its leaf — but it creates a bypass when a contract fails to guard against it. An attacker who knows the current root value can pass it as the leaf data and submit an empty proof, satisfying the verification check without being included in any legitimate tree.

Vulnerable code:

// VULNERABLE: empty proof passes when leaf == root
function isAllowlisted(
    bytes32[] calldata proof,
    address account,
    uint256 amount
) external view returns (bool) {
    bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encodePacked(account, amount))));
    return MerkleProof.verify(proof, merkleRoot, leaf);
}

An attacker sets account and amount to values whose double-keccak equals merkleRoot, then calls with proof = []. In practice, reversing a hash is infeasible, but if merkleRoot was ever set carelessly — for example to a known leaf hash value — this bypass is trivially exploitable.

Fix — require a minimum proof length:

// FIXED: proof must contain at least one sibling node
function isAllowlisted(
    bytes32[] calldata proof,
    address account,
    uint256 amount
) external view returns (bool) {
    require(proof.length > 0, "Proof cannot be empty");
    bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encodePacked(account, amount))));
    return MerkleProof.verify(proof, merkleRoot, leaf);
}

For trees with a known, fixed depth, you can additionally require proof.length == TREE_DEPTH to reject over- and under-length proofs entirely.

Detection tips: Search all MerkleProof.verify call sites for a preceding require(proof.length > 0, ...) guard. Absence of this check, combined with a mutable or externally known root, is an exploitable configuration.


Defending Your Protocol

The six vulnerabilities above share a common thread: each one exploits an implicit assumption that the Merkle proof library itself cannot enforce. The library verifies hash paths — it cannot know whether your leaves are domain-separated, whether roots are scoped to a specific deployment, or whether a nullifier has been recorded. That reasoning lives entirely in your contract code.

A sound Merkle proof implementation requires all of the following: double-hashed leaves to prevent second preimage attacks; msg.sender committed in every leaf that gates access by identity; a nullifier or bitmap that marks each leaf consumed; an immutable or timelocked root; explicit tree IDs when multiple trees coexist; and a minimum proof length guard against the empty proof edge case.

If you want a systematic review of your airdrop contract, allowlist implementation, or any other Merkle-gated mechanism, ContractScan provides automated vulnerability scanning alongside expert manual audits. Submit your contract at contractscan.io and get a detailed security report before you go live.


Important Notes

This post is for informational and educational purposes only. It does not constitute financial, legal, or investment advice. The security analysis provided is based on available data and automated tools, which may not capture all potential vulnerabilities. Always conduct a professional audit before deploying smart contracts.

Scan your contract for this vulnerability
Free QuickScan — Unlimited quick scans. No signup required.. No signup required.
Scan a Contract →