Snowman Merkle Airdrop

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

Signature Replay Vulnerability Due to Dynamic Message Hash

Root + Impact

Description

  • Describe the normal behavior in one or more sentences
    The `getMessageHash()` function generates a hash based on the current balance of the receiver. If a user's balance changes and then returns to the original value, an old signature could potentially be reused, though the claim status flag provides some protection.

  • Explain the specific issue or problem in one or more sentences
    The message hash is dynamically generated based on the current balance, which means the same balance value will produce the same hash.

```solidity
// @> SnowmanAirdrop.sol:112-122
function getMessageHash(address receiver) public view returns (bytes32) {
if (i_snow.balanceOf(receiver) == 0) {
revert SA__ZeroAmount();
}
uint256 amount = i_snow.balanceOf(receiver);
return _hashTypedDataV4(
keccak256(abi.encode(MESSAGE_TYPEHASH, SnowmanClaim({receiver: receiver, amount: amount})))
);
}
```
If a user signs a message with balance X, then their balance changes, and later returns to X, the same signature would be valid again. However, the `s_hasClaimedSnowman` mapping prevents actual replay in most cases.

Risk

Likelihood:

  • * Requires balance to return to exact previous value

    * User must not have claimed yet (first time)

    * Lower likelihood due to claim status protection

    * Could occur if claim status is reset or in edge cases

Impact:

  • * Potential signature reuse if claim status is bypassed

    * Theoretical vulnerability even if mitigated by other checks

    * Could be exploited if other vulnerabilities allow bypassing claim status

Proof of Concept

```solidity
function testSignatureReplay() public {
address user = makeAddr("user");
// User has 1 Snow, signs message
vm.prank(user);
snow.earnSnow();
bytes32 digest1 = airdrop.getMessageHash(user);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(userKey, digest1);
// User transfers away Snow
vm.prank(user);
snow.transfer(bob, 1);
// User earns Snow again (balance returns to 1)
vm.warp(block.timestamp + 1 weeks);
vm.prank(user);
snow.earnSnow();
bytes32 digest2 = airdrop.getMessageHash(user);
// digest1 == digest2 (same balance, same hash)
// Old signature is still valid for new balance
// But claim status prevents actual replay
}
```

Recommended Mitigation

```diff
// SnowmanAirdrop.sol
+ mapping(address => uint256) private s_nonces;
function getMessageHash(address receiver) public view returns (bytes32) {
if (i_snow.balanceOf(receiver) == 0) {
revert SA__ZeroAmount();
}
uint256 amount = i_snow.balanceOf(receiver);
+ uint256 nonce = s_nonces[receiver];
return _hashTypedDataV4(
- keccak256(abi.encode(MESSAGE_TYPEHASH, SnowmanClaim({receiver: receiver, amount: amount})))
+ keccak256(abi.encode(MESSAGE_TYPEHASH, SnowmanClaim({receiver: receiver, amount: amount}), nonce))
);
}
function claimSnowman(...) external nonReentrant {
// ... existing code ...
s_hasClaimedSnowman[receiver] = true;
+ s_nonces[receiver]++;
// ... rest of function ...
}
```
Also update the MESSAGE_TYPEHASH:
```diff
- bytes32 private constant MESSAGE_TYPEHASH = keccak256("SnowmanClaim(addres receiver, uint256 amount)");
+ bytes32 private constant MESSAGE_TYPEHASH = keccak256("SnowmanClaim(address receiver, uint256 amount, uint256 nonce)");
```
Updates

Lead Judging Commences

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