Skip to content

Post-Execution Osaka Revalidation Can Poison BlockBuilder State and Produce an Invalid Block #4271

@N0zoM1z0

Description

@N0zoM1z0

Summary

A non-atomic state transition exists in BlockBuilder.addTransaction() in the @ethereumjs/vm@10.1.1 snapshot.

The issue can be triggered with a 7-blob blob transaction that is valid to construct under pre-Osaka rules, then manually wrapped as an EIP-7594 (wrapper_version = 1) network wrapper and decoded through createBlob4844TxFromSerializedNetworkWrapper(). When this decoded transaction is later passed into an Osaka BlockBuilder, addTransaction() executes the transaction and commits its effects before a later Osaka-specific revalidation step fails with:

7 blobs exceeds max 6 blobs per tx (EIP-7594)

After the exception, the builder is left in a partially updated state:

  • the transaction is not added to transactions
  • gasUsed remains 0
  • blobGasUsed is already updated to 917504
  • the VM state is already modified
  • build() can still produce a block whose header reflects the poisoned state

In the reproduced test case, the resulting block has transactions.length = 0 and gasUsed = 0, but blobGasUsed = 917504 and a poisoned stateRoot. Replaying that block on a clean VM fails with invalid block stateRoot.

Root Cause Analysis

The issue is caused by two behaviors combining into a non-atomic failure path.

First, createBlob4844TxFromSerializedNetworkWrapper() constructs the base blob transaction from txValues before applying the decoded networkWrapperVersion. The decoded wrapper version and blob-sidecar fields are then assigned onto the already-constructed object afterward. As a result, a transaction can be decoded under a pre-Osaka Common, while later carrying networkWrapperVersion = 1 after decode, without the constructor's early wrapper/common consistency checks being re-run.

Second, BlockBuilder.addTransaction() is not atomic across its full processing pipeline. The method:

  1. performs initial builder-level checks
  2. creates a temporary block context
  3. calls runTx()
  4. updates blob accounting
  5. reconstructs the minimal blob transaction form with createMinimal4844TxFromNetworkWrapper()
  6. only after that appends the transaction/result and updates gasUsed

The critical detail is that runTx() commits its own journal checkpoint on success. In the reproduced path, execution succeeds, so transaction side effects are already committed into the builder's enclosing state. After that, addTransaction() increments blobGasUsed, and only then re-runs Osaka validation indirectly by calling createMinimal4844TxFromNetworkWrapper() under the Osaka Common. That reconstruction re-enters Blob4844Tx validation and throws because Osaka limits blob transactions to 6 blobs.

At that point, addTransaction() exits by exception, but there is no rollback of the already-committed transaction effects and no rollback of the already-updated blobGasUsed. At the same time, the transaction was never appended to transactions, no receipt/result was recorded, and gasUsed was never increased. This leaves the builder in a split state where execution side effects and some accounting have been committed, but the transaction is absent from the block body.

Impact

This issue allows a malformed cross-hardfork blob input to poison BlockBuilder state.

In the reproduced scenario, a caller can cause the builder to retain state changes from a transaction that addTransaction() reported as rejected. The builder can then finalize a block whose header is inconsistent with its transaction list. Specifically, the reproduced block has:

  • transactions.length = 0
  • gasUsed = 0
  • blobGasUsed = 917504
  • a poisoned stateRoot

The observed poisoned stateRoot is:

0xe779f8d659ebd019455840826364ac8ceffcb55ec101a59ca86a12f5d77fbd26

The clean-state comparison root in the test is:

0x71b4f91151036b980ed5eaf7989738978ea37ef78a29bad777d26e912f2ca6be

When the built block is replayed on a clean VM, validation fails with invalid block stateRoot.

Based on the reproduced behavior, this is a correctness and safety issue in block construction. The demonstrated impact is local builder-state corruption and invalid block assembly. The PoC does not show consensus bypass, acceptance of an invalid block by honest re-execution, or unauthorized asset movement.

Reproduce Steps

  1. Use the local ethereumjs-monorepo snapshot at commit:

    a1d1a76d37d976bebae09ca2e4943f81253601ab

  2. Use the added test file:

    ethereumjs-monorepo/packages/vm/test/api/EIPs/ethjs-osaka-builder-state-poison.spec.ts

here: https://gist.github.com/N0zoM1z0/5dfb1c635be3fc2a95b063ca64f4621a

  1. The test performs the following steps:

    • creates a 7-blob blob transaction using a pre-Osaka Common
    • manually serializes it as a wrapper_version = 1 EIP-7594 network wrapper
    • decodes it with createBlob4844TxFromSerializedNetworkWrapper()
    • passes the decoded transaction into an Osaka BlockBuilder.addTransaction()
    • asserts that addTransaction() throws 7 blobs exceeds max 6 blobs per tx (EIP-7594)
    • verifies that the builder is already poisoned after the throw
    • finalizes the builder and verifies that the produced block is externally invalid when replayed on a clean VM
  2. Run:

cd /ethereumjs-monorepo/packages/vm
npx vitest run -c ./vitest.config.ts ./test/api/EIPs/ethjs-osaka-builder-state-poison.spec.ts
  1. Expected observations from the reproduced run:

    • addTransaction() throws 7 blobs exceeds max 6 blobs per tx (EIP-7594)
    • builder.blobGasUsed === 917504
    • the builder's stateRoot differs from the clean-state copy
    • build() returns a block with transactions.length = 0 and gasUsed = 0
    • that same block still has blobGasUsed = 917504
    • replaying the built block on a clean VM fails with invalid block stateRoot

Recommended Fix

  1. Make BlockBuilder.addTransaction() atomic.

    The entire per-transaction flow should succeed or fail as a unit. In practice, this means wrapping the full sequence — execution, blob-specific post-processing, minimal-transaction reconstruction, accounting updates, and transaction/result insertion — in a builder-level rollback boundary, and reverting all builder-visible effects on any exception.

  2. Move all blob-transaction revalidation that can still fail to a point before runTx().

    In the reproduced path, the fatal Osaka revalidation happens after execution has already succeeded. Any validation that can reject the transaction should happen before execution begins.

  3. Validate network-wrapper version consistency during decoding, not by post-construction mutation.

    createBlob4844TxFromSerializedNetworkWrapper() should not be able to construct a transaction under one fork context and then assign a conflicting wrapper version afterward without re-running the relevant validation logic.

  4. Only update blobGasUsed, transaction arrays, receipts, and gas accounting after all post-execution checks succeed.

    This reduces the chance of partial commits even if future validation paths are added.

  5. Keep a regression test covering this exact path.

    The current PoC is a good regression target because it proves both the triggering condition and the externally visible invalid-block outcome.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions