In the Espresso streamer we pin (espresso-streamers at 22c396a, pulled in by #480),
checkBatch returns BatchUndecided from three places before it ever compares the signer:
uninitialized finality at op/op_streamer_v2.go:530, a header reporting L1 finality ahead of ours
at :536, and a failed EspressoBatcherAtBlock call at :549. The signer comparison is at :558.
process then inserts undecided batches at :508. So in any of those windows, anything posted to
our namespace lands in memory without its signature having been checked.
The ordering is deliberate, with a comment at :565-568 explaining it. Our argument is that the
intent is wrong, not that it was an oversight.
Why it matters to us
An attacker needs very little: the ability to post to the namespace, which is permissionless and
said so outright at derivation/espresso_batch.go:122, any keypair to self-sign with, and the
current L2 tip, which is public. They can't manufacture the window, since l1Finalized comes from
the real HotShot header. They don't need to. It recurs on every 10s finality tick, and stretches to
minutes on the degraded path, where a failed direct L1 query falls back to syncStatus.FinalizedL1
that op-node only refreshes every 384s by default.
Nothing bounds what accumulates. v1 capped the buffer at 1024 entries and rejected anything below
the consumer's position. v2 rejects only num <= lastFinalizedL2 (batch_store.go:58), so a batch
claiming an L2 height near 2^63 sits there until finality catches up, which it never will.
Deduplication is by hash, and an attacker varies the hash for free by touching any header field.
The CPU cost is the sharper end. batch_store.go:78-83 rescans the whole candidate map on every
insert to compute the order key, which is O(N²) in candidates at one height, under the same write
lock peek takes. We confirmed the scaling properly rather than asserting it: elapsed/N² stays
flat within about 3% from N=2500 to N=20000, with N=20000 landing in the 1 to 2 second range
depending on machine.
One correction to how we first described the impact. Each insert holds the lock only for its own
slice and releases between calls, so no single Peek blocks for the whole flood. The worse effect
is that peek itself scans every candidate at nextBatchPos on each call, so flooding the live
position, which is public knowledge, makes every later Peek pay that cost again with no
amortization.
What needs to change
Move the signer check to the front. It's a local ecrecover already done at unmarshal, so it's
cheaper than the comparisons currently ahead of it, and it closes the insert path to unsigned junk.
Track next-order per height instead of rescanning. This one is independent of the rest and could
land on its own.
Cap candidates per height, and cap how far above nextBatchPos anything can be stored.
Verification
The quadratic behaviour was measured with a standalone benchmark rather than estimated. Everything
else is read against 22c396a, currently the head of espresso-streamers#36.
This is the same class as audit finding ST-1. The v1 mechanism was fixed; the structural defence
that bounded it went with the rewrite. Of the findings in this batch it's the one I'd fix first, and
it has no upstream issue.
In the Espresso streamer we pin (
espresso-streamersat22c396a, pulled in by #480),checkBatchreturnsBatchUndecidedfrom three places before it ever compares the signer:uninitialized finality at
op/op_streamer_v2.go:530, a header reporting L1 finality ahead of oursat
:536, and a failedEspressoBatcherAtBlockcall at:549. The signer comparison is at:558.processthen inserts undecided batches at:508. So in any of those windows, anything posted toour namespace lands in memory without its signature having been checked.
The ordering is deliberate, with a comment at
:565-568explaining it. Our argument is that theintent is wrong, not that it was an oversight.
Why it matters to us
An attacker needs very little: the ability to post to the namespace, which is permissionless and
said so outright at
derivation/espresso_batch.go:122, any keypair to self-sign with, and thecurrent L2 tip, which is public. They can't manufacture the window, since
l1Finalizedcomes fromthe real HotShot header. They don't need to. It recurs on every 10s finality tick, and stretches to
minutes on the degraded path, where a failed direct L1 query falls back to
syncStatus.FinalizedL1that op-node only refreshes every 384s by default.
Nothing bounds what accumulates. v1 capped the buffer at 1024 entries and rejected anything below
the consumer's position. v2 rejects only
num <= lastFinalizedL2(batch_store.go:58), so a batchclaiming an L2 height near 2^63 sits there until finality catches up, which it never will.
Deduplication is by hash, and an attacker varies the hash for free by touching any header field.
The CPU cost is the sharper end.
batch_store.go:78-83rescans the whole candidate map on everyinsert to compute the order key, which is O(N²) in candidates at one height, under the same write
lock
peektakes. We confirmed the scaling properly rather than asserting it:elapsed/N²staysflat within about 3% from N=2500 to N=20000, with N=20000 landing in the 1 to 2 second range
depending on machine.
One correction to how we first described the impact. Each insert holds the lock only for its own
slice and releases between calls, so no single
Peekblocks for the whole flood. The worse effectis that
peekitself scans every candidate atnextBatchPoson each call, so flooding the liveposition, which is public knowledge, makes every later
Peekpay that cost again with noamortization.
What needs to change
Move the signer check to the front. It's a local
ecrecoveralready done at unmarshal, so it'scheaper than the comparisons currently ahead of it, and it closes the insert path to unsigned junk.
Track next-order per height instead of rescanning. This one is independent of the rest and could
land on its own.
Cap candidates per height, and cap how far above
nextBatchPosanything can be stored.Verification
The quadratic behaviour was measured with a standalone benchmark rather than estimated. Everything
else is read against
22c396a, currently the head of espresso-streamers#36.This is the same class as audit finding ST-1. The v1 mechanism was fixed; the structural defence
that bounded it went with the rewrite. Of the findings in this batch it's the one I'd fix first, and
it has no upstream issue.