Logo

How ZKSWap’s $5M Exploit Could’ve Been Prevented with Onchain Monitoring

Deep Dive
How ZKSWap’s $5M Exploit Could’ve Been Prevented with Onchain Monitoring

On July 9, 2025, ZKSwap’s Layer 1 Ethereum bridge suffered a $5 million exploit targeting its emergency withdrawal mechanism.

At its core, the exploit was alarmingly straightforward: the mechanism responsible for verifying zero-knowledge proofs failed to actually verify them. This oversight allowed the attacker to freely generate fake withdrawal proofs, effectively bypassing the bridge’s fundamental security guarantees.

In this report, we’ll analyze precisely how the exploit unfolded, examine the code-level flaws that enabled it, and highlight why proactive, real-time monitoring is essential for detecting and preventing such incidents before significant harm occurs.

Background: ZKSWap and the bridge architecture

ZKSWap, operating under the broader ZKBase project, is a Layer 2 scaling solution built on Ethereum that leverages zero-knowledge rollups (zk-rollups) to enhance transaction throughput and reduce costs. A critical component of this infrastructure is the bridge that enables users to transfer assets between Ethereum Layer 1 (L1) and the ZKSwap rollup Layer 2 (L2).

zkbase.png

Under normal operations, users deposit assets onto the L2 environment, enjoying fast and inexpensive transactions secured by cryptographic proofs. To return assets to L1, withdrawals typically require operator-generated validity proofs.

However, a key design element, called "Exodus Mode", was included to ensure users could independently withdraw their funds from L2 in emergencies, such as prolonged downtime or operator unresponsiveness.

In Exodus Mode, the bridge allows users to manually reclaim their funds by submitting proofs directly to the Ethereum L1 contract, demonstrating that they held specific balances within the last verified L2 state. This fallback mechanism is intended as a crucial safety net, ensuring users maintain sovereignty over their assets even in the worst-case scenario.

Unfortunately, as we’ll see in the following sections, it was precisely this fallback mechanism that attackers exploited, highlighting the risks inherent in even the most well-intentioned emergency safeguards.

Attack overview

On February 2, 2025, ZKSwap finalized its last verified Layer 1 block, anchoring the state root onto Ethereum Layer 1. This state root was intended as the canonical reference point for emergency withdrawals.

Months later, on July 9, 2025, an attacker triggered ZKSwap’s Exodus Mode, submitting fabricated cryptographic proofs to the bridge contract. Due to a critical flaw (specifically, the verifyExitProof() function not performing actual cryptographic verification) the attacker’s invalid proofs were accepted as genuine.

The attacker repeatedly exploited this oversight by submitting fraudulent proofs across multiple token balances, artificially inflating their onchain withdrawal amounts. Leveraging these illegitimate balances, the attacker executed standard withdrawal transactions, successfully extracting approximately $5 million in assets from the bridge.

How Exodus Mode works

To understand how the attacker managed to exploit the bridge, we first need to understand how Exodus Mode is supposed to work.

ZK-rollups depend on operators to submit validity proofs that reflect correct L2 state transitions. If the operator becomes unavailable or malicious, users must still be able to reclaim their funds. Exodus Mode is designed to handle this scenario.

Once activated, Exodus Mode allows users to exit by submitting a proof that their balance existed in the last known-good state (the most recent L2 state root verified on Ethereum). The exit() function handles these withdrawals and is expected to follow a strict sequence:

  1. Validate the Proof: The user provides a cryptographic proof (e.g., Merkle or zk-SNARK) showing that their (accountId, address, tokenId, amount) tuple was part of the final L2 state root. This proof is passed to the verifyExitProof() function.
  2. Credit the Withdrawal: If the proof is valid, the claimed amount is recorded in the onchain balancesToWithdraw mapping for that user and token.
  3. Nullify the Proof: The corresponding leaf or identifier is marked as "exited" to prevent reuse or double claims.

This mechanism is fundamental to the rollup trust model: even if the sequencer or operator disappears, the L1 contract must remain a secure and final authority for asset recovery. But if any of these steps are flawed, this safety mechanism becomes a liability - and the escape hatch becomes a free-for-all.

The vulnerability: Fake proofs, real withdrawals

The core vulnerability in ZKSwap’s bridge was in how it handled proof verification during Exodus Mode. Specifically, the verifyExitProof() function, responsible for confirming that a user’s withdrawal claim was legitimate, didn’t actually verify anything.

The vulnerability becomes clearer when we look at the relevant portion of the ZKSwap bridge contract. Below is an annotated version of the exit() function:

1function exit(
2 uint32 accountId,
3 uint16 tokenId,
4 uint128 amount,
5 uint256[] calldata proof
6) external payable /* nonReentrant */ {
7 require(exodusMode, "fet11");
8 require((exited[tokenId] & 0xff) == 0, "fet12"); // not exited yet
9
10 // Call the verifier
11 bool ok = verifier.verifyExitProof(
12 blocks[totalBlocksVerified].stateRoot,
13 accountId,
14 msg.sender,
15 tokenId,
16 amount,
17 proof
18 );
19 require(ok, "fet13");
20
21 bytes32 key = _balanceKey(msg.sender, tokenId);
22
23 // Safe add (uint128 math)
24 uint128 current = balancesToWithdraw[key];
25 uint128 newBal = current + amount; // overflow check already done in asm
26 balancesToWithdraw[key] = newBal;
27
28 // Mark token as exited
29 exited[tokenId] = (exited[tokenId] & ~uint8(0xff)) | 0x01;
30
31 // release reentrancy lock
32}

The exit() function is the heart of ZKSwap’s emergency withdrawal flow. It’s called by users during Exodus Mode to reclaim funds from the last verified Layer 2 state. The general flow of the code is very similar to what you’d expect - the function ensures that:

  1. The bridge is in Exodus Mode (require(exodusMode)).
  2. The withdrawal has not already been executed (require(!exited)).
  3. The user’s cryptographic proof is valid (require(ok, "fet13")).
  4. The user’s balance is safely credited onchain (balancesToWithdraw[key] = newBal).
  5. The withdrawal is marked as completed (exited[...] = true).

The most critical part of this flow is the verifyExitProof() function, which was intended to be the gatekeeper of Exodus Mode.

This function is responsible for validating that a user’s claimed withdrawal was backed by a legitimate proof of inclusion in the final Layer 2 state. In any secure zk-rollup, this step is non-negotiable: it ensures that exit claims correspond to real balances in the last verified state.

But in ZKSwap’s bridge, verifyExitProof() did none of that. Notice anything strange about the code (src)?

1 function verifyExitProof(
2 bytes32 _rootHash,
3 uint32 _accountId,
4 address _owner,
5 uint16 _tokenId,
6 uint128 _amount,
7 uint256[] calldata _proof
8 ) external view returns (bool) {
9 return true;
10 bytes32 commitment = sha256(abi.encodePacked(_rootHash, _accountId, _owner, _tokenId, _amount));
11
12 uint256[] memory inputs = new uint256[](1);
13 uint256 mask = (~uint256(0)) >> 3;
14 inputs[0] = uint256(commitment) & mask;
15 Proof memory proof = deserialize_proof(inputs, _proof);
16 VerificationKey memory vk = getVkExit();
17 require(vk.num_inputs == inputs.length);
18 return verify(proof, vk);
19 }

At first glance, one might say that it looks reasonable - seems like there are a lot of verifications being made in this function.

But on a closer look, the issue becomes clear - the first line is a simple return true, which means that none of the checks performed in the following lines are actually executed. The function accepts arbitrary data and blindly returns true, signaling that the claim is valid, even when it’s completely fabricated.

This effectively disables the most important security control in the emergency withdrawal path. From the bridge’s perspective, any caller could prove ownership of any amount of any token, at any time. And because the rest of the exit() logic trusted this return value without further validation, the system fell apart.

This is the kind of failure that breaks the entire rollup trust model: the contract stops being a verifier of state and becomes a no-questions-asked faucet.

Exploitation flow: how the attacker drained the funds

The non-existent checks in verifyExitProof() were enough to compromise the bridge. But the situation was made worse by weak nullifier logic designed to prevent double exits.

The exited mapping, which tracked whether a user had already withdrawn a given balance, relied on fragile bit-packing and failed to enforce uniqueness correctly. Combined, these flaws allowed for a full exploitation of the bridge - which is exactly what the attacker had done.

Here’s a breakdown of the full exploit chain:

Trigger Exodus Mode

  • The contract stops normal rollup operations on Feb 2, 2025 and allows emergency exit() calls.
  • The exploiter simply call triggerExodusIfNeeded which moved to contract to Exodus Mode
trigger.png

Submit arbitrary “Proofs”:

  • The attacker constructed inputs with made-up (accountId, tokenId, amount) tuples and passed them to exit().
  • Because verifyExitProof() didn’t validate anything, these calls were accepted.
  • This was done repeatedly across 15 different tokenId variations.
proofs.png

Credit fake balances:

  • Each accepted exit added to the attacker’s balancesToWithdraw for that token. These balances were recorded onchain and treated as legitimate.
  • Nullifier logic (intended to block repeats) is either token-wide or written to the wrong slot, so it doesn’t stop multiple claims.
balance.png

Execute withdrawals:

  • Once the balances were credited, the attacker called the standard withdraw() function to transfer tokens out of the bridge and into the exploit contract.
withdrawals.png
  • Finally, with the bridge funds siphoned, the attacker started to move the stolen assets away from the exploit contract into multiple other addresses
exploit_moves-1.png

How this exploit could’ve been prevented

By the time the funds were withdrawn, the attacker had already submitted multiple fake exit transactions and accumulated inflated balances onchain. But this wasn’t a zero-second, MEV-style exploit.

Looking closely at the timeline, there was a window of more than ten minutes between the exploit contract’s deployment (14:12:35 UTC) and the first actual withdrawal (14:25:23 UTC).

timeline.png

During that time, several onchain signals fired in sequence:

  • A new contract was deployed targeting the bridge
  • Exodus Mode was activated
  • A series of exit transactions were sent with arbitrary-looking calldata
  • Withdrawal balances spiked for a previously inactive address

These weren’t subtle anomalies. They were clearly visible onchain, and they happened in a predictable order. Any monitoring system watching for contract deployments, mode changes, and suspicious exit activity could have caught them - and acted.

platform.png

If real-time detection had been in place, this exploit could have been stopped. The bridge could have paused withdrawals, blocked the attack contract, or at minimum escalated the incident to a human before any funds were lost.

Key lessons

The ZKSwap bridge exploit highlights a familiar but dangerous failure mode: the assumption that critical verification logic is “just working.” In this case, the bridge’s fallback mechanism - meant to preserve user access in emergencies - was left wide open due to a missing check that should have been foundational to its design.

Here are the key takeaways for any team building or maintaining similar infrastructure:

Always-on, real-time monitoring is non-negotiable

The attacker had a ten-minute lead time between deploying their contract and draining funds. That’s not a narrow window. That's an opportunity.

Security systems need to operate continuously and in real-time, not in batches or polling cycles. If you’re not monitoring contract deployments, function calls, and mode transitions as they happen, you’re effectively flying blind during the only period when intervention is still possible.

Detection must trigger safeguards (automatically and fast)

Spotting malicious behavior isn’t enough. Detection has to be wired into automated responses: triggering pause switches, applying rate limits, or blocking specific callers. And when escalation is needed, it must reach the right person immediately. Not a Slack channel, not a dashboard, not a ticket queue.

The attacker in this case didn’t move instantly - there was time to act. But detection without action is just a delayed postmortem.

Correlate signals and drill the response path

A contract deployment isn’t unusual. A mode change isn’t necessarily suspicious.

But when they happen in sequence - deploy → Exodus Mode → exit transactions - that’s a high-confidence threat pattern.

Your monitoring needs to correlate these events, not just flag them in isolation. And once you can detect them, you need to drill the response. Know who’s on call. Know what gets paused. Know how fast you can act.

If you’re not rehearsing that flow, it won’t be ready when it counts.

Final thoughts

The ZKSwap bridge exploit wasn’t complex. It didn’t rely on novel cryptography, obscure compiler tricks, or multi-step coordination. It was simple: a missing verification check in a critical piece of fallback logic. That single omission turned a safety mechanism into a vulnerability and cost users $5 million.

But the deeper failure wasn’t just in the code. It was in the absence of visibility. There was time to detect this. There were signals that something was wrong. Without monitoring, they were ignored.

This exploit is a reminder that real-world attacks rarely happen all at once. They unfold, sometimes slowly, sometimes predictably. What matters is whether anyone is watching closely enough to act before it’s too late.


Appendix: Transactions and Onchain IOCs

StepTx HashDescription
Exodus mode triggering0xfdb9...1182Exploiter caused the contract to switch into emergency exit mode
Fake exits / balance inflation0x5488...e054Attacker credited themselves across many tokens using bogus “proofs”
Withdrawal (Example TX)0xfa9f...56a3One of multiple transactions that shows the funds being drained
Cash out (Example TX)0xde0c...0aabAfter draining the bridge, the attacker is cashing out funds from the exploit contract