Website fingerprinting
Why it matters Even when an observer cannot see which site you visit, the shape of a page load can give it away: how much you send, how much comes back. This lab shows how that guess is made, how padding fights it, and why lab results make the attack look stronger than it is in real life.
Think of parcels in plain brown paper. You cannot read the label, but a long thin parcel is probably a poster and a heavy square one is probably books. Page loads are the same: a video site pulls down lots of data, a text forum very little. The wrapping hides the contents, not the size and shape.
Website fingerprinting: guessing which website someone loaded from the pattern of their encrypted traffic alone, by comparing it with patterns recorded earlier.
Cell: a fixed-size unit of traffic. In this lab a page load is a list of cells: 1 for a cell you sent, -1 for a cell you received.
A simple classifier: nearest centroid
First reduce each load to two numbers, its features: outgoing cells and incoming cells. cells.count(1) and cells.count(-1) do it. Then, for each site, average the features of its training loads. That average point is the site's centroid, its typical shape. To classify a new load, find the centroid closest to it.
Centroid: the average of a group of points: average x, average y. Here, one per site.
import math
from watched.leg7b_onion import page_loads
data = page_loads(seed=3) # {"train": [...], "test": [...]}
load = data["train"][0]
print(load["site"], len(load["cells"]))
print(math.dist((30, 400), (33, 420))) # straight-line distance between two points
# min(sorted(model), key=lambda s: ...) picks the site with the smallest value,
# and on a tie the one that comes first alphabetically.The defence: padding
Padding adds dummy cells so loads look less like their site. The research design called FRONT adds a random number of dummies, mostly near the start of a load where a page reveals the most. pad_traces(traces, budget, seed) adds up to budget dummy cells in each direction to every load.
To measure it fairly, assume the observer knows you pad and trains on padded loads too. Padding lowers accuracy rather than defeating the attack, and the dummies cost bandwidth. You will measure both: the accuracy drop and the overhead, the extra cells as a fraction of the original:
overhead = total_cells_after_padding / total_cells_before - 1 # 0.5 means 50% more trafficTor has a framework for padding circuits, but heavy padding against website fingerprinting is not switched on for ordinary browsing (checked October 2026), largely because of this bandwidth cost.
Why the lab overstates the attack
Here the observer knows you visit one of eight sites. That is a closed world. Real people visit millions of sites: an open world. Base rates then bite. Suppose a classifier flags a sensitive site correctly 95% of the time and wrongly flags 5% of other loads. If only 1 load in 1,000 is really to that site, then out of 100,000 loads about 95 true visits are flagged, along with about 4,995 false alarms. Over 98% of the alarms are wrong.
A common misconception: "Website fingerprinting means Tor is broken." It means one specific observer, the one between you and your guard, can make educated guesses that work far better in a lab than in the messy open world. That is a real risk worth defending against, not a reason to give up.
All loads here are simulated. No real traffic is recorded or analysed.
In other languages
The nearest-centroid step, elsewhere:
// JavaScript
function classify(model, cells) {
const f = [cells.filter(c => c === 1).length, cells.filter(c => c === -1).length];
let best = null, bestD = Infinity;
for (const site of Object.keys(model).sort()) {
const d = Math.hypot(f[0] - model[site][0], f[1] - model[site][1]);
if (d < bestD) { best = site; bestD = d; }
}
return best;
}// Rust
fn dist(c: &(f64, f64), out: f64, inc: f64) -> f64 {
((c.0 - out).powi(2) + (c.1 - inc).powi(2)).sqrt()
}
fn classify(model: &BTreeMap<String, (f64, f64)>, cells: &[i8]) -> String {
let out = cells.iter().filter(|&&c| c == 1).count() as f64;
let inc = cells.iter().filter(|&&c| c == -1).count() as f64;
model.iter()
.min_by(|a, b| dist(a.1, out, inc).partial_cmp(&dist(b.1, out, inc)).unwrap())
.map(|(s, _)| s.clone()).unwrap()
}Recap
- Encrypted page loads still have a shape; comparing shapes can guess the site.
- Nearest centroid: average each site's features, then pick the closest average.
- Padding lowers accuracy rather than defeating the attack, and costs bandwidth: measure both.
- Closed-world lab numbers overstate real attacks, because base rates produce many false alarms.
Write four functions:
features(cells): the tuple(outgoing, incoming), the counts of1and-1.train(traces): a dict mapping eachtrace["site"]to the average outgoing and average incoming count of its traces.classify(model, cells): the site whose centroid is nearest bymath.dist; usemin(sorted(model), key=...)so ties go to the alphabetically first site.accuracy(model, traces): the fraction classified as their own site.
acc_plain (train on data["train"], test on data["test"]), acc_padded (train a new model on padded_train, test on padded_test) and overhead (total cells in padded_test divided by total cells in data["test"], minus 1). Tests also use other loads.
This lesson is locked
Lessons open one at a time. Finish the previous lesson to unlock this one.