Traffic analysis
Why it matters Encryption hides what you say, but not when you say it or how much. Watching only the timing of traffic can reveal who talks to whom, even through an anonymity network. Seeing it work on simulated flows makes clear what Tor can and cannot promise, and why the strongest defence costs so much.
Imagine you cannot open anyone's post, but you can stand at two post offices. Every time a burst of letters leaves Ana's house, a similar burst arrives at a clinic a moment later. You never read a word, yet you can guess Ana writes to the clinic.
Metadata: data about a message rather than its contents: when it was sent, how big it was, where it came from and where it went.
Traffic analysis: learning things from the pattern of communication (timing, sizes, volumes) without reading the contents.
The setting
An observer watches two places: the link between users and their guard, and the link between exits and websites. The function tor_flows(seed) in watched.leg7b_onion gives you simulated recordings of both. Every packet is one cell, Tor's fixed-size unit of traffic (514 bytes), so size tells you nothing. Timing is all you have. The flow names on the two sides are shuffled, and the exit side is a little delayed.
from watched.leg7b_onion import tor_flows
flows = tor_flows(seed=1)
print(flows["entry"][0]["flow"]) # e01
print(flows["entry"][0]["packets"][:3]) # [(time_in_seconds, size), ...]
print(flows["exit"][0]["flow"]) # x01, but which user is it?
# flows["answers"] holds the truth, so you can score your guesses.Step 1: turn packets into a shape
Count how many packets fall in each second: second 0 holds times from 0 up to (not including) 1, and so on. int(t) gives the second a time belongs to. A flow becomes a list like [0, 7, 9, 1, 0, 0, 6, ...]: its shape over time.
Step 2: compare two shapes
Correlation: a number from -1 to 1 saying how much two lists rise and fall together. 1 means perfectly in step, 0 means no relation. Python has it built in: statistics.correlation(a, b).
If a list never changes (every value equal), correlation is undefined and Python raises statistics.StatisticsError. Catch it and use 0.0, meaning "no evidence either way".
import statistics
print(statistics.correlation([1, 5, 2, 8], [2, 9, 3, 15])) # close to 1: in step
try:
statistics.correlation([3, 3, 3], [1, 2, 3])
except statistics.StatisticsError:
print("a flat list has no correlation")Step 3: match each entry flow to its most similar exit flow
For every entry flow, try every exit flow and keep the one with the highest correlation. In this simulation that alone pairs almost everyone correctly. Real attacks of this kind, studied by researchers, work in the same spirit.
The defences, and their price
- Random cover traffic: add fake cells at random times. It blurs the shape a little, but bursts still stick out. You will see it barely helps.
- Constant rate: send exactly the same number of cells every second, filling gaps with dummies and making real cells wait their turn. Every second looks identical, so the shape vanishes. The price is bandwidth (all those dummies) and delay (cells queue during bursts).
Tor adds a little padding to blur coarse connection records, but it does not hide the shape of your traffic from someone who can watch both ends (checked October 2026). It chooses low delay so the web stays usable. The final lesson in this module looks at networks that choose the other way.
A common misconception: "If everything is encrypted, nobody can tell who I talk to." Encryption hides contents. Timing and volume are still visible, and they are often enough.
This lab uses simulated flows only. It is about understanding the risk so you can judge defences; it is not a method for watching real people, and real networks are far noisier.
In other languages
Counting packets per second and computing a correlation, elsewhere:
// JavaScript
function countsPerSecond(packets, seconds) {
const counts = new Array(seconds).fill(0);
for (const [t] of packets) if (t >= 0 && t < seconds) counts[Math.floor(t)]++;
return counts;
}
function correlation(a, b) {
const n = a.length, ma = a.reduce((x, y) => x + y) / n, mb = b.reduce((x, y) => x + y) / n;
let num = 0, da = 0, db = 0;
for (let i = 0; i < n; i++) { num += (a[i]-ma)*(b[i]-mb); da += (a[i]-ma)**2; db += (b[i]-mb)**2; }
return da && db ? num / Math.sqrt(da * db) : 0;
}// Go
func countsPerSecond(times []float64, seconds int) []int {
counts := make([]int, seconds)
for _, t := range times {
if t >= 0 && t < float64(seconds) { counts[int(t)]++ }
}
return counts
}Recap
- Encryption hides contents, not timing or volume (metadata).
- Counting packets per second gives each flow a shape; correlation finds matching shapes at both ends.
- Random cover traffic barely helps; constant-rate sending hides the shape but costs bandwidth and delay.
- Tor favours low delay, so it cannot stop someone who watches both ends.
Write four functions, then use them on tor_flows(seed=1):
counts_per_second(packets, seconds): a list ofsecondsnumbers, the count of packets whose timetsatisfies0 <= t < seconds, placed at indexint(t).similarity(a, b):statistics.correlation(a, b), or0.0if that raisesstatistics.StatisticsError.match(entry, exits, seconds): a dict mapping each entry flow's"flow"id to the id of the exit flow with the highest similarity.accuracy(guesses, answers): the fraction of ids inanswersthat your guesses got right.
acc_plain (no defence), acc_cover (entry side after add_cover_traffic(flows["entry"], rate=20, seed=1, seconds=S)) and acc_constant (entry side after constant_rate(flows["entry"], per_second=10, seconds=S)). Tests also use new recordings.
This lesson is locked
Lessons open one at a time. Finish the previous lesson to unlock this one.