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.
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:
tor_relay_*) — counters and gauges the Tor process already computes: onionskins processed and dropped, circuits opened, DoS-defense actions taken, out-of-memory bytes freed. These are totals, not events; there is no per-source or per-connection record to leak.node_exporter) — CPU utilization, free memory, and the kernel's own OOM-kill counter. Machine health, nothing about traffic.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 well | The 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 when | Scrape 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 outages | Watch 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 network | Correlate 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 was | We 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. |
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.
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.
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.
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:
DoSCircuitCreationRate=2, DoSCircuitCreationBurst=60, DoSCircuitCreationMinConnections=2 — all consensus defaults, verifiable in any signed consensus). Spread across a large, distributed pool, every source stays under its own limit while the per-relay total is enormous. Per-source limits that never sum into a per-relay ceiling can't stop a large, distributed pool.DoSConnectionMaxConcurrentCount (default 50) is enforced per tor process, so on a machine running many relay instances a single source can legally hold 50 connections to each relay — thousands in total — without any one relay seeing more than 50. A source parking thousands of connections on one machine should be visible to some layer of the defense.circ_proto_violation counts — it surged more than 150x in this wave's second week), but it does not penalize the source. Feeding a high violation rate from a source into the DoS subsystem — flag and refuse, the way DoSCircuitCreation already does — would act on an unusually clean abuse signal, since real traffic doesn't send malformed circuits en masse. One caveat worth flagging for anyone studying this attack: the violation signal is invisible from outside — circ_proto_violation appears in no CollecTor archive and no Onionoo field, only on a relay's own MetricsPort, so no third party can corroborate the malformed-circuit count. Exposing it in some aggregate, public form would be its own small observability win.accept(), the kernel drops completed connections at the listen backlog (ListenOverflows / ListenDrops) — and because both accept() and the TLS handshake run on the single main-thread event loop that is already absorbing the circuit flood, this is exactly where CPU pressure turns into silent, Tor-invisible connection loss. An operator watching only Tor's own metrics sees none of it, even as the kernel turns away connections by the billion. Exposing accept-queue pressure in MetricsPort — or even a coarse "shedding connections at the kernel" indicator — would make this layer visible, with no new logging. (We trace it in detail in The Flood Below Tor.) Longer term, a connection-accept path that isn't single-threaded — offloading the TLS handshake the way ntor already is, or sharding the listener — would let a saturated relay degrade visibly instead of dropping connections in silence; but the durable answer remains keeping the flood off the CPU in the first place (the aggregate-cap and proof-of-work items above).MaxMemInQueues never fires and the kernel does the killing — Tor never gets a chance to defend itself gracefully.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.