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();
}
@> i_snow.safeTransferFrom(receiver, address(this), amount);
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);
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:
Removing the ability for attackers to arbitrarily specify a victim's address
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);
}