Snowman Merkle Airdrop

First Flight #42
Beginner FriendlyFoundrySolidityNFT
100 EXP
View results
Submission Details
Severity: medium
Valid

[H-4] `SnowmanAirdrop::claimSnowman()` Uses Current Token Balance For Merkle Proof Making It Susceptible to DoS

[H-4] SnowmanAirdrop::claimSnowman() Uses Current Token Balance For Merkle Proof Making It Susceptible to DoS

Description

  • In the normal case, users would earn Snow tokens through the Snow contract and would not have their balances changed after the farming phase. The current amount of Snow tokens a user would have when calling claimSnowman() would be the same as the amount that was used to generate the merkle tree.

  • However this would also make the function susceptible to DoS attacks as bad actors can manually transfer tokens after the farming phase to cause a mismatch between current amount and the amount used to generate the merkle tree, causing merkle proof to fail.

function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
external
nonReentrant
{
if (receiver == address(0)) {
revert SA__ZeroAddress();
}
if (i_snow.balanceOf(receiver) == 0) {
revert SA__ZeroAmount();
}
if (!_isValidSignature(receiver, getMessageHash(receiver), v, r, s)) {
revert SA__InvalidSignature();
}
@> uint256 amount = i_snow.balanceOf(receiver); // uses current token balance
@> bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, amount))));
@> if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) {
revert SA__InvalidProof();
}
i_snow.safeTransferFrom(receiver, address(this), amount); // send tokens to contract... akin to burning
s_hasClaimedSnowman[receiver] = true;
emit SnowmanClaimedSuccessfully(receiver, amount);
i_snowman.mintSnowman(receiver, amount);
}

Risk

Likelihood: High

  • It would be very simple for attackers to transfer ERC20 tokens manually if they want to cause a DoS to a particular address.

Impact: Medium

  • Users may not understand why their claims are failing, but it could also be solved by manually transferring excess tokens out of their wallets so that their balance aligns with the original balance.

Proof of Concept

The following test case shows how alice is no longer able to claim once bob transfers additional Snow tokens to her.

function testDenialOfService() public {
// Alice claim test
assert(nft.balanceOf(alice) == 0);
vm.prank(alice);
snow.approve(address(airdrop), 1);
// Get alice's digest
bytes32 alDigest = airdrop.getMessageHash(alice);
// alice signs a message
(uint8 alV, bytes32 alR, bytes32 alS) = vm.sign(alKey, alDigest);
// attacker sends an additional Snow token to Alice so that the merkle proof fails
vm.prank(bob);
snow.transfer(alice, 1);
assert(snow.balanceOf(alice) == 2);
assert(snow.balanceOf(bob) == 0);
// satoshi tries to claim on behalf of alice using her signed message
vm.prank(satoshi);
vm.expectRevert();
airdrop.claimSnowman(alice, AL_PROOF, alV, alR, alS);
assert(nft.balanceOf(alice) == 0);
}

Recommended Mitigation

The function should accept an additional variable for users to input how much tokens they own, which should correspond to the amount used to build the merkle tree with. The drawback of this is that users would need to track how many tokens they own at the end of the farming phase and before the airdrop.

- function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
+ function claimSnowman(address receiver, uint256 claimAmount, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
external
nonReentrant
{
if (receiver == address(0)) {
revert SA__ZeroAddress();
}
if (i_snow.balanceOf(receiver) == 0) {
revert SA__ZeroAmount();
}
if (!_isValidSignature(receiver, getMessageHash(receiver), v, r, s)) {
revert SA__InvalidSignature();
}
- uint256 amount = i_snow.balanceOf(receiver);
- bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, amount))));
+ bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, claimAmount))));
if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) {
revert SA__InvalidProof();
}
- i_snow.safeTransferFrom(receiver, address(this), amount); // send tokens to contract... akin to burning
+ i_snow.safeTransferFrom(receiver, address(this), claimAmount);
s_hasClaimedSnowman[receiver] = true;
- emit SnowmanClaimedSuccessfully(receiver, amount);
+ emit SnowmanClaimedSuccessfully(receiver, claimAmount);
- i_snowman.mintSnowman(receiver, amount);
+ i_snowman.mintSnowman(receiver, claimAmount);
}
Updates

Lead Judging Commences

yeahchibyke Lead Judge 3 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Invalid merkle-proof as a result of snow balance change before claim action

Claims use snow balance of receiver to compute the merkle leaf, making proofs invalid if the user’s balance changes (e.g., via transfers). Attackers can manipulate balances or frontrun claims to match eligible amounts, disrupting the airdrop.

Support

FAQs

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