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).

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:
- 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 theverifyExitProof()
function. - Credit the Withdrawal: If the proof is valid, the claimed amount is recorded in the onchain
balancesToWithdraw
mapping for that user and token. - 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 proof6) external payable /* nonReentrant */ {7 require(exodusMode, "fet11");8 require((exited[tokenId] & 0xff) == 0, "fet12"); // not exited yet910 // Call the verifier11 bool ok = verifier.verifyExitProof(12 blocks[totalBlocksVerified].stateRoot,13 accountId,14 msg.sender,15 tokenId,16 amount,17 proof18 );19 require(ok, "fet13");2021 bytes32 key = _balanceKey(msg.sender, tokenId);2223 // Safe add (uint128 math)24 uint128 current = balancesToWithdraw[key];25 uint128 newBal = current + amount; // overflow check already done in asm26 balancesToWithdraw[key] = newBal;2728 // Mark token as exited29 exited[tokenId] = (exited[tokenId] & ~uint8(0xff)) | 0x01;3031 // release reentrancy lock32}
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:
- The bridge is in Exodus Mode (
require(exodusMode)
). - The withdrawal has not already been executed (
require(!exited)
). - The user’s cryptographic proof is valid (
require(ok, "fet13")
). - The user’s balance is safely credited onchain (
balancesToWithdraw[key] = newBal
). - 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 _proof8 ) external view returns (bool) {9 return true;10 bytes32 commitment = sha256(abi.encodePacked(_rootHash, _accountId, _owner, _tokenId, _amount));1112 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

Submit arbitrary “Proofs”:
- The attacker constructed inputs with made-up
(accountId, tokenId, amount)
tuples and passed them toexit()
. - Because
verifyExitProof()
didn’t validate anything, these calls were accepted. - This was done repeatedly across 15 different
tokenId
variations.

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.

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.

- Finally, with the bridge funds siphoned, the attacker started to move the stolen assets away from the exploit contract into multiple other addresses

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
).

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.

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
Entity | Address |
Main Attacker EOA | 0x0a652decf9caca373e2b50607ecb7b069d71a7ba |
Main exploit contract | 0x2D3103c8Fdd9d9411E24f555fdad6B22F29F613A |
Fake Token Contract | 0xF0628CE987B7cf5d3687E1A7656fE37b556742dE |
Attacker EOA | 0xc6751e605869a06f91fee6039ae806d089e4c32e |
Attacker EOA | 0xf7fd39cbdc12d5002975d3f16ed67583e6243d68 |
Attacker EOA | 0x3aa199a8362e399ce47e7d12467f8c5034f2713b |
Victim contract | 0x8eca806aecc86ce90da803b080ca4e3a9b8097ad |
Step | Tx Hash | Description |
Exodus mode triggering | 0xfdb9...1182 | Exploiter caused the contract to switch into emergency exit mode |
Fake exits / balance inflation | 0x5488...e054 | Attacker credited themselves across many tokens using bogus “proofs” |
Withdrawal (Example TX) | 0xfa9f...56a3 | One of multiple transactions that shows the funds being drained |
Cash out (Example TX) | 0xde0c...0aab | After draining the bridge, the attacker is cashing out funds from the exploit contract |