News | OpenZeppelin

The Notorious Bug Digest #6: Balancer Side Story and Rust Specific Issues

Written by Jonas Merhej, Frank Lei, Ionut-Viorel Gingu | January 12, 2026

Welcome to The Notorious Bug Digest #6, a curated compilation of insights into recent Web3 bugs and security incidents. When our security researchers aren’t diving into audits, they dedicate time to staying up-to-date with the latest security space, analyzing audit reports, and dissecting on-chain incidents. We believe this knowledge is invaluable to the broader security community, offering a resource for researchers to sharpen their skills and helping newcomers navigate the world of Web3 security. Join us as we explore this batch of bugs together!

Balancer Hack Side Story: Bypassing Layer-Level Asset Freezes

On November 3, 2025, Balancer V2 was hacked due to a precision-loss vulnerability in its Stable Pool module. OpenZeppelin published a detailed analysis outlining the root cause and how the issue was introduced.

As a fork of Balancer, the Beets protocol on the Sonic chain was also subsequently affected by the same vulnerability, allowing the attacker to steal approximately $3M worth of Beets Staked Sonic (stS) tokens.

In response, the Sonic team attempted to contain the exploit by updating their Special Fee Contract, a privileged contract capable of modifying the EVM state database, to freeze the attacker's account. As shown in Image A below, the added freeze function sets the account's native token balance to zero and replaces the account's code with a special contract that has no public functions and cannot receive native tokens.

This L1-level freeze method seemed effective at first: The attacker had not approved any other address to move their tokens, so they could only initiate transfers themselves. With zero native tokens, they could not pay the gas fees to do so.

However, the Beets Staked Sonic token includes a method that doesn't require the token holder to pay gas: permit. Defined in ERC-2612, it allows for changing an account's ERC-20 allowance via a signed message from the account. Unlike approve, the token holder does not need to send a transaction and thus does not need to hold any native tokens. By leveraging this, the attacker first used permit to grant allowance to another account they controlled, then triggered a transferFrom call to move the frozen tokens to that account, and finally swapped the stS tokens across multiple transactions.

Takeaway: Asset freezes should be implemented at the token-contract level. Otherwise, attackers may bypass freezing by using meta transactions, permit approvals or similar gasless-authorization schemes.

Bug Analysis: usize Arithmetic Can Lead to Non-Determinism and Panics in ZKsync OS

This critical bug was identified in a recent ZKsync OS audit performed by OpenZeppelin. We will first explain the behavior of usize in Rust, then we will go over how ZKsync OS uses two different architectures under the hood, and finally explain the uncovered issues.

Quirks of usize

The usize unsigned integer type resolves to a 32-bit unsigned integer on a 32-bit machine and a 64-bit unsigned integer on a 64-bit machine. While usize is generally useful for memory indexing, its architecture-dependent nature can lead to inconsistencies when the same code runs on different hardware targets, especially in systems like ZKsync OS that operate on both 32-bit and 64-bit architectures.

Consider an example where a usize variable is used to store an offset value. If this offset needs to accommodate values larger than 2 ^ 32 - 1, a 32-bit system will not be able to represent it, potentially leading to truncation or conversion errors. On a 64-bit system, the same value would be handled without issue. This discrepancy can cause critical differences in program behavior and execution flow across architectures, even if the eventual outcome is a failure. The problem arises because the failure occurs at different points in the execution, which is problematic for a system that requires deterministic behavior for proof generation.

Double Architecture Setup

At its heart, ZKsync OS leverages both 32- and 64-bit architectures to optimize for performance and proof generation. In live transaction processing (“forward mode”), the system utilizes a high-throughput, performance-tuned environment that benefits from 64-bit operations, maximizing speed and efficiency for real-world blockchain workloads. Conversely, when generating zero-knowledge proofs (“proof mode”), the architecture switches to a RISC-V-based 32-bit simulation environment, ensuring a performant execution for proof generation.

Inconsistencies Caused By usize

During the audit, multiple instances of inconsistencies due to usize were found across the codebase, demonstrating how usize arithmetic can lead to non-determinism:

  1. In the l1_messenger's sendToL1(bytes) function, dynamic byte-parameter parsing utilizes usize. A crafted calldata with a length near u32::MAX, but an insufficient actual length can cause an offset to point to non-existent data. This leads to a critical discrepancy: the subsequent addition passes on a 64-bit sequencer but fails later due to the short calldata, whereas the same operation on a 32-bit prover would revert earlier during the checked addition.
  2. In the l2_base_token module, the l2_base_token_hook_inner function attempts to convert message_offset to usize using the try_into function. A malicious user can craft calldata with an offset that is near 2^32-1, while simultaneously ensuring the calldata is short enough for the pointer to reference non-existent data. Consequently, the code behaves differently depending on the target architecture. On 64-bit systems, it leads to an error later during the offset validity check. Conversely, on 32-bit systems, the error occurs earlier during the addition operation. Although both of the transactions will fail, each failure occurs in a different location. This presents a problem because the prover run needs to produce results identical to those computed in runner mode. Otherwise, the block execution cannot be proven on L1, causing the L1 block verification to halt.

Such bugs may sometimes be less obvious as they may also come up during rounding, not addition. For instance, ZKsync OS relies on its bootloader to orchestrate core functions, including transaction processing within a block. The try_begin_next_tx function is called by the bootloader at the start of each new transaction. This function rounds the reported byte length up to a machine word boundary (USIZE_SIZE) and then attempts to iterate over the transaction's content.

However, a vulnerability exists in 32-bit systems where USIZE_SIZE is 4. In these environments, calculating next_tx_len_bytes.next_multiple_of(USIZE_SIZE) can lead to a usize overflow if the input size is close to u32::MAX. In release builds, this overflow wraps to 0, resulting in next_tx_len_usize_words becoming 0 even when the transaction content iterator is not empty. This issue does not occur on 64-bit targets for an input of that size, creating a discrepancy in how transaction data is handled across different architectures.

Takeaway: In systems requiring deterministic behavior across architectures, architecture-dependent types like usizeshould be avoided for arithmetic. Instead, fixed-size integers (e.g., u32 or u64) should be used, which will ensure consistent and predictable results across all environments.

Bug Analysis: L1->L2 Execution Halted Due to Return Data Buffer Overflow

The ZKsync OS execution environment preallocates a 128 MB return_data buffer to store data returned from external calls. When data is returned from an external call, it is copied to the buffer and the remaining return_data buffer shrinks. Not having enough space left to copy in return_data will lead to a panic.

We highlighted an attack vector whereby the return_data buffer could be intentionally overflown by a malicious transaction. An attacker can craft a contract that, through many external calls to either user-space programs or the Identity precompile, overflows the return_data buffer while still staying well below the maximum gas allowed within ZKsync OS. While this issue is critical for all transactions, it carries additional consequences for the L1->L2 transaction queue.

A panic within an L1->L2 transaction would cause the current L1->L2 transaction to fail permanently. This is due to the nature of ZKsync's L1->L2 transactions, which are added to a queue and only processed in order. This creates a complete blockage, halting the entire queue and preventing all subsequent L1->L2 transactions from ever being processed.

The issue was mitigated by doubling the return_data buffer size, ensuring it cannot be overflown while still staying lower than the maximum gas limit. Additionally, the ZKsync OS system now considers this type of error as a "fatal error" from an intentional attack, and such transactions will revert while spending the gas used.

Takeaway: Transaction processing logic should be carefully reviewed for any scenario that can crash the node. Such scenarios should be avoided or gracefully handled, otherwise the transaction processing will potentially indefinitely crash the node upon each restart.

Disclaimer

It is important to emphasize that the intent behind this content is not to criticize or blame the affected projects but to provide objective overviews that serve as educational material for the community to learn from and better protect projects in the future.