Snowman Merkle Airdrop

First Flight #42
Beginner FriendlyFoundrySolidityNFT
100 EXP
View results
Submission Details
Impact: high
Likelihood: high
Invalid

Unsafe `receiver` as EIP-712 Signer in `SnowmanAirdrop.sol::_isValidSignature()` function

[High] Unsafe receiver as EIP-712 Signer in SnowmanAirdrop.sol::_isValidSignature() function


Description

The _isValidSignature() function is intended to verify an EIP-712 signature. It constructs a message hash (getMessageHash) that includes the receiver address and an amount (derived from i_snow.balanceOf(receiver)). The critical flaw is that the signature is primarily bound only to the receiver and the amount, but not to the actual address (msg.sender) that calls the claimSnowman function. This creates a significant vulnerability for signature replay and phishing attacks.


Risk

  1. Replay Attacks: An attacker can intercept a valid signed message from a legitimate user (victim). Since the signature is not bound to the msg.sender (the caller), the attacker can then use this victim's signature to execute the claimSnowman function themselves, potentially controlling aspects of the transaction or timing, even if the NFT is ultimately minted to the victim. The provided Proof of Code demonstrates this perfectly.

  2. Phishing Risk: Attackers can trick a valid wallet holder into signing a seemingly innocuous message (e.g., for a "test claim"). Once signed, the attacker can then replay this signature to claim NFTs or tokens on the victim's behalf, or even for themselves if the receiver address in the calldata could be manipulated (though current getMessageHash prevents this for receiver).

  3. Loss of Expected Tokens/Control: While the current PoC shows the NFT minted for Alice, the attacker Satoshi gains the ability to trigger Alice's claims. In a more complex scenario, if the amount check was less strict, an attacker could potentially drain funds or claim more than intended.


Proof of Concept

The getMessageHash function uses abi.encode(MESSAGE_TYPEHASH, SnowmanClaim({receiver: receiver, amount: amount})) to create the hash. Notably, msg.sender (the caller) is absent from this hash.

The provided test_ReplaySignature_Phishing_BySatoshi() in TestSnowmanAirdrop.t.sol vividly demonstrates this:

function test_ReplaySignature_Phishing_BySatoshi() public {
// Setup: Alice is the victim who signs a message
(alice, alKey) = makeAddrAndKey("alice");
vm.deal(alice, 1 ether);
vm.prank(alice);
snow.approve(address(airdrop), 1);
// Alice signs a valid digest
bytes32 digest = airdrop.getMessageHash(alice); // Digest does NOT include msg.sender
(uint8 v, bytes32 r, bytes32 s) = vm.sign(alKey, digest);
// Attacker Satoshi calls the claim using Alice's signed message
vm.prank(satoshi); // Satoshi is NOT Alice, but calls the function
airdrop.claimSnowman(alice, AL_PROOF, v, r, s); // Satoshi successfully calls on Alice's behalf
// Assert that NFT is minted FOR Alice, even though SATOSHI called
assertEq(nft.balanceOf(alice), 1);
assertEq(nft.ownerOf(0), alice);
// Assert attacker spent no tokens
assertEq(snow.balanceOf(satoshi), 0);
console2.log("Replay Attack worked: Satoshi claimed on behalf of Alice using signature.");
}

Recommended Mitigation

To prevent signature replay attacks and bind the signature to the transaction executor, always include msg.sender in the EIP-712 typed data hash. Additionally, consider implementing a unique, contract-managed nonce for each signature to prevent replay attacks even if msg.sender is compromised or bypassed.

  1. Bind to msg.sender: Modify the SnowmanClaim struct and getMessageHash to include the expected msg.sender of the claimSnowman function.

  2. Nonce-based Replay Protection: Implement a mapping (mapping(bytes32 => bool) public s_usedNonces;) to track used nonces. Each signature should include a unique nonce, which is marked as used after a successful claim.

+// Update the struct to include msg.sender and a nonce for stronger binding
+struct SnowmanClaim {
+ address receiver;
+ uint256 amount;
+ uint256 nonce; // Unique nonce for replay protection
+ address caller; // The address expected to call claimSnowman
+}
function getMessageHash(address receiver, uint256 amount, uint256 nonce, address caller) public view returns (bytes32) {
- if (i_snow.balanceOf(receiver) == 0) {
- revert SA__ZeroAmount();
- }
- uint256 amount = i_snow.balanceOf(receiver); // This amount should come from the snapshot, not live balance (refer to H-9)
return _hashTypedDataV4(
- keccak256(abi.encode(MESSAGE_TYPEHASH, SnowmanClaim({receiver: receiver, amount: amount})))
+ keccak256(abi.encode(MESSAGE_TYPEHASH, SnowmanClaim({receiver: receiver, amount: amount, nonce: nonce, caller: caller})))
);
}
function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s, uint256 nonce)
external
nonReentrant
{
+ require(!s_hasClaimedSnowman[receiver], "SA__AlreadyClaimed"); // From M-2 mitigation
+ require(!s_usedNonces[nonce], "SA__NonceAlreadyUsed"); // Check if nonce has been used
if (receiver == address(0)) {
revert SA__ZeroAddress();
}
- if (i_snow.balanceOf(receiver) == 0) { // This check should be re-evaluated based on H-9
- revert SA__ZeroAmount();
- }
- uint256 amount = i_snow.balanceOf(receiver); // Problematic derivation of amount for Merkle Proof (refer to H-9)
+ // The 'amount' should be explicitly passed as an argument, derived from the off-chain snapshot.
+ // For this mitigation, we assume an 'expectedAmount' argument is added to claimSnowman.
+ uint256 expectedAmount = amount; // Replace 'amount' with the passed 'expectedAmount' argument
- if (!_isValidSignature(receiver, getMessageHash(receiver), v, r, s)) {
+ if (!_isValidSignature(receiver, getMessageHash(receiver, expectedAmount, nonce, msg.sender), v, r, s)) {
revert SA__InvalidSignature();
}
- bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, amount)))); // Problematic for H-9
+ bytes32 leaf = keccak256(abi.encode(receiver, expectedAmount)); // Use the expectedAmount for leaf generation
if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) {
revert SA__InvalidProof();
}
- i_snow.safeTransferFrom(receiver, address(this), amount); // send tokens to contract... akin to burning (refer to H-8)
+ i_snow.safeTransferFrom(receiver, address(this), expectedAmount); // Using expectedAmount (refer to H-8 for flow clarification)
+ s_usedNonces[nonce] = true; // Mark nonce as used after successful claim
s_hasClaimedSnowman[receiver] = true; // This check now comes *after* successful claim logic
- emit SnowmanClaimedSuccessfully(receiver, amount);
+ emit SnowmanClaimedSuccessfully(receiver, expectedAmount);
- i_snowman.mintSnowman(receiver, amount);
+ i_snowman.mintSnowman(receiver, expectedAmount);
}
Updates

Lead Judging Commences

yeahchibyke Lead Judge 3 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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