Reproduced Exploit
Aztec V1 Escape-Hatch Exploit — Unbacked Withdrawals via Verifier-Trusted Rollup Proofs
1. The Aztec V1 rollup exposes escapeHatch(bytes proofData, bytes signatures, bytes viewingKeys) (contracts_RollupProcessor.sol:347-356) — a permissionless exit path. Unlike processRollup(...), which requires a registered rollupProviders[provider] and a provider signature, escapeHatch requires only…
Loss
~$2.2M — 1,158 ETH + 150,000 DAI + 0.46963295 renBTC drained from the Aztec V1 rollup's pooled balances. renB…
Chain
Ethereum
Category
Access Control
Date
Jun 2026
Source & credit. Exploit reproduction, trace data, and analysis adapted from DeFiHackLabs by SunWeb3Sec — an open registry of reproduced on-chain exploits. Standalone Foundry PoC and full write-up: 2026-06-AztecEscapeHatch_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/AztecEscapeHatch_exp.sol.
Vulnerability classes: vuln/access-control/missing-auth · vuln/logic/missing-check · vuln/bridge/missing-validation
Reproduction: the PoC compiles & runs in an isolated Foundry project at this project folder. The three exploit transactions (ETH, DAI, renBTC) are reproduced as three separate fork tests against a local anvil snapshot. Full verbose trace: output.txt. Verified vulnerable source: RollupProcessor and the proof acceptor TurboVerifier.
Key info#
| Loss | ~$2.2M — 1,158 ETH + 150,000 DAI + 0.46963295 renBTC drained from the Aztec V1 rollup's pooled balances. renBTC tx 0x9e1d6ab7…, ETH tx 0xab306cd2…, DAI tx 0x5c196c37… |
| Vulnerable contract | RollupProcessor — 0x737901bea3eeb88459df9ef1BE8fF3Ae1B42A2ba |
| Proof acceptor | TurboVerifier (Turbo-PLONK) — 0x48Cb7BA00D087541dC8E2B3738f80fDd1FEe8Ce8 |
| Victim pool | The rollup contract's own pooled deposits (ETH held directly; DAI 0x6B17…1d0F; renBTC 0xEB4C…b27D) |
| Attacker EOA | 0x6952d9246e9aFE8B887B2877225163436F78E97F |
| Attacker contract | none — the attacker EOA calls escapeHatch(...) directly |
| Attack tx hash | renBTC 0x9e1d6ab7c20ae235409d7dd3a9cd47c04f07293585b3498b8beed82d6f6b03ca (+ ETH / DAI txs above) |
| Chain / block / date | Ethereum mainnet / blocks 25,339,093 (ETH), 25,339,168 (DAI), 25,339,171 (renBTC) / Jun 2026 |
| Compiler / optimizer | Solidity v0.6.10+commit.00c0fcaf, optimizer enabled, 200 runs (both RollupProcessor and TurboVerifier) |
| Bug class | Value-conservation violation — the on-chain settlement layer pays out a proof-encoded withdrawal to an arbitrary address with no on-chain check that the withdrawal is backed by a matching deposit; the entire value-conservation invariant is delegated to a ZK circuit the attacker was able to satisfy with crafted proofs. |
TL;DR#
-
The Aztec V1 rollup exposes
escapeHatch(bytes proofData, bytes signatures, bytes viewingKeys)(contracts_RollupProcessor.sol:347-356) — a permissionless exit path. UnlikeprocessRollup(...), which requires a registeredrollupProviders[provider]and a provider signature,escapeHatchrequires only that the escape-hatch block window is open and forwards straight toprocessRollupProof. -
processRollupProofdoes exactly two things (:390-397): it callsverifyProofAndUpdateState(proofData)(which hands the proof to theTurboVerifierand, on success, overwrites the on-chaindataRoot/nullRoot/rootRoot), then callsprocessDepositsAndWithdrawals(proofData, numTxs, signatures), which walks the proof's inner transactions and pays out everypublicOutputfield as a withdrawal. -
The
withdraw(...)helper (:647-662) sendswithdrawValueto the proof-encodedoutputOwner— ETH via a rawcall, ERC20 viatransfer— and then merely incrementstotalWithdrawn[assetId]. There is no on-chain check that the withdrawal is matched by a real deposit or by a note that genuinely existed in the previous data tree. The contract trusts that theTurboVerifierwould only accept a proof whose net deposits equal net withdrawals. -
The attacker submitted three rollup proofs (rollupId
0x1187/0x1188/0x1189= 4487/4488/4489) that theTurboVerifieraccepted (PRECOMPILES::ecpairing(...) → true, thenrequire(result, 'Proof failed')passes — see output.txt:135, output.txt:291, output.txt:444) yet whose decoded inner transactions authorised paying the rollup's pooled ETH/DAI/renBTC to the attacker's own EOA — with no matching deposit. -
Each proof advances the rollup state cleanly (
nextRollupIdticks 4487→4488→4489, dataRoot updates), so each escapeHatch call looks like a perfectly valid rollup block to the contract — the only observable effect is the payout. Net result: the attacker drains 1,158 ETH (Attacker::fallback{value: 1158 ETH}, output.txt:297) + 150,000 DAI (DAI.transfer(Attacker, 150000e18), output.txt:140-141) + 0.46963295 renBTC (renBTC.transfer(Attacker, 46963295), output.txt:450-452) from the rollup's pooled assets, asserted by the three PoC tests (AztecEscapeHatch_exp.sol:47, :60, :73).
Background — what Aztec V1 does#
Aztec V1 is a privacy-preserving zk-rollup. Users deposit ERC20/ETH into the RollupProcessor, which
holds the pooled funds, and trade/transfer privately inside the rollup using shielded notes. A rollup
"block" is a Turbo-PLONK proof attesting that a batch of inner transactions (deposits, withdrawals,
private transfers, account updates) is valid and consistent with the previous Merkle roots. The contract
stores three roots — the note dataRoot, the nullRoot (spent-note nullifiers), and the rootRoot
(history of data roots)
(contracts_RollupProcessor.sol:25-27) —
and advances them every time a proof is accepted.
Two entry points submit a proof:
processRollup(...)— the normal path (:358-388). It requiresrollupProviders[provider] == trueand a validproviderSignatureover the public inputs, then runs the same proof/withdrawal machinery and reimburses gas to a fee distributor.escapeHatch(...)— the censorship-resistance fallback (:347-356). It is permissionless: it checks only that the escape-hatch block window is open (getEscapeHatchStatus(), :168-181) and then callsprocessRollupProofdirectly — no provider, no provider signature.
Both paths converge on processRollupProof → verifyProofAndUpdateState → processDepositsAndWithdrawals,
so the withdrawal logic is identical; escapeHatch simply removes the operator gate.
On-chain parameters relevant to the attack:
| Parameter | Value | Source |
|---|---|---|
numberOfAssets | 4 | :34 |
ethAssetId | 0 | :39 |
rollupNumPubInputs | 10 + numberOfAssets = 14 | :36 |
nextRollupId before attack | 4487 (= 0x1187) | output.txt:304 (@ 5: 4487 → 4488) |
| Verifier address | 0x48Cb7BA00D087541dC8E2B3738f80fDd1FEe8Ce8 | output.txt:21 |
| ETH paid out | 1,158 ETH (= 1158e18 wei) | output.txt:297 |
| DAI paid out | 150,000 DAI (= 1.5e23 wei) | output.txt:140-141 |
| renBTC paid out | 0.46963295 renBTC (= 46963295, 8 decimals) | output.txt:450-452 |
The vulnerable code#
1. escapeHatch is permissionless and forwards straight to proof processing#
function escapeHatch(
bytes calldata proofData,
bytes calldata signatures,
bytes calldata viewingKeys
) external override whenNotPaused {
(bool isOpen, ) = getEscapeHatchStatus();
require(isOpen, 'Rollup Processor: ESCAPE_BLOCK_RANGE_INCORRECT');
processRollupProof(proofData, signatures, viewingKeys);
}
(contracts_RollupProcessor.sol:347-356)
The only gate is the block-window check. Anyone can call it; there is no rollupProviders check and no
provider signature, in contrast to processRollup
(:369-372).
2. Proof processing: verify, then blindly pay out#
function processRollupProof(
bytes memory proofData,
bytes memory signatures,
bytes calldata /*viewingKeys*/
) internal {
uint256 numTxs = verifyProofAndUpdateState(proofData);
processDepositsAndWithdrawals(proofData, numTxs, signatures);
}
(contracts_RollupProcessor.sol:390-397)
verifyProofAndUpdateState hands the proof to the verifier via assembly staticcall, requires the call
to succeed, and then commits the new roots from the proof to storage:
// Check the proof is valid!
require(proof_verified, 'proof verification failed');
// Update state variables.
dataRoot = newDataRoot;
nullRoot = newNullRoot;
nextRollupId = rollupId.add(1);
rootRoot = newRootRoot;
dataSize = newDataSize;
(contracts_RollupProcessor.sol:465-473)
validateMerkleRoots checks only that the proof's old roots equal the current on-chain roots and that
the rollupId is sequential
(:523-527) — i.e. that the proof
is a valid successor to the current state. It does not independently constrain how much value the
proof is allowed to withdraw; that constraint lives only inside the ZK circuit.
3. The withdrawal: pay the proof-encoded amount, then just bump a counter#
if (publicOutput > 0) {
address outputOwner;
assembly {
outputOwner := mload(add(proofDataPtr, 0x160))
}
withdraw(publicOutput, outputOwner, assetId);
}
(contracts_RollupProcessor.sol:611-617)
function withdraw(
uint256 withdrawValue,
address receiverAddress,
uint256 assetId
) internal {
require(receiverAddress != address(0), 'Rollup Processor: ZERO_ADDRESS');
if (assetId == 0) {
// We explicitly do not throw if this call fails, as this opens up the possiblity of
// griefing attacks, as engineering a failed withdrawal will invalidate an entire rollup block
payable(receiverAddress).call{gas: 30000, value: withdrawValue}('');
} else {
address assetAddress = getSupportedAsset(assetId);
IERC20(assetAddress).transfer(receiverAddress, withdrawValue);
}
totalWithdrawn[assetId] = totalWithdrawn[assetId].add(withdrawValue);
}
(contracts_RollupProcessor.sol:647-662)
The withdrawal amount (publicOutput) and recipient (outputOwner) come straight from the proof's inner
transaction. The function neither debits a per-user deposit ledger nor verifies that withdrawValue was
ever deposited — it pays out and increments totalWithdrawn. Value conservation is entirely the
circuit's responsibility. Once the attacker produced proofs the TurboVerifier accepted, the contract
paid out whatever those proofs encoded.
4. The verifier the contract trusts#
function verify(bytes calldata, uint256 rollup_size) external override {
// extract the correct rollup verification key
Types.VerificationKey memory vk = VerificationKeys.getKeyById(rollup_size);
...
bool result = perform_pairing(
batch_opening_commitment,
batch_evaluation_g1_scalar,
challenges,
decoded_proof,
vk
);
require(result, 'Proof failed');
}
(contracts_verifier_TurboVerifier.sol:41-102)
The verifier reverts with 'Proof failed' if the final pairing check fails. In every exploit
transaction the pairing returns true (output.txt:135, output.txt:291,
output.txt:444), so verify returns cleanly and the rollup proceeds to pay out. The
attacker's three proofs satisfied the Turbo-PLONK verification relation while encoding unbacked
withdrawals — the root cause is that the on-chain layer has no defense in depth behind that single
verifier call.
Root cause — why it was possible#
A complete delegation of the value-conservation invariant to the proof system, with no on-chain backstop, exposed permissionlessly. Three design facts compose into the loss:
-
escapeHatchis permissionless. The normalprocessRolluppath requires a registered provider and a provider signature;escapeHatchremoves both gates and is open to anyone whenever the escape window is open (:347-356). So the attacker, holding accepted proofs, could submit them directly. -
The withdrawal path has no on-chain accounting check.
processDepositsAndWithdrawalspays out every inner-txpublicOutputto itsoutputOwner(:611-617) andwithdrawonly incrementstotalWithdrawnafterwards (:661). Nothing checks that the rollup's net deposits ≥ net withdrawals, thattotalWithdrawn[assetId] ≤ totalDeposited[assetId], or that the withdrawn note ever existed. The contract assumes the circuit guarantees this. -
The on-chain "validation" only checks state continuity, not value.
validateMerkleRootschecks the proof's old roots match current roots and the id is sequential (:523-527), then the new roots are committed unconditionally (:469-473). A proof that is a valid successor but encodes a theft passes every on-chain check.
Given proofs the TurboVerifier accepts (the entry condition — the attacker produced three such proofs,
verified by the ecpairing → true results in the trace), the contract had no second line of defense.
The classic mitigation — a per-asset on-chain invariant totalWithdrawn[id] + balance reconciliation ≤ totalDeposited[id] — was never enforced. Each escapeHatch call advanced the rollup id cleanly
(@ 5: 4487→4488→4489, output.txt:304, output.txt:152,
output.txt:461) and emitted a normal RollupProcessed event, so on-chain the three thefts
were indistinguishable from honest rollup blocks except for the payouts.
Preconditions#
- Possession of rollup proofs the
TurboVerifieraccepts that encode withdrawals to the attacker's address with no matching deposit. This is the entry condition; in the PoC the three craftedproofDatablobs are supplied verbatim and pass verification (output.txt:135, output.txt:291, output.txt:444). - The escape-hatch block window must be open (
getEscapeHatchStatus()returnsisOpen == true, :352-353). The PoC forks at the real attack blocks, where the window was open. - Each proof's old roots must equal the current on-chain roots and its rollupId must be the next id (:523-527). The three proofs are therefore chained: rollupId 4487 → 4488 → 4489, each consuming the roots committed by the previous one. This is why the PoC runs three sequential fork tests at consecutive blocks rather than one tx.
- No working capital, no flash loan, no attacker contract. The attacker EOA simply calls
escapeHatchdirectly (AztecEscapeHatch_exp.sol:45-46). The cost is only gas.
Attack walkthrough (with on-chain numbers from the trace)#
The three exploit transactions are independent escapeHatch calls, each consuming the rollup state left by the previous one. All figures are taken directly from the Foundry trace; raw integers are shown with a human-readable approximation where the trace prints wei.
| # | Step | rollupId (state @ slot 5) | Verifier result | Asset paid out (raw → human) | Recipient |
|---|---|---|---|---|---|
| 1 | ETH theft — escapeHatch(proof 0x1187, "", "") (output.txt:177); verify(...) (output.txt:178); pairing → true (output.txt:291-292) | 4487 → 4488 (output.txt:304) | accepted | fallback{value: 1158000000000000000000} (~1,158 ETH) (output.txt:297) | Attacker |
| 2 | DAI theft — escapeHatch(proof 0x1188, "", "") (output.txt:20); verify(...) (output.txt:21); pairing → true (output.txt:135) | 4488 → 4489 (output.txt:152) | accepted | DAI.transfer(Attacker, 150000000000000000000000) (~150,000 DAI) (output.txt:140-141) | Attacker |
| 3 | renBTC theft — escapeHatch(proof 0x1189, "", "") (output.txt:330); verify(...) (output.txt:331); pairing → true (output.txt:444-445) | 4489 → 4490 (output.txt:461) | accepted | renBTC.transfer(Attacker, 46963295) (~0.46963295 renBTC, 8 dp) (output.txt:450-452) | Attacker |
Notes from the trace:
- In each transaction the verifier sub-call is a long sequence of
modexp/ecmul/ecadd/ecpairingprecompile calls; the finalPRECOMPILES::ecpairing([...]) → trueis the proof acceptance (output.txt:135). The rollup then emits aRollupProcessedevent (topic 00xf1034928…fb974f, topic 1 = rollupId, e.g.0x…1188at output.txt:137-139). - For ETH (assetId 0) the payout is the raw
call{gas:30000, value:…}to the attacker, which the trace records asAttacker::fallback{value: 1158000000000000000000}(output.txt:297). - For DAI the payout is
IERC20(DAI).transfer(Attacker, 1.5e23)with the matchingTransfer(from: AztecRollup, to: Attacker, 1.5e23)event (output.txt:140-141); the attacker's DAI balance afterward reads150000000000000000000000(output.txt:155). - renBTC is itself a proxy; the rollup's
transferdelegatecalls into impl0xe2d6cCAC…(output.txt:451), emittingTransfer(AztecRollup → Attacker, 46963295)(output.txt:452); the attacker's renBTC balance afterward reads46963295(output.txt:468).
Profit / loss accounting#
The three withdrawals are pure outflows from the rollup's pooled balances to the attacker — there is no
matching inflow, which is exactly the broken invariant. Each test measures balanceAfter − balanceBefore
for the attacker and asserts a lower bound.
| Asset | Attacker before | Attacker after | Net stolen (asserted) | PoC assertion |
|---|---|---|---|---|
| ETH | 0 (test baseline) | +1,158 ETH | 1,158.000000000000000000 ETH (output.txt:165, output.txt:309) | assertGt(profit, 1000 ether) (AztecEscapeHatch_exp.sol:47) |
| DAI | 0 (output.txt:17) | 150,000 DAI (output.txt:155) | 150,000.000000000000000000 DAI (output.txt:6, output.txt:160) | assertGt(profit, 100_000e18) (:60) |
| renBTC | 0 | 46,963,295 (8 dp) (output.txt:468) | 0.46963295 renBTC (output.txt:314, output.txt:476) | assertGt(profit, 0.4e8) (:73) |
The logged profits — ETH profit: 1158.0 (output.txt:165),
DAI profit: 150000.0 (output.txt:6),
renBTC profit: 0.46963295 (output.txt:314) — are precisely the rollup's pooled funds that
left without any deposit on the other side, reconciling to the ~$2.2M headline loss in the PoC
@KeyInfo header.
Diagrams#
Sequence of one escapeHatch theft#
Rollup state evolution across the three thefts#
The flaw inside processRollupProof / withdraw#
Why it is theft: value conservation before vs. after#
Why each magic number#
proofData(the three hex blobs in the PoC): these are the crafted Turbo-PLONK rollup proofs. Their leading word encodes the rollupId —…00001187(4487, ETH),…00001188(4488, DAI),…00001189(4489, renBTC) — which must match the contract'snextRollupId(:527). The inner-tx public-output fields encode the withdrawal amounts and the attacker address0x6952d9246e9afe8b887b2877225163436f78e97fas theoutputOwner(visible inside the calldata at output.txt:20). The signatures/viewingKeys arguments are passed empty ("") because the withdrawal path needs neither.- Block numbers 25,339,093 / 25,339,168 / 25,339,171: the live blocks of the ETH, DAI and renBTC exploit transactions, in chain order. Each fork test pins to its block so the on-chain roots equal the proof's old roots (AztecEscapeHatch_exp.sol:39, :52, :65).
- Assertion thresholds
1000 ether/100_000e18/0.4e8: lower bounds the PoC checks against the actual payouts of 1,158 ETH, 150,000 DAI and 0.46963295 renBTC — chosen below the true amounts so the test confirms a material drain without hard-coding the exact wei. assetId0 vs non-zero:assetId == 0is ETH, paid via a rawcall(:653-656); DAI and renBTC use non-zero asset ids resolved throughgetSupportedAsset(assetId)and paid viatransfer(:657-659).
Remediation#
- Enforce value conservation on-chain, not only in the circuit. Track per-asset
totalDeposited[assetId]andtotalWithdrawn[assetId](the contract already maintains both (:218, :661)) and add an invariant inwithdraw/processDepositsAndWithdrawalsthat rejects any block whose net withdrawals would pushtotalWithdrawn[assetId]abovetotalDeposited[assetId](plus realised fees). A single proof should never be able to remove more value than was ever deposited. - Bind the withdrawal authorisation to verified prior deposits / notes. Require that each withdrawn
note's existence in the previous
dataRoot(and its non-membership innullRoot) is part of the public input the on-chain layer checks, rather than trusting the circuit to have done so silently. - Treat the verifier as a single point of failure and add defense in depth. A bug or verification-key mismatch in the Turbo-PLONK verifier (or any path that lets a malformed proof pass) directly translates to fund loss because nothing downstream re-checks value. Add per-block withdrawal caps, a circuit-breaker that pauses on anomalous outflow, and reconciliation against the contract's actual token balances.
- Reconsider the permissionless escape hatch's blast radius. The escape hatch is a valuable censorship-resistance feature, but because it shares the unchecked withdrawal path it lets anyone with an accepted proof drain the pool. Gate the value it can move per call/per window, or route escape-hatch exits through a stricter, deposit-matched withdrawal accounting than the operator path.
- Audit the Turbo-PLONK public-input constraints and verification keys (
getKeyById, contracts_verifier_TurboVerifier.sol:43) to ensure the accepted proofs could not have encoded unbacked withdrawals in the first place.
How to reproduce#
The PoC runs offline against a local anvil snapshot (anvil_state.json in this folder). The fork URL
in the test is http://127.0.0.1:8545 resolved via the mainnet alias; the shared harness boots anvil
from the saved state, so no public archive RPC is contacted. foundry.toml sets evm_version = 'cancun'.
_shared/run_poc.sh 2026-06-AztecEscapeHatch_exp --mt testExploit -vvvvv
(The suite exposes three exploit tests — testExploit_ETH, testExploit_DAI, testExploit_renBTC;
run them with --mt 'testExploit_' to execute all three.)
- Each test calls
vm.createSelectFork("http://127.0.0.1:8545", <block>)at the ETH/DAI/renBTC attack blocks, thenvm.prank(ATTACKER)andescapeHatch(proofData, "", ""). - Result: all three tests pass; each logs its profit via
log_named_decimal_uint.
Expected tail (from output.txt):
[PASS] testExploit_DAI() (gas: 449169)
Logs:
DAI profit: 150000.000000000000000000
[PASS] testExploit_ETH() (gas: 417550)
Logs:
ETH profit: 1158.000000000000000000
[PASS] testExploit_renBTC() (gas: 453621)
renBTC profit: 0.46963295
Suite result: ok. 3 passed; 0 failed; 0 skipped; finished in 10.90s (26.30s CPU time)
Ran 1 test suite in 11.44s (10.90s CPU time): 3 tests passed, 0 failed, 0 skipped (3 total tests)
Reference: Aztec V1 escape-hatch unbacked-withdrawal exploit — verified vulnerable source at https://etherscan.io/address/0x737901bea3eeb88459df9ef1be8ff3ae1b42a2ba#code (Ethereum mainnet, Jun 2026, ~$2.2M).
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2026-06-AztecEscapeHatch_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
AztecEscapeHatch_exp.sol. - Attack transaction: view on explorer.
Alerts & third-party analyses
- DeFiHackLabs incident explorer: search "Aztec V1 Escape-Hatch Exploit".
- Web3Sec X hacked database: search.
- Rekt leaderboard: search.
- Solodit incident search: search.
These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.