0xtruthlover
← Writing

Disclosure4 min read

A free Flow dry run that would take 7,300 years to finish

Flow's EVM dry run never charged for gas, so one free script could start a loop on an Access Node that no timeout could stop.

Severity
Medium
Program
Flow Protocol
Platform
HackenProof
Status
Fixed and paid

Flow’s EVM.dryRun forgot to count the gas it spent. Anyone could send a free script to a public Access Node, start an infinite loop with an absurd gas budget, and pin a CPU core with a loop that, on paper, would take about 7,300 years to finish.

No account, no balance, no fee. A handful of those requests and the node stops answering.

I reported it on HackenProof as High. The Flow team confirmed every technical point, rated it Medium, merged a fix and paid it.

How Flow meters EVM work

Flow runs an Ethereum-style EVM inside its own execution environment, the FVM. Cadence, Flow’s native smart contract language, reaches that EVM through a contract handler with two main doors:

  • run, for real, state-changing transactions.
  • dryRun, for gas estimation and read-only calls.

Every EVM execution has to fit inside the FVM’s computation budget, a meter that stops any script that does too much work. For scripts the default cap is 100,000 units.

run enforces that twice. Before executing, it checks there is enough budget left for the gas the transaction asks for. After executing, it charges the gas actually used against the FVM meter. Ask for too much, or burn too much, and the script stops.

Access Nodes are the public, permissionless servers that answer user requests. One of their endpoints runs any Cadence script for free. That is fine, because the meter keeps scripts short.

The meter that never ran

dryRun did neither check. It only metered the cost of decoding the transaction, then handed it straight to the EVM.

run(tx):
  checkGasLimit(tx.gas)      // before
  res = execute(tx)
  meterGasUsage(res)         // after

dryRun(tx):
  meter(decodeCost(tx))      // only this
  res = dryRunExecute(tx)    // no check, no charge

Two more details meant nothing else would step in:

  • The gas cap is off. Dry runs skip the usual transaction checks in go-ethereum (the standard Ethereum client code Flow’s EVM is built on), including the 30 million gas cap per transaction. Reasonable for estimation, since you do not know the gas yet.
  • The block limit is the maximum. Flow’s default block gas limit is the largest 64-bit number, about 18.4 quintillion.

Skipping the cap is fine only if the meter is watching. Here the meter was asleep, so a dry run could declare any gas limit and run until it used it up.

Pinning a CPU core

  1. Send a Cadence script to an Access Node’s free script endpoint.
  2. Inside it, ask the EVM for a dry run of a tiny contract that loops forever, and give it an absurdly large gas budget.
  3. Nothing checks that gas against the FVM budget, and nothing charges for it afterward, so the 100,000 limit is never reached.
  4. The EVM spins. go-ethereum’s execution function takes no cancellation signal, so no timeout can interrupt it once it starts.
  5. The request’s connection times out after 10 to 30 seconds. The goroutine, Go’s lightweight thread, keeps running anyway as a zombie.
  6. Repeat a few times. Each request holds one CPU core until its gas runs out.

I proved it with two small unit tests against Flow’s own EVM emulator. One showed a dry run accepting a gas limit more than three times the normal cap, and confirmed the block limit really was the 64-bit maximum.

The other sent the four-byte loop through a dry run with a small gas limit. It went through with no FVM intervention, and the meter never stepped in. Scale that loop up to the kind of gas budget nothing was checking, and the arithmetic comes out at roughly 7,300 years.

Who it hurts

Access Nodes serve everyone who talks to Flow. This attack was free, unauthenticated and needed no on-chain setup.

Each request ties up a goroutine and a CPU core until the node is restarted. Rate limits and timeouts do not help, because they cannot stop work that is already running. A modest number of concurrent requests is enough to make a node unresponsive.

It stops there: no funds at risk, no state corruption, no privilege escalation. It hits node availability, and a restart recovers it. The team rated it Medium.

Make dryRun match run

I recommended making dryRun match run:

  • Check before. Validate the requested gas against the remaining FVM budget before executing.
  • Charge after. Meter the gas actually used once execution returns.
  • Cap, do not reject. Since dry runs are used for estimation, clamp very high gas limits to a safe ceiling, such as the remaining budget, instead of failing outright.

The team confirmed the bug and merged a fix that brings dryRun in line with run.

What stuck with me

  • When you remove one guard on purpose, write down which guard replaces it. Turning off the gas cap was a sensible choice that quietly made metering the only line of defense.
  • Sibling functions drift. If run and dryRun do the same work, they should share the same safety checks, ideally the same code.
  • Timeouts are not cancellation. Closing the connection does nothing to a loop that never asks whether it should stop.