On June 14, 2026 at 12:26:23 UTC, Aztec Connect’s Ethereum rollup processor was drained in transaction 0x074ec9317d8336db37e8c348fbdd7515573ff4088239c77ab429f522509aeeb1. The attacker submitted 14 crafted processRollup(bytes,bytes) calls through helper contracts into the Aztec Connect proxy 0xff1f2b4adb9df6fc8eafecdcbf96a2b351680455, which delegated to verified implementation RollupProcessorV3 at 0x7d657ddcf7e2a5fd118dc8a6ddc3dc308adc2728. CertiK reported the incident at about $2.19M; on-chain transfers confirmed the proxy sent 908.986707307963496632 ETH plus DAI, wstETH, yvDAI, yvWETH, LUSD, and yvLUSD to the attacker.

The root cause was a mismatch between the rollup transaction data that was accepted into the proof/public-input hashing flow and the smaller transaction count used for L1 deposit/withdrawal side effects. Each crafted rollup had rollupSize = 1024, numRollupTxs = 32, and numRealTxs = 1, so decodeProof() hashed one 32-transaction inner-rollup chunk. In the first seven rollups, the second decoded transaction was a deposit-like public input outside numRealTxs; it was part of the hashed chunk but skipped by processDepositsAndWithdrawals(), which only iterates numRealTxs. Later rollups used real withdrawal records to send assets from the Aztec proxy to the attacker.

Root Cause

Vulnerable Contract

  • Proxy / custody contract: 0xff1f2b4adb9df6fc8eafecdcbf96a2b351680455 (Aztec: Connect).
  • Verified implementation: 0x7d657ddcf7e2a5fd118dc8a6ddc3dc308adc2728 (RollupProcessorV3).
  • Verifier called by the implementation: 0xb7baa1420f88b7758e341c93463426a2b7651cfb via verify(bytes,uint256).

Vulnerable Function

RollupProcessorV3.processRollup(bytes,bytes) selector 0xf81cccbe is the externally called rollup entrypoint. It decodes proof data, verifies the proof/public-input hash, updates rollup state, and then applies L1 deposit/withdrawal effects.

Vulnerable Code

function processRollup(bytes calldata, bytes calldata _signatures) external {
    (bytes memory proofData, uint256 numTxs, uint256 publicInputsHash) = decodeProof();
    address rollupBeneficiary = extractRollupBeneficiary(proofData);

    processRollupProof(proofData, _signatures, numTxs, publicInputsHash, rollupBeneficiary);
    transferFee(proofData, rollupBeneficiary);
}

function processRollupProof(
    bytes memory _proofData,
    bytes memory _signatures,
    uint256 _numTxs,
    uint256 _publicInputsHash,
    address _rollupBeneficiary
) internal {
    uint256 rollupId = verifyProofAndUpdateState(_proofData, _publicInputsHash);
    processDepositsAndWithdrawals(_proofData, _numTxs, _signatures); // <-- VULNERABILITY: side effects bounded by numRealTxs
    bytes32[] memory nextDefiHashes = processBridgeCalls(_proofData, _rollupBeneficiary);
    emit RollupProcessed(rollupId, nextDefiHashes, msg.sender);
}

function processDepositsAndWithdrawals(bytes memory _proofData, uint256 _numTxs, bytes memory _signatures)
    internal
{
    uint256 sigIndex = 0x00;
    uint256 proofDataPtr;
    uint256 end;
    assembly {
        proofDataPtr := add(ROLLUP_HEADER_LENGTH, add(_proofData, 0x20))
        end := add(proofDataPtr, mul(_numTxs, TX_PUBLIC_INPUT_LENGTH))
    }

    while (proofDataPtr < end) {
        uint256 publicValue;
        assembly {
            publicValue := mload(add(proofDataPtr, 0xa0))
        }
        if (publicValue > 0) {
            uint256 proofId;
            uint256 assetId;
            address publicOwner;
            assembly {
                proofId := mload(proofDataPtr)
                assetId := mload(add(proofDataPtr, 0xe0))
                publicOwner := mload(add(proofDataPtr, 0xc0))
            }

            if (proofId == 1) {
                // signature / approval check omitted for report brevity
                decreasePendingDepositBalance(assetId, publicOwner, publicValue);
            }
            if (proofId == 2) {
                withdraw(publicValue, publicOwner, assetId);
            }
        }
        unchecked {
            proofDataPtr += TX_PUBLIC_INPUT_LENGTH;
        }
    }
}

Why It’s Vulnerable

Expected behavior: every transaction record that can contribute to the rollup state transition or justify a later public withdrawal should be consistently bound to the proof and to the L1 side-effect checks. If a public deposit appears in the rollup transaction data, the L1 processor should validate approval/signature and reduce the corresponding pending deposit balance; if it is not a real transaction, it should not be usable to justify value movement.

Actual behavior: decodeProof() reads numRealTxs from the header, but for a rollup with rollupSize = 1024 and numRollupTxs = 32, it computes hashes at the inner-rollup chunk granularity. With numRealTxs = 1, the first non-empty inner rollup still covers 32 decoded slots. The exploit placed a second deposit-like record in that first chunk. That record was accepted into the proof/public-input hashing path, but processDepositsAndWithdrawals() only looped over the first record because _numTxs = 1. The L1 deposit approval and pending-balance decrease for the second record were therefore skipped, while subsequent valid-looking withdrawal records caused withdraw() to release assets from custody.

Attack Execution

High-Level Flow

  1. The attacker EOA 0x0f18d8b44a740272f0be4d08338d2b165b7edd17 called attack contract 0x06f585f74e0da633ae813a0f23fb9900b61d0fcd.
  2. The attack contract used three helper contracts to submit 14 processRollup(bytes,bytes) calls to Aztec Connect.
  3. Rollups 13277 through 13283 each had numRealTxs = 1 but carried a second deposit-like encoded transaction outside that count.
  4. Rollups 13284 through 13290 used real withdrawal records for asset IDs 1, 2, 3, 4, 10, 15, and 0, with the attacker EOA as public owner.
  5. processDepositsAndWithdrawals() reached withdraw(...) on those withdrawal records, transferring ETH and supported ERC20 assets out of the Aztec proxy.

Detailed Call Trace

The top-level transaction called 0x06f585f74e0da633ae813a0f23fb9900b61d0fcd with selector 0x6f3ce701. The attack contract then called helper contracts 0xe810f602aa8b45cc565f93e2141e740f0a640bce, 0xd10916a49e09321c983f0dfd8a546ae83fb0e0f8, and 0x276e972ffd99bd8df705db3f735a95f655ccf6ec. Those helpers made 14 calls into the Aztec proxy with selector 0xf81cccbe, and each proxy call delegated to RollupProcessorV3.

Every rollup call invoked the verifier contract 0xb7baa1420f88b7758e341c93463426a2b7651cfb through verify(bytes,uint256). The first seven rollups contained a second decoded deposit record with owner 0x7564105e977516c53be337314c7e53838967bdac and asset/value pairs matching the later withdrawals. The last seven rollups contained one real withdrawal record each, sending the corresponding value to the attacker EOA. The ETH withdrawal amount was 908.986707307963496632 ETH; the earlier skipped ETH deposit-like amount was higher by 0.00059732 ETH, matching the rollup fee transfers observed to the Aztec fee distributor.

Financial Impact

On-chain transfers from the Aztec proxy to the attacker confirmed:

  • 908.986707307963496632 ETH
  • 270,513.054157632152892774 DAI
  • 167.890392526462638142 wstETH
  • 4,873.856656463299373447 yvDAI
  • 16.569551230652464753 yvWETH
  • 9,273.734320992446140764 LUSD
  • 359.047484523695640986 yvLUSD

CertiK reported the total at approximately $2.19M. The exact USD equivalent depends on ETH, wstETH, and Yearn vault-share pricing at the incident time, so the report treats the token-unit movements above as the authoritative on-chain impact.

Evidence

  • Transaction: 0x074ec9317d8336db37e8c348fbdd7515573ff4088239c77ab429f522509aeeb1 on Ethereum, block 25315715, status success.
  • Attacker EOA: 0x0f18d8b44a740272f0be4d08338d2b165b7edd17.
  • Attack contract: 0x06f585f74e0da633ae813a0f23fb9900b61d0fcd.
  • Vulnerable proxy: 0xff1f2b4adb9df6fc8eafecdcbf96a2b351680455.
  • Vulnerable implementation: 0x7d657ddcf7e2a5fd118dc8a6ddc3dc308adc2728.
  • All 14 rollups had numRealTxs = 1 and hashedDecodedTxSlotsByDecodeProof = 32 in the decoded proof data.
  • Rollups 13277 through 13283 had second deposit-like records outside numRealTxs; rollups 13284 through 13290 had real withdrawal records to the attacker.
  • Asset IDs at block 25315715: 0 ETH, 1 DAI, 2 wstETH, 3 yvDAI, 4 yvWETH, 10 LUSD, 15 yvLUSD.

Remediation

  • Enforce one consistent transaction count for proof hashing, rollup state transition, and L1 side effects.
  • Reject encoded transaction data that contains non-padding records beyond numRealTxs, or require L1 deposit/withdrawal processing to validate every non-padding public-value record included in the committed inner-rollup chunks.
  • Add invariant tests that compare decoded transaction records, numRealTxs, inner-rollup chunk hashing, deposit balance decreases, and withdrawals for partially filled rollups.
  • Treat public asset movement as a high-risk sink: every withdraw(publicValue, publicOwner, assetId) must be traceable to a fully verified and L1-accounted source of funds.