Target is hidden
This information is not publicly available.
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.
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.
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.
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.
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.
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.
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.
v0.87.0 source at commit 9081b0ed38c43c120afb7c80f8f6cd418ca5ad70.build_patched_bb.sh.venv, and internet access for the one-time source/dependency build.The selected Hardhat test performs these steps:
DepositVerifier, WithdrawVerifier, InclusionVerifier,
ZendexVaultManager, TreeOperator and ZendexVault contracts.DEFAULT_ADMIN_ROLE.n + p.n + p reverts.n reverts.n + p passes the unmodified Solidity verifier.n.n reverts with
BaseManager__NullifierConsumed.n + p, then asserts that
the recipient received 200 ZEN in total and that both raw replay keys are marked used.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.
DEFAULT_ADMIN_ROLE membership.n + p reverts: rules out a calldata-only proof mutation and confirms the raw
bytes participate in Fiat-Shamir.n reverts: confirms the new proof is specifically bound to the
non-canonical encoding rather than being generally accepted.n + p verifies: establishes that the unmodified Solidity verifier accepts
the specially generated proof.n and n + p separately.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 are hidden
This information is not publicly available.
Comments are hidden
This information is not publicly available.