Defending Against Circuit-Building DoS: Better Metrics, and What Tor Could Fix

By 1AEO Team • July 13, 2026 • Lessons and next steps from the June–July 2026 circuit-building DoS wave

Part 5 of a five-part series on the June–July 2026 circuit-building DoS.  1. The Tor Network Is Under a Circuit-Building DoS Wave — the network-wide view from Tor's public data.  2. Anatomy of the Attack — the three waves and how each one works.  3. What It Actually Costs — the CPU and memory bill, and how 1AEO ranked.  4. The Flood Below Tor — the connection flood Tor's own counters cannot see.  5. Defending Against Circuit-Building DoS (you are here) — better metrics and the protocol fixes we're asking for.

The circuit-building DoS wave hitting the Tor network made two things clear on our own guard-relay fleet: we needed to see the attack more clearly, and the durable fix belongs in the Tor protocol, not on the operator's server. This post covers both — first how we're improving our own monitoring in a way that adds zero logging and touches no personal data, then the specific things we think the Tor Project could change.

Part 1 — Seeing the Attack Without Watching the Users #

The obvious way to study an attack is to record what's arriving: capture connections, log source addresses, inspect traffic. We do not do that, and we are not going to. A Tor relay exists to move traffic it cannot read and does not remember. Everything in our companion posts — the CPU curves, the memory ratchet, the drop counts, even the finding that the flood came from a large, distributed source pool — comes from aggregate counters, never from a packet capture, a connection log, or any tally of source addresses. That last finding is an inference, not a measurement of who connected: the fleet held many times its normal open-circuit count even as Tor's per-source circuit-rate defense rejected fast sources throughout, so the load had to be spread across a very large number of sources each staying under the per-source limit — something the aggregate counters show without our ever counting a single IP.

Our monitoring principle is simple: measure the relay's own health and the attack's aggregate shape, never the individual source. Concretely, that means we build only on two kinds of source, both aggregate by construction:

Where we want more visibility, we get it by adding more aggregate signals, and by correlating them against public data (the CollecTor archives and Onionoo) rather than against anything we'd have to record ourselves. The gaps this wave exposed, and how we're closing them privacy-safely:

What we couldn't see wellThe privacy-safe fix
Per-relay memory growth (the fragmentation that drove the OOM kills)Export each relay process's resident-memory total (one gauge per relay). It's a number of bytes, not a record of anything.
Which host actually shed circuits, and whenScrape the existing onionskins_total{action="dropped"} counter per relay — and never infer drops from the Heartbeat assigned/requested gap (see Part 2), which is not a drop count.
Whether kernel memory pressure was turning into outagesWatch node_memory_MemAvailable_bytes and node_vmstat_oom_kill at the host level, alongside Tor's metrics — the OOM story is invisible to a Tor-only dashboard.
How our standing compared to the rest of the networkCorrelate our aggregate health against the public CollecTor/Onionoo record. The comparison lives in public data, so we never have to build a private one.
How large or coordinated the attacking source pool wasWe never count or bucket source addresses at all — that would mean inspecting them. We infer only that the pool is large and distributed (aggregate circuit load Tor's per-source limits couldn't cap), and we make no claim about a specific IP count or whether the pool was identical across hosts — rather than overstate a finding we deliberately didn't collect.
The test we hold ourselves to: if a metric could help someone reconstruct who used the relay or what they did, we don't collect it — even to fight an attack. Retained counters describe the relay; they must never identify who is behind a connection. Fighting a DoS is not a license to start surveilling the people the relay exists to protect.

Part 2 — Measurement Traps We Hit (So You Don't Have To) #

Two metrics fooled us early, and both are easy traps for any relay operator. We publish them because the correction is more useful than the mistake.

"assigned/requested" is not "dropped" #

Tor's Heartbeat prints a line like Circuit handshake stats since last time: ... 41000/42700 NTor, .... It is tempting — and we did exactly this — to read the pair as assigned/requested and call the gap "dropped onionskins." Across a fleet that gap sums to billions and tracks CPU load, so it looks like a clean overload signal. It is wrong. Reading the Tor 0.4.9 source settles it: requested increments for every create cell that arrives from another relay (edge-originated cells are excluded, command.c), assigned only when a handshake reaches a CPU worker (cpuworker.c), and a real drop — queue full — is a separate counter incremented only at onion_queue.c, which is what MetricsPort action="dropped" exposes. The gap is mostly circuits abandoned before a worker reached them (closed by the DoS defenses, by timeouts, or by the originating side) — churn, not overflow.

Trust tor_relay_load_onionskins_total{action="dropped"} and the log line "Your computer is too slow to handle this many circuit creation requests." If that warning isn't in your logs, you aren't dropping — no matter what the Heartbeat gap says.

The inbound-connection gauge drifts to zero between restarts #

Tor's inbound-connection gauge (tor_relay_connections{state="opened", direction="received"} in Tor 0.4.9.x) drifts steadily toward zero between process restarts — over ~10–14 days it loses most of its value while kernel-level established-TCP counts stay flat — and every restart re-syncs it to reality. The cause is an accounting asymmetry in the source: the gauge is incremented only once a connection is fully accepted, but decremented for every connection torn down, including ones closed before they were ever counted (common under a connection flood). An underflow guard floors it at zero, so the unmatched decrements ratchet it below reality until the process restarts. Left uncaught, this can masquerade as a connection flood or a connection collapse. We have a full write-up with the source walk-through, a reproducer, and a proposed fix coming; the short version for now is: use the drift-immune counter tor_relay_connections_total{state="created"} and the host's own TCP counts for connection levels, not the opened-connection gauge.

Part 3 — What the Tor Project Could Change #

The uncomfortable core of this attack is that it is cheap to send and expensive to absorb, and the expensive parts — CPU burned on handshakes, RAM held by idle circuits — land on the operator with no protocol-level circuit breaker. Tor's existing DoS mitigations engaged and did real work, but they throttle at the level of a single source, and this flood is fanned out across a large, distributed pool of sources so that no individual source trips a limit while the aggregate saturates the relay. Directions we'd like to see the Tor Project weigh:

We're running relays, publishing data, and comparing notes with other operators — but the durable fix belongs in the protocol. We are glad to share raw, aggregate data with Tor Project developers. Reach out.

Join the Mission