Status DataClose notification

Zendex Disclosed Report

Audit Report

ZendexVaultManager accepts non-canonical BN254 nullifiers, letting one note bypass replay protection and withdraw pooled funds twice

Target

Target is hidden

This information is not publicly available.

Vulnerability details

Summary

ZendexVaultManager.withdraw verifies a BN254 field element as the note nullifier, then parses the same 32 bytes as an unrestricted Solidity uint256 and uses that raw integer as the replay key. The generated verifier reduces the value modulo the BN254 scalar modulus for the proof relation, but neither the verifier integration nor the manager requires the calldata encoding to be the canonical representative below the modulus.

Mallory (the attacker) can therefore produce one proof whose withdrawal nullifier is encoded as n + p, where n is her note's canonical nullifier and p is the BN254 scalar modulus. The proof constrains the field element n, while the manager records the different storage key n + p. After an honest withdrawal consumes nullifierUsed[n], the same note and inclusion proof can release pooled tokens again because nullifierUsed[n + p] is still false.

The authorised local PoC used the repository's unmodified Solidity verifier, manager, tree and vault. It assigned administration to a different signer and asserted that Mallory lacked DEFAULT_ADMIN_ROLE. Alice (the victim) first deposited 1,000 ZEN under a separate shielded key. The attacker's 100 ZEN note was then withdrawn once with n and again with n + p: 200 ZEN left the vault, for a 100 ZEN excess release. The victim's nullifier remained unused, but her otherwise valid 1,000 ZEN withdrawal reverted because only 900 ZEN remained. This is not a calldata-only mutation: a stock proof paired with n + p was rejected. The attacker modifies only her local prover transcript serialization so the proof is bound to the same raw bytes that the Solidity verifier hashes.

Root cause

contracts/BaseManager.sol:124-130 forwards public inputs without a field-range check:

function _verifyProof(ProofData memory data, IVerifierHub.VerifierType verifierType) internal view {
    ZendexBaseManagerStorage storage $ = _baseManagerStorage();
    require(
        IVerifier($.verifierHub.getVerifier(verifierType)).verify(data.proof, data.publicInputs),
        BaseManager__VerificationFailed()
    );
}

The generated withdrawal verifier uses two representations of the same input. Its Fiat-Shamir transcript hashes the raw calldata bytes at contracts/verifiers/WithdrawVerifier.sol:480-492:

for (uint256 i = 0; i < publicInputsSize - PAIRING_POINTS_SIZE; i++) {
    round0[3 + i] = bytes32(publicInputs[i]);
}

Later, the public-input relation converts each value with FrLib.fromBytes32 at lines 1600-1616. That helper is defined at lines 151-154 as:

function fromBytes32(bytes32 value) internal pure returns(Fr) {
    return Fr.wrap(uint256(value) % MODULUS);
}

Consequently, a proof generated against raw transcript bytes n + p is checked algebraically against (n + p) % p == n. The verifier checks proof length and public-input count, but it never checks uint256(publicInputs[i]) < MODULUS.

ZendexVaultManager._parseWithdrawalInputs then preserves the unreduced representation at line 212:

uint256 nullifier = uint256(publicInputs[2]);

withdraw calls _validateNullifier, then _consume, and releases the requested amount from the shared vault (ZendexVaultManager.sol:91-106). The replay check and write are keyed by the raw integer:

require(!$.tree.nullifierUsed(nullifier), BaseManager__NullifierConsumed());
nullifierUsed[nullifier] = true;

Those are BaseManager.sol:136-139 and TreeOperator.sol:156-158. The contract therefore treats n and n + p as different nullifiers even though the proof system treats them as the same field element.

The withdraw circuit itself is sound for canonical fields. At circuits/withdraw/src/main.nr:19-22 it computes the nullifier and asserts equality:

let rho_hash = poseidon::bn254::hash_1(([rho]));
let calculated_nullifier = poseidon::bn254::hash_3([rho_hash, pk_x, pk_y]);
assert(nullifier == calculated_nullifier);

The failure is at the field-to-uint256 integration boundary, not in Poseidon or the equality constraint.

This is the verifier wired by the pinned deployment script: scripts/deploy/zendex.ts:187-190 deploys WithdrawVerifier, and line 248 assigns that address to the verifier hub's withdrawalVerifier slot. The generated file explains the boundary, but the protocol-level defect and primary fix are in the in-scope BaseManager.sol: it accepts field-valued calldata and then allows managers to use non-canonical raw integers as state keys.

Why a stock proof mutation fails

The raw public inputs are included in the Fiat-Shamir transcript. Changing n to n + p after a stock proof has been generated changes the challenges and makes verification revert. The PoC has a negative control for exactly this case.

For the positive case, the PoC applies a narrow change to official Barretenberg v0.87.0: on the first Keccak transcript serialization it replaces the selected public-input encoding with x + Fr::modulus. The witness relation and every circuit value remain x; only the raw bytes hashed into the transcript change. The caller supplies those same x + p bytes to the unmodified Solidity verifier. No verifier contract, circuit, key or in-scope contract is patched.

Demonstrated impact

The recorded run used:

n     = 18486976309083136428851298136435732963491879441506497806463197884936171968773
n + p = 40375219180922411651097703881693008052040243841922532150161402071511980464390

The test first withdrew the 100 ZEN note with canonical n. An exact replay then reverted with BaseManager__NullifierConsumed. The specially generated proof paired with n + p passed the unmodified verifier and the manager released another 100 ZEN for the same note and inclusion proof. Both nullifierUsed[n] and nullifierUsed[n + p] ended true.

The vault held 1,100 ZEN before the withdrawals: the victim's independently owned 1,000 ZEN note plus the attacker's 100 ZEN note. It released 200 ZEN against the attacker's note, creating a 100 ZEN shortfall. The excess is 9.09% of the pre-withdraw vault balance and 10% of the victim's deposit. These are fixture ratios, not measurements of a live deployment. After the attack the victim's note and inclusion proof still verified, her nullifier remained unused, and her withdrawal reverted at the real vault sink because the contract could not release 1,000 ZEN from its remaining 900 ZEN.

For the tested n, four additional multiples of p remain below 2^256, so there are five raw representatives in total. That arithmetic suggests further replays, but only n and n + p were executed and are claimed here.

Not a known issue in the supplied prior audit

The supplied Hacken report contains 57 findings. Its only use of "non-canonical" concerns ECDSA permit signature malleability in ZendexERC20.sol, not BN254 public-input encoding. Its "Missing Nullifier Validation in removeLiquidity" finding concerns an absent consume call, not a field element being addressed by several storage keys. Searches of the report found no fromBytes32, FrLib, modulus, field-canonicity, public-input-encoding or replay-key finding.

This does not establish that a private or invite-only issue tracker is empty. The issue tracker was not enumerable, so no claim is made about material outside the supplied audit report and source.

Recommendation

Reject non-canonical field encodings before verification and before any public input is parsed or used as a storage key. The narrow in-scope fix is a loop in BaseManager._verifyProof:

error BaseManager__NonCanonicalPublicInput();

uint256 constant BN254_SCALAR_MODULUS =
    21888242871839275222246405745257275088548364400416034343698204186575808495617;

for (uint256 i; i < data.publicInputs.length; ++i) {
    require(uint256(data.publicInputs[i]) < BN254_SCALAR_MODULUS, BaseManager__NonCanonicalPublicInput());
}

The custom error must be declared alongside BaseManager's existing errors; the name above is a proposal, not an existing symbol.

Run this check before calling the generated verifier. As defence in depth, every replay and uniqueness key should either reject values outside the field or use a canonical representation; silently reducing only at the storage layer would create ambiguity and should not replace input rejection.

Regression tests should cover canonical success, n + p rejection before verifier execution, unchanged rejection of an exact canonical replay, the maximum canonical value p - 1, and rejection of p itself.

Validation steps

Validation basis

The target was the unversioned repository commit b25f3c3a633056907690ed868deaae0b4ad33407. The repository has one commit and no release tag, so no first affected release or fixed release can be claimed. The dependency lock resolves @aztec/bb.js to 0.87.0 and @noir-lang/noir_js to 1.0.0-beta.7.

The PoC was run only against Hardhat's local in-memory chain. It does not contact a public or production deployment. The final standalone test completed with 1 passing (16s) on Node v22.23.2.

Package prerequisites

  • Node.js v22.23.2 and the target repository at the pinned commit.
  • The attached Hardhat PoC.
  • Official Aztec v0.87.0 source at commit 9081b0ed38c43c120afb7c80f8f6cd418ca5ad70.
  • The attached transcript patch, build-only dependency-fetch patch and build_patched_bb.sh.
  • GCC/G++ 13, Python 3 with venv, and internet access for the one-time source/dependency build.

Test logic

The selected Hardhat test performs these steps:

  1. Deploys the repository's real DepositVerifier, WithdrawVerifier, InclusionVerifier, ZendexVaultManager, TreeOperator and ZendexVault contracts.
  2. Assigns the deployer/admin role to a different signer and asserts that Mallory's address lacks DEFAULT_ADMIN_ROLE.
  3. Alice (the victim) deposits a 1,000 ZEN note under a separate shielded key; Mallory (the attacker) creates her own 100 ZEN note.
  4. Generates one inclusion proof and one canonical withdrawal proof for Mallory's note.
  5. Uses the official v0.87.0 prover with the narrow transcript patch to generate the otherwise equivalent proof bound to raw nullifier bytes n + p.
  6. Confirms that a stock proof paired with n + p reverts.
  7. Confirms that the patched proof paired with canonical n reverts.
  8. Confirms that the patched proof paired with n + p passes the unmodified Solidity verifier.
  9. Withdraws once with canonical n.
  10. Confirms that an exact replay of canonical n reverts with BaseManager__NullifierConsumed.
  11. Reuses the same note and inclusion proof with the patched proof and n + p, then asserts that the recipient received 200 ZEN in total and that both raw replay keys are marked used.
  12. Generates a valid withdrawal for Alice's still-unspent 1,000 ZEN note and confirms it reverts because the vault holds only 900 ZEN; Alice's nullifier remains unused after the reverted call.

Recorded output

The authorised run printed:

ATTACKER_IS_DEPLOYER false
ATTACKER_IS_ADMIN false
CANONICAL_NULLIFIER 18486976309083136428851298136435732963491879441506497806463197884936171968773
NON_CANONICAL_NULLIFIER 40375219180922411651097703881693008052040243841922532150161402071511980464390
ATTACKER_DEPOSIT 100.0 ZEN
TOTAL_WITHDRAWN 200.0 ZEN
NET_GAIN 100.0 ZEN
VAULT_SHORTFALL 100.0 ZEN
VICTIM_NOTE_OWED 1000.0 ZEN
VAULT_AFTER_ATTACK 900.0 ZEN
VICTIM_WITHDRAW reverted: vault is short by 100.0 ZEN

1 passing (16s)

ATTACKER_IS_DEPLOYER false is backed by the manager proxy's deployment transaction, and ATTACKER_IS_ADMIN false is backed by a role assertion against that manager. The output labels NET_GAIN as withdrawal proceeds minus the attacker's 100 ZEN note. The victim's 1,000 ZEN note uses a separate wallet and shielded key. The failed victim withdrawal is executed in the same test after the two releases, rather than inferred only from the balance arithmetic.

Controls and what they rule out

  • Attacker is neither deployer nor admin: the manager proxy's deployment transaction uses a different address and the manager returns false for the attacker's DEFAULT_ADMIN_ROLE membership.
  • Stock proof + n + p reverts: rules out a calldata-only proof mutation and confirms the raw bytes participate in Fiat-Shamir.
  • Patched proof + n reverts: confirms the new proof is specifically bound to the non-canonical encoding rather than being generally accepted.
  • Patched proof + n + p verifies: establishes that the unmodified Solidity verifier accepts the specially generated proof.
  • Exact canonical replay reverts: confirms ordinary replay protection is enabled and that the second success depends on the distinct raw storage key.
  • Both mapping entries are true: shows the manager and tree stored n and n + p separately.
  • Recipient increase and vault decrease equal 200 ZEN: establishes the effect at the real custody sink, not only at the verifier.
  • The victim's valid withdrawal reverts and her nullifier stays unused: establishes independent user harm caused by the 100 ZEN shortfall.

Offline source check

Without running the test, compare these paths at the pinned commit:

  • contracts/verifiers/WithdrawVerifier.sol:151-154: public inputs are reduced modulo p.
  • contracts/verifiers/WithdrawVerifier.sol:480-492: raw public-input bytes enter the transcript.
  • contracts/verifiers/WithdrawVerifier.sol:1567-1578: length checks exist, but no per-element < p check.
  • contracts/BaseManager.sol:124-130: public inputs are forwarded without validation.
  • contracts/ZendexVaultManager.sol:207-222: the raw third input becomes the uint256 nullifier.
  • contracts/BaseManager.sol:136-139 and contracts/TreeOperator.sol:156-158: the raw integer is the replay key.
  • contracts/ZendexVaultManager.sol:91-106 and contracts/ZendexVault.sol:48-52: a fresh raw key reaches the token release.

Attachments

Attachments are hidden

This information is not publicly available.

Comments are hidden

This information is not publicly available.