> For the complete documentation index, see [llms.txt](https://zokyo-auditing-tutorials.gitbook.io/zokyo-tutorials/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://zokyo-auditing-tutorials.gitbook.io/zokyo-tutorials/tutorial-16-zero-knowledge-zk/bugs-in-the-wild/a16z-zkdrops-missing-nullifier-range-check.md).

# a16z ZkDrops: Missing Nullifier Range Check

{% hint style="info" %}
[**Book an audit with Zokyo**](https://www.zokyo.io/)
{% endhint %}

**Summary**

Related Vulnerabilities: 1. [Under-constrained Circuits](/zokyo-tutorials/tutorial-16-zero-knowledge-zk/common-vulnerabilities-in-zk-code/under-constrained-circuits.md), 2. [Nondeterministic Circuits](/zokyo-tutorials/tutorial-16-zero-knowledge-zk/common-vulnerabilities-in-zk-code/nondeterministic-circuits.md), 3. [Arithmetic Over/Under Flows](/zokyo-tutorials/tutorial-16-zero-knowledge-zk/common-vulnerabilities-in-zk-code/arithmetic-over-under-flows.md)

Identified By: [Kobi Gurkan](https://github.com/kobigurk)

ZkDrops is very similar to the [0xPARC StealthDrop](/zokyo-tutorials/tutorial-16-zero-knowledge-zk/bugs-in-the-wild/0xparc-stealthdrop-nondeterministic-nullifier.md) (related bug just above). 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 twice.

**The 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");
```

### Conclusion

The vulnerability and its explanation revolve around the differences in bit-lengths between the Ethereum Virtual Machine (EVM) and zk-SNARK scalar fields. Let me break down the key points for you:

#### Background:

1. **zk-SNARKs**: These are cryptographic proofs that can verify the accuracy of a computation without revealing the actual inputs to the computation.
2. **Nullifiers**: Nullifiers are unique identifiers associated with claiming an airdrop. They ensure that each airdrop can only be claimed once.
3. **EVM and SNARK Scalar Field Bit Lengths**: The Ethereum Virtual Machine (EVM) allows for integers of 256 bits. However, zk-SNARKs typically operate on a scalar field of size 254 bits.
4. **Modulo Operation**: If a number exceeds the size of the field, it wraps around starting from zero again, much like an odometer in a car that rolls over after reaching its maximum displayable value.

#### The Vulnerability:

Because of the size difference mentioned above, if someone creates a nullifier that's larger than the zk-SNARK's field (e.g., a 256-bit number), when it's processed inside the zk-SNARK proof generation, it gets wrapped around (using modulo operation).

For illustration:

* Let's say the maximum value for the zk-SNARK field is `p`.
* If someone creates a nullifier `x` such that `x = p + 5`, within the zk-SNARK computation, this becomes just `5` because of the modulo operation (`x % p = 5`).
* Now, the vulnerability arises because the smart contract on the Ethereum chain, which doesn't inherently understand zk-SNARK fields, sees two distinct nullifiers: `p + 5` and `5`. Thus, it would allow an airdrop claim for both, even though, within the zk-SNARK, they're effectively the same.

#### The Fix:

The solution is to add a check in the Ethereum smart contract to ensure that any nullifier submitted is within the acceptable range of the zk-SNARK field (i.e., 0 to `p-1`). If it's outside this range (e.g., if it's 256 bits and too large), the smart contract will reject it. By ensuring that the nullifiers are within the correct range, you prevent the possibility of two different nullifiers effectively becoming the same within the zk-SNARK computation.

**Further explaination on why the proof still passes:**

The proof still passes because, within the zk-SNARK circuit, everything is computed modulo the scalar field size `p`.

*To further explain*

Let's take a simple example:

Imagine a zk-SNARK scalar field that has a maximum size of `10` (in reality, it's a much larger prime number, but this is just for illustrative purposes). This means any computation within the zk-SNARK would wrap around this number.

If a user creates a nullifier value of `12`, the zk-SNARK circuit would compute with this value as if it's `2` because `12 % 10 = 2`. Thus, when the proof is generated, it's effectively using the value `2`.

Now, when this proof is submitted to the Ethereum smart contract, the contract doesn't inherently "know" about this modulo operation inside the zk-SNARK. So if the user submits a nullifier of `12` for one claim and then uses the equivalent modulo value `2` for another, both are seen as different by the contract. But for the zk-SNARK circuit, both are essentially the same, so proofs for both will pass.

That's the crux of the vulnerability. The discrepancy arises because the Ethereum smart contract operates on 256-bit numbers without the constraints of the zk-SNARK scalar field. In contrast, inside the zk-SNARK circuit, numbers are automatically wrapped around the scalar field size. So, unless the Ethereum contract is explicitly coded to account for this, it'll treat values like `12` and `2` as distinct, even though the zk-SNARK circuit sees them as equivalent.<br>

**References**

1. [Github PR](https://github.com/a16z/zkdrops/pull/2)


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://zokyo-auditing-tutorials.gitbook.io/zokyo-tutorials/tutorial-16-zero-knowledge-zk/bugs-in-the-wild/a16z-zkdrops-missing-nullifier-range-check.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
