For the complete documentation index, see llms.txt. This page is also available as Markdown.

76311 bc low valid late metering is discarded after a non meteringdatapending non inclusion allowing enforce mode per transaction and per flashblock resource budgets to be bypassed

Submitted on May 3rd 2026 at 19:52:12 UTC by @legat for Audit Comp | Base Azul

  • Report ID: #76311

  • Report Type: Blockchain/DLT

  • Report severity: Low

  • Target: https://github.com/base/base/tree/v0.8.0-rc.28

  • Impacts:

    • Causing network processing nodes to process transactions from the mempool beyond set parameters

Description

Title

MeteringStore::insert() discards valid late MeterBundleResponse data after a first builder miss even when the transaction was not included, allowing ordinary pending transactions to retry with execution_time_us=None and state_root_gas=None under ExecutionMeteringMode::Enforce.

Summary

Base Azul's Flashblocks builder can enforce predicted execution-time and state-root-gas limits through resource metering. In the intended flow, a transaction's MeterBundleResponse is delivered to the metering provider, the builder converts the response into concrete TxResources, and ExecutionInfo::is_tx_over_limits() rejects transactions whose predicted resource usage exceeds the configured per-transaction or per-flashblock limits.

The current MeteringStore breaks that flow in a normal retry state:

  1. MeteringStore::get(tx_hash) misses before metering has arrived and records the transaction in needed_at.

  2. The builder does not include the transaction for an ordinary non-metering reason, such as gas-fit at the current block/flashblock boundary.

  3. The valid MeterBundleResponse then arrives through the normal MeteringProvider::insert boundary.

  4. MeteringStore::insert() sees the prior needed_at entry and returns without caching the response.

  5. The still-pending transaction retries with no cached response.

  6. The builder constructs TxResources with execution_time_us=None and state_root_gas=None.

  7. The Enforce-mode execution-time and state-root-gas branches are skipped because they only run when those optional fields are present.

The attached PoC proves this with the scoped base/base @ v0.8.0-rc.28 repository using the real in-process Flashblocks builder, real txpool ingress, real Engine API block production, the real MeteringStore, and the real ExecutionInfo::is_tx_over_limits() path.

The final passing run contains three exploit/control scenarios:

  • Single-transaction primitive: a transaction is considered before metering is cached, non-included for gas-fit, receives valid late metering, has that response discarded, and is later included unmetered under Enforce mode.

  • Cumulative per-flashblock bypass: five independent race-affected transactions each have valid metering claiming 75,000us, above the configured 50,000us per-transaction limit. All five responses are discarded, all five retry unmetered, and all five are included in one retry flashblock. The bypassed metered claim is 375,000us against a 200,000us per-flashblock budget: 187% of the configured budget.

  • Control: the same transaction class with metering inserted before selection is rejected by Enforce mode, proving the limit configuration works when the valid response is not discarded.

Finding Description

Intended behavior

When resource metering is enabled and the builder runs in ExecutionMeteringMode::Enforce, a valid metering response for a transaction that remains pending must be cached and enforced on the next builder attempt.

The safe invariant is:

Late-arrival accounting is only safe once the transaction no longer needs the response. A prior get() miss alone does not prove that the transaction was included or that the response is no longer useful. A considered-but-not-included transaction can remain in the pool and be selected again.

Actual behavior

MeteringStore::get() records a miss for every cache miss, before the builder knows whether the transaction will be included:

MeteringStore::insert() then treats the presence of that miss marker as terminal late-arrival state and returns without caching the valid response:

That conflates two states:

Only State A can safely consume the response without caching it. State B must cache the response because the transaction can be selected in a later block or flashblock.

The builder reaches State B naturally. The builder asks the metering provider for the transaction response:

If no response is present, the builder later converts the missing response into missing optional resource fields:

For a missing response, execution_time_us and state_root_gas are None.

ExecutionInfo::is_tx_over_limits() evaluates those limits only when the fields are present:

The resulting vulnerable sequence is:

The passing PoC logs demonstrate this exact sequence for both one transaction and five transactions in a single retry flashblock.

Root Cause

The root cause is that MeteringStore uses one marker, needed_at, for two different meanings:

Those states are not equivalent. A cache miss happens before final inclusion/non-inclusion is known. A transaction can be considered, miss metering, fail to fit into the current block/flashblock, and remain pending. The late response for that still-pending transaction is security-relevant and must be cached.

The vulnerable composition is:

  1. get() records needed_at on any miss.

  2. Only the explicit MeteringDataPending wait-window skip clears the marker.

  3. Other non-inclusion paths, including gas-fit non-inclusion, can leave needed_at set.

  4. insert() treats needed_at as a discard signal and returns without caching.

  5. Enforce mode fails open for missing optional resource fields.

This is a state-machine bug in production builder/metering code, not a telemetry-only issue.

Impact Explanation

Medium — operator-configured ExecutionMeteringMode::Enforce resource budgets can be bypassed for still-pending transactions after valid late metering is discarded.

The PoC proves two levels of impact.

First, it proves a single transaction whose valid response claims 75,000us of execution time is included after the response is discarded, despite the configured per-transaction limit being 50,000us:

Second, it proves that the bypass scales across multiple ordinary public transactions in one retry flashblock:

All five race transactions fit into one retry flashblock gas target, all five responses are discarded, and all five retry unmetered. This bypasses both the per-transaction limit and the per-flashblock budget that Enforce mode was configured to enforce.

Likelihood Explanation

Likelihood is realistic for metering-enabled deployments because the necessary states are normal asynchronous builder states:

  • metering data is produced outside the builder's immediate transaction-selection loop;

  • a transaction can be selected before its response is cached;

  • transactions can fail inclusion for non-metering reasons, including gas-fit at a block/flashblock boundary;

  • the transaction can remain pending and be retried;

  • the late response can arrive between the first miss and the retry; and

  • the same MeteringProvider::insert path is used by the production metering ingress boundary.

The PoC directly calls MeteringProvider::insert only to deterministically deliver the metering response at the production provider boundary; the attacker-side trigger is ordinary signed transaction submission plus a normal asynchronous late-delivery race.

Severity

Medium.

The PoC uses real builder components and proves that valid metering data is discarded while the transaction is still pending, after which Enforce-mode limits are skipped.

The Medium framing is based on:

  • a real, in-scope production state-machine bug;

  • ordinary public transaction ingress;

  • no privileged or governance role assumptions;

  • per-transaction Enforce limit bypass;

  • cumulative per-flashblock budget bypass with five independent race-affected transactions; and

  • explicit control evidence that the same metering data rejects when cached normally.

Attack Path / Reproduction Path

1

A Base Azul Flashblocks builder deployment enables resource metering with ExecutionMeteringMode::Enforce.

2

Per-transaction execution-time and per-flashblock execution-time budgets are configured.

3

A public user submits one or more transactions through ordinary transaction ingress.

4

The transactions enter the real txpool.

5

The builder considers the transactions before their MeterBundleResponse objects are cached.

6

MeteringStore::get(tx_hash) misses and records needed_at[tx_hash] for each transaction.

7

The transactions are not included in that first attempt for an ordinary non-metering reason. The PoC uses gas-fit non-inclusion.

8

Valid metering responses arrive for the same transaction hashes.

9

The metering provider calls MeteringStore::insert(tx_hash, response).

10

insert() sees needed_at[tx_hash] and returns without caching the response.

11

The transactions remain pending.

12

A later builder attempt selects the same transactions.

13

MeteringStore::get(tx_hash) still returns None.

14

The builder constructs TxResources with missing execution_time_us and state_root_gas fields.

15

ExecutionInfo::is_tx_over_limits() skips execution-time and state-root-gas checks because the fields are absent.

16

The transactions are included under Enforce mode.

17

A control transaction with metering cached before first selection is rejected, proving the bypass comes from the late-discard state transition.

External Preconditions

  • The builder deployment enables resource metering.

  • The builder runs with ExecutionMeteringMode::Enforce.

  • Execution-time and/or state-root-gas limits are configured.

  • A public transaction enters the txpool before its metering response is cached.

  • The transaction is considered and remains pending after a non-metering non-inclusion.

  • The valid response arrives after the first miss and before a later retry.

Internal Preconditions

  • MeteringStore::get(tx_hash) misses and records needed_at.

  • The transaction is not committed in the first attempt.

  • The non-inclusion path does not clear needed_at.

  • MeteringStore::insert(tx_hash, response) observes needed_at and returns without caching the response.

  • The later retry reads resource_usage=None.

  • TxResources.execution_time_us and TxResources.state_root_gas are None.

  • ExecutionInfo::is_tx_over_limits() only evaluates the relevant limits when those fields are present.

Recommendation

Track “metering was needed” separately from “the transaction was included or otherwise consumed without metering.” A prior get() miss is not sufficient proof that a later valid response should be discarded.

Primary fix

Change MeteringStore::insert() so a valid response for a still-pending transaction is cached even when needed_at exists.

One safe design:

The fixed behavior should be:

Additional hardening

  1. Clear or reclassify needed_at when a transaction is considered but not committed for gas fit, DA fit, block-size fit, target-block mismatch, or other non-metering reasons.

  2. Add a separate metric for “late response cached for still-pending tx” instead of treating every late response as consumed.

  3. Add a regression test where a transaction first misses metering, is not included, receives a valid late response, and is retried. The retry must see Some(MeterBundleResponse).

  4. Add a regression test proving that a transaction actually included without metering can still be counted as a true late-arrival event without reusing stale data after commit.

  5. Fail closed under Enforce mode when required metering fields are missing after the configured wait policy has elapsed.

Affected Code (must be fixed in scope files)

Mandatory fix 1: MeteringStore::get() records a miss before inclusion/non-inclusion is known

  • File: crates/builder/metering/src/store.rs

  • Lines: L18-L25, L68-L79

  • Links:

    • https://github.com/base/base/blob/v0.8.0-rc.28/crates/builder/metering/src/store.rs#L18-L25

    • https://github.com/base/base/blob/v0.8.0-rc.28/crates/builder/metering/src/store.rs#L68-L79

get() records needed_at as soon as the builder needs data and does not have it. At that moment, the store does not know whether the transaction will be included, skipped, rejected for another resource, or remain pending.

Mandatory fix 2: MeteringStore::insert() discards valid responses whenever needed_at exists

  • File: crates/builder/metering/src/store.rs

  • Lines: L88-L101

  • Link: https://github.com/base/base/blob/v0.8.0-rc.28/crates/builder/metering/src/store.rs#L88-L101

This is the primary patch location. insert() must not treat a prior miss as sufficient proof that the response should be consumed without caching.

Mandatory fix 3: only the explicit MeteringDataPending skip clears needed_at

  • File: crates/builder/core/src/flashblocks/context.rs

  • Lines: L692-L710

  • Link: https://github.com/base/base/blob/v0.8.0-rc.28/crates/builder/core/src/flashblocks/context.rs#L692-L710

The builder calls metering_provider.skip(&tx_hash) only in the explicit wait-window MeteringDataPending path. Other non-inclusion paths can leave the miss marker in place while the transaction remains pending.

Mandatory fix 4: missing metering is converted into optional TxResources fields

  • File: crates/builder/core/src/flashblocks/context.rs

  • Lines: L713-L739

  • Link: https://github.com/base/base/blob/v0.8.0-rc.28/crates/builder/core/src/flashblocks/context.rs#L713-L739

When resource_usage is absent, predicted_execution_time_us and state_root_gas are absent. The later enforcement path therefore has no values to check.

Mandatory fix 5: execution-time and state-root-gas limits are checked only when optional fields are present

  • File: crates/builder/core/src/execution.rs

  • Lines: L60-L65, L334-L370

  • Links:

    • https://github.com/base/base/blob/v0.8.0-rc.28/crates/builder/core/src/execution.rs#L60-L65

    • https://github.com/base/base/blob/v0.8.0-rc.28/crates/builder/core/src/execution.rs#L334-L370

The checker skips execution-time and state-root-gas enforcement when those fields are None. This makes the store discard security-relevant, not merely telemetry-relevant.

Mandatory fix 6: metering RPC ingress uses the same insert() boundary

  • File: crates/builder/metering/src/ext.rs

  • Lines: L12-L22, L47-L54

  • Links:

    • https://github.com/base/base/blob/v0.8.0-rc.28/crates/builder/metering/src/ext.rs#L12-L22

    • https://github.com/base/base/blob/v0.8.0-rc.28/crates/builder/metering/src/ext.rs#L47-L54

The production metering extension forwards metering responses to self.store.insert(tx_hash, metering). The vulnerable store transition is therefore on the normal metering-ingress boundary.

Mandatory fix 7: cleanup only removes committed or permanently rejected transactions

  • File: crates/builder/core/src/flashblocks/payload.rs

  • Lines: L596-L617

  • Link: https://github.com/base/base/blob/v0.8.0-rc.28/crates/builder/core/src/flashblocks/payload.rs#L596-L617

A transaction that was considered but not committed can remain available for a later builder attempt. If its valid metering response was discarded, that later attempt can proceed with missing resource fields.

Proof of Concept

This PoC contains the complete scenario needed to prove the Medium bug with the current fixed test file.

It proves:

  • the target transactions enter the real txpool;

  • the builder considers them before metering has arrived;

  • the first build does not include them because of gas-fit, a non-MeteringDataPending non-inclusion reason;

  • the real store records needed_at;

  • valid expensive responses are delivered through MeteringProvider::insert;

  • the store discards the responses and retains zero cached entries;

  • the retry block includes the transactions under Enforce mode;

  • five transactions fit one retry flashblock gas target and bypass a cumulative 375,000us metered claim against a 200,000us budget; and

  • a control transaction with response data inserted before selection is rejected.

Files / placement

Place the PoC here:

Run command

Run from the base/base @ v0.8.0-rc.28 repository root:

Scenario 1: single transaction state-machine bug

  1. Configure the real local builder with resource metering enabled, ExecutionMeteringMode::Enforce, and realistic execution limits.

  2. Submit one transaction through the normal transaction builder helper into the real txpool.

  3. Build a first constrained block/flashblock.

  4. The transaction is considered, MeteringStore::get() records needed_at, and the transaction is not included for gas-fit.

  5. Insert a valid MeterBundleResponse claiming 75,000us, which exceeds the 50,000us per-transaction limit.

  6. Prove the store still has no cached entry for the hash after insert.

  7. Build a retry block with sufficient gas capacity.

  8. Prove the transaction is included unmetered.

Scenario 2: cumulative per-flashblock budget bypass

  1. Fund five distinct senders.

  2. Submit five independent transactions through the real txpool.

  3. Build a first constrained block/flashblock that considers all five and rejects all five for gas-fit.

  4. Insert valid MeterBundleResponse values for all five hashes.

  5. Prove all five responses are discarded.

  6. Build a retry block whose first flashblock gas target is 12,000,000.

  7. Prove the five declared-gas transactions total 10,000,000, so all five fit one retry flashblock target.

  8. Prove all five are included.

  9. Prove the bypassed metered claim is 375,000us, or 187% of the configured 200,000us per-flashblock budget.

Scenario 3: control path

  1. Submit a control transaction through the real txpool.

  2. Insert the same kind of metering response before first builder selection.

  3. Build a block with enough gas capacity.

  4. Prove the transaction is rejected for execution_time and is not included.

Passing proof logs from final run

PoC source (full)

Was this helpful?