Snowman Merkle Airdrop

AI First Flight #10
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Impact: medium
Likelihood: medium
Invalid

Balance Manipulation Between Reads in claimSnowman()

Root + Impact

Description

  • Describe the normal behavior in one or more sentences
    The `claimSnowman()` function reads the user's Snow token balance twice at different points in execution. If the balance changes between these reads (e.g., through a transfer), the amount used for minting may not match the amount validated in the Merkle proof, leading to incorrect NFT distribution.

  • Explain the specific issue or problem in one or more sentences
    The function checks the balance early to ensure it's non-zero, then reads it again later to determine how many NFTs to mint. Between these two reads, the balance could change.

```solidity
// @> SnowmanAirdrop.sol:76-78
if (i_snow.balanceOf(receiver) == 0) {
revert SA__ZeroAmount();
}
```
```solidity
// @> SnowmanAirdrop.sol:84
uint256 amount = i_snow.balanceOf(receiver); // @> Second read - balance may have changed
```
The Merkle proof is validated with the amount from the second read, but if the balance changed between the first check and this read, the proof validation might pass for a different amount than what gets minted.

Risk

Likelihood:

  • * Users can transfer Snow tokens between the two balance reads

    * Flash loan attacks could temporarily manipulate balances

    * Reentrancy (though guarded) or external calls could change state

    * Occurs when users have multiple transactions pending

Impact:

  • * Incorrect number of NFTs minted relative to Merkle proof

    * Potential for minting more or fewer NFTs than intended

    * Mismatch between staked amount and received NFTs

    * Accounting inconsistencies

Proof of Concept

```solidity
function testBalanceManipulation() public {
SnowmanAirdrop airdrop = deployAirdrop();
address alice = makeAddr("alice");
address bob = makeAddr("bob");
// Alice has 1 Snow token
vm.prank(alice);
snow.earnSnow();
// Alice approves airdrop
vm.prank(alice);
snow.approve(address(airdrop), 1);
// Get signature for balance of 1
bytes32 digest = airdrop.getMessageHash(alice);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(aliceKey, digest);
// Before claiming, Alice transfers her Snow to Bob
vm.prank(alice);
snow.transfer(bob, 1);
// Now when claimSnowman is called:
// First check: balance is 0, should revert
// But if we modify the flow, or if there's a race condition...
// Alternatively, if balance increases between reads:
vm.prank(alice);
snow.earnSnow(); // Now has 1 again
// But Merkle proof was for original balance
// This could cause issues
}
```

Recommended Mitigation

```diff
// SnowmanAirdrop.sol
function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
external
nonReentrant
{
if (receiver == address(0)) {
revert SA__ZeroAddress();
}
+ // Read balance once and use throughout
+ uint256 amount = i_snow.balanceOf(receiver);
+
- if (i_snow.balanceOf(receiver) == 0) {
+ if (amount == 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))));
if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) {
revert SA__InvalidProof();
}
i_snow.safeTransferFrom(receiver, address(this), amount);
s_hasClaimedSnowman[receiver] = true;
emit SnowmanClaimedSuccessfully(receiver, amount);
i_snowman.mintSnowman(receiver, amount);
}
```
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 17 days 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!