ethereum-optimism / ethereum-optimism/optimism
State-persistent partial DoS in OptimismPortal2 high-gas deposits via ResourceMetering gas burn exceeding L1 block gas limit
- Dominant language
- Go
- Stars
- 6.5k
- Forks
- 4k
- Avg merge
- 2d 15h
- Merged PRs (30d)
- 145
Description
## Disclosure note
I am opening this publicly after prior discussion with OP Labs security.
John Mardlin from OP Labs discussed the finding with me by email and said that publishing a technical write-up is fine because the affected code is public..
This issue is not intended as an Immunefi escalation or a bounty request. I am posting it here because the behavior is reproducible on a mainnet fork, the affected code lives in this repo, and I think the mechanics are useful for maintainers and other researchers to understand.
I also want to be precise about the impact from the start: I am not claiming permanent loss of funds, and I am not claiming a full permanent bridge freeze. The issue is a state-persistent partial liveness failure for historically used high-gas L1 -> L2 deposits.
## Summary
I found a liveness failure in the `OptimismPortal2.depositTransaction()` path.
The external entrypoint is:
```solidity
OptimismPortal2.depositTransaction(...)
```
The relevant metering path is:
```solidity
OptimismPortal2.depositTransaction(...)
-> metered(_gasLimit)
-> ResourceMetering._metered(...)
-> gasCost = amount * prevBaseFee / Math.max(block.basefee, 1 gwei)
-> Burn.gas(gasCost - usedGas)
```
When `prevBaseFee` has been raised by previous large deposits, the computed `gasCost` can exceed the amount of gas that can physically fit in an L1 block.
When that happens, the transaction does not fail through the explicit `ResourceMetering.OutOfGas()` path. It fails with a real EVM out-of-gas inside `Burn.gas()`.
That distinction matters because the metering update does not persist after an EVM OOG. The transaction rolls back, the deposit log is not emitted, and the metering state remains at the last successful value. If that last successful value is already in the elevated/congested region, later high-gas deposits can keep failing the same way.
## Affected code
Primary file:
```
packages/contracts-bedrock/src/L1/ResourceMetering.sol
```
Relevant logic:
```solidity
uint256 resourceCost = uint256(_amount) * uint256(params.prevBaseFee);
uint256 gasCost = resourceCost / Math.max(block.basefee, 1 gwei);
uint256 usedGas = _initialGas - gasleft();
if (gasCost > usedGas) {
Burn.gas(gasCost - usedGas);
}
```
External entrypoint:
```
packages/contracts-bedrock/src/L1/OptimismPortal2.sol
```
Relevant function:
```solidity
depositTransaction(...)
```
The bug is not really in the body of `depositTransaction()` itself. `depositTransaction()` is the externally reachable path. The failure primitive is in `ResourceMetering._metered()`, specifically the interaction between:
```
prevBaseFee
block.basefee
Math.max(block.basefee, 1 gwei)
Burn.gas(...)
```
## Important correction about the 1 gwei floor
I want to be careful here because it is easy to explain this wrong.
The `1 gwei` floor does not make `gasCost` larger compared to using the real sub-1 gwei basefee directly. It actually reduces the theoretical gas cost compared to no floor.
For example, if L1 basefee is `0.149 gwei` and `prevBaseFee` is around `4.5 gwei`:
```
without the floor:
gasCost = 20M * 4.5 / 0.149
~= 604M gas
with the 1 gwei floor:
gasCost = 20M * 4.5 / 1
~= 90M gas
```
So the floor makes the result less extreme.
But it is still far above the L1 block gas limit.
The vulnerability does not strictly require `block.basefee < 1 gwei`. What it requires is:
```
amount * prevBaseFee / max(block.basefee, 1 gwei) > available L1 transaction/block gas
```
For a 20M deposit and a 30M L1 block gas limit, the rough threshold at the 1 gwei denominator is:
```
prevBaseFee > 1.5 gwei
```
That threshold can be crossed quickly after successful high-gas deposits.
The `1 gwei` floor matters because it fixes the minimum denominator and therefore fixes the low-basefee threshold. The old code comment assumes that a sustained sub-1 gwei L1 gas environment is not a practical concern. That assumption is no longer something I would rely on.
## Why the failure is state-persistent
I previously used the word "frozen" for this, but a more precise phrase is:
```
the metering state does not advance after OOG
```
When a victim transaction fails inside `Burn.gas()`, the whole transaction reverts. So even if `_metered()` computed new parameters before the burn, none of those writes are persisted.
I verified this by reading `params()` immediately before and after the victim OOG transaction.
The values remain unchanged.
In the reproduced state:
```
prevBaseFee remains elevated
prevBoughtGas remains at the last successful value
prevBlockNum remains at the last successful metered block
```
This does not mean the system can never recover.
Recovery can happen if:
```
1. L1 basefee rises enough,
2. a small enough metered transaction succeeds,
3. the relevant parameters are changed,
4. the contract is upgraded,
5. some other successful path advances the metering state.
```
The point is narrower: during the bad window, high-gas deposits can fail in a way that prevents the metering state from naturally advancing through those failed attempts.
## Initial fork reproduction
I first reproduced this on a mainnet fork using the real deployed portal.
No `vm.store`.
No mock portal.
No storage manipulation.
Fork block:
```
25050253
```
Portal:
```
0xbEb5Fc579115071764c7423A4f12eDde41f106Ed
```
SystemConfig:
```
0x229047fed2591dbec1eF1118d64F7aF3dB9EB290
```
Observed deployed config:
```
maxResourceLimit: 20,000,000
minimumBaseFee: 1,000,000,000
baseFeeMaxChangeDenominator: 8
elasticityMultiplier: 10
```
Initial portal params at the fork:
```
prevBaseFee: 1,000,000,000
prevBoughtGas: 517,494
prevBlockNum: 25,050,253
```
The test sends real `depositTransaction()` calls to the deployed portal on the fork and rolls blocks forward.
The projected victim burn crosses the L1 block gas limit very quickly:
```
block 1:
stored prevBaseFee: 1.00 gwei
victim gasCost if L1 denominator is 1 gwei: 37,500,000
block 2:
stored prevBaseFee: 2.12 gwei
victim gasCost if L1 denominator is 1 gwei: 57,187,500
block 3:
stored prevBaseFee: 4.51 gwei
victim gasCost if L1 denominator is 1 gwei: 99,023,437
block 4:
attacker deposit itself OOGs
```
The attacker's own transaction failing at block 4 is not the problem for the reproduction. The important part is that the state has already crossed into a region where later high-gas deposits can become unexecutable.
The expected output from the original fork test includes:
```
victim deposit reverted: YES -- OOG on real portal
storage confirmed frozen: DoS self-sustains without attacker
```
A more precise wording for the second line would be:
```
metering params unchanged after OOG; state does not advance through failed high-gas deposits
```
## More practical boundary test
After discussing the initial report with OP Labs, I spent more time on the "prohibitively large gas" question because I wanted to understand where the practical cutoff actually sits.
I reproduced the sequence as top-level transactions on an Anvil mainnet fork using `cast send` against the deployed `OptimismPortal2`, not only an internal Foundry call.
Setup:
```
fork block: 25050253
L1 basefee scenario: ~3.97 gwei
transaction gas limit: 16,777,216
storage manipulation: none
prevBaseFee reached: ~9.025 gwei
```
Observed cutoff:
```
7.25M L2 gas:
succeeds and updates params
7.30M L2 gas:
fails with receipt status=0
emits no TransactionDeposited log
leaves params unchanged
7.35M+ L2 gas:
same failure pattern
```
This is the part that made me think the issue is not only about artificial 20M deposits.
At this elevated metering state, the cutoff falls into a range that has been used historically by real bridge and messenger deposits.
## Historical deposit scan
I scanned historical `TransactionDeposited` logs from the deployed portal.
Across:
```
239,556 deposits
```
I found:
```
317 deposits with L2 gasLimit >= 7.30M
53 deposits above 10M
```
I then decoded the top high-gas examples.
The top 100 were not random synthetic calls. They were `relayMessage(...)` calls through the `L2CrossDomainMessenger`.
Breakdown:
```
44 finalizeRelayBatch(...)
40 finalizeBridgeERC20(...)
9 finalizeRelay(...)
6 finalizeEscrowMigration(...)
1 deploy(...)
```
The `finalizeBridgeERC20(...)` group was the most useful concrete example.
All 40 examples in that group were WLD / Worldcoin bridge finalizations with:
```
relayMinGasLimit: 10,000,000
depositGasLimit: 10,447,890
```
That means the affected range is not just something created by the PoC. It includes historically used CrossDomainMessenger / bridge-finalization messages.
## Concrete WLD replay
I replayed one historical WLD bridge finalization.
Historical tx:
```
0x62e6bb428f59f96389207639368ee8638d2485e9e9db06c64c1e1a1b5361e4cd
```
Operation:
```
finalizeBridgeERC20(...)
```
Token:
```
WLD / Worldcoin
```
Amount:
```
80,000 WLD
```
Deposit gas limit:
```
10,447,890
```
Relay min gas limit:
```
10,000,000
```
On a clean fork, the reconstructed deposit succeeds.
It emits `TransactionDeposited` and updates `ResourceMetering.params`.
After preparing `ResourceMetering` to the elevated state used in the PoC, the exact same historical WLD deposit fails:
```
receipt status: 0
deposit log emitted: no
gas usage: near full transaction gas budget
params updated: no
```
So the same historical deposit that succeeded in production can be reproduced as failing under the elevated ResourceMetering state.
## Full standard bridge path replay
I also reproduced it through the full standard bridge path, not only by reconstructing the `depositTransaction()` payload directly against the portal.
Historical bridge tx:
```
0x62e6bb428f59f96389207639368ee8638d2485e9e9db06c64c1e1a1b5361e4cd
```
L1 bridge:
```
0x99c9fc46f92e8a1c0dec1b1747d010903e884be1
```
Selector:
```
0x58a997f6
```
On a clean fork, replaying the same top-level bridge calldata succeeds:
```
WLD is transferred to the bridge
bridge/messenger events are emitted
TransactionDeposited is emitted by the portal
ResourceMetering.params are updated
```
After organically driving `ResourceMetering.prevBaseFee` to around `9.025 gwei`, the exact same top-level bridge call fails:
```
receipt status: 0
gas used: ~15.5M
logs emitted: none
params updated: no
```
The WLD balances and allowance remain unchanged because the whole transaction rolls back.
That is the strongest reproduction I have right now:
```
a historical standard-bridge WLD deposit that succeeded in production can be replayed end-to-end as failing under the elevated ResourceMetering state
```
## Why I think this is different from the known griefing issue
I understand there is a known ResourceMetering griefing pattern where an attacker fills the per-block resource limit and causes following deposits to hit the explicit resource limit condition.
I do not think this is the same failure mode.
Known griefing path:
```
- attacker fills the per-block resource limit
- future deposits hit an explicit metering/resource condition
- attack generally requires repeated activity
- the behavior is bounded by the normal resource accounting path
```
This issue:
```
- works through elevated prevBaseFee
- computes a physical gas burn that can exceed the L1 block gas limit
- fails with real EVM out-of-gas inside Burn.gas()
- failed high-gas deposits do not advance ResourceMetering.params
- affects historically used high-gas bridge/messenger deposits, not only synthetic max deposits
```
The practical difference is not that funds are lost. They are not.
The practical difference is that a class of valid high-gas L1 -> L2 deposits can become temporarily unexecutable while the metering state and L1 gas conditions remain in the bad region.
## Expected result
A valid deposit that is accepted by the resource metering configuration should either:
```
1. execute successfully,
2. fail through an explicit and recoverable metering condition,
3. or leave the metering system in a state that can naturally advance.
```
## Actual result
A valid high-gas deposit can cause `ResourceMetering._metered()` to request a `Burn.gas()` amount larger than the L1 transaction/block can provide.
The transaction then fails with real EVM OOG.
Because the transaction reverts, the deposit emits no logs and the metering state does not advance.
## Impact
The precise impact is:
```
state-persistent partial liveness failure for historically used high-gas L1 -> L2 deposits
```
I am not claiming:
```
- permanent loss of funds
- direct theft
- permanent bridge freeze
- regular-user daily UX breakage
```
I am claiming:
```
- high-gas deposits can become temporarily unexecutable
- the affected gas range includes historically used bridge/messenger operations
- failed attempts do not advance metering state
- recovery depends on external conditions or a successful lower-gas metered transaction
```
The WLD bridge replay is the clearest example I have: the same historical standard bridge deposit succeeds on a clean fork and fails after ResourceMetering is organically driven into the elevated state.
## Reproduction notes
Original fork reproduction:
```
export ETH_RPC="https://1rpc.io/eth"
forge test --match-contract PoCEndToEnd -vvv --fork-url "$ETH_RPC"
```
The important constraints are:
```
- real deployed portal
- real deployed SystemConfig
- mainnet fork
- no vm.store
- no mock portal
- depositTransaction() used as the external entrypoint
- params() read from the real portal after each step
```
For the later reproduction, I used top-level fork transactions with `cast send` against the deployed contracts to avoid hiding anything inside Foundry internals.
I can provide the cleaned-up scripts, WLD replay output, decoded historical examples, and the updated fork-only PoC if useful.
## Suggested mitigation directions
I am not sure what the cleanest fix is, but the dangerous combination seems to be:
```
unbounded physical gas burn
+ elevated prevBaseFee
+ denominator based on L1 block.basefee with a fixed 1 gwei floor
+ state update that cannot persist if Burn.gas() OOGs
```
Possible directions:
```
1. cap the maximum physical gas burn to something that can actually execute,
2. add an explicit upper bound before calling Burn.gas(),
3. revisit the fixed 1 gwei denominator floor,
4. make the metering update recoverable even when the burn path cannot complete,
5. add invariant tests for low L1 basefee and elevated prevBaseFee,
6. add fork tests for historically used high-gas bridge deposits.
```
The invariant I would test is:
```
For any deposit amount accepted by ResourceMetering, _metered() should not require a Burn.gas() amount that can exceed the executable L1 gas limit.
```
A second invariant would be:
```
A failed high-gas deposit should not leave the metering system stuck in the same state that caused the failure.
```
## Closing note
I am posting this because the subtle part is not just congestion.
The subtle part is that the metering logic can ask the EVM to burn more gas than a block can contain. When that happens, the accounting update itself cannot land.
That turns a gas-pricing assumption into a partial liveness failure.
Again, I am not presenting this as funds-at-risk or as a permanent bridge freeze. The most accurate description is narrower:
```
a state-persistent partial DoS condition affecting historically used high-gas L1 -> L2 deposits under elevated ResourceMetering state
```
Contributor guide
Research direction
Start with packages/contracts-bedrock/src/L1/ResourceMetering.sol, especially _metered(), then trace the OptimismPortal2.depositTransaction() entrypoint. Reproduce the reported cutoff on the mainnet fork at block 25050253 and compare the 7.25M and 7.30M gas cases. Done requires a maintainer-approved mitigation and regression coverage showing that high-gas deposits do not become unexecutable without the metering state advancing.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- solidity
- Domain
- backend-api-design, blockchain
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100