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

76538 bc low post execution max gas per txn rejection retries over cap transactions for free

Submitted on May 4th 2026 at 19:24:58 UTC by @adeolu for Audit Comp | Base Azul

  • Report ID: #76538

  • 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

Affected code:

Summary

--builder.max_gas_per_txn is an optional builder-side execution cap. It is disabled by default (None), but when an operator enables it, the builder fully executes a candidate transaction first and only then checks whether gas_used > max_gas_per_txn.

If the transaction exceeds the configured cap, the builder rejects it before committing state, creating a receipt, pruning it as committed, or charging the sender. The rejection only calls best_txs.mark_invalid(sender, nonce) for the current iterator. TxnExecutionError::MaxGasUsageExceeded is not classified as permanent, so the transaction hash is not added to diag.permanently_rejected_txs, is not inserted into the rejection cache, and is not removed from the builder txpool.

On the next flashblock or block, refresh_iterator(...) rebuilds the best-transaction iterator from the same pool. A high-priority over-cap transaction can therefore be selected, fully executed, rejected, and retained again. The attacker pays no gas because the transaction is never mined and the sender nonce never advances.

The direct one-sender impact is a sender-level nonce-chain pin plus repeated builder-side EVM work. With many funded sender accounts, the same condition can be scaled into builder resource pressure: each account supplies a high-priority current-nonce transaction that passes pre-execution checks, consumes EVM time, is rejected after execution, and remains available for retry.

This maps most directly to:

It is configuration-dependent because max_gas_per_txn is not enabled by default. The issue is still security-relevant for any builder deployment that enables the advertised cap: the cap becomes a free repeated-computation primitive instead of a one-time exclusion rule.

Required Conditions

The issue shows when these configs and conditions line up:

  • The builder operator enables --builder.max_gas_per_txn.

  • The attacker can submit normal signed transactions into the builder's txpool.

  • The attacker transaction's gas_limit is low enough to pass normal pool and block gas checks.

  • The transaction's actual EVM gas_used exceeds max_gas_per_txn.

  • The transaction carries enough priority fee to be selected near the top of the builder ordering.

  • The transaction is at the sender's current executable nonce. Later same-sender nonces can be pinned behind it, but the repeated-execution primitive is the current-nonce transaction.

The attack does not require a malicious batcher, invalid signatures, bad L1 data, a malicious sequencer, a malicious builder operator, or direct corruption of builder state. The only operator-side condition is enabling the documented builder cap.

Severity evaluation

Severity: High when the cap is enabled; configuration-dependent otherwise.

The builder's configured per-transaction gas cap is intended to bound which transactions the builder includes. Instead, an over-cap transaction can be executed repeatedly after it is known to exceed the cap. That causes the builder to process a mempool transaction beyond the configured parameter on every iterator refresh.

The PoC configures a real in-process builder with max_gas_per_txn = 25_000 and uses the existing random_big_transaction() helper, whose comment states that the transaction uses about 86,220 gas. The transaction passes pre-execution admission with a normal 210,000 gas limit, executes in the EVM, exceeds the configured cap, and is rejected after execution.

The PoC demonstrates:

Vulnerability Detail

1

The builder exposes an optional max_gas_per_txn cap

Source: bin/builder/src/cli.rs:63-64

The CLI value is copied into BuilderConfig.

Source: bin/builder/src/cli.rs:187

The default config leaves the cap disabled.

Source: config.rs:124-135

2

The cap is checked only after full EVM execution

Source: context.rs:820-853

Only after evm.transact(&tx) returns does the builder read result.gas_used() and enforce max_gas_per_txn.

Source: context.rs:880-905

The continue happens before cumulative gas is updated, before receipt creation, before state changes are pushed into the payload, and before the transaction is marked committed. The builder has already paid the CPU cost of EVM execution, but the sender is not charged because the transaction is not included.

3

MaxGasUsageExceeded is not permanent

Source: execution.rs:170-194

Since MaxGasUsageExceeded is not matched here, the post-execution rejection is treated as transient.

4

Only permanent rejections and committed transactions are removed from the pool

Source: payload.rs:572-617

An over-cap transaction rejected by MaxGasUsageExceeded is neither committed nor permanent. Therefore it is not pruned, not removed, and not rejection-cached. A later refresh_iterator(...) can select the same transaction again from the same pool.

5

The PoC transaction is a normal low-gas-limit transaction that burns more than the configured cap

Source: utils.rs:50-56

The test sets the builder cap to 25,000, while this transaction uses about 86,220 gas. The transaction's gas limit remains the default 210,000, so it passes ordinary pre-execution gas-limit checks and reaches the vulnerable post-execution branch.

Attack Path

1

A builder deployment enables --builder.max_gas_per_txn, for example 25,000, to exclude expensive individual transactions.

2

The attacker funds one or more normal EOAs.

3

Each attacker EOA submits a signed current-nonce transaction with a high priority fee and code/calldata that uses more gas than the configured cap, while keeping gas_limit within normal block and txpool limits.

4

The builder orders the high-priority attacker transaction near the top of the candidate set.

5

The builder fully executes the transaction with evm.transact(&tx).

6

After execution, gas_used > max_gas_per_txn, so the builder records MaxGasUsageExceeded, calls best_txs.mark_invalid(sender, nonce), and skips committing the transaction.

7

Because the rejection happens before receipt/state commit, the attacker pays no gas and the attacker nonce does not advance.

8

Because MaxGasUsageExceeded is not permanent, the transaction is not added to diag.permanently_rejected_txs, not removed from the pool, and not rejection-cached.

9

On the next flashblock or block, refresh_iterator(...) rebuilds from the same txpool, so the same high-priority over-cap transaction can be selected and executed again.

10

With many funded EOAs, the attacker repeats the same current-nonce pattern across accounts to create sustained builder-side EVM work and keep high-priority over-cap candidates at the front of selection.

Impact

The directly proven impact is repeated builder-side EVM execution beyond a configured per-transaction gas cap, without gas payment by the attacker.

For one sender, the over-cap transaction at nonce N also pins that sender's nonce chain: a normal follow-up transaction at N + 1 remains in the pool but cannot execute because nonce N never commits. That is a per-sender liveness issue.

For a broader service-impact scenario, the attacker scales across many funded sender accounts. Each sender contributes a current-nonce over-cap transaction, avoiding the limitation that one sender can only contribute a small number of useful pending nonce slots under default txpool sender limits. The attacker does not need future nonces to create the repeated-computation effect; one current-nonce over-cap transaction per account is enough.

The attacker's direct execution cost is favorable:

This can increase builder CPU consumption and transaction-selection work while violating the configured max_gas_per_txn exclusion semantics.

Proof of Concept

The PoC is a builder-node E2E test. It starts a real in-process builder node, enables max_gas_per_txn, submits an over-cap attacker transaction at nonce N, submits a normal same-sender follow-up transaction at nonce N + 1, submits unrelated user transactions, builds blocks with the real flashblocks payload builder, and checks the resulting chain and txpool state.

The PoC proves all of the following:

  1. The attacker transaction is executed and rejected after exceeding max_gas_per_txn.

  2. The rejected over-cap transaction remains in the builder txpool after block construction.

  3. The same-sender follow-up transaction remains pinned behind the over-cap head nonce.

  4. Unrelated user transactions are still included when the pool is not saturated.

  5. The attacker balance and nonce remain unchanged after repeated block builds, proving the attacker pays no execution fee and the transaction is never mined.

  6. A later block build sees the same retained over-cap transaction condition again, confirming the retry loop.

These steps start from a freshly cloned repository.

Step 1: clone and checkout the vulnerable version

Step 2: apply the PoC test

Apply this patch from the repository root:

The small with_nonce helper change in crates/builder/core/src/test_utils/txs.rs is only for deterministic test construction. It makes the existing explicit nonce helper set both the transaction field and the builder's internal nonce override. The vulnerability itself is in the builder payload-selection path shown above.

Step 3: format-check and compile-check the test

Expected result:

The repository's rustfmt config may print warnings about nightly-only options such as imports_granularity, group_imports, and trailing_comma. Those warnings are expected on stable rustfmt; the command should still exit successfully.

Step 4: run the PoC

Observed result on a freshly patched checkout:

On macOS, the test process may also print shutdown messages similar to:

Those shutdown messages occur after the assertions pass and are not part of the vulnerability. The PoC result is the ok test status.

What the PoC demonstrates

The core assertions are:

This confirms the pool is not globally blocked in the one-sender case; an unrelated sender can still be included.

The over-cap transaction and the same-sender follow-up remain in the builder pool after block construction.

The attacker pays no gas and the nonce does not advance because the over-cap transaction is never mined.

A later block build still observes the same retained over-cap transaction and pinned follow-up. This is the retry loop.

Validation

Formatting:

Result: passed. The command prints warnings about nightly-only rustfmt options in the repository config, but exits successfully.

Compile check:

Result: passed.

Builder-node E2E PoC:

Result: passed.

The local workspace used for validation contained additional PoC tests, so its final test summary reported a different filtered out count than a fresh checkout. The vulnerability assertions and the ok status are the relevant result.

Do not allow a transaction that has already exceeded the configured per-transaction gas cap to re-enter selection on the next iterator refresh.

Was this helpful?