A16z ZkDrops: Missing Nullifier Range Check
a16z ZkDrops: Missing Nullifier Range Check
Identified By: Kobi Gurkan
ZkDrops is very similar to the 0xPARC StealthDrop. ZkDrops requires that users post a nullifier on-chain when they claim an airdrop. If they try to claim the airdrop twice, the second claim will fail because the nullifier has already been seen by the smart contract. However, since the EVM allows numbers (256 bits) larger than the snark scalar field order, arithmetic overflows allowed users to submit different nullifiers for the same airdrop claim. This made it possible for a user to claim a single airdrop multiple times.
Background
In order to claim an airdrop, users must post a nullifier on-chain. If the nullifier is already present on-chain, the airdrop will fail. The nullifier is supposed to be computed in a deterministic way such that given the same input parameters (the user’s claim in this case), the output nullifier will always be the same. The nullifier is stored on-chain as a 256 bit unsigned integer.
Since the SNARK scalar field is 254 bits, a nullifier that is > 254 bits
will be reduced modulo the SNARK field during the proof generation process. For example, let p = SNARK scalar field order
. Then any number x
in the proof generation process will be reduced to x % p
. So p + 1
will be reduced to 1
.
The Vulnerability
The smart contract that checked whether a nullifier has been seen before or not, did not verify whether the nullifier was within the SNARK scalar field. So, if a user has a nullifier x >= p
, then they could use both x and x % p
as separate nullifiers. These both will be evaluated to x % p
within the circuit, so both would generate a successful proof. When the user first claims an airdrop with the x
nullifier, x
hasn't been seen before so it is successful. Then when the user claims the same airdrop with x % p
, that value hasn't been seen by the contract before either, so it is successful as well. The user has now claimed the airdrop twiceThe Fix
The fix to this issue is to add a range check in the smart contract. This range check should ensure that all nullifiers are within the SNARK scalar field so that no duplicate nullifiers satisfy the circuit. The following function to claim an airdrop:
/// @notice verifies the proof, collects the airdrop if valid, and prevents this proof from working again.
function collectAirdrop(bytes calldata proof, bytes32 nullifierHash) public {
require(!nullifierSpent[nullifierHash], "Airdrop already redeemed");
uint[] memory pubSignals = new uint[](3);
pubSignals[0] = uint256(root);
pubSignals[1] = uint256(nullifierHash);
pubSignals[2] = uint256(uint160(msg.sender));
require(verifier.verifyProof(proof, pubSignals), "Proof verification failed");
nullifierSpent[nullifierHash] = true;
airdropToken.transfer(msg.sender, amountPerRedemption);
}
Was fixed by adding this range check:
require(uint256(nullifierHash) < SNARK_FIELD ,"Nullifier is not within the field");
References
https://github.com/0xPARC/zk-bug-tracker#zkdrops-1
Related Vulnerabilities
Under-Constrained Circuits vulnerability