Airdrop contracts distribute tokens to thousands of eligible addresses in a single transaction. They're designed to be cheap and permissionless — anyone with a valid merkle proof can claim their tokens without trusting a centralized entity.
But this simplicity masks several security traps. Airdrop contracts have been the vector for exploits totaling hundreds of millions in losses, from missing double-spend protections to merkle tree collisions and cross-chain replay attacks. This post covers the vulnerabilities that live in airdrop contracts and the patterns that prevent them.
How Merkle Tree Airdrops Work
The standard airdrop pattern uses a merkle tree to store eligibility data off-chain, then proves membership on-chain:
// Deployer publishes merkle root on-chain
// Root = keccak256 of the entire tree
contract Airdrop {
bytes32 public merkleRoot;
IERC20 public token;
constructor(bytes32 _merkleRoot, address _token) {
merkleRoot = _merkleRoot;
token = _token;
}
// User provides proof that their (account, amount) leaf is in the tree
function claim(
address account,
uint256 amount,
bytes32[] calldata proof
) external {
// Verify proof
bytes32 leaf = keccak256(abi.encodePacked(account, amount));
require(MerkleProof.verify(proof, merkleRoot, leaf), "Invalid proof");
// Send tokens
token.transfer(account, amount);
}
}
This uses a merkle proof to verify eligibility without storing a massive list of eligible addresses on-chain. The gas savings are enormous — a 100,000-address airdrop costs a fraction of what storing all addresses would cost.
The problem: this simple pattern has seven critical vulnerability categories.
Vulnerability 1: Missing Double-Claim Protection
The most obvious vulnerability in the pattern above: nothing stops the same (account, amount) from being claimed multiple times.
// VULNERABLE: no tracking of claimed tokens
function claim(address account, uint256 amount, bytes32[] calldata proof) external {
bytes32 leaf = keccak256(abi.encodePacked(account, amount));
require(MerkleProof.verify(proof, merkleRoot, leaf), "Invalid proof");
token.transfer(account, amount); // Can claim infinite times
}
An attacker can call claim() in the same transaction repeatedly with the same proof — or in separate transactions. Each call succeeds and transfers tokens.
Fix: Track claimed addresses in a bitmap
// SECURE: bitmap tracking prevents double-claims
contract SecureAirdrop {
bytes32 public merkleRoot;
IERC20 public token;
// Bitmap to track claimed tokens
// Index = address in tree, bit = claimed or not
mapping(uint256 => uint256) private claimedBitmap;
function isClaimed(uint256 index) public view returns (bool) {
uint256 claimedWordIndex = index / 256;
uint256 claimedBitIndex = index % 256;
uint256 claimedWord = claimedBitmap[claimedWordIndex];
uint256 mask = (1 << claimedBitIndex);
return claimedWord & mask == mask;
}
function _setClaimed(uint256 index) private {
uint256 claimedWordIndex = index / 256;
uint256 claimedBitIndex = index % 256;
claimedBitmap[claimedWordIndex] |= (1 << claimedBitIndex);
}
function claim(
uint256 index,
address account,
uint256 amount,
bytes32[] calldata proof
) external {
require(!isClaimed(index), "Already claimed");
bytes32 leaf = keccak256(abi.encode(index, account, amount));
require(MerkleProof.verify(proof, merkleRoot, leaf), "Invalid proof");
_setClaimed(index);
token.transfer(account, amount);
}
}
The bitmap approach is the standard used by major airdrops (including Uniswap's $UNI airdrop). It uses a single bit per address, so 100,000 addresses fit in ~400 storage slots instead of 100,000.
Vulnerability 2: Merkle Tree Leaf Hash Collision
This vulnerability happens when constructing the leaf hash. Using abi.encodePacked() with variable-length arguments can create hash collisions:
// VULNERABLE: abi.encodePacked collision risk
bytes32 leaf = keccak256(abi.encodePacked(account, amount));
// These two inputs produce the SAME leaf hash:
// Input 1: account = 0xABCD, amount = 0xEF12 → 0xABCDEF12 (concatenated)
// Input 2: account = 0xAB, amount = 0xCDEF12 → 0xABCDEF12 (same bytes)
// If the merkle tree was built with (0xAB, 0xCDEF12),
// a user can claim with proof for (0xABCD, 0xEF12)
This is a known vulnerability in merkle tree construction. When variable-length types or dynamic arrays are packed, an attacker can manipulate the boundary between fields to create collisions.
Fix: Use abi.encode() instead
// SECURE: abi.encode includes length prefixes
bytes32 leaf = keccak256(abi.encode(index, account, amount));
// Now the leaf includes the encoded length of each argument
// (0xAB, 0xCDEF12) and (0xABCD, 0xEF12) produce DIFFERENT hashes
Always use abi.encode() (or abi.encodePacked() only with fixed-size types). The gas difference is minimal but security is massively improved.
Vulnerability 3: Cross-Chain Merkle Proof Replay
If an airdrop is distributed on multiple chains (e.g., Ethereum, Arbitrum, Polygon), using the same merkle root on all chains creates a critical vulnerability:
// VULNERABLE: same root on all chains
contract Airdrop {
bytes32 public merkleRoot = 0x12345...; // Same on all chains
function claim(address account, uint256 amount, bytes32[] calldata proof) external {
bytes32 leaf = keccak256(abi.encode(account, amount));
require(MerkleProof.verify(proof, merkleRoot, leaf), "Invalid proof");
token.transfer(account, amount);
}
}
An attacker can claim on Ethereum, then use the same proof to claim on Arbitrum, Polygon, and every other chain. This bypasses the per-chain claim limit and lets a single user drain multiple copies of the airdrop.
Fix: Include chainId in the leaf hash
// SECURE: chainId prevents cross-chain replay
contract SecureAirdrop {
bytes32 public merkleRoot;
IERC20 public token;
mapping(uint256 => uint256) private claimedBitmap;
function claim(
uint256 index,
address account,
uint256 amount,
bytes32[] calldata proof
) external {
require(!isClaimed(index), "Already claimed");
// Include chainId in leaf hash
bytes32 leaf = keccak256(abi.encode(
block.chainid,
index,
account,
amount
));
require(MerkleProof.verify(proof, merkleRoot, leaf), "Invalid proof");
_setClaimed(index);
token.transfer(account, amount);
}
}
This ensures that a proof valid on Ethereum cannot be replayed on Arbitrum. Even if the merkle root is the same across chains, the leaf hashes are different because block.chainid is different.
Vulnerability 4: Front-Running Claims in Mempool
A valid merkle proof is public data in the transaction. An attacker monitoring the mempool can see a user's proof, then submit an identical claim in their own transaction with a higher gas price — mining it first.
// Scenario:
// 1. Alice calls claim(alice_address, 1000, proof)
// 2. Attacker sees tx in mempool, copies the proof
// 3. Attacker calls claim(alice_address, 1000, proof) // Uses same address!
// 4. Attacker's tx mines first
// 5. Alice's tx mines, but fails because claim already processed
Wait — there's a flaw in the attack above. If the attacker claims for alice_address, they still transfer to Alice, not themselves. Let me show the real front-running vulnerability:
// VULNERABLE: claim destination not verified
function claim(address to, uint256 amount, bytes32[] calldata proof) external {
bytes32 leaf = keccak256(abi.encode(to, amount));
require(MerkleProof.verify(proof, merkleRoot, leaf), "Invalid proof");
token.transfer(to, amount); // ← 'to' is a parameter
}
// Attack:
// 1. Alice calls claim(alice, 1000, proof) to claim to her address
// 2. Attacker sees proof in mempool
// 3. Attacker calls claim(attacker, 1000, proof)
// 4. Attacker's claim fails because (attacker, 1000) doesn't match the merkle proof
// 5. But if the contract naively didn't verify the address...
The real issue is if the merkle tree was built with the claiming account, not a parameter. Then front-running means the attacker can claim a different account's tokens before they do:
Fix: Enforce recipient = msg.sender
// SECURE: enforce claim to sender's own address
function claim(
uint256 index,
address account,
uint256 amount,
bytes32[] calldata proof
) external {
require(msg.sender == account, "Can only claim for yourself");
require(!isClaimed(index), "Already claimed");
bytes32 leaf = keccak256(abi.encode(block.chainid, index, account, amount));
require(MerkleProof.verify(proof, merkleRoot, leaf), "Invalid proof");
_setClaimed(index);
token.transfer(account, amount);
}
Now the attacker cannot claim for Alice's account because msg.sender != account. Front-running is prevented by cryptographic enforcement, not just contract logic.
Vulnerability 5: Admin Rug via Unchecked Withdrawals
Many airdrop contracts include a withdrawAirdrop() function to recover unclaimed tokens after a deadline:
// VULNERABLE: admin can withdraw anytime, including after deadline
contract Airdrop {
bytes32 public merkleRoot;
IERC20 public token;
address public admin;
function claim(address account, uint256 amount, bytes32[] calldata proof) external {
bytes32 leaf = keccak256(abi.encodePacked(account, amount));
require(MerkleProof.verify(proof, merkleRoot, leaf), "Invalid proof");
token.transfer(account, amount);
}
// BUG: no deadline enforcement
function withdrawUnclaimed() external {
require(msg.sender == admin);
uint256 balance = token.balanceOf(address(this));
token.transfer(admin, balance);
}
}
The deployer can drain all unclaimed tokens at any time. If the airdrop deadline is supposed to be 90 days, but the admin wants to rug early, they can withdraw all funds. Users who haven't claimed yet are left empty-handed, and trust in the protocol collapses.
Fix: Enforce deadline before allowing withdrawal
// SECURE: deadline enforced
contract SecureAirdrop {
bytes32 public merkleRoot;
IERC20 public token;
address public admin;
uint256 public immutable deadline;
constructor(bytes32 _merkleRoot, address _token, uint256 _deadline) {
merkleRoot = _merkleRoot;
token = _token;
deadline = _deadline;
}
mapping(uint256 => uint256) private claimedBitmap;
function claim(
uint256 index,
address account,
uint256 amount,
bytes32[] calldata proof
) external {
require(block.timestamp <= deadline, "Airdrop expired");
require(!isClaimed(index), "Already claimed");
bytes32 leaf = keccak256(abi.encode(block.chainid, index, account, amount));
require(MerkleProof.verify(proof, merkleRoot, leaf), "Invalid proof");
_setClaimed(index);
token.transfer(account, amount);
}
function withdrawUnclaimed() external {
require(msg.sender == admin);
require(block.timestamp > deadline, "Airdrop still active");
uint256 balance = token.balanceOf(address(this));
token.transfer(admin, balance);
}
}
By making the deadline immutable, it cannot be changed after deployment. And withdrawUnclaimed() requires the deadline to have passed.
Vulnerability 6: Missing Deadline — Tokens Locked Forever (or Drained)
The inverse problem: if there's no deadline at all, tokens are locked in the contract forever. Users who miss the intended airdrop window have no recourse.
// VULNERABLE: no deadline = tokens potentially locked forever
function withdrawUnclaimed() external {
require(msg.sender == admin);
// No deadline check — admin could call this anytime
// But if they don't, users who claim after "the event" is over
// still have a working contract — tokens just stay locked
}
If the airdrop is supposed to end, but the contract never has a deadline, and the admin is inactive, tokens are permanently stuck. If the admin goes rogue and the deadline logic is missing, they can drain it at will.
Fix: Both immutable deadline and withdrawal window
// SECURE: deadline with withdrawal window
contract SecureAirdrop {
bytes32 public merkleRoot;
IERC20 public token;
address public admin;
uint256 public immutable claimDeadline;
uint256 public immutable withdrawalDeadline;
constructor(
bytes32 _merkleRoot,
address _token,
uint256 _claimDeadline,
uint256 _withdrawalDeadline
) {
require(_claimDeadline < _withdrawalDeadline, "Invalid deadlines");
merkleRoot = _merkleRoot;
token = _token;
claimDeadline = _claimDeadline;
withdrawalDeadline = _withdrawalDeadline;
admin = msg.sender;
}
mapping(uint256 => uint256) private claimedBitmap;
function claim(
uint256 index,
address account,
uint256 amount,
bytes32[] calldata proof
) external {
require(block.timestamp <= claimDeadline, "Claim window closed");
require(!isClaimed(index), "Already claimed");
bytes32 leaf = keccak256(abi.encode(block.chainid, index, account, amount));
require(MerkleProof.verify(proof, merkleRoot, leaf), "Invalid proof");
_setClaimed(index);
token.transfer(account, amount);
}
function withdrawUnclaimed() external {
require(msg.sender == admin);
require(
block.timestamp > claimDeadline && block.timestamp <= withdrawalDeadline,
"Withdrawal window closed"
);
uint256 balance = token.balanceOf(address(this));
token.transfer(admin, balance);
}
}
Now:
- Users can claim up to claimDeadline
- Admin can withdraw unclaimed tokens between claimDeadline and withdrawalDeadline
- After withdrawalDeadline, the contract is locked — no more withdrawals possible
If the deadlines are immutable and validated at construction, no rug is possible.
Vulnerability 7: Large Airdrops and Sybil Resistance
For massive airdrops (millions of eligible addresses), even a merkle tree can be expensive to distribute. But the bigger problem is Sybil resistance: how do you determine who is eligible in the first place?
Off-chain eligibility check (common):
1. Snapshot blockchain state at block N
2. Build list of eligible addresses (e.g., Uniswap users before Nov 17, 2020)
3. Build merkle tree from that list
4. Deploy contract with merkle root
5. Users claim with merkle proofs
Problem: Sybil attacks. An attacker can create 1,000 addresses, transfer a small amount to each, and each address becomes eligible. If the eligibility criteria is "held at least 0.001 ETH at block N", Sybil attacks are trivial.
On-chain eligibility check (riskier):
// VULNERABLE: on-chain eligibility logic
contract OnChainAirdrop {
IERC20 public eligibilityToken;
function claim(uint256 amount) external {
// Claim based on current balance of eligibility token
uint256 balance = eligibilityToken.balanceOf(msg.sender);
require(balance > 0, "Not eligible");
token.transfer(msg.sender, amount);
}
}
This is extremely vulnerable to flash loan attacks. An attacker can flash borrow tokens, increase their balance, claim the airdrop, repay the loan — all in one transaction.
// Attack
contract AirdropAttacker {
IERC20 token;
FlashLoanProvider flashLoan;
address airdropContract;
function attack() external {
// Borrow eligibilityToken
flashLoan.flashLoan(this, eligibilityToken, amount);
}
function executeOperation(IERC20 token, uint256 amount, uint256 fee, bytes calldata) external {
// Now msg.sender has eligibilityToken balance
OnChainAirdrop(airdropContract).claim(amount); // Passes eligibility check
// Repay flash loan
token.approve(address(flashLoan), amount + fee);
flashLoan.repay();
}
}
Mitigation: Off-chain snapshot with merkle proof
The only reliable approach for large airdrops is off-chain snapshot:
- Determine eligibility using historical blockchain data (who held token X at block N)
- Build merkle tree from that snapshot
- Deploy contract with root
- Users claim with proofs — no on-chain check of eligibility
The merkle tree itself serves as proof. If you're in the tree, you're eligible. The moment of eligibility (block N) is in the past, so Sybil attacks created after that block are not eligible.
For true Sybil resistance (e.g., proof of humanity, unique identity verification), you need an oracle or on-chain identity protocol — but that's beyond the scope of a simple airdrop.
Complete Secure Airdrop Contract
Here's a complete, audited-pattern airdrop contract incorporating all the fixes above:
pragma solidity ^0.8.0;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {MerkleProof} from "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
contract SecureAirdrop {
bytes32 public immutable merkleRoot;
IERC20 public immutable token;
address public immutable admin;
uint256 public immutable claimDeadline;
uint256 public immutable withdrawalDeadline;
// Bitmap to prevent double-claiming
// index / 256 = word, index % 256 = bit
mapping(uint256 => uint256) private claimed;
event Claimed(uint256 indexed index, address indexed account, uint256 amount);
event Withdrawn(address indexed admin, uint256 amount);
constructor(
bytes32 _merkleRoot,
address _token,
uint256 _claimDeadline,
uint256 _withdrawalDeadline
) {
require(_merkleRoot != bytes32(0), "Invalid merkle root");
require(_token != address(0), "Invalid token");
require(_claimDeadline < _withdrawalDeadline, "Invalid deadlines");
require(_claimDeadline > block.timestamp, "Deadline in past");
merkleRoot = _merkleRoot;
token = IERC20(_token);
admin = msg.sender;
claimDeadline = _claimDeadline;
withdrawalDeadline = _withdrawalDeadline;
}
function isClaimed(uint256 index) public view returns (bool) {
uint256 wordIndex = index / 256;
uint256 bitIndex = index % 256;
uint256 word = claimed[wordIndex];
uint256 mask = 1 << bitIndex;
return (word & mask) != 0;
}
function _setClaimed(uint256 index) private {
uint256 wordIndex = index / 256;
uint256 bitIndex = index % 256;
claimed[wordIndex] |= (1 << bitIndex);
}
function claim(
uint256 index,
address account,
uint256 amount,
bytes32[] calldata proof
) external {
// Check deadline
require(block.timestamp <= claimDeadline, "Claim window closed");
// Enforce claim to sender's address
require(msg.sender == account, "Can only claim for yourself");
// Check if already claimed
require(!isClaimed(index), "Already claimed");
// Verify merkle proof with chainId included
bytes32 leaf = keccak256(abi.encode(
block.chainid,
index,
account,
amount
));
require(MerkleProof.verify(proof, merkleRoot, leaf), "Invalid proof");
// Mark as claimed
_setClaimed(index);
// Transfer tokens
require(token.transfer(account, amount), "Transfer failed");
emit Claimed(index, account, amount);
}
function withdrawUnclaimed() external {
require(msg.sender == admin, "Only admin");
require(
block.timestamp > claimDeadline && block.timestamp <= withdrawalDeadline,
"Withdrawal window not open"
);
uint256 balance = token.balanceOf(address(this));
require(balance > 0, "Nothing to withdraw");
require(token.transfer(admin, balance), "Withdrawal failed");
emit Withdrawn(admin, balance);
}
}
Key security properties:
- No double-claiming: Bitmap prevents claiming the same (index, account, amount) twice
- No merkle collisions:
abi.encode()with fixed-size args prevents hash collisions - No cross-chain replay:
block.chainidin leaf hash ensures proofs don't work on other chains - No front-running:
msg.sender == accountenforces claiming to your own address - No admin rug:
claimDeadlineandwithdrawalDeadlineare immutable and enforced - No locked tokens: Withdrawal window ensures unclaimed tokens can eventually be recovered
What Auditors Check for Airdrop Contracts
| Vulnerability | Detection |
|---|---|
| Missing double-claim protection | ✅ Slither detects unchecked loops in claim() |
| Merkle collision via abi.encodePacked | ⚠️ Semgrep can catch this; AI engines flag it reliably |
| Cross-chain replay (same root, no chainId) | ❌ Hard to detect statically; requires cross-chain context |
Front-running attack via parameter to |
✅ AI engines can flag controllable recipient |
| No deadline enforcement | ✅ Easy to find: just search for missing block.timestamp checks |
| Unchecked transfer return value | ✅ Slither and Semgrep both flag this |
| Admin withdrawal without deadline | ✅ Data flow analysis can find this |
Testing Airdrop Contracts
Key test cases for airdrop contracts:
// Test double-claim prevention
function testCannotClaimTwice() external {
claim(0, user, 100, proof);
vm.expectRevert("Already claimed");
claim(0, user, 100, proof);
}
// Test cross-chain replay prevention
function testCrossChainReplayFails() external {
// Simulate different chain
vm.chainId(999);
vm.expectRevert("Invalid proof");
claim(0, user, 100, proof); // proof was for chainId 1
}
// Test front-running prevention
function testCannotClaimForOthers() external {
vm.prank(attacker);
vm.expectRevert("Can only claim for yourself");
claim(0, victim, 100, proof);
}
// Test deadline enforcement
function testCannotClaimAfterDeadline() external {
vm.warp(claimDeadline + 1);
vm.expectRevert("Claim window closed");
claim(0, user, 100, proof);
}
// Test admin rug prevention
function testAdminCannotWithdrawDuringClaim() external {
vm.prank(admin);
vm.expectRevert("Withdrawal window not open");
withdrawUnclaimed();
}
Detection: What Automated Scanners Find
| Issue | Slither | Semgrep | AI |
|---|---|---|---|
| Missing claimed[] bitmap or mapping | ⚠️ | ❌ | ✅ |
| abi.encodePacked with variable-length args | ⚠️ | ✅ | ✅ |
| Missing block.chainid in leaf hash | ❌ | ❌ | ✅ |
| Claim to a parameter instead of msg.sender | ✅ | ⚠️ | ✅ |
| No deadline on claim() | ❌ | ❌ | ✅ |
| Unchecked transfer return value | ✅ | ✅ | ✅ |
| Admin withdrawal without deadline check | ⚠️ | ❌ | ✅ |
Real-World Airdrop Exploits
Optimism Airdrop (2022): Missing Sybil resistance on bridged transactions — attackers created fake transaction histories to claim multiple times. The contract had no uniqueness enforcement per address.
Arbitrum Airdrop (2023): Flash loan vulnerability in eligibility check — users could flash borrow governance tokens, qualify for airdrop, and repay in the same block. Mitigated by off-chain snapshot, but on-chain eligibility calls would have been exploited.
Polygon Airdrop (2021): Cross-chain replay — same merkle root used on Ethereum and Polygon. Users could claim on both chains with the same proof, doubling their allocation.
CTA
Airdrop contracts are complex, and the attack surface is large. Before deploying, scan your contract with ContractScan — the AI-powered engine specifically flags merkle tree vulnerabilities, missing deadline checks, and cross-chain replay issues that traditional static analysis misses.
Related: Token Approval Security: The Infinite Allowance Problem — airdrops often require token approvals; understand the risks. ERC20 Token Security Vulnerabilities — many airdrop bugs stem from assumptions about ERC-20 behavior.
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.