AZTEC v1 PoC / zkDAI Demo Commented validateCommitments() + Zero-Address ecrecover Drain (2018-12-02)

  • Name: AZTEC v1 PoC / zkDAI demo commented validateCommitments() + zero-address ecrecover drain
  • Date: 2018-12-02
  • Protocol: AZTEC v1 PoC / zkDAI demo
  • Chain: Ethereum
  • Category: proof verification failure / skipped curve-point validation / zero-address note-ownership spoofing / demo drain
  • Loss: 30.6 DAI from AZTEC’s own proof-of-concept zkDAI contract; AZTEC said no third-party funds were affected
  • Summary: Two independent implementation mistakes combined into one exploit path. First, the deployed AZTEC.sol verifier path was not actually calling validateCommitments, a subroutine meant to check that elliptic-curve points and proof integers were well formed before the verifier processed them. Second, the zkDAI token contract’s input-note authorization path let invalid notes alias to address(0): noteRegistry returned the zero address for a nonexistent note hash, and attacker-chosen ECDSA parameters could make Solidity ecrecover also return address(0). Together, those bugs let the attacker present malformed proof / note material, satisfy input-note validation against the default zero owner, and withdraw 30.6 DAI from the demo contract.
  • Trigger / entry point:
    • The incident surfaced through a live Ethereum transaction against AZTEC’s proof-of-concept zero-knowledge DAI contract.
    • The exploit transaction cited by AZTEC was 0xe7ed7b45a7e21a308722fc75b4b897607fed436a3e0d950f69dbdff8011a4945.
    • The relevant verifier surface was AZTEC.sol, whose validateCommitments subroutine was supposed to reject malformed elliptic-curve points and malformed verifier integers before proof verification continued.
    • The relevant spend surface was the zkDAI token contract’s confidentialTransfer path, which called validateInputNote for each claimed input note.
  • Root cause:
    • The verifier implementation did not actually execute its commitment / point well-formedness gate in the deployed proof-of-concept path because the validateCommitments call had been commented out.
    • AZTEC’s own disclosure said a local test case was already failing because of that commented-out call, but deployment still proceeded without the full battery of tests running. The bug therefore survived not only as a code omission but as a release-gating failure on verifier admission checks.
    • The zkDAI note-spend path treated nonexistent note owner and failed signature recovery as values that could both become address(0).
    • Those two failures composed badly: malformed proof / note objects survived the verifier boundary, and the note-ownership check failed open on the application’s default-value semantics.
    • The protocol therefore lost the invariant that an accepted input note corresponds to a real registered note owned by a real signer.
  • Failure path:
    1. The attacker targeted the proof-of-concept zkDAI contract and supplied proof / note material that should have been rejected by AZTEC’s commitment-validation routine.
    2. Because the deployed verifier path was not calling validateCommitments, malformed inputs were allowed to continue deeper into verification.
    3. In the zkDAI token’s confidentialTransfer flow, each claimed input note was checked by validateInputNote.
    4. For an invalid or nonexistent note, the noteRegistry mapping returned Ethereum’s default mapping value, address(0).
    5. The attacker also supplied ECDSA parameters that caused Solidity’s ecrecover helper to return address(0).
    6. The note-validation logic compared noteRegistry[noteHash] with the recovered signer and saw equality at the zero address, even though no legitimate note owner had approved the spend.
    7. That composite false positive let the attacker satisfy the spend path and withdraw 30.6 DAI from the demo contract.
  • What the attacker needed to be true:
    • The deployed verifier had to omit the call that checked proof commitments and integers for well-formedness.
    • The attacker had to be able to submit invalid note / commitment material that would have been rejected if validateCommitments had run.
    • validateInputNote had to trust a recovered signer value without separately rejecting address(0).
    • The invalid note hash had to resolve to the default zero address in noteRegistry.
    • The demo contract had to still hold DAI that an accepted confidential transfer path could withdraw.
  • Technical anatomy worth remembering:
    • This was not a break of AZTEC’s underlying cryptography in the abstract; AZTEC explicitly framed it as a bug in the demo implementation.
    • The verifier-side mistake was brutally mundane and therefore worth preserving: the correct validation helper existed, but the deployed path did not call it.
    • Just as important, the release process did not fail closed. AZTEC said a local test was already catching the commented-out path, yet deployment was still allowed to proceed without the full suite. For proof systems, the helper exists in source is irrelevant if release tooling can ship a path that stops exercising it.
    • The monetization path was equally mundane: default-zero mapping semantics plus ecrecover returning address(0) for attacker-chosen parameters created an authorization false positive.
    • The incident is a good reminder that malformed-proof admission often only becomes theft when a second application-layer default-value bug helps it cross the final spend boundary.
  • Representative transaction and artifacts:
    • Exploit transaction: 0xe7ed7b45a7e21a308722fc75b4b897607fed436a3e0d950f69dbdff8011a4945
    • Vulnerable verifier contract family: AZTEC.sol
    • Vulnerable spend entrypoint: confidentialTransfer
    • Vulnerable verifier helper that was not called in the deployed path: validateCommitments
    • Vulnerable note-authorization helper: validateInputNote
    • Fixed replacement contract addresses published by AZTEC: 0xA43F8675850ac3f60A4D4cec954F1A1B0e1dbB07 (aztec.sol) and 0xcf65A4e884373Ad12cd91c8C868F1DE9DA48501F (aztecToken.sol)
  • Response / mitigation:
    • AZTEC replaced the proof-of-concept contracts with updated versions that re-enabled the omitted validation subroutine and fixed note-owner validation.
    • The team said future deployment scripts would add more tests, minimum coverage assertions, and manual approval checkpoints so deployment could not quietly proceed after malformed-input coverage had already regressed.
    • AZTEC also announced a bug bounty program alongside the disclosure.

Pattern tags

Why the exploit worked

  • The first failure sat at the verifier-admission boundary: validateCommitments() existed precisely to reject malformed commitments, points, and verifier integers before they became arithmetic or note-level reality, but the deployed path never called it.
  • That alone still needed a second fail-open surface to cash out, and zkDAI provided one. validateInputNote let nonexistent note owner and failed signature recovery both collapse into address(0), so malformed proof / note material could cross the last ownership check looking authorized.
  • The exploit therefore did not defeat AZTEC’s confidential-transfer design by proving a false statement within a sound verifier. It relied on one joined boundary collapse: malformed objects survived the parser / verifier gate, then application-level default semantics promoted the same malformed object into spend authority.
  • The real broken invariant was that accepted input note should have meant this note exists, its proof object was well formed, and a non-default owner really authorized it. Instead, each clause was allowed to degrade independently and the combination became enough to withdraw DAI.

Detection and audit pivots

  • Trace proof-supplied commitments, note hashes, and recovery outputs through one full spend path and ask where malformed objects are guaranteed to die before arithmetic, ownership comparison, or withdrawal logic sees them.
  • Search for helper functions like validateCommitments() that reviewers assume are mandatory but the live funded path may not actually call. Path-level tests matter more than the presence of the helper in source.
  • Treat address(0), empty mapping entries, and other default values as live authorization hazards anywhere proof or note validity is supposed to imply real ownership. Failed recovery and missing ownership should not be comparable states.
  • Build negative tests that combine malformed proof objects with downstream default-value behavior, not only bad proof rejected unit tests in isolation. This incident paid out because two individually mundane failures were allowed to compose.

Failure-prevention checklist

  • Keep proof-element admission checks on the one funded path, not merely available in source. Helpers like validateCommitments() should be exercised by deployment-blocking path tests so a commented-out call cannot survive release.
  • Fail closed on malformed proof elements before they can reach note-ownership or withdrawal code. Invalid curve points, malformed verifier integers, and parser failures should produce a hard revert rather than a partially initialized object that later code might reinterpret.
  • Treat missing owner, failed signature recovery, and real signer as three different states. None of them should be representable by the same default sentinel such as address(0) inside authorization logic.
  • Require positive existence proofs before spend authorization. A note should have to prove both this note hash is registered and this signer is the registered nonzero owner; equality against a default mapping value is not ownership.
  • Add composite negative tests that pair malformed proof objects with default-value behavior in downstream contracts. This exploit only monetized because verifier admission and note-authorization both degraded in the same direction.
  • Make release gating treat skipped negative tests as a security event. AZTEC’s own writeup says a local failing test already existed; that should have been enough to block deployment automatically.
  • Review helper-return semantics from EVM precompiles and built-ins as part of the security boundary. ecrecover returning address(0) is not harmless plumbing when proof admission is supposed to imply spend authority.
  • Keep demo, appendix, or PoC contracts on the same fail-closed standard whenever they can custody real assets. Research code stops being an excuse once the contract can still withdraw DAI.

Audit prompts

  • Where exactly do malformed curve points, verifier integers, note hashes, or proof-element encodings die on the live spend path, and what proves that the funded deployment still executes those checks?
  • If a helper like validateCommitments() exists, what test or deployment assertion proves the live contract actually calls it on every relevant verifier entrypoint?
  • Can any nonexistent object, failed recovery result, or parser error collapse to the same sentinel value as a valid owner or valid note state?
  • Does note-spend authorization prove both note existence and signer ownership, or only equality between two values that may both have defaulted to zero?
  • Which negative tests combine malformed proof elements with downstream authorization semantics instead of testing the verifier and the spend path in isolation?
  • What release controls prevent a funded deployment from proceeding when the full negative-test battery is skipped, reduced, or already failing locally?
  • If a proof-facing helper or EVM primitive fails, what exact value reaches storage comparison or withdrawal code, and is that value explicitly deauthorized?
  • For demo or transitional proof systems, what concrete control keeps appendix code holding real funds from inheriting weaker release discipline than production code?

Lessons that should already be standard

  • A proof helper that exists in source but is not exercised on the live funded path does not exist for security purposes.
  • Malformed proof-object rejection and downstream ownership checks belong to one security boundary. Attackers only need one path where both layers fail open in compatible ways.
  • address(0) is not a neutral placeholder in authorization code. When missing ownership and failed recovery both map there, default-value semantics become spend authority.
  • Negative tests should compose layers, not only verify each layer alone. Real exploits often pay out when a verifier bug and an application bug fail in the same direction.
  • Release discipline is part of verifier correctness. A locally failing malformed-input test that does not stop deployment is already a broken proof-admission boundary.
  • PoC, demo, and research appendix are operational labels, not safety properties. If the contract can hold redeemable assets, it needs the same fail-closed parser, authorization, and release posture as production code.

Why this incident matters

  • It is a compact early proof-systems case where the cryptographic story and the application story both mattered: malformed proof-object admission alone was not the whole cash-out path, but it was the first broken gate.
  • It preserves an implementation lesson that still recurs in modern zk systems: proof verifiers fail at the admission boundary just as often as they fail in the headline pairing equation.
  • It is also a clean reminder that address(0) is not a neutral placeholder when verifier success is supposed to imply real authorization, which is why proof-element admission boundary discipline treats parser validation and downstream default-value deauthorization as one joined security boundary.

Internal linkages

  • Core recurring pattern: curve-point-validation-failure.
  • Positive-design companion: proof-element-admission-boundary-discipline — the clean healthy-side question here is where malformed notes, commitments, and recovery failures should have died before either pairing logic or address(0)-shaped defaults could become spend authority.
  • Broader sentinel-semantics companion: sentinel-lane-separation-and-default-value-deauthorization — useful when the sharper cross-family question is why failed recovery outputs and blank ownership state must not collapse onto the same address-shaped value.
  • Adjacent statement-level contrast: witness-public-input-binding-failure — useful when the proof relation itself is underbound, whereas this incident primarily failed before malformed proof objects should even have entered the verifier.
  • Adjacent setup-level contrast: verification-key-independence-collapse — that family captures toxic setup / verifier-key failures, not implementation paths that forgot to validate proof elements.

Control surface