Skip to content

Instantly share code, notes, and snippets.

@m0wer
Created July 9, 2026 17:38
Show Gist options
  • Select an option

  • Save m0wer/a454363f1972cd42486162a03ee6e3b2 to your computer and use it in GitHub Desktop.

Select an option

Save m0wer/a454363f1972cd42486162a03ee6e3b2 to your computer and use it in GitHub Desktop.
Tor guard-fingerprint timing attack on WabiSabi

Tor guard-fingerprint timing attack on WabiSabi: measured on real Tor

TL;DR. A plaintext observer of a WabiSabi round (the coordinator, its TLS-terminating CDN, or the VPS behind it) sees per-request timing. Tor pins each client to a long-lived guard, so that client's circuits share a near-constant latency offset — a fingerprint that re-links a round's anonymous outputs to its known inputs. On the live Tor network (8 guards, tor 0.4.9.8) the offset is real but small next to per-request jitter (build: 167 ms offset vs 461 ms jitter; guard RTT 23 vs 56 ms), so a single request barely beats chance — but that is a best case, measured from one fiber host: it omits each user's own last-mile latency, a stable per-client offset that only strengthens the fingerprint. The leak is amplification: a round makes many guard-pinned requests per client, and averaging recovers the offset. At 64 requests/client an 8-client round's effective anonymity set falls from ~7.5 to 4.6 (build) or 3.8 (residual guard RTT after the circuit-pool fix), ~45-50% of outputs uniquely linked; and a realistic 100 ms of last-mile latency spread alone (guard RTT, just q=8) drops it to 4.1, or 2.8 at 200 ms. It also compounds: ~84% of rounds fail and a client retries with the same inputs, so a coin's linkage rises from 27% in one round to 98% over 32 retries. The pre-established-circuit-pool fix removes build latency and reliability failures from the critical phase but not the guard fingerprint; closing that needs per-request timing randomization (restores ~7.2 of 8 at a multi-second cost) or per-request guard diversity.

0. Who can run this, and what it yields

The timing signal only groups requests by client. Turning that into an input-UTXO -> output-UTXO map needs the request contents, so observers differ:

observer sees plaintext (UTXOs)? sees timing? result
Tor exit node no (TLS to coordinator) only its sampled circuits coarse traffic correlation, no UTXO map
coordinator yes yes input <-> output UTXO map
Cloudflare / TLS terminator yes (terminates TLS) yes input <-> output UTXO map
VPS host behind TLS termination yes yes input <-> output UTXO map

The mapping needs a plaintext observer: the coordinator, the TLS-terminating CDN (the popular Wasabi coordinators sit behind Cloudflare), or the VPS between them. Input registration reveals the outpoint, so input identities are known; output registration reveals the output script but is supposed to be unlinkable; the guard fingerprint is what re-attaches the anonymous output to its known input. A Tor exit sees only ciphertext on a sampled subset of circuits, so it can cluster circuits but not label them with coins. Everything below assumes the plaintext observer.

1. Why the guard is a fingerprint

Tor pins a client to one guard (first hop) for months, so every circuit it builds shares that hop: end-to-end latency has lower variance around a per-client offset than latency across clients. Wasabi already isolates the phases onto different circuits — ResolveLifetimeByIdentity gives the input side (alice) 1.5 h circuits, the output side (bob) 40 s circuits, and coordination (satoshi) a persistent one (WasabiHttpClientFactory.cs#L118-L129) — but identity isolation does not change the guard, so a client's input and output requests still ride the same first hop. That shared guard is the residual fingerprint the plaintext observer measures and partitions on:

flowchart LR
    subgraph CA["Client A (guard g_A)"]
        A1[input registration] --> A2[reissuance x N] --> A3[output registration]
    end
    subgraph CB["Client B (guard g_B)"]
        B1[input registration] --> B2[reissuance x N] --> B3[output registration]
    end
    A1 & A2 & A3 -->|"latency ~ offset(g_A) + jitter"| O[Plaintext observer]
    B1 & B2 & B3 -->|"latency ~ offset(g_B) + jitter"| O
    O -->|"average many samples per client"| P[Cluster requests by offset]
    P --> L["Link anonymous output to the input with the closest offset"]
Loading

Two observables bound the effect:

  • build — full three-hop circuit build latency, what a client pays with no pre-established circuits (today's Wasabi). Large and guard-influenced, but also polluted by middle/exit choice.
  • guard_rtt — one-hop (guard-only) handshake latency, a proxy for the residual RTT over an already established circuit. This is what is left after the circuit-pool fix: smaller, and a cleaner guard signal.

2. Measuring it on live Tor

tor_probe.py drives a real tor daemon over its control port (stem), pinning a fixed guard per modelled client and timing two circuits: a full three-hop build through a random middle/exit (build), and a one-hop circuit ending at the guard (guard_rtt):

dt = _timed_build(controller, [guard_fp, m, e], timeout)   # build: 3-hop
...
dt = _timed_build(controller, [guard_fp], timeout)         # guard_rtt: 1-hop

tor_probe.py#L92-L109, timed by _timed_build wrapping Controller.new_circuit(path=..., await_build=True).

The committed snapshot (data/tor_latency_dataset.json, tor 0.4.9.8, 8 guards, 12 build + 12 guard-RTT samples each) gives the headline statistic — within-guard jitter over across-guard offset spread — computed in variance_ratio:

observable across-guard offset spread per-request jitter jitter/offset ratio
build (3-hop) 167 ms 461 ms 2.77
guard_rtt (1-hop) 23 ms 56 ms 2.48

The ratio > 1 is the honest headline: for a single request the jitter hides the guard. The attacker's job is to average it out.

One caveat cuts the other way, and it matters. This dataset was measured from a single, well-connected (fiber) host, so the across-guard spread above is only how far apart the guards are from that one machine — it sets every client's own last-mile latency to zero. Real users each sit behind their own link (fiber, cable, DSL, mobile, satellite, a distant continent) whose RTT is a stable per-client constant added to every guard-pinned request. That is exactly the kind of persistent per-client offset the fingerprint feeds on, and the measurement omits it entirely, so these ratios are a best case. §4 (P5) puts it back.

# fresh dataset (needs a running tor with an open control port):
python -m coinjoin_simulator.tor_timing.tor_probe --control-port 9051 \
    --guards 8 --builds 12 --rtts 12 --out data/tor_latency_dataset.json
python run_tor_timing_study.py          # writes tor_timing_results.json

3. The attack: averaging out the jitter

For each anonymous output the attacker averages q guard-pinned requests per client on each side, shrinking each side's jitter by sqrt(q):

input_latency = tuple(statistics.mean(draw(i) for _ in range(q_in)) for i in range(n_clients))
output_mean = tuple(statistics.mean(draw(c) for _ in range(requests_per_client)) for c in range(n_clients))

round.py#L80-L93

It then matches every output against every known input with a softmax over the squared latency difference, and scores each output by its effective anonymity set — 2^entropy of the posterior, i.e. the equivalent number of equally likely candidate inputs (an effective set of 1 means uniquely linked):

var_in = observation.within_std_ms**2 / observation.input_requests_per_client
var_out = observation.within_std_ms**2 / observation.requests_per_client
tau2 = max(var_in + var_out, 1e-9)
...
for c in range(n):
    out = observation.output_mean_ms[c]
    posterior = _softmax_neg_sq([out - inputs[i] for i in range(n)], tau2)
    eff_sets.append(effective_anonymity_set(posterior))

partition.py#L68-L88

q is the guard-pinned requests averaged per client per side — this is what lets amplification beat the raw per-request jitter of §2.

4. Results (real guards, 8-client round)

P0 — amplification (no-defense build latency):

q effective set (of 8) deanonymized
1 7.52 23%
8 6.72 22%
16 6.18 32%
32 5.63 37%
64 4.61 45%

P0 — amplification (pool-defense residual guard RTT):

q effective set (of 8) deanonymized
1 7.16 23%
16 5.58 37%
32 4.72 37%
64 3.82 51%

The residual guard RTT deanonymizes more than the raw build at high q, because it is a cleaner guard signal once middle/exit jitter is averaged out. So the circuit-pool fix does not remove this leak.

P1 — round size (synthetic, calibrated to the real dataset, fixed q): the absolute effective set grows with the round (4.3 -> 41.9 as N goes 8 -> 100) and the deanonymized fraction falls (38% -> 5%). Larger anonymity sets dilute the per-request timing leak; small rounds are the worst case.

P2 — timing-randomization defense (build, q=64): a client-side random per-request delay closes the fingerprint, but must be large because amplification averages small delays away:

extra delay effective set (of 8) deanonymized
0 ms 4.61 45%
2000 ms 5.62 31%
8000 ms 7.17 17%

P3 — active amplification (reject weak signal): the coordinator acts only on high-confidence outputs; rejecting a round is free given the ~16% round success rate, so this is a covert amplifier:

keep outputs with confidence >= fraction kept linkage on kept
0.0 (all) 100% 39%
0.3 42% 62%
0.6 15% 94%
0.9 4% 97%

P5 — access-latency sensitivity (guard RTT, q=8). The fiber measurement (§2) sets each user's last-mile latency to zero. Adding a per-client access offset drawn from a half-normal of the given spread — the diversity of real users' links to their guard — leaves amplification fixed and only restores that missing stable offset (AccessLatencyModel):

per-client last-mile spread effective set (of 8) deanonymized
0 ms (the fiber measurement) 6.40 22%
25 ms 6.08 27%
50 ms 5.41 30%
100 ms 4.12 41%
150 ms 3.32 50%
200 ms 2.84 57%

This is the important row of the whole study: at guard RTT and only q=8 (before any retry amplification), a population with ~100 ms of last-mile spread — routine once mobile, DSL, congested Wi-Fi or geographically distant users are in the mix — already sees the effective set fall from 6.4 to 4.1, and a 200 ms spread halves it to 2.8 with 57% of outputs uniquely linked. The users with the worst connectivity, who most depend on Tor, are the most exposed.

5. The strongest lever: retrying the same coin across failed rounds

Rounds fail ~84% of the time and a client retries with the same inputs, so one coin is re-registered many times. Each retry gives the observer another shot and sharpens its estimate of that input's offset, since it accumulates more guard samples for the same persistent client:

for r in range(n_rounds):
    obs = generate_round(
        model, n_clients=n_clients, requests_per_client=requests_per_client,
        observable=observable, rng=rng,
        input_requests_per_client=(r + 1) * requests_per_client,
    )
    res = attack_round(obs)
    correct.append(res.output_correct[target])
    confidence.append(res.output_confidence[target])

intersection.py#L69-L82

An active observer need not wait passively: it commits once confidence crosses a threshold, forcing retries by dropping or delaying responses — indistinguishable from Wasabi's normal unreliability:

for r in range(n_rounds):
    if confidence[r] >= confidence_threshold:
        committed = True
        active_linked = correct[r]
        break

intersection.py#L91-L95

P4 — retry intersection, on real guards, guard RTT, q=8/round:

retry rounds per-round linkage any-round linkage commit-on-confidence linkage
1 27% 27% 1%
4 30% 61% 12%
8 30% 79% 21%
16 32% 91% 25%
32 33% 98% 25%

Two honest readings. Any-round (98%) is the capability when the observer can later confirm which attempt was right — realistic here, since these are its own coordinator rounds and the outputs get spent on-chain, so an independent later signal (spend clustering, amounts) confirms the winning guess. Commit-on-confidence (~25%) is what it gets acting purely on its own timing confidence with no external check: raw posterior confidence is weakly calibrated at guard RTT and q=8, so self-selection alone is limited. The active disruption (drop/delay to force retries, reject weak rounds) manufactures the many shots; it is free given Wasabi's unreliability and covert because forced failures look normal.

6. What this means, and the fixes

  • The guard fingerprint is real but weak per request; the danger is the many guard-pinned requests a WabiSabi round already makes. This is the mixnet- literature pattern of exponential attacker advantage per marginal bit of leak, so the burden of proof is on the defender.
  • Pre-established validated circuit pool (Kogman's transport fix): necessary — it removes build latency, SOCKS serialization, and reliability failures from the critical phase — but it leaves the guard-RTT fingerprint (P0 guard_rtt).
  • Per-request timing randomization (P2) closes the fingerprint but is costly; per-request guard/circuit diversity attacks the root cause but fights Tor's guard design.
  • Larger anonymity sets (P1) dilute the leak; small rounds are the worst case.
  • Worse-connected users are more exposed (P5): the per-client last-mile RTT is a stable offset the fiber measurement omits, so the real-world leak is larger than the headline ratios suggest, and worst precisely for mobile, congested or geographically distant users — the ones who most rely on Tor.
  • Any residual leak is covertly amplified by dropping low-confidence rounds (P3) and forcing retries of the same coin (P4), which is why "deanonymize this specific transaction or it is not a problem" is the wrong standard: the right unit is the coin over its whole retry history, not one round.
  • Don't retry a failed round with the same inputs blindly: the multi-shot intersection (P4) is the strongest lever, so re-randomizing coin selection (or not re-registering the same coin after a suspicious failure) blunts it.

References

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment