Severity: Medium
Impact: A user can sign a message authorizing a claim for a specific amount of Snow tokens, but later execute the claim with a completely different amount. This undermines the integrity of the off-chain signature and can lead to unauthorized claims of larger quantities than intended.
Affected Contract: SnowmanAirdrop.sol
The claimSnowman function verifies a signature to prove that the receiver authorized the claim. However, the signed message includes the receiver's Snow balance at the time of signing, yet the actual claim uses the current Snow balance at the moment of the transaction. There is no check that the balance used during claiming matches the balance that was signed. Consequently, a user can sign once for a small balance, later increase their balance, and claim a much larger amount using the same signature.
The signature is created over a digest that includes the receiver's Snow balance as returned by getMessageHash:
The claimSnowman function then recovers the signer and checks that the signer is the receiver, but never uses the signed amount. Instead, it reads the current balance:
There is no requirement that amount == signedAmount. This means the signature only proves that the receiver authorized some claim, not a specific amount. If the user’s balance changes between signing and claiming (e.g., they buy more Snow tokens), they can claim a higher amount with an old signature.
The following scenario illustrates the vulnerability:
Alice holds 100 Snow. She signs a message (via getMessageHash) authorizing a claim for 100 tokens.
Alice later buys 900 more Snow, bringing her balance to 1000.
Alice calls claimSnowman with a valid Merkle proof for (Alice, 1000). The proof was presumably generated by the protocol for a snapshot where Alice’s balance was 1000. The signature from step 1 is still valid because it only checks that the signer is Alice, not the amount.
The contract uses Alice’s current balance (1000) to compute the leaf and to stake tokens. The old signature (intended for 100) passes the check, and Alice receives 1000 Snowman NFTs instead of 100.
A Foundry test can confirm this by:
Setting up a Merkle tree with leaves for both (Alice, 100) and (Alice, 1000).
Having Alice sign a message when her balance is 100.
Changing her balance to 1000 (by minting more tokens to her).
Calling claimSnowman with the signature from the 100-case but with the proof for 1000.
Observing that the transaction succeeds and she receives 1000 NFTs.
Integrity of signature-based claims is broken: The signature is supposed to authenticate a specific amount, but it is effectively a blank check for any amount.
Potential for abuse with balance manipulation: Users can intentionally sign when their balance is low, then inflate it (via purchases or transfers) to claim more NFTs than they originally committed to.
Undermines the off-chain coordination: The protocol’s design assumes that the signed message locks in a certain distribution amount, but the contract does not enforce this.
The signed amount must be passed as a parameter to claimSnowman and verified against the actual balance used. The function signature should be changed to include the amount that was signed, and the digest should be reconstructed using that parameter.
For example:
Additionally, the Merkle leaf should be built from the same amount to ensure consistency. This change ensures that the signature is bound to a specific amount and cannot be reused for a different balance.
# 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 ... } ```
The contest is live. Earn rewards by submitting a finding.
Submissions are being reviewed by our AI judge. Results will be available in a few minutes.
View all submissionsThe contest is complete and the rewards are being distributed.