Code doesn’t lie, but it does mislead when you’re not looking at the full stack. Last week, I spent 48 hours auditing a fresh ZK-EVM implementation that promised a 30% reduction in proof generation costs. The team was proud of their custom precompile for elliptic curve pairing checks. They should have been worried. What I found was a textbook case of optimization-driven blind spots—a flaw that turns a gas-saving feature into a potential chain rollback vector.
Context
Zero-Knowledge Ethereum Virtual Machines (ZK-EVMs) are the backbone of Layer 2 scaling. They execute Ethereum transactions off-chain, produce a validity proof, and submit it to L1 for finality. The bottleneck is always the proof generation time. Every microsecond shaved off the prover’s circuit is a win for throughput. Precompiles are Ethereum’s equivalent of hardware acceleration—hardcoded contracts for expensive operations like elliptic curve arithmetic (EIP-196, EIP-197). ZK-EVMs often add custom precompiles to handle ZK-specific operations more efficiently.
This particular project, let’s call it ‘ZK-Nexus’, introduced a new precompile for multi-scalar multiplication (MSM) used in their Groth16 verifier. The idea was sound: replace a generic MSM circuit with a dedicated precompile that parallelizes the computation using native CPU instructions. The benchmark looked impressive—proof generation dropped from 3.2 seconds to 2.1 seconds. But benchmarks are not security audits.
Core Analysis
The precompile was implemented as a Solidity contract that calls into a native Rust library via the EVM’s EXTCODEHASH and DELEGATECALL opcodes. On the surface, it’s a standard pattern. But the devil is in the boundary conditions. I ran a fuzzing campaign against the precompile’s input validation. The function expected two arrays: a list of points (G1) and a list of scalars (256-bit integers). The length of both arrays must match. The code checked if (scalars.length != points.length) revert. So far so good.
But the check happened after the arrays were already loaded into memory. The EVM’s CALLDATALOAD instruction reads 32-byte words from calldata. The precompile’s ABI encoding allowed for a nested array of variable length. The loaded length was used to allocate memory for the Rust side. Here’s the kicker: the length check used the Solidity length property, which is the number of elements in the array. But the actual memory allocation for the Rust side used a byte-length calculation based on the element size. If the attacker passed an array of size 0, the length check passed (0 == 0), but the byte-length calculation would still allocate a small buffer (e.g., 32 bytes for the empty array header). The Rust code then iterated over the elements using the array length from the ABI decoder, which was also 0. No iteration, no crash. But what if the attacker passed a scalar array of length 1 and a point array of length 0? The check 1 != 0 would revert. No exploit there.
I dug deeper. The precompile used a second check: if (scalars.length == 0) return 0. That was a gas optimization to skip computation for empty inputs. The problem is that this check was placed after the memory allocation. An attacker could craft a call where scalars.length is non-zero but points.length is zero. The length check would revert? Wait, no: the check is if (scalars.length != points.length) revert. So if scalars.length = 1, points.length = 0, it reverts. But what about overflow? The arrays are uint256 lengths. No overflow there.
I decided to look at the actual Rust code. I decompiled the precompile’s binary. The Rust function signature was fn msm(scalars: &[Scalar], points: &[G1Point]) -> G1Point. The Rust code used std::slice::from_raw_parts to create slices from the memory pointers. The pointers and lengths were passed from the EVM host. The EVM host code (in Go) for the precompile call was:
func (p *Precompile) Run(input []byte) ([]byte, error) {
// decode ABI
scalars, points, err := abi.Decode(input)
if err != nil { return nil, err }
if len(scalars) != len(points) {
return nil, fmt.Errorf("length mismatch")
}
// allocate memory for Rust
scalarsPtr := allocScalars(scalars)
pointsPtr := allocPoints(points)
result := msm(scalarsPtr, pointsPtr, len(scalars))
return encode(result), nil
}
Wait, the Go code passed len(scalars) as the third argument to msm, not len(points). That’s correct because they are equal after the check. But what if the check was bypassed? The check was in Go, and the Rust function was called via cgo. The Go code performed the length check unconditionally. So no bypass.
I was missing something. I looked at the allocScalars function. It allocated memory on the heap using malloc and copied the scalar values. The allocation size was len(scalars) * 32. But len(scalars) is a uint64. If len(scalars) was 0, allocation size was 0, which is fine. If len(scalars) was huge, multiplication could overflow. But the input is from the EVM, limited by gas and block size. No overflow.
Then I found it. The precompile had a fallback path for when the scalar array was empty. The Rust code check if (scalars.len() == 0) { return G1Point::identity(); }. This was correct. But the Rust code also had a second check: if (points.len() == 0) { return G1Point::identity(); }. However, the Go code only checked len(scalars) != len(points). If both arrays were empty, the check passes (0==0), and the Rust code returns identity. Fine.
But what if scalars.len() == 0 and points.len() == 1? The Go check 0 != 1 would revert. No exploit. I was stuck. I decided to run the actual fuzzer with a coverage-guided tool. After 10 million iterations, the fuzzer found a crash: a segfault in the Rust code. The input was: scalars array of length 1, but the scalar values were encoded as a single 32-byte zero, and points array of length 1, but the point was encoded as 64 bytes of zeros. That should be valid. Why segfault?

I replayed the input. The Go code decoded the scalars as [0] and points as [ (0,0) ]. Lengths match, check passes. Then allocScalars allocated 32 bytes, copied the scalar. allocPoints allocated 64 bytes, copied the point. Then msm was called with len(scalars)=1. The Rust code then tried to read the scalar and point. The scalar is fine. The point is a pair of field elements. The Rust code used a library that expects points to be in Montgomery form. The zero point (0,0) is the identity element. The library had a special case for identity: if both coordinates are zero, return identity. But the library’s check was if (x.is_zero() && y.is_zero()). The is_zero() method checks if the internal representation is all zeros. But the zero point was encoded as 64 bytes of zeros, which when loaded into the library’s internal representation (which uses a different field modulus for internal representation), the zeros became a valid representation of zero? Actually, the library expected the point to be in affine coordinates, and the identity is represented as (0,0) in affine. So it should be fine.

But the segfault was in the library’s point addition function. The fuzzer input had the point as (0,0), but the scalar was 0. The MSM of a single point with scalar zero should return identity. The library’s point multiplication function: scalar * point. If scalar is zero, it should return identity. But the library had a bug: when scalar is zero, it still performed a double-and-add loop, which at the first iteration checks if the bit is set. Since scalar is zero, no bits are set, so it returns the initial point (which is the identity). But the initial point was set to the input point (0,0). The double-and-add loop started with result = identity. But the identity was represented as a special constant INFINITY with a flag. However, the library’s add function did not check for the identity flag correctly when the other point was (0,0). Specifically, the add function checked if (self.is_infinity()) return other; and if (other.is_infinity()) return self;. But other.is_infinity() checked if the point is the identity point. The identity point is represented as (0,0) in affine, but the library used a projective coordinate representation where the identity is (0,1,0). The is_infinity() method checked if the Z coordinate is zero. For the input point (0,0) in affine, when converted to projective, it becomes (0,0,1). So Z is not zero, so is_infinity() returns false. Then the addition proceeds to compute (x1, y1, z1) + (x2, y2, z2). The formula for point addition on the alt_bn128 curve (used in Ethereum) works for all points except the identity. If one of the points is the identity, the result is the other point. But since the library didn’t recognize the input point as identity, it tried to compute the addition normally. That involves computing lambda = (y2 - y1) / (x2 - x1). Since both points are (0,0,1), x1==x2==0, y1==y2==0, the denominator is zero, leading to a division by zero in the field. The library’s field arithmetic did not handle division by zero; it returned a garbage value, which then caused a memory access out of bounds (the segfault).
So the bug was: the precompile’s Rust library did not treat the affine point (0,0) as the identity, even though the Ethereum spec defines the point at infinity for the alt_bn128 curve as (0,0) in affine coordinates (when using the standard ABI from EIP-196). The library was designed for a different representation (projective coordinates with Z=0 for identity). The Go code did not convert the affine (0,0) to the library’s internal representation of identity. The precompile’s documentation said it accepts points in the standard Ethereum format, but the implementation was inconsistent.
This is a classic boundary condition: the identity element is a corner case. Many cryptographic libraries treat it separately. The precompile’s optimization bypassed the safety check because the Go code assumed the Rust library would handle identity correctly, but it didn’t. The result: an attacker can cause a crash (segfault) of the sequencer node by calling the precompile with a zero scalar and a zero point. More importantly, if the crash happens during block production, the sequencer might fail to produce a proof, delaying the batch submission. In a worst-case scenario, if the crash corrupts the node’s state, it could lead to a chain fork.
Contrarian Angle
You might think the fix is simple: add a check in the Go code to convert (0,0) to the library’s identity flag. But the real issue is the trust boundary between the host (Go) and the guest (Rust). The precompile is a performance optimization, but it introduces a complex foreign function interface (FFI) that is notoriously tricky to secure. The team spent weeks optimizing the gas cost but only a few hours on the FFI boundary. This is a common blind spot in the ZK space: teams focus on the cryptography and the circuits, but the node’s execution environment (the EVM host) is treated as a commodity. The precompile is not a black box; it’s a new attack surface. The optimization saved 1.1 seconds per proof, but at the cost of introducing a potential denial-of-service vector. In a bull market, teams are pressured to ship fast. The narrative is “ZK-EVM is ready for mainnet.” But the reality is that every new precompile is a liability until it’s formally verified.
Furthermore, the identity element bug is not just a crash. I reconstructed a scenario: if the sequencer node crashes, the validator set might need to wait for a timeout before reassigning the block. In a Layer 2 with a single sequencer, that means a halt. The team’s response was to patch the Rust library to treat (0,0) as infinity. But they didn’t audit the rest of the FFI. I found two other issues: (1) memory leaks in the allocScalars function when the Rust function panics, and (2) a potential type confusion in the encoding of the result point. The second issue is more severe: the Rust function returns a G1 point in projective coordinates, but the Go code expects affine coordinates. The Go code assumed the Rust function returns affine, but the Rust code returned projective (x, y, z) with z=1 for non-identity. The Go code then encoded the x and y directly, ignoring z. For identity, the Rust function returned (0,1,0) but the Go code encoded (0,1) as a point, which is not a valid point on the curve (since 1 is not the correct y for x=0). The verifier on L1 would reject this proof, causing a failed batch. This is a critical bug: the precompile generates invalid proofs for the edge case of zero scalar or identity point.
Takeaway
The precompile paradox is this: optimization is good, but it shifts the security burden from the mathematical soundness of the proof to the correctness of the implementation. The ZK community obsesses over the circuit’s constraints, but the biggest risk is often the code that feeds data into the circuit. The next time you see a protocol boasting about a 30% gas reduction from a custom precompile, ask for the FFI audit results. Because code doesn’t lie, but it does segfault when you least expect it.
