Skip to content

catwith1hat/evm-breaker

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

589 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

evm-breaker — LLM-driven EVM cross-client audit

An autonomous audit of the Ethereum execution layer across six production clients at the Fusaka (Osaka + Fulu) hard fork, driven entirely by a large language model.

Clients audited: geth · erigon · besu · nethermind · ethrex · revm/reth


Methodology

The audit is structured as a sequence of hypothesis-driven items. Each item picks a candidate divergence surface, audits six client source trees in parallel, records the finding, and where a divergence is confirmed, produces a reproducing fixture (EF state test, EF blockchain test, Kurtosis devnet demo, or code-level reproducer).

Full methodology, prompt templates, and repository conventions: METHODOLOGY.md

Every item in the audit (whether or not it produced a divergence): ITEM_TOC.md


Active findings (as of 2026-05-10)

# Finding Split Mainnet reach
#44 revm/reth bytecode-decoder rejects malformed 0xef01... prefixes as bad EIP-7702 designators; 5 strict clients accept as legacy bytecode reth (1-vs-5) D — custom chain
#45 tx-level consequence of #44: revm/reth refuse state with malformed 0xef01... sender bytecode; 5 strict clients reject the tx instead reth (1-vs-5) D — custom chain
#45b revm/reth fake_exponential u128 overflow → wrong BLOBBASEFEE reth (1-vs-5) D — custom chain
#52 ethrex !was_empty guard: pre-existing empty accounts not deleted by EIP-161 ethrex (1-vs-5) D — custom chain
#60 ethrex amount > 0 filter in process_withdrawals composes with #52 EIP-161 bug; forks on zero-amount withdrawal to pre-existing empty account ethrex (1-vs-5) D — custom chain
#61 revm/reth fake_exponential u128 overflow surface (#45b carryover) under Prague EIP-7691/7918 schedule reth (1-vs-5) D — custom chain
#62 revm/reth missing EIP-7610 storage-root collision check in CREATE geth, reth (2-vs-4) D — custom chain
#86 EIP-4788 system-call halt-on-revert: only reth strict-rejects per spec; geth/erigon/ethrex/nethermind tolerate; besu crashes besu, erigon, ethrex, geth, nethermind (5-vs-1) D — custom chain
#90 EIP-2935 syscall-revert: erigon/nethermind bypass EVM and extend the bad-block chain; geth/besu/reth/ethrex halt erigon, nethermind (2-vs-4) D — custom chain
#112 erigon parses tx type 0x05 (gated by --aa, off on mainnet); ethrex parses 0x7d/0x7e for L2 — by-design custom-chain extensions erigon, ethrex (2-vs-4) D — custom chain
#89 Second fake_exponential overflow at ~900M excess_blob_gas → 4-way BLOBBASEFEE split erigon, ethrex, nethermind, reth (4-vs-2) D — synthetic state
#96 ethrex skips zero-amount withdrawal; pre-existing empty account not deleted ethrex (1-vs-5) D — synthetic state
#99 codeHash=0 in EIP-161 predicate: 3-vs-3 split besu, erigon, reth (3-vs-3) D — synthetic state
#106 EIP-6110 deposit log ABI offset validation: geth blind-extracts, others validate geth (1-vs-5) D — synthetic state
#107 geth has no empty-code pre-check for EIP-7002/7251 system calls geth (1-vs-5) D — synthetic state
#108 T2.2 pre-Prague-disallow of requestsHash header missing in geth/besu (T2.1 closed; unreachable on post-Pectra) besu, geth (2-vs-4) D — synthetic state
#121 withdrawalsRoot absent from header: ethrex lacks fork-gating check ethrex (1-vs-5) D — synthetic state
#122 parentBeaconBlockRoot absent from header: besu lacks fork-gating check; silently skips EIP-4788, state diverges on subsequent blocks besu (1-vs-5) D — synthetic state
#169 EIP-7928 says withdrawal recipients MUST be in BAL regardless of amount. All 6 clients record them — BAL hashes match. Ethrex composes with #60: BAL recorder records the recipient (extend_touched_addresses bypasses the amount filter), but the state-transition pipeline filters amount > 0 in process_withdrawals — recipient address is NOT actually touched in state, so EIP-161 cleanup of pre-existing empty accounts doesn't fire. The divergence shows up as a state-root mismatch, NOT a BAL hash mismatch. ethrex (1-vs-5) D — synthetic state
#175 EIP-7928 Engine API: all 6 clients use camelCase blockAccessList JSON field (alloy serde(rename_all = "camelCase") applies to reth despite snake_case Rust field names). Hex-encoded RLP everywhere. Empty-BAL serialization differs: erigon omitempty, ethrex skip_serializing_if, besu conditional, nethermind JsonRequired always-include. Validation strictness varies (erigon/reth explicit, besu/ethrex implicit RLP failure). All differences are operational, not consensus-level. reth (1-vs-5) D — contained upstream
#185 EIP-7594 mandates a 6-blob-per-tx limit enforced at THREE checkpoints (submission, network, block). All 6 clients use the constant 6 correctly; geth/besu/erigon/nethermind/reth enforce at all three checkpoints. ethrex enforces ONLY at block-execution time (default_hook.rs:636); its mempool validate_transaction at blockchain.rs:2411 has no per-tx blob count check. Spec-text violation but consensus-preserving — block-validation catches it. Relay-interop / mempool-DoS surface only. ethrex (1-vs-5) D — contained upstream
#187 EIP-7594 §"Networking" requires blob-tx senders to compute cell KZG proofs and include them in the EIP-4844 tx-pool wrapper. All 6 clients implement cell-proof support; geth/besu/reth/ethrex/nethermind hard-cutoff V0 wrappers post-Osaka. erigon auto-upgrades V0→V1 server-side at JSON-RPC submission (rpc/jsonrpc/send_transaction.go:64-70) for tooling backward compat — spec-text deviation but consensus-preserving (V1 wrappers propagate normally). erigon (1-vs-5) D — contained upstream
#161 EIP-8037 reinterprets EIP-7825's 2^24 tx.gas_limit cap as a regular-gas-only cap. ethrex (validation.rs:150) disables the cap entirely on Amsterdam — accepts unbounded tx.gas_limit. geth (state_transition.go:392) skips the pre-execution check on Amsterdam and relies on buyGas() pool-deduction at line 362 — soft cap only. besu/erigon/reth enforce intrinsic regular-gas cap; only besu adds an explicit runtime regular-gas check. nethermind unclear. Multi-way; splits [geth, ethrex] as the clearest outliers. geth, ethrex (2-vs-4) mainnet-glamsterdam
#164 revm's post-hoc state-delta BAL recorder correctly handles accessed-but-unchanged ACCOUNTS via empty BalWrites entries (initial account-level hypothesis refuted), but does NOT capture SLOAD-only STORAGE reads — update_reads() exists at bal/account.rs:353 but is never invoked during commit(). Storage reads only enter via external try_from_alloy(). 1-vs-5 split on every block with any pure SLOAD. reth (1-vs-5) mainnet-glamsterdam
#165 EIP-7928 SELFDESTRUCT-in-tx requires no nonce/code changes for the destroyed account, balance entry only if pre-tx > 0, and intra-tx storage writes demoted to storage_reads. nethermind and reth/revm both record nonce_changes and code_changes for SELFDESTRUCT'd accounts (rule 2/3 violations); ethrex's track_selfdestruct() and geth's selfDestructed-skip-finalise are spec-aligned. besu/erigon agree on rules 2-3. nethermind, reth (2-vs-4) mainnet-glamsterdam
#166 EIP-7928 says zero block reward MUST NOT cause coinbase inclusion. geth (state_transition.go:720 — explicit GetBalance with TODO), besu (MainnetTransactionProcessor.java:569 — getOrCreate+addTouchedAccount unconditional), reth (load_account_mut+incr_balance(0) creates EvmState entry → coinbase in BAL via #164's empty-BalWrites mechanism) all violate. erigon spec-aligned (skips AddBalance when tipAmount=0). ethrex spec-aligned via gas_price≠0 heuristic. nethermind unclear. geth, besu, reth (3-vs-3) mainnet-glamsterdam
#167 EIP-7928 says authority MUST be in BAL only if loaded into accessed_addresses; auth-fail-before-load MUST NOT be included. besu records preemptively at CodeDelegationProcessor.java:128 — confirmed 1-vs-5 spec violation. geth, erigon, nethermind, ethrex, reth/revm all record only after ecrecover succeeds. besu (1-vs-5) mainnet-glamsterdam
#170 EIP-7928 mandates EIP-7002 / EIP-7251 dequeue's slot-4+ reads in storage_reads. nethermind suppresses them via predicate exemption at BlockAccessList.cs:626 (IsFreeStorageReadAccount, hardcoded against the two predeploy addresses); reth omits them via the broader item-#164 storage_reads bug. geth/besu/erigon/ethrex emit them. 4-vs-2 split on every block with active withdrawal/consolidation queues. nethermind, reth (2-vs-4) mainnet-glamsterdam
#171 reth omits the post-exec BAL-index bump after apply_post_execution_changes() at execute.rs:618 and at the eth_blockAccessList RPC helper bal.rs:58 — confirmed source-level; post-exec EIP-7002/7251 entries get block_access_index = n instead of n+1, diverging from the canonical 5 on every Glamsterdam block with non-empty post-exec queues. reth (1-vs-5) mainnet-glamsterdam
#173 EIP-7928 demands no-op SSTORE (post=pre) be demoted to storage_reads, not storage_changes. nethermind keeps the slot in storage_changes (per-index dedup ignores pre-tx baseline); reth/revm omits the slot entirely (journal diff sees no mutation, then never calls update_reads()). geth, besu, erigon, ethrex all demote correctly. 4-vs-2 split. nethermind, reth (2-vs-4) mainnet-glamsterdam
#176 EIP-7928 BAL recording for the two pre-execution system contracts (EIP-2935 history storage, EIP-4788 beacon root). Symmetric to item #170 (post-exec EIP-7002/7251) but at block_access_index=0. Source-level audit (2026-05-10): geth/besu/erigon/ethrex/nethermind spec-aligned (pre-exec system calls flow through the standard recorder; nethermind's IsFreeStorageReadAccount predicate at BlockAccessList.cs:624-629 hardcodes only EIP-7002/7251, so EIP-2935/4788 reads land normally). reth/revm omits storage_reads via the broader item #164 bug (update_reads() at bal/account.rs:353 never invoked by commit()). 5-vs-1 split on every Glamsterdam block. reth (1-vs-5) mainnet-glamsterdam

Remediated findings

# Finding Split Mainnet reach
#15 ethrex BLS12-381 from_uncompressed misinterpreted byte[0]=0x40 as the infinity flag, returning the identity point instead of rejecting coords ≥ p (5-vs-1; fixed upstream in ethrex 28f3e58677) ethrex (1-vs-5) Active — anyone
#163 ethrex restore_cache_state omitted exists flag; reverted tx that touched a non-existent address leaked exists=true into the per-tx cache, causing a same-block EIP-7702 SetCode tx with that authority to apply the spurious 12500-gas refund (5-vs-1; fixed upstream in ethrex d71b53d68 / v12.0.0-rc.2) ethrex (1-vs-5) Active — anyone
#127 ethrex accepted blob versioned hash with 0x02 prefix (5-vs-1; fixed upstream in ethrex 0458d5e67) ethrex (1-vs-5) Active — proposer

Cross-cutting observations

Header-field fork-gating class (#121, #122): every new-at-fork header field has at least one permissive client, but the permissive set differs per field — no shared abstraction forces consistency. Only erigon and reth are strict on all three audited fields. (Note: #108requestsHash — was also part of this class but is closed: T2.1 patched in geth, T2.2 impossible on post-Pectra chains.) For why the divergence doesn't propagate through the engine API on mainnet, see each item's own Engine-API non-exploitability section.

Field Fork Permissive
withdrawalsRoot Shanghai ethrex
parentBeaconBlockRoot Cancun besu
requestsHash Prague geth, besu — closed, see #108

ethrex EIP-161 class (#52, #96): two independent code paths (!was_empty guard and filter(amount>0) in withdrawals) share the same root cause — EIP-161 never fires on accounts ethrex doesn't touch.

geth system-call asymmetry (#86, #90): ProcessBeaconBlockRoot silently discards errors; ProcessParentBlockHash panics. Opposite error semantics in the same file.


Repository layout

itemNN/          per-item audit and fixtures
  README.md      finding, hypotheses, client code references
  fixture/       reproducing test (EF state/blockchain test, Go/Rust/Python)
WORKLOG.md       full sequential audit log (152 items)
METHODOLOGY.md   prompt templates and process documentation
AGENTS.md        agent instructions
items/045b/kurtosis/   live devnet demo (blob-fee fork, revm/reth fake_exponential overflow)

About

LLM-assisted EVM breaker

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors