Snowman Merkle Airdrop

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

Arbitrary Token Drain via Unrestricted safeTransferFrom in SnowAirdrop.sol::claimSnowman()

Arbitrary receiver Used in transferFrom of SnowmanAirdrop.sol::claimSnowman() - Enables Unauthorized Token Drain

Description

  • The claimSnowman() function should allow approved user to claim an NFT by transferring their SNOW tokens to the contract once, with proper authorization checks

  • The function uses an arbitrary receiver parameter in safeTransferFrom() , allowing anyone to drain SNOW tokens from any address that has approved the contract, regardless of the victim's intent

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();
}
// @report-written - arbitrary-send-erc20(Use msg.sender as from in transferFrom.)
@> 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);
}

Risk

Likelihood:

  • Users commonly approve token allowances - Most interactiosn witj Defi protocols require token approvals, making this attack surface always present

  • Attack requires no special conditions - Exploitable whenever any user approves the contract, regardless of their NFT claim intent

Impact:

  • Complete fund drain - Attackers can drain all approved SNOW tokens from victims in a single transactions

  • Permananet loss - Stolen tokens are irrecoverable once trsanferred, violating user trust in the protocol

Proof of Concept

This below test demonstrates that, attacker can drain all approved SNOW tokens of

eli, regardless of NFT claim intent.

function testUnintendedNFTClaimByAttackerToDrainVictimFunds() public {
address attacker = makeAddr("attacker");
vm.deal(attacker, 1 ether);
vm.prank(eli);
snow.approve(address(airdrop), 1);
bytes32 eliDigest = airdrop.getMessageHash(eli);
vm.prank(attacker);
(uint8 eliV, bytes32 eliR, bytes32 eliS) = vm.sign(eliKey, eliDigest);
// This is not intended to be called by the eli, but attacker tries to drain the eli funds
airdrop.claimSnowman(eli, ELI_PROOF, eliV, eliR, eliS);
assert(nft.balanceOf(eli) == 1);
}

Recommended Mitigation

The original claimSnowman() function allowed any caller to drain SNOW tokens from any receiver address that had approved the contract, regardless of whether the receiver intended to claim an NFT.

Solution:

  1. Removing the ability for attackers to arbitrarily specify a victim's address

  2. Ensuring only the caller (msg.sender) can transfer their own tokens.

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); // send tokens to contract... akin to burning
+ i_snow.safeTransferFrom(msg.sender, address(this), amount);
s_hasClaimedSnowman[receiver] = true;
emit SnowmanClaimedSuccessfully(receiver, amount);
i_snowman.mintSnowman(receiver, amount);
}
Updates

Lead Judging Commences

yeahchibyke Lead Judge
5 months ago
yeahchibyke Lead Judge 5 months ago
Submission Judgement Published
Invalidated
Reason: Design choice

Support

FAQs

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