EIP-196 Vulnerability Patterns
EIP-196 introduces precompiled contracts for efficient elliptic curve operations on the alt_bn128 (BN254) curve. These precompiles are widely used in zkSNARK verification, ECIES encryption, and other cryptographic applications. However, developers often fail to implement proper input validation that matches the precompile’s internal checks, leading to serious vulnerabilities.
This article analyzes 1 critical EIP-196 vulnerability pattern with a real audit case to help you understand the security requirements of elliptic curve precompiles.
EIP-196 Overview
Precompile Addresses
| Address | Operation | Gas Cost |
|---|---|---|
0x06 |
EC Addition (ECADD) | 150 |
0x07 |
EC Scalar Multiplication (ECMUL) | 6000 |
0x08 |
Pairing Check (EIP-197) | 45000 + 34000*k |
alt_bn128 Curve Parameters
Curve Equation: y² = x³ + 3
Field Modulus (p):
21888242871839275222246405745257275088696311157297823662689037894645226208583
Group Order (n):
21888242871839275222246405745257275088548364400416034343698204186575808495617
Generator Point: G = (1, 2)
Precompile Input Validation (Critical!)
The precompile performs two checks in sequence:
+------------------------------------------------------------------+
| Precompile Input Validation |
+------------------------------------------------------------------+
When calling ecMul (0x07) with Point P = (x, y) and scalar s:
Step 1: Field Modulus Check (FIRST!)
+---------------------------+
| Is x < FIELD_MODULUS? | NO --> REVERT (Invalid input)
| Is y < FIELD_MODULUS? | NO --> REVERT (Invalid input)
+---------------------------+
| YES
v
Step 2: On-Curve Check
+---------------------------+
| Is y² ≡ x³ + 3 (mod p)? | NO --> REVERT (Not on curve)
+---------------------------+
| YES
v
Step 3: Execute Operation
+---------------------------+
| Return P * s |
+---------------------------+
The field modulus check happens BEFORE the on-curve check!
A point can satisfy y² ≡ x³ + 3 but still be invalid if x ≥ p or y ≥ p
Vulnerability Patterns
1. Precompile Input Validation Bypass
Prerequisite Knowledge:
EIP-196 defines the exact semantics for the elliptic curve precompiles at addresses 0x06, 0x07, and 0x08. Specifically, the “Exact Semantics” section states: “If any of the field elements (point coordinates) is equal or larger than the field modulus p, the contract fails.” The precompile performs this validation internally and will revert if inputs are out of bounds.
Root Cause:
When contracts implement custom validation functions to check if a point is valid before using it with the precompile, they often only verify the curve equation (y² ≡ x³ + 3) but forget to check the field bounds (x < FIELD_MODULUS && y < FIELD_MODULUS).
The critical issue is that Solidity’s mulmod operation automatically wraps values that exceed the modulus:
// mulmod behavior:
mulmod(FIELD_MODULUS + 1, FIELD_MODULUS + 1, FIELD_MODULUS)
= ((p+1) * (p+1)) % p
= 1 // ← Looks valid!
// But precompile 0x07 checks FIRST: is x < FIELD_MODULUS?
// If x = p+1 → REVERT immediately, no calculation
This creates a validation mismatch: the contract’s custom check passes, but the precompile rejects the input.
Attack Vector:
An attacker submits a point with coordinates ≥ FIELD_MODULUS. The contract’s validation passes (due to mulmod wrapping), but when the precompile is called later, it reverts. If the contract doesn’t handle this revert gracefully, it can lead to:
- Denial of Service (DoS) - blocking critical operations
- Permanent fund locking - if funds are committed before the precompile call
Source:
- [sherlock/2024-03-axis-finance] It is possible to DoS batch auctions by submitting invalid AltBn128 points when bidding (
issue-147.md)moonraker/src/lib/ECIES.solBusiness Context: Sealed-Bid Auction with ECIES Encryption
Axis Finance uses ECIES (Elliptic Curve Integrated Encryption Scheme) for sealed-bid auctions:
┌──────────────────────────────────────────────────────────────────┐ │ ECIES Key Exchange │ └──────────────────────────────────────────────────────────────────┘ Prerequisites: - Auctioneer has keypair: (auctioneerPrivKey, auctioneerPubKey) - auctioneerPubKey is public, auctioneerPrivKey is secret Bidding: ┌─────────────┐ │ Bidder │ 1. Generate one-time keypair: (bidderPrivKey, bidderPubKey) │ │ 2. Compute sharedSecret = bidderPrivKey * auctioneerPubKey │ │ 3. Encrypt bid amount with sharedSecret │ │ 4. Submit: (encryptedBid, bidderPubKey) + quote tokens (USDC) │ │ ← Only submit PUBLIC key, private key stays local! └─────────────┘ Decryption: ┌─────────────┐ │ Auctioneer │ 1. Get bidderPubKey from chain │ │ 2. Compute sharedSecret = auctioneerPrivKey * bidderPubKey │ │ (ECIES: A_priv * B_pub = B_priv * A_pub = sharedSecret) │ │ 3. Decrypt bid amount with sharedSecret └─────────────┘Fund Flow:
Phase 1: Bidding (Bids encrypted, funds locked) ┌─────────────┐ ┌─────────────────┐ │ Bidder │ │ Auction Contract│ │ │ Submit: (encryptedBid, bidderPubKey) │ │ │ + Transfer quote tokens (e.g. 1000 USDC) │ │ │ ─────────────────────────────────────► │ │ │ │ Store bid data │ │ │ │ Lock funds │ └─────────────┘ └─────────────────┘ Phase 2: Reveal (After auction ends, decrypt all bids) ┌─────────────────┐ ┌─────────────────┐ │ Auctioneer │ │ Auction Contract│ │ │ Call decryptAndSortBids() │ │ │ │ ─────────────────────────────────► │ │ │ │ For each bid: │ │ │ │ - ECIES.decrypt │ │ │ │ - uses ecMul │ │ │ │ (addr 0x07) │ └─────────────────┘ └─────────────────┘ Phase 3: Settlement ┌─────────────────────────────────────────────────┐ │ if (bidPrice > marginalPrice) │ │ → Winner! Receive auctioned tokens │ │ → Pay at marginal price, excess refunded │ │ │ │ else │ │ → Loser, 100% refund of quote tokens │ └─────────────────────────────────────────────────┘The Attack:
Attacker submits bid with INVALID public key (x ≥ FIELD_MODULUS): ┌─────────────┐ Submit bid with pubKey.x = FIELD_MODULUS + 1 │ Attacker │ ──────────────────────────────────────────────► └─────────────┘ │ ▼ ┌─────────────────────┐ │ Contract.isValid() │ ← Only checks: y² ≡ x³ + 3 │ Returns: TRUE ✓ │ Does NOT check: x < FIELD_MODULUS └─────────────────────┘ │ ▼ (Bid accepted and stored with locked funds) Later, when auction ends: ┌─────────────────┐ decryptAndSortBids() │ Auctioneer │ ──────────────────────────────────────────► └─────────────────┘ │ ▼ ┌─────────────────────┐ │ ECIES.decrypt() │ │ calls ecMul(0x07) │ └─────────────────────┘ │ ▼ ┌─────────────────────┐ │ Precompile 0x07 │ ← Checks: x < FIELD_MODULUS │ REVERTS! │ Invalid input detected └─────────────────────┘ │ ▼ ┌─────────────────────────────────────────┐ │ ENTIRE AUCTION FAILS │ │ All funds permanently locked! │ └─────────────────────────────────────────┘Vulnerable Code:
// Constants uint256 public constant FIELD_MODULUS = 21_888_242_871_839_275_222_246_405_745_257_275_088_696_311_157_297_823_662_689_037_894_645_226_208_583; // Field arithmetic using mulmod/addmod function _fieldmul(uint256 a, uint256 b) private pure returns (uint256 c) { assembly { c := mulmod(a, b, FIELD_MODULUS) // c = (a * b) % FIELD_MODULUS } } function _fieldadd(uint256 a, uint256 b) private pure returns (uint256 c) { assembly { c := addmod(a, b, FIELD_MODULUS) // c = (a + b) % FIELD_MODULUS } } // Vulnerability: mulmod auto-wraps values >= FIELD_MODULUS // Example: mulmod(FIELD_MODULUS + 1, FIELD_MODULUS + 1, FIELD_MODULUS) // = ((p+1) * (p+1)) % p = 1 ← looks valid! // But precompile 0x07 checks FIRST: is x < FIELD_MODULUS? NO → REVERT function isOnBn128(Point memory p) public pure returns (bool) { // check if the provided point is on the bn128 curve y**2 = x**3 + 3 return _fieldmul(p.y, p.y) == _fieldadd(_fieldmul(p.x, _fieldmul(p.x, p.x)), 3); // VULNERABILITY: Only checks curve equation, mulmod wraps out-of-range values } function isValid(Point memory p) public pure returns (bool) { return isOnBn128(p) && !(p.x == 1 && p.y == 2) && !(p.x == 0 && p.y == 0); // VULNERABILITY: Missing check for p.x < FIELD_MODULUS && p.y < FIELD_MODULUS } // FIX: Add range check BEFORE curve check function isValid_FIXED(Point memory p) public pure returns (bool) { if (p.x >= FIELD_MODULUS || p.y >= FIELD_MODULUS) return false; // ← ADD THIS return isOnBn128(p) && !(p.x == 1 && p.y == 2) && !(p.x == 0 && p.y == 0); }
Common Mistake Pattern
// ❌ WRONG: Only checks curve equation
function isValid(Point memory p) public pure returns (bool) {
return mulmod(p.y, p.y, FIELD_MODULUS) ==
addmod(mulmod(p.x, mulmod(p.x, p.x, FIELD_MODULUS), FIELD_MODULUS), 3, FIELD_MODULUS);
// Missing: p.x < FIELD_MODULUS && p.y < FIELD_MODULUS
}
// ✅ CORRECT: Checks field bounds FIRST, then curve equation
function isValid(Point memory p) public pure returns (bool) {
// Field bounds check (matches precompile behavior)
if (p.x >= FIELD_MODULUS || p.y >= FIELD_MODULUS) {
return false;
}
// Then curve equation check
return mulmod(p.y, p.y, FIELD_MODULUS) ==
addmod(mulmod(p.x, mulmod(p.x, p.x, FIELD_MODULUS), FIELD_MODULUS), 3, FIELD_MODULUS);
}
EIP-196 Audit Checklist
When auditing contracts that use elliptic curve precompiles, ensure you check these items:
| # | Check Item | Related Pattern |
|---|---|---|
| ✅ | Field bounds validation Are coordinates checked against FIELD_MODULUS before any curve equation checks? | Pattern 1 |
| ✅ | Validation consistency Does the contract’s validation match the precompile’s validation exactly? | Pattern 1 |
| ✅ | Error handling If precompile call fails, are funds protected? Can the operation be retried? | Pattern 1 |
| ✅ | Input source Is the point from user input that could be maliciously crafted? | Pattern 1 |
InfiniteSec