Root + Impact
Description
The claimSnowman function allows any caller to transfer SNOW tokens from any receiver address to the contract, provided
the caller can supply a valid signature and Merkle proof for that receiver. While the function includes signature verification and
Merkle proof validation, the receiver parameter is user-controlled and not tied to the caller (msg.sender). This enables a malicious
actor to initiate transfers from any address that has approved the contract (or has a universal allowance) and for which the attacker
can obtain or forge the required signature and proof.
@> 69 | function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
@> 70 | external
@> 71 | nonReentrant
@> 72 | {
@> 73 | if (receiver == address(0)) {
@> 74 | revert SA__ZeroAddress();
@> 75 | }
@> 76 | if (i_snow.balanceOf(receiver) == 0) {
@> 77 | revert SA__ZeroAmount();
@> 78 | }
@> 79 |
@> 80 | if (!_isValidSignature(receiver, getMessageHash(receiver), v, r, s)) {
@> 81 | revert SA__InvalidSignature();
@> 82 | }
@> 83 |
@> 84 | uint256 amount = i_snow.balanceOf(receiver);
@> 85 |
@> 86 | bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, amount))));
@> 87 |
@> 88 | if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) {
@> 89 | revert SA__InvalidProof();
@> 90 | }
@> 91 |
@> 92 | i_snow.safeTransferFrom(receiver, address(this), amount);
Risk
Likelihood:
• Medium: The attack requires the attacker to obtain a valid signature and Merkle proof for a target address. If signatures are
generated off-chain and distributed (e.g., via a backend API) or if the signing process is flawed (e.g., using eth_sign which signs
a prefixed hash), they could be intercepted or replayed. Additionally, if the Merkle tree is public, proofs are readily available.
Impact:
High: An attacker can drain SNOW tokens from any user who has granted an allowance to the contract. The stolen tokens are
transferred to the contract (effectively burned in this context, as they are meant to be exchanged for SNOWMAN tokens), causing
permanent loss to the victim.
Proof of Concept
function test_arbitraryTransferFrom() public {
address victim = address(0x123...);
address attacker = address(this);
bytes32[] memory merkleProof = getMerkleProofForVictim();
(uint8 v, bytes32 r, bytes32 s) = getSignatureForVictim();
uint256 victimBalance = snow.balanceOf(victim);
assert(victimBalance > 0);
assert(snow.allowance(victim, address(snowmanAirdrop)) >= victimBalance);
snowmanAirdrop.claimSnowman(victim, merkleProof, v, r, s);
assert(snow.balanceOf(victim) == 0);
assert(snow.balanceOf(address(snowmanAirdrop)) == victimBalance);
}
Recommended Mitigation
- function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
+ function claimSnowman(bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
external
nonReentrant
{
+ address receiver = msg.sender;
if (receiver == address(0)) {
revert SA__ZeroAddress();
}