October 20, 2026
|
Exploit Postmortem
Cover image for "Aztec Connect's $2.19M Loss and How Olympix Would Have Prevented It"

Aztec Connect's $2.19M Loss and How Olympix Would Have Prevented It

Date: June 14, 2026

Chain: Ethereum

Loss: about $2.19M in ETH, DAI, wstETH, LUSD and three Yearn vault tokens

Class: proof verification and settlement reading the same rollup data with different boundaries

Aztec Labs shut down Aztec Connect the way a careful team is supposed to. It stopped taking deposits in 2023, kept withdrawals running for another full year with reminders urging users to exit, and in April 2024 revoked every admin role and renounced every bit of upgrade authority it held, so that no one, including Aztec itself, could ever touch what users left behind. The contract was meant to sit on Ethereum as a permanent, ownerless exit door.

On June 14, 2026, more than two years after the last key was renounced, someone walked through that door with about $2.19M. The zero-knowledge proofs that guarded the contract did their job on every call. The money left because the code that checked the proof and the code that moved the funds were reading the same data with two different rulers.

A Privacy Rollup With No One Left at the Controls

Aztec Connect launched in 2022 as a zk-rollup that let people use DeFi protocols like Aave and Lido on Ethereum while zero-knowledge proofs kept their transaction details private. Users' assets sat in the rollup's contract on Ethereum, a proxy at 0xFF1F...0455, and a batch of rollup transactions only took effect on Ethereum once a valid proof for it was submitted to processRollup(). The contract checked that proof with a separate verifier contract and then settled the batch: it collected deposits users had approved, paid out withdrawals, and passed DeFi interactions to bridge contracts.

The version running onchain in 2026 was RollupProcessorV3, whose implementation at 0x7d65...2728 was deployed on April 8, 2024, after the product was deprecated. It let remaining users submit their own batches and exit without depending on Aztec's sequencer, which had stopped running on March 31, 2024. The verified V3 source shows processRollup() guarded only by whenNotPaused and allowAsyncReenter, with no rollup-provider check, and nobody was left who could pause it. Onchain, rollupProviders(attacker) returns false, and every one of the attacker's calls still went through. It was a generous change, and it also meant the proof check became the only thing standing between the contract and anyone who called it.

The Proof Counted 32 and the Contract Counted One

Every batch submitted to processRollup() carries a field called numRealTxs, which says how many real transactions are inside it. Decoder.sol's decodeProof() reads it straight from calldata and returns it as numTxs, with nothing tying it to anything else. The same function also reads rollupSize, the maximum number of transactions in the block, and the number of inner rollups, and it rounds numRealTxs up to whole inner-rollup chunks to decide how much data goes into the SHA-256 hash the proof commits to. SlowMist calculated that with the attacker's settings, rollupSize = 1024 and 32 inner rollups, that chunk is 32 transaction slots, or 8,192 bytes, and the proof commits to every one of them.

The verified RollupProcessorV3 source on Blockscout then hands that same numTxs to settlement. From processRollupProof():

    function processRollupProof(
        bytes memory _proofData,
        bytes memory _signatures,
        uint256 _numTxs,
        uint256 _publicInputsHash,
        address _rollupBeneficiary
    ) internal {
        uint256 rollupId = verifyProofAndUpdateState(_proofData, _publicInputsHash);
        processDepositsAndWithdrawals(_proofData, _numTxs, _signatures);
        bytes32[] memory nextDefiHashes = processBridgeCalls(_proofData, _rollupBeneficiary);
        emit RollupProcessed(rollupId, nextDefiHashes, msg.sender);
    }

The function takes the decoded proof data, the signatures, the transaction count and the hash of public inputs. Its first line sends the proof and that hash to the verifier, which checks the whole committed chunk. Its second line settles deposits and withdrawals, and the only length it receives is _numTxs, the number the caller typed. The third and fourth lines handle DeFi bridge calls and log the rollup. And from the same file, the start of processDepositsAndWithdrawals():

    function processDepositsAndWithdrawals(bytes memory _proofData, uint256 _numTxs, bytes memory _signatures)
        internal
    {
        uint256 sigIndex = 0x00;
        uint256 proofDataPtr;
        uint256 end;
        assembly {
            // add 0x20 to skip over 1st member of the bytes type (the length field).
            // Also skip over the rollup header.
            proofDataPtr := add(ROLLUP_HEADER_LENGTH, add(_proofData, 0x20))

            // compute the position of proofDataPtr after we iterate through every transaction
            end := add(proofDataPtr, mul(_numTxs, TX_PUBLIC_INPUT_LENGTH))
        }

        // This is a bit of a hot loop, we iterate over every tx to determine whether to process deposits or withdrawals.
        while (proofDataPtr < end) {

The assembly block points proofDataPtr at the first transaction slot, just past the rollup header. It then sets end to that pointer plus _numTxs slots, so the loop that follows, the one that checks a deposit was approved and pulls its funds in, or pays a withdrawal out, stops after exactly numRealTxs transactions. Anything sitting in slots past that point was hashed into the proof and accepted by the verifier, and the loop never reads it.

That left a strip of slots the proof covered and settlement never looked at. BlockSec Phalcon traced the root cause to exactly that mismatch between the transaction set the proof enforced and the boundary L1 settlement used, and Aztec Labs' own report put it in one line: "It is possible to submit a row that is accepted by the proof, but that the settlement code never processes." Halborn adds that the proof validation logic only verified part of the proof data, leaving token-transfer parameters outside the verified area.

The simplest way to picture it is a bank where an auditor stamps all 32 deposit slips in a bundle as genuine, and the teller only counts cash for the first slip. The other 31 still land in the ledger as deposits, and nobody ever collected money for them. Nothing in the cryptography failed, and the proof system and its trusted setup weren't compromised. The proof was valid every time, and it described something the contract never actually checked.

Seven Rollups to Mint, Seven to Withdraw

The attacker's wallet, 0x0F18...dD17, got its only funding before the attack at 09:26:47 UTC on June 14, about 0.098 ETH withdrawn from Tornado Cash's 0.1 ETH pool. Between 12:16 and 12:26 UTC the attacker deployed five contracts, and Rekt reports that Blockaid flagged the deployment about six minutes before the drain. At 12:26:23 UTC a single transaction in block 25,315,715 called the attack contract at 0x06f5...0fcD with selector 0x6f3ce701, and that contract pushed 14 crafted rollups, numbered 13277 through 13290, through helper relayer contracts into the proxy, using 4,513,539 gas.

Each of the first seven rollups declared numRealTxs = 1. According to SlowMist, slot one, the only one settlement read, held a no-op with a public value of zero, and the gap slots held deposits with a nonzero public value credited to the attacker's L2 account. Those deposits were in the hashed data, so the verifier accepted them, but no approval check ran and no pending deposit balance went down. After seven rounds, the attacker held a large unbacked L2 balance, and the contract's real holdings on Ethereum hadn't moved at all.

The next seven rollups each put a single withdrawal in the slot settlement does read, one per asset, with the attacker's wallet as the public owner, and the contract paid out against balances it had never received. DarkNavy matched the skipped deposit amounts to the later withdrawals, with ETH higher by 0.00059732 ETH, the amount sent to Aztec's fee distributor. Rollups 13284 through 13290 sent the attacker 270,513.05 DAI, 167.89 wstETH, 4,873.86 yvDAI, 16.57 yvWETH, 9,273.73 LUSD, 359.05 yvLUSD and 908.99 ETH. There was no flash loan, no reentrancy and no stolen key. Aztec Labs identified the incident internally at 12:30 UTC.

Nobody Left to Pause It

Aztec Labs confirmed the drain about two hours later and was blunt about its options: "Aztec Labs holds no admin keys or control over the system; it cannot be paused or upgraded by us." The Aztec Foundation confirmed the incident had no connection to the AZTEC token or the current Aztec network. CertiK had gone public at about 13:52 UTC, and SlowMist published its analysis the next day.

The same flaw stayed open. At 20:28 UTC that evening, a second wallet was funded with 0.1 ETH through a relayer contract, and from 04:00 to 05:17 UTC on June 15 it submitted another 14 rollups, starting at rollupId 13291 where the first attacker stopped. Aztec Labs reports it used the same principle to pull DeFi bridge positions out of the rollup, worth about $88K, and moved the proceeds toward what looks like an exchange deposit service. As of October 20, 2026, Blockscout showed the Aztec Connect proxy holding no ETH.

The first attacker's assets sat in the wallet for weeks before they started moving. PeckShield tracked a 145 ETH deposit into Tornado Cash on July 2 and a 300 ETH deposit on August 8, which brought the total to 500 ETH, about 55% of the ETH taken. On October 19, 2026, the same wallet swapped more of the haul through ParaSwap's router and sent a run of 100, 10, 1 and 0.1 ETH deposits to the Tornado Cash router, the last at 15:45:35 UTC, and as of October 20 it held about 0.036 ETH. Protos reported that a different legacy Aztec contract was drained of about $2M on June 17 through a separate Escape Hatch flaw.

A Final Upgrade With No Second Chance

Aztec Labs says the bug wasn't found in an external audit, and its report links Solidified's audit of the system. The protocol also ran a bug bounty from launch until the 2024 renouncement, and Rekt notes a $450,000 bounty was paid in 2023 for a different critical flaw in the same codebase. Rekt says the April 2024 upgrade reportedly shipped without an external audit of its own. Audits and bounties cover the scope and the threat model they're given, and the V3 change altered that threat model: as long as only approved rollup providers could call processRollup(), the numRealTxs boundary sat behind a trusted party, and once anyone could call it, the boundary became the attack surface.

Renouncing control is what made Aztec Connect trustworthy to the users who stayed, and it's also what turned a code flaw into a permanent one. The 2024 upgrade was the last change anyone would ever be able to make to that contract, and the gap shipped with it.

How Olympix Finds the Slots Nobody Settled

Olympix reads a contract the way an attacker does, as a set of states, transitions and entry points, and it takes in everything around the code as well, here the rollup design, the sunset plan and the V3 change that opened processRollup() to anyone. From that it infers the invariants the system depends on. A rollup bridge depends on one anyone can state in a sentence: every balance credited on L2 has to be backed by an asset the contract actually received on L1, which means every transaction the proof commits to has to be a transaction settlement processes.

processRollup() accepts arbitrary calldata, so its state space looks far too large to search blindly, and signals are what narrow it down. The signal in the quoted code is a single caller-supplied length, numTxs, that bounds the settlement loop through end, while a different, rounded-up length decides what gets hashed for the proof. Olympix's static analysis surfaces that pattern, and the engine narrows from every possible path to the feasible ones, then to the paths that can touch the invariant, which puts the gap between those two lengths at the center of the search.

Once that boundary is flagged, BugPoCer takes over from the other end of the problem. It starts from the rollup as a whole, takes the backing rule as the thing to break, and runs Counterexample-Guided Invariant Refutation over batches that pull numRealTxs and the hashed chunk size apart. The counterexample for this exploit would contain a batch that declares one transaction while the proof covers 32, a deposit in a slot past the boundary credited to an outside L2 account, the verifier returning true, the L2 balance rising while the contract's L1 token balances and pending deposit records stay flat, and then a second batch with an in-boundary withdrawal that moves real tokens out. The minimal conditions are an open processRollup(), a numRealTxs smaller than the chunk the proof hashes, and a nonzero public value in a slot between them. BugPoCer returns that sequence with a generated Forge test that replays it.

In Aztec's workflow, that counterexample would have surfaced at the merge that introduced V3, because that's the change that made the boundary reachable by anyone, before the April 2024 upgrade shipped and before the keys were renounced. The engine runs automatically in development, at every merge and before release, so the comparison between the loop bound and the hashed length gets made on every run, including the last run before a team gives up control for good.

The Last Upgrade Is the One That Has to Be Right

Aztec Connect was drained more than two years after anyone could change it, by a batch that passed every cryptographic check it faced. Any team that splits verification and execution across two code paths, and especially one about to give up its keys for good, needs evidence that both paths read exactly the same inputs before that final upgrade ships. To watch Olympix infer the invariants in your own contracts and hand back a working proof of concept for every one that breaks, book a technical walkthrough.

On-Chain References

Protocol: Aztec Connect (RollupProcessorV3)

Chain: Ethereum

Date: June 14, 2026, 12:26:23 UTC

Loss: about $2.19M (908.99 ETH, 270,513.05 DAI, 167.89 wstETH, 9,273.73 LUSD, 4,873.86 yvDAI, 359.05 yvLUSD, 16.57 yvWETH)

Attacker EOA: 0x0F18...dD17

Attack contract: 0x06f5...0fcD

Helper contracts (per DarkNavy): 0xe810...0bce, 0xd109...e0f8, 0x276e...f6ec

Aztec Connect proxy (victim): 0xFF1F...0455

RollupProcessorV3 implementation (deployed April 8, 2024): 0x7d65...2728

Verifier contract: 0xb7ba...1cfb

Exploit tx: 0x074e...eeb1

Block: 25,315,715

Funding tx (Tornado Cash 0.1 ETH pool, 09:26:47 UTC): 0x3f05...634e

Sources

Aztec Labs: Aztec Connect Incident Post-Mortem

SlowMist: Analysis of the $2.19 Million Asset Theft from Aztec Connect

BlockSec Phalcon: numRealTxs / _numTxs Mismatch Root Cause

DarkNavy: Aztec Connect numRealTxs Mismatch Drain on Ethereum

Rekt: Aztec Connect

Cryptopolitan: Aztec Exploiter Moves 500 ETH to Tornado Cash (citing PeckShield)

More from Olympix:

No items found.

Ready to Shift Security Assurance In-House? Talk to Our Security Experts Today.