AirDropper

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

No ERC20 recovery path in MerkleAirdrop (immutable root and token, no sweep, setter or deadline) turns tokens stranded by a misconfigured deployment, or sent by mistake, into a permanent loss

Root + Impact

Description

  • An airdrop contract normally lets its operator recover tokens it cannot distribute, either through a claim deadline followed by an owner sweep or through an owner-only root setter. This contract even declares a MerkleRootUpdated event (L20).

  • i_airdropToken and i_merkleRoot are immutable (L16-L17). The deployed dispatcher exposes exactly eight functions: claim, claimFees, getMerkleRoot, getAirdropToken, getFee, plus owner, transferOwnership and renounceOwnership from Ownable. The only ERC20 outflow is claim (L39). claimFees (L42-L47) moves only ETH. MerkleRootUpdated is never emitted: its topic hash is absent from the runtime bytecode. Any ERC20 balance that the fixed root and token cannot pay out therefore has no exit. That includes the 100 USDC stranded by either of the two deploy-script misconfigurations reported separately, and any other ERC20 sent to the contract by mistake.

// src/MerkleAirdrop.sol L16-L20: token and root are fixed at construction; MerkleRootUpdated is declared but never emitted
@> IERC20 private immutable i_airdropToken; // L16
@> bytes32 private immutable i_merkleRoot; // L17
​
event Claimed(address account, uint256 amount);
@> event MerkleRootUpdated(bytes32 newMerkleRoot); // L20
​
// src/MerkleAirdrop.sol L39: the only ERC20 outflow in the contract
i_airdropToken.safeTransfer(account, amount); // L39
​
// src/MerkleAirdrop.sol L42-L47: the owner's only privileged function moves ETH, never an ERC20
function claimFees() external onlyOwner {
@> (bool succ,) = payable(owner()).call{ value: address(this).balance }(""); // L43
if (!succ) {
revert MerkleAirdrop__TransferFailed();
}
}

Risk

Likelihood:

  • The lock applies whenever the deployed root or token cannot pay out the funded balance. The in-scope Deploy.s.sol produces this in two independent ways: the token address at L8 differs from the one at L18, and the 25e18 root at L9 does not match the 100e6 funding at L11.

  • It also applies when an ERC20 other than i_airdropToken is sent to the contract by mistake. Both cases start from a mistake by a trusted deployer or by a user, so likelihood is Low.

Impact:

  • Stranded tokens are permanently locked. In the PoC, the owner's claimFees, ten common rescue/setter selectors, and a transfer of ownership to a new owner all recover nothing. That leaves 100e6 locked under the wrong-token configuration, 100e6 under the wrong-root configuration, and 1000e6 of a foreign token sent by mistake.

  • This finding causes no loss on its own. It is the reason the separate deploy misconfigurations become a permanent loss instead of a recover-and-redeploy. Unclaimed allocations of i_airdropToken are not stuck, because the missing claimed-state check lets a valid leaf be replayed to drain them, and the PoC shows this. A surplus of i_airdropToken is drained the same way in leaf-sized chunks, so only a remainder smaller than one leaf stays locked: 10e6 of a 60e6 surplus in the PoC.

Proof of Concept

The PoC is self-contained. It uses an inline 6-decimal mock token and one- and two-leaf Merkle trees built in the test. It shows the following:

  • It parses the deployed runtime bytecode for its PUSH4 <selector> EQ dispatcher entries and finds exactly the eight expected functions.

  • It shows that Claimed's topic is in the bytecode and MerkleRootUpdated's is not.

  • Under three configurations, the owner tries every recovery route and fails: the Deploy.s.sol wrong-token addresses, a 25e18 root funded with 100e6, and a foreign ERC20 sent by mistake.

  • It records the two narrowings stated above: an unclaimed allocation is drained by replay, and only sub-leaf dust of a surplus stays locked.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
​
import { Test, console2 } from "forge-std/Test.sol";
import { Vm } from "forge-std/Vm.sol";
import { MerkleAirdrop, IERC20 } from "../src/MerkleAirdrop.sol";
import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol";
​
/// 6-decimal stand-in for USDC, open mint (test-only).
contract MockUSDC6 is ERC20 {
constructor() ERC20("USD Coin", "USDC") { }
​
function decimals() public pure override returns (uint8) {
return 6;
}
​
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
}
​
/// AD-04: no ERC20 recovery path (immutable token + root, no sweep, no deadline, no setter).
contract PoC_AD_04 is Test {
// Literals verbatim from script/Deploy.s.sol
address constant DEPLOY_TOKEN_ARG = 0x1D17CbCf0D6d143135be902365d2e5E2a16538d4; // L8 -> i_airdropToken
address constant DEPLOY_FUNDED_TOKEN = 0x1d17CBcF0D6D143135aE902365D2E5e2A16538D4; // L18 -> funded
uint256 constant DEPLOY_FUNDING = 4 * (25 * 1e6); // L11
​
uint256 constant FEE = 1e9;
uint256 constant ALLOC = 25 * 1e6;
​
address owner = makeAddr("owner");
address alice = makeAddr("alice");
address bob = makeAddr("bob");
​
bytes32 constant ROOT_UPDATED_TOPIC = keccak256("MerkleRootUpdated(bytes32)");
bytes32 constant CLAIMED_TOPIC = keccak256("Claimed(address,uint256)");
​
function setUp() public {
vm.deal(alice, 10 ether);
vm.deal(bob, 10 ether);
}
​
// ------------------------------------------------------------------ helpers
function _leaf(address account, uint256 amount) internal pure returns (bytes32) {
return keccak256(bytes.concat(keccak256(abi.encode(account, amount))));
}
​
function _hashPair(bytes32 a, bytes32 b) internal pure returns (bytes32) {
return a < b ? keccak256(abi.encodePacked(a, b)) : keccak256(abi.encodePacked(b, a));
}
​
function _deploy(bytes32 root, address token) internal returns (MerkleAirdrop d) {
vm.prank(owner);
d = new MerkleAirdrop(root, IERC20(token));
}
​
/// Collect every selector the runtime dispatcher compares with EQ (PUSH4 <sel> EQ).
function _dispatcherSelectors(address target) internal view returns (bytes4[] memory out, uint256 n) {
bytes memory code = target.code;
uint256 len = code.length;
// strip CBOR metadata (last 2 bytes = metadata length)
uint256 meta = (uint256(uint8(code[len - 2])) << 8) | uint256(uint8(code[len - 1]));
uint256 end = len - meta - 2;
out = new bytes4[](64);
for (uint256 i = 0; i < end; i++) {
uint8 op = uint8(code[i]);
if (op == 0x63 && i + 5 < end && uint8(code[i + 5]) == 0x14) {
out[n++] = bytes4(
(uint32(uint8(code[i + 1])) << 24) | (uint32(uint8(code[i + 2])) << 16)
| (uint32(uint8(code[i + 3])) << 8) | uint32(uint8(code[i + 4]))
);
}
if (op >= 0x60 && op <= 0x7f) i += op - 0x5f; // skip PUSH data
}
}
​
function _codeContains(bytes memory code, bytes32 needle) internal pure returns (bool) {
if (code.length < 32) return false;
for (uint256 i = 0; i + 32 <= code.length; i++) {
bytes32 w;
assembly {
w := mload(add(add(code, 32), i))
}
if (w == needle) return true;
}
return false;
}
​
/// The owner exercises every non-claim state-changing function plus common rescue/setter selectors.
/// Returns how many rescue/setter attempts succeeded (expected: 0).
function _ownerTriesToRecover(MerkleAirdrop d, address token) internal returns (uint256 succeeded) {
bytes[] memory attempts = new bytes[](10);
attempts[0] = abi.encodeWithSignature("sweep(address)", token);
attempts[1] = abi.encodeWithSignature("sweep(address,address)", token, owner);
attempts[2] = abi.encodeWithSignature("rescueTokens(address,uint256)", token, 1);
attempts[3] = abi.encodeWithSignature("recoverERC20(address,uint256)", token, 1);
attempts[4] = abi.encodeWithSignature("withdraw()");
attempts[5] = abi.encodeWithSignature("withdrawTokens(address)", token);
attempts[6] = abi.encodeWithSignature("emergencyWithdraw(address)", token);
attempts[7] = abi.encodeWithSignature("setMerkleRoot(bytes32)", bytes32(uint256(1)));
attempts[8] = abi.encodeWithSignature("updateMerkleRoot(bytes32)", bytes32(uint256(1)));
attempts[9] = abi.encodeWithSignature("setAirdropToken(address)", token);
​
vm.startPrank(owner);
d.claimFees(); // the owner's only privileged function: moves ETH only
for (uint256 i = 0; i < attempts.length; i++) {
(bool ok,) = address(d).call(attempts[i]);
if (ok) succeeded++;
}
// transferOwnership to a fresh owner gives no new power either
address newOwner = makeAddr("newOwner");
d.transferOwnership(newOwner);
vm.stopPrank();
vm.prank(newOwner);
d.claimFees();
}
​
// ------------------------------------------------------------------ 0. external surface
/// The full external ABI (read from the deployed dispatcher) is claim, claimFees, three getters and
/// the inherited Ownable trio. No sweep, no setter, no deadline; MerkleRootUpdated is never emitted.
function test_Surface_OnlyClaimMovesErc20_AndRootUpdatedNeverEmitted() public {
MockUSDC6 usdc = new MockUSDC6();
MerkleAirdrop d = _deploy(_leaf(alice, ALLOC), address(usdc));
​
(bytes4[] memory sels, uint256 n) = _dispatcherSelectors(address(d));
bytes4[8] memory expected = [
MerkleAirdrop.claim.selector,
MerkleAirdrop.claimFees.selector,
MerkleAirdrop.getMerkleRoot.selector,
MerkleAirdrop.getAirdropToken.selector,
MerkleAirdrop.getFee.selector,
Ownable.owner.selector,
Ownable.transferOwnership.selector,
Ownable.renounceOwnership.selector
];
console2.log("dispatcher selector count:", n);
assertEq(n, 8, "dispatcher has exactly 8 external functions");
for (uint256 i = 0; i < n; i++) {
bool found;
for (uint256 j = 0; j < 8; j++) {
if (sels[i] == expected[j]) found = true;
}
console2.logBytes4(sels[i]);
assertTrue(found, "unexpected selector in dispatcher");
}
​
// Event topic of Claimed is embedded (emitted); MerkleRootUpdated's topic is not (never emitted).
assertTrue(_codeContains(address(d).code, CLAIMED_TOPIC), "Claimed topic present (method sanity)");
assertFalse(_codeContains(address(d).code, ROOT_UPDATED_TOPIC), "MerkleRootUpdated never emitted");
​
// Root and token getters are fixed for the lifetime of the contract (immutables in runtime code).
assertEq(d.getMerkleRoot(), _leaf(alice, ALLOC));
assertEq(address(d.getAirdropToken()), address(usdc));
}
​
// ------------------------------------------------------------------ baseline
/// Intended behaviour: a valid leaf claims its allocation; this is the only ERC20 outflow.
function test_Baseline_ValidClaimPaysOut() public {
MockUSDC6 usdc = new MockUSDC6();
MerkleAirdrop d = _deploy(_leaf(alice, ALLOC), address(usdc)); // one-leaf tree: root == leaf
usdc.mint(address(d), ALLOC);
​
vm.prank(alice);
d.claim{ value: FEE }(alice, ALLOC, new bytes32[](0));
assertEq(usdc.balanceOf(alice), ALLOC);
assertEq(usdc.balanceOf(address(d)), 0);
}
​
// ------------------------------------------------------------------ limb 1: wrong token funded
/// Deploy.s.sol passes L8's address as i_airdropToken but funds L18's (different) address.
/// The funded 100 USDC can be moved by nobody: claim() only touches i_airdropToken, and the owner has no sweep.
function test_Limb1_WrongTokenFunded_PermanentlyLocked() public {
assertTrue(DEPLOY_TOKEN_ARG != DEPLOY_FUNDED_TOKEN, "Deploy.s.sol uses two different addresses");
​
// Put a working ERC20 at the funded address (L18); L8's address is left without code.
MockUSDC6 impl = new MockUSDC6();
vm.etch(DEPLOY_FUNDED_TOKEN, address(impl).code);
MockUSDC6 funded = MockUSDC6(DEPLOY_FUNDED_TOKEN);
​
MerkleAirdrop d = _deploy(_leaf(alice, ALLOC), DEPLOY_TOKEN_ARG);
funded.mint(address(d), DEPLOY_FUNDING); // Deploy.s.sol L18 transfer
assertEq(funded.balanceOf(address(d)), DEPLOY_FUNDING);
​
// A valid recipient cannot claim (claim targets the unfunded/no-code token).
vm.prank(alice);
vm.expectRevert();
d.claim{ value: FEE }(alice, ALLOC, new bytes32[](0));
​
uint256 ok = _ownerTriesToRecover(d, DEPLOY_FUNDED_TOKEN);
assertEq(ok, 0, "no rescue/setter call succeeded");
assertEq(address(d.getAirdropToken()), DEPLOY_TOKEN_ARG, "token cannot be changed");
assertEq(funded.balanceOf(address(d)), DEPLOY_FUNDING, "100 USDC still locked in the airdrop");
assertEq(funded.balanceOf(owner), 0, "owner recovered nothing");
console2.log("locked funded-token balance:", funded.balanceOf(address(d)));
}
​
// ------------------------------------------------------------------ limb 2: wrong amount in root
/// Root commits 25e18 while the contract is funded with 6-decimal USDC (100e6).
/// Nobody can claim, the owner can neither fix the root nor reclaim the 100 USDC.
function test_Limb2_WrongRootAmount_PermanentlyLocked() public {
MockUSDC6 usdc = new MockUSDC6();
bytes32 badRoot = _leaf(alice, 25 * 1e18);
MerkleAirdrop d = _deploy(badRoot, address(usdc));
usdc.mint(address(d), DEPLOY_FUNDING);
​
vm.startPrank(alice);
vm.expectRevert(); // ERC20InsufficientBalance: 25e18 > 100e6
d.claim{ value: FEE }(alice, 25 * 1e18, new bytes32[](0));
vm.expectRevert(MerkleAirdrop.MerkleAirdrop__InvalidProof.selector); // intended 25e6 is not in the tree
d.claim{ value: FEE }(alice, ALLOC, new bytes32[](0));
vm.stopPrank();
​
uint256 ok = _ownerTriesToRecover(d, address(usdc));
assertEq(ok, 0, "no rescue/setter call succeeded");
assertEq(d.getMerkleRoot(), badRoot, "root cannot be corrected");
assertEq(usdc.balanceOf(address(d)), DEPLOY_FUNDING, "100 USDC still locked");
assertEq(usdc.balanceOf(owner), 0);
console2.log("locked USDC under bad root:", usdc.balanceOf(address(d)));
}
​
// ------------------------------------------------------------------ limb 3: unclaimed allocation (NARROWED)
/// Limb 3 as written is FALSE for this code: with a working root, an unclaimed allocation is NOT stuck,
/// because claim() has no "claimed" bitmap and any valid leaf can be replayed to drain it.
function test_Limb3_UnclaimedAllocation_IsNotStuck_ReplayDrainsIt() public {
MockUSDC6 usdc = new MockUSDC6();
bytes32 la = _leaf(alice, ALLOC);
bytes32 lb = _leaf(bob, ALLOC);
MerkleAirdrop d = _deploy(_hashPair(la, lb), address(usdc));
usdc.mint(address(d), 2 * ALLOC);
​
bytes32[] memory proofA = new bytes32[](1);
proofA[0] = lb;
​
vm.startPrank(alice);
d.claim{ value: FEE }(alice, ALLOC, proofA);
// bob never claims; alice replays her own leaf and takes bob's 25 USDC
d.claim{ value: FEE }(alice, ALLOC, proofA);
vm.stopPrank();
​
assertEq(usdc.balanceOf(address(d)), 0, "unclaimed allocation was drained, not stuck");
assertEq(usdc.balanceOf(alice), 2 * ALLOC);
}
​
// ------------------------------------------------------------------ limb 4a: foreign ERC20 sent by mistake
function test_Limb4_ForeignErc20SentByMistake_PermanentlyLocked() public {
MockUSDC6 usdc = new MockUSDC6();
MockUSDC6 other = new MockUSDC6();
MerkleAirdrop d = _deploy(_leaf(alice, ALLOC), address(usdc));
usdc.mint(address(d), ALLOC);
other.mint(address(d), 1000e6); // sent by mistake
​
vm.prank(alice);
d.claim{ value: FEE }(alice, ALLOC, new bytes32[](0)); // claims never touch `other`
​
uint256 ok = _ownerTriesToRecover(d, address(other));
assertEq(ok, 0);
assertEq(other.balanceOf(address(d)), 1000e6, "foreign ERC20 locked forever");
assertEq(other.balanceOf(owner), 0);
}
​
// ------------------------------------------------------------------ limb 4b: surplus i_airdropToken (NARROWED)
/// A surplus of i_airdropToken is not wholly stuck: replay drains it to a leaf holder in leaf-sized chunks.
/// Only the remainder smaller than every leaf amount is permanently locked.
function test_Limb4_SurplusAirdropToken_ReplayDrains_OnlyDustLocked() public {
MockUSDC6 usdc = new MockUSDC6();
MerkleAirdrop d = _deploy(_leaf(alice, ALLOC), address(usdc));
usdc.mint(address(d), ALLOC + 60e6); // 60 USDC surplus
​
vm.startPrank(alice);
for (uint256 i = 0; i < 3; i++) {
d.claim{ value: FEE }(alice, ALLOC, new bytes32[](0));
}
vm.expectRevert(); // 10e6 < 25e6
d.claim{ value: FEE }(alice, ALLOC, new bytes32[](0));
vm.stopPrank();
​
uint256 ok = _ownerTriesToRecover(d, address(usdc));
assertEq(ok, 0);
assertEq(usdc.balanceOf(alice), 75e6, "surplus went to the leaf holder via replay");
assertEq(usdc.balanceOf(address(d)), 10e6, "only sub-leaf remainder is locked");
assertEq(usdc.balanceOf(owner), 0);
}
}

Run from the repository root, with the file saved as test/PoC_AD-04.t.sol:

FOUNDRY_FFI=false FOUNDRY_DISABLE_NIGHTLY_WARNING=1 forge test --match-path test/PoC_AD-04.t.sol -vv

Result: Suite result: ok. 7 passed; 0 failed; 0 skipped.

Recommended Mitigation

--- a/src/MerkleAirdrop.sol
+++ b/src/MerkleAirdrop.sol
@@ -11,13 +11,16 @@
error MerkleAirdrop__InvalidFeeAmount();
error MerkleAirdrop__InvalidProof();
error MerkleAirdrop__TransferFailed();
+ error MerkleAirdrop__ClaimWindowClosed();
+ error MerkleAirdrop__ClaimWindowOpen();
uint256 private constant FEE = 1e9;
IERC20 private immutable i_airdropToken;
bytes32 private immutable i_merkleRoot;
+ uint256 private constant CLAIM_PERIOD = 90 days;
+ uint256 private immutable i_claimDeadline;
event Claimed(address account, uint256 amount);
- event MerkleRootUpdated(bytes32 newMerkleRoot);
/*//////////////////////////////////////////////////////////////
FUNCTIONS
@@ -25,12 +28,16 @@
constructor(bytes32 merkleRoot, IERC20 airdropToken) Ownable(msg.sender) {
i_merkleRoot = merkleRoot;
i_airdropToken = airdropToken;
+ i_claimDeadline = block.timestamp + CLAIM_PERIOD;
}
function claim(address account, uint256 amount, bytes32[] calldata merkleProof) external payable {
if (msg.value != FEE) {
revert MerkleAirdrop__InvalidFeeAmount();
}
+ if (block.timestamp > i_claimDeadline) {
+ revert MerkleAirdrop__ClaimWindowClosed();
+ }
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(account, amount))));
if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) {
revert MerkleAirdrop__InvalidProof();
@@ -46,6 +53,14 @@
}
}
+ /// @notice Recover any ERC20 held by the contract; the airdrop token only after the claim window closes.
+ function sweep(IERC20 token, address to) external onlyOwner {
+ if (address(token) == address(i_airdropToken) && block.timestamp <= i_claimDeadline) {
+ revert MerkleAirdrop__ClaimWindowOpen();
+ }
+ token.safeTransfer(to, token.balanceOf(address(this)));
+ }
+
/*//////////////////////////////////////////////////////////////
VIEW AND PURE
//////////////////////////////////////////////////////////////*/
Updates

Lead Judging Commences

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