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

76081 bc low builder max gas per txn over cap transactions are repeatedly re executed because post execution gas cap rejects are not permanently evicted

Submitted on May 2nd 2026 at 15:40:34 UTC by @legat for Audit Comp | Base Azul

  • Report ID: #76081

  • 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

Summary

Base Azul's Flashblocks builder has a configured per-transaction gas guardrail, builder.max_gas_per_txn, that is intended to exclude transactions whose actual EVM gas usage exceeds a configured cap. The current implementation enforces that cap only after full EVM execution, and the resulting MaxGasUsageExceeded rejection is treated as a local iterator invalidation instead of a persistent txpool/rejection-cache eviction.

In execute_best_transactions, the builder calls the real EVM through evm.transact(&tx), receives ResultAndState, reads result.gas_used(), and only then checks whether the transaction exceeded builder_config.max_gas_per_txn. When the cap is exceeded, the code records TxnExecutionError::MaxGasUsageExceeded and calls best_txs.mark_invalid(tx.signer(), tx.nonce()). It does not put the transaction hash into diag.permanently_rejected_txs.

TxnExecutionError::is_permanent() also does not classify MaxGasUsageExceeded as permanent. After each payload/flashblock build attempt, cleanup removes only hashes in diag.permanently_rejected_txs and separately prunes transactions that were actually committed. A max-gas-exceeded transaction is neither committed nor permanently rejected. As a result, it remains in the real txpool, remains outside the shared rejection cache, and can be selected and fully executed again by later builder attempts.

This turns the configured gas guardrail into a repeatable unpaid builder-work sink:

  1. A public attacker submits valid high-priority transactions whose static validity checks pass and whose actual EVM gas usage exceeds builder.max_gas_per_txn.

  2. The builder selects an attacker transaction from the real txpool.

  3. The builder fully executes it in the real EVM.

  4. Only after execution, the post-execution gas-used check rejects it.

  5. Because the transaction is excluded before state commit, the attacker pays no gas for that builder execution work.

  6. Because the rejection is not permanent, the transaction is not rejection-cached, not removed from the txpool, and not pruned as committed.

  7. Later builder attempts can select and fully execute the same transaction again.

The attached single-file PoC proves the bug in the scoped base/base @ v0.8.0-rc.28 repository using the real in-process builder, real txpool observer, real signed transactions submitted through the normal provider, and real Engine API block production. It does not mock or reimplement the vulnerable builder path.

The PoC proves:

  • the over-cap transaction enters the real txpool;

  • the builder excludes the same over-cap transaction in each produced block;

  • normal control transactions from another sender are included, proving block production continues while the over-cap transaction remains available;

  • after each rejection, the over-cap transaction remains physically present in the real txpool;

  • after each rejection, the over-cap transaction remains Pending;

  • after each rejection, the over-cap transaction is not in the shared rejection cache;

  • repeated payload_builder debug logs show the same tx hash reaching result="max gas usage exceeded" fifteen times during one successful PoC run; and

  • the test passes end-to-end against the repository's actual builder/txpool/Engine API harness.

Finding Description

Intended behavior

When builder.max_gas_per_txn is configured, a transaction that exceeds the configured per-transaction actual-gas cap should not repeatedly consume builder execution resources across later block/flashblock attempts under the same active configuration.

There are two safe implementation choices:

  1. reject transactions before full EVM execution when their declared gas limit is incompatible with the active cap; or

  2. if the cap is intentionally enforced from post-execution gas_used, persistently reject or evict the transaction for the active cap/config context after the builder has paid the EVM execution cost and discovered that the transaction cannot be included.

The repository already contains persistent-removal machinery for transaction classes that are intrinsically not includable. TxnExecutionError::is_permanent() identifies reject-cacheable errors, diag.permanently_rejected_txs carries those hashes out of execution, and payload cleanup calls best_txs.mark_rejected(), metering_provider.remove(), and pool.remove_transactions() for those hashes.

The intended invariant is:

For a configured max_gas_per_txn cap, a transaction whose actual gas usage exceeds the cap is not includable under the active configuration. It should therefore be pruned or rejection-cached for that configuration context after the first post-execution rejection.

Actual behavior

The actual implementation discovers the over-cap condition only after full EVM execution and then performs only a local iterator invalidation.

execute_best_transactions calls evm.transact(&tx) before it checks max_gas_per_txn:

Only after the EVM returns does the builder read result.gas_used() and enforce the configured gas cap:

This branch does not push the tx hash into diag.permanently_rejected_txs.

The permanent classifier also omits MaxGasUsageExceeded:

Payload cleanup only removes permanently rejected transactions and committed transactions:

The iterator wrapper confirms why this is persistent across later builder attempts. refresh_iterator() replaces the inner best-tx iterator from the pool, mark_rejected() inserts hashes into the shared rejection cache, and mark_invalid() only proxies to the current inner iterator:

The resulting vulnerable sequence is:

The PoC demonstrates this exact behavior. The same over-cap tx hash remains Pending, remains physically present in the pool, and remains absent from the rejection cache after three real block-building rounds:

The debug logs also show fifteen repeated post-EVM max gas usage exceeded events for the same tx hash in one successful run. Those lines are emitted from the builder's transaction-consideration logging after the execution result is known, so repeated appearances for the same hash prove repeated real EVM execution attempts rather than merely stale txpool presence.

Root Cause

The root cause is that MaxGasUsageExceeded is treated as a transient current-iterator invalidation even though it means the transaction is not includable under the active builder configuration.

First, the cap is enforced after execution:

Second, the over-cap transaction is excluded before commit. The attacker does not pay gas, and the builder discards the state changes, but the builder has already paid the EVM execution cost.

Third, the rejection is not recorded as permanent. The MaxGasUsageExceeded branch only calls diag.record_rejection(&err) and best_txs.mark_invalid(...); it never pushes the tx hash into diag.permanently_rejected_txs.

Fourth, TxnExecutionError::is_permanent() omits MaxGasUsageExceeded, so common permanent-rejection plumbing cannot classify this error as reject-cacheable/removable.

Fifth, cleanup only removes permanently rejected tx hashes and committed tx hashes:

Sixth, the iterator wrapper distinguishes current-iterator invalidation from persistent rejection. mark_invalid() does not write the shared rejection cache; mark_rejected() does.

The combined root-cause sequence is therefore:

Impact Explanation

Medium — repeated unpaid builder execution and transaction-inclusion degradation when the configured builder.max_gas_per_txn guardrail is enabled.

A public attacker can submit valid high-priority transactions whose static validity checks pass and whose actual EVM gas usage exceeds the configured builder.max_gas_per_txn cap. Each time the builder attempts one of these transactions, it fully executes the transaction through the EVM, then rejects it only after measuring actual gas used.

Because the transaction is rejected before commit, the attacker pays no gas for this builder work. Because the transaction is not permanently rejected, not rejection-cached, not removed from the txpool, and not pruned as committed, the same transaction remains retryable across future flashblocks/blocks. Repeating this with many attacker-controlled accounts/transactions turns the configured cap from a one-time protection into a repeatable unpaid builder-work sink.

The attached PoC demonstrates the concrete impact with a real transaction and real block-building rounds:

The impact is sustained builder resource consumption and transaction-inclusion degradation under a real configured guardrail.

This issue is not merely a logging, telemetry, or local display mismatch. It affects the builder's transaction-selection and execution loop: attacker transactions repeatedly consume actual EVM execution work while remaining unpaid and retryable.

Likelihood Explanation

Likelihood is realistic for deployments that enable builder.max_gas_per_txn as a per-transaction gas guardrail.

The external attacker requirements are simple:

  • submit ordinary public signed transactions through normal transaction ingress;

  • set high enough priority to keep them attractive to the best-transaction iterator;

  • ensure the transactions' static validity checks pass; and

  • make actual EVM gas usage exceed the configured max_gas_per_txn value.

The internal condition is a real builder configuration. The config field exists in BuilderConfig:

The default is disabled:

It is a real bug in the configured runtime path: when the guardrail is enabled, public over-cap transactions can repeatedly consume builder execution work without paying gas and without being evicted.

The in-repository configuration helper also demonstrates that this path is intended to be configurable and testable:

The PoC uses this real configuration path and proves that the over-cap transaction survives across real builder attempts.

Attack Path / Reproduction Path

  1. A Base Azul builder deployment enables builder.max_gas_per_txn as a per-transaction gas guardrail.

  2. The attacker submits a valid high-priority transaction whose static checks pass and whose actual EVM gas usage exceeds the configured cap.

  3. The transaction enters the real txpool as Pending or Queued.

  4. The builder refreshes its best-transaction iterator from the txpool.

  5. The builder selects the attacker transaction.

  6. The builder checks cancellation, then calls evm.transact(&tx).

  7. The EVM fully executes the transaction and returns result and state.

  8. The builder reads result.gas_used().

  9. The builder detects gas_used > max_gas_per_txn.

  10. The builder records TxnExecutionError::MaxGasUsageExceeded.

  11. The builder calls only best_txs.mark_invalid(tx.signer(), tx.nonce()).

  12. The builder does not commit the transaction state.

  13. The attacker pays no gas for the builder's EVM work.

  14. The builder does not add the tx hash to diag.permanently_rejected_txs.

  15. TxnExecutionError::is_permanent() does not classify MaxGasUsageExceeded as permanent.

  16. Payload cleanup does not call mark_rejected, metering_provider.remove, or pool.remove_transactions for the tx.

  17. The transaction remains pending/queued in the pool and absent from the rejection cache.

  18. A later block/flashblock attempt refreshes the best-tx iterator from the pool.

  19. The same over-cap tx is selected again.

  20. The builder fully executes the same tx again and rejects it again.

  21. Repeating the pattern with many attacker-controlled accounts/txs burns builder execution resources and degrades normal transaction inclusion.

External Preconditions

  • builder.max_gas_per_txn is enabled in the builder configuration.

  • The attacker can submit ordinary public signed transactions.

  • The attacker can fund one or more accounts with enough balance for transaction validity.

  • The attacker can craft transactions whose static validity checks pass while actual EVM gas usage exceeds the configured cap.

  • No governance, admin, sequencer, prover, TEE/ZK, privileged key, L1 validator, or third-party bridge-validator compromise is required.

Internal Preconditions

  • The Flashblocks builder uses BuilderConfig.max_gas_per_txn.

  • execute_best_transactions reaches evm.transact(&tx) for the attacker transaction.

  • The post-execution gas_used > max_gas_per_txn branch fires.

  • The branch calls only best_txs.mark_invalid(tx.signer(), tx.nonce()).

  • MaxGasUsageExceeded is not classified as permanent by TxnExecutionError::is_permanent().

  • The tx hash is not pushed into diag.permanently_rejected_txs.

  • Payload cleanup removes only diag.permanently_rejected_txs and committed txs.

  • The tx remains in the txpool and outside the shared rejection cache.

  • A later builder attempt refreshes the best-tx iterator from the pool.

Recommendation

Make MaxGasUsageExceeded non-retryable for the active builder configuration, or avoid the expensive post-execution rejection path entirely.

Primary fix

Classify MaxGasUsageExceeded as reject-cacheable/removable for the active parent/config context.

When gas_used > max_gas_per_txn fires:

  1. add the tx hash to diag.permanently_rejected_txs, or an equivalent over-gas rejection bucket;

  2. call best_txs.mark_rejected() for that hash after execution;

  3. remove associated metering data;

  4. remove the tx from the pool; and

  5. ensure the tx cannot re-enter through iterator refresh or P2P re-gossip under the same active cap.

The fixed path should become:

If max_gas_per_txn can change at runtime, rejection-cache entries should be bound to a config/version epoch or parent/payload context so a later cap increase can safely re-evaluate previously rejected transactions.

Additional hardening

  1. Reject conservatively before full EVM execution when tx.gas_limit > max_gas_per_txn, if that matches intended semantics for this cap.

  2. Keep the post-execution check for cases where actual gas must be measured, but make its rejection path persistent for the active configuration.

  3. Add a regression test where an over-cap transaction is attempted once, then a later flashblock/block refresh occurs, and the transaction must not be selected/executed again under the same cap.

  4. Add an assertion that MaxGasUsageExceeded either enters diag.permanently_rejected_txs or a dedicated config-scoped rejection cache.

  5. Add metrics distinguishing first-time over-cap rejection from repeated attempts; any repeated attempt for the same tx hash under the same cap should fail the regression test.

Affected Code (must be fixed in scope files)

Mandatory fix 1: max_gas_per_txn is enforced after full EVM execution

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

  • Lines: L811-L821, L880-L904

  • Links:

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

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

The builder calls evm.transact(&tx) before the max_gas_per_txn check. After the post-execution gas check fires, the branch calls only best_txs.mark_invalid(tx.signer(), tx.nonce()) and continues. This is the primary patch location for either pre-execution rejection or persistent over-cap rejection.

Mandatory fix 2: MaxGasUsageExceeded is not classified as permanent

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

  • Lines: L170-L172, L179-L194

  • Links:

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

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

MaxGasUsageExceeded exists as a transaction execution error, but is_permanent() does not include it. As a result, common permanent-rejection plumbing does not treat the over-cap transaction as reject-cacheable/removable.

Mandatory fix 3: payload cleanup removes only diag.permanently_rejected_txs and committed 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

Payload cleanup calls mark_rejected, metering_provider.remove, and pool.remove_transactions only for diag.permanently_rejected_txs, and then separately prunes committed transactions. Since the MaxGasUsageExceeded path does not populate diag.permanently_rejected_txs and the transaction is not committed, the over-cap tx avoids both cleanup paths.

Mandatory fix 4: mark_invalid() is only current-iterator invalidation, while mark_rejected() writes the shared rejection cache

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

  • Lines: L89-L103, L119-L139

  • Links:

    • https://github.com/base/base/blob/v0.8.0-rc.28/crates/builder/core/src/flashblocks/best_txs.rs#L89-L103

    • https://github.com/base/base/blob/v0.8.0-rc.28/crates/builder/core/src/flashblocks/best_txs.rs#L119-L139

The over-cap branch uses mark_invalid, which does not insert the tx hash into the shared rejection cache. Later refresh_iterator() calls can therefore see the same pool transaction again.

Mandatory fix 5: max_gas_per_txn is a real builder configuration option and defaults to disabled

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

  • Lines: L45-L46, L135-L135, L170-L174

  • Links:

    • https://github.com/base/base/blob/v0.8.0-rc.28/crates/builder/core/src/config.rs#L45-L46

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

    • https://github.com/base/base/blob/v0.8.0-rc.28/crates/builder/core/src/config.rs#L170-L174

This confirms the report's scope and configuration caveat: the issue is real when the cap is enabled, but the default config has max_gas_per_txn: None. The fix should preserve safe behavior across config changes.

Proof of Concept

This POC contains the complete scenario needed to prove the Medium bug.

It proves:

  • the over-cap transaction enters the real txpool;

  • the transaction is excluded by the configured post-execution max_gas_per_txn guardrail;

  • the transaction is not included in any built block;

  • the transaction remains physically present in the real txpool after every rejection;

  • the transaction remains Pending after every rejection;

  • the transaction is not inserted into the shared rejection cache;

  • normal control transactions are included in each round; and

  • repeated debug logs show the same tx hash reaching result="max gas usage exceeded" fifteen times.

The PoC uses the real in-process builder, real txpool observer, real signed transactions submitted through the normal RPC provider, and real Engine API FCU/getPayload/newPayload/forkchoice block production.

Scenario: one over-cap tx survives and is retried across three real builder rounds

  1. Configure the builder with BuilderConfig::for_tests().with_max_gas_per_txn(Some(25_000)).

  2. Start the real in-process builder/test instance.

  3. Fund a separate account for control transactions.

  4. Submit one high-priority over-cap transaction using the repository's transaction builder helper.

  5. Wait until the tx appears in the real txpool as Pending or Queued.

  6. Assert it is not initially in the rejection cache.

  7. For each of three rounds, submit a normal control transaction from a separate account.

  8. Build a real block through the Engine API test harness.

  9. Assert the control transaction is included.

  10. Assert the over-cap transaction is not included.

  11. Assert the over-cap transaction still exists in the real txpool.

  12. Assert the over-cap transaction remains Pending or Queued.

  13. Assert the over-cap transaction is not in the shared rejection cache.

  14. Assert the final result is POC_RESULT=vulnerable_behavior_confirmed.

Test file name:

Where to place:

Command to run from the base-azul-offchain repository root:

Logs:

Repeated execution proof for the same over-cap hash:

PoC source (full)

Was this helpful?