Your Accounts Move in Lockstep: De-Synchronizing the Traffic Rhythms That Link a Shared Proxy Pool
You gave every account its own exit IP. You split the cookie jars, the local storage, the browser profiles. You audited the pipeline for correlation leaks and found nothing. And still, a pattern-matching system on the origin side can group your accounts with a simple query — because they all wake up at the same time.
Traffic-shape correlation is the blind spot of otherwise careful multi-account engineering. We are trained to think of identity as things a request carries: IP, fingerprint, cookies, headers. But identity is also the request's position in time. When thirty accounts begin their daily runs within the same ninety-second window, take identical inter-request pauses (because they share a time.sleep(2)), and go silent at the same moment your cron window closes, the aggregate traffic curve looks like a single controller operating thirty puppets — because it is. The IPs differ; the conductor doesn't.
The core idea: correlation resistance must be applied to the schedule, not just the payload. Each account needs its own phase, its own rhythm, and its own working hours — derived from its own identity, not from your infrastructure's convenience. This article shows what lockstep looks like in your own logs, and a scheduler that eliminates it.
What lockstep looks like from the origin's side
An origin analyst (or more realistically, an automated clustering job) sees per-account request time series. Lockstep accounts look like this, viewed at one-minute resolution:
minute: 0 1 2 3 4 5 6 7 8 9
account A: . ## ## ## ## ## ## . . .
account B: . ## ## ## ## ## ## . . .
account C: . ## ## ## ## ## ## . . .
... (all 30 accounts: identical bars)
Every pairwise cross-correlation of those series spikes at lag zero. Clustering on "accounts whose activity curves correlate above 0.9 at zero lag" returns your entire fleet in one bucket. No fingerprinting, no IP matching — just pandas.
The correlations that betray you, in descending order of severity:
-
Start-time phase. All accounts begin their session at nearly the same wall-clock minute. This is almost always a cron artifact:
0 9 * * *launches every worker simultaneously. -
Rhythm signature. The distribution of inter-request gaps is identical across accounts because it comes from shared code — one
sleep()constant, one backoff function, one page-wait. - Working-hours envelope. All accounts active in the same time window and silent outside it. Real humans in different cities and time zones don't share an envelope; cron jobs do.
- Burst co-occurrence. Retries, rate-limit pauses, and error clusters happen at the same instants across accounts — typically because one shared event (a provider hiccup, a scheduler retry storm) hits every worker identically.
Point 4 deserves emphasis: shared infrastructure produces shared incidents, and shared incidents are timestamps in every account's log simultaneously. Even with perfect per-account independence, a single origin outage that triggers retries across all workers writes a correlated mark into thirty time series at once.
The de-synchronized scheduler
The fix is a per-account schedule with three randomized components — phase, rhythm, and envelope — each derived from a stable per-account seed so that the schedule is consistent for a given identity across days (accounts are also suspicious if their rhythms change every run).
import hashlib
import random
def account_rng(account_id: str, day: str, salt: bytes = b"v1") -> random.Random:
"""Deterministic per (account, day): stable within the day,
different across accounts, drifting slowly across days."""
h = hashlib.blake2b(f"{salt}:{account_id}:{day}".encode(), digest_size=16)
return random.Random(h.digest())
def session_phase(rng: random.Random, window: tuple[int, int]) -> int:
"""Start offset in seconds inside the allowed window, beta-shaped:
most accounts start somewhere mid-window, a few early, a few late."""
u = rng.betavariate(2.0, 2.0)
return int(window[0] + u * (window[1] - window[0]))
def make_rhythm(rng: random.Random):
"""This account's own inter-request gap distribution."""
base = rng.uniform(4.0, 12.0) # its characteristic pace
jitter = rng.uniform(0.3, 0.8) # how noisy its pace is
def gap() -> float:
return max(1.0, rng.gauss(base, base * jitter))
return gap
def working_hours(rng: random.Random) -> tuple[int, int]:
"""Each account 'lives' in a slightly different part of the day."""
start_h = rng.choice([7, 8, 9, 10, 11])
span_h = rng.uniform(6.0, 11.0)
return start_h * 3600, int((start_h + span_h) * 3600)
The scheduler then assembles these into a run plan, and — critically — staggered starts are scheduled, not natural:
from datetime import datetime, timedelta
def build_day_plan(accounts: list[str], date: str):
day_window = (7 * 3600, 21 * 3600) # global allowed window
plans = {}
for acc in accounts:
rng = account_rng(acc, date)
phase = session_phase(rng, day_window)
gap = make_rhythm(rng)
hours = working_hours(rng)
plans[acc] = {
"start_at": phase,
"gap_fn": gap,
"session_span": min(hours[1] - hours[0], 8 * 3600),
}
return plans
def phase_separation(plans: dict, min_gap_s: float = 90.0) -> dict:
"""Second pass: enforce a minimum spacing between any two starts.
Beta-phases alone still collide occasionally; make collision impossible."""
starts = sorted(plans.items(), key=lambda kv: kv[1]["start_at"])
for (a1, p1), (a2, p2) in zip(starts, starts[1:]):
if p2["start_at"] - p1["start_at"] < min_gap_s:
p2["start_at"] = p1["start_at"] + min_gap_s
return plans
Two design decisions in this scheduler are load-bearing:
-
Deterministic seeded randomness. The schedule must be reproducible (for debugging and for re-running a day after a crash) but must also differ across accounts and drift slowly across days. A fixed seed per account forever is its own fingerprint — a cluster of accounts whose rhythms never evolve over months is as unnatural as a cluster that resets daily. The
(account, day)seed with a salt gives stability within a day and slow evolution across weeks. - Envelope per account, not per fleet. Real identities in different geographies have different active hours. If your exits are geo-targeted to different regions, align each account's working hours to its exit's region — an account browsing through a Tokyo exit that is active at 3 a.m. Tokyo time is a second, redundant correlation signal layered on top of the first.
Sanity-check it the way the origin would
Don't trust the design; test it with the adversary's own tool. Compute the pairwise zero-lag correlation matrix of your fleet's per-minute activity curves before and after de-synchronization:
import numpy as np
import pandas as pd
def lockstep_score(activity: pd.DataFrame) -> float:
"""Fraction of account pairs with zero-lag correlation > 0.9.
activity: rows = one-minute bins, columns = accounts, values = req counts."""
corr = activity.corr()
n = len(corr)
pairs = n * (n - 1) / 2
high = ((corr > 0.9).sum().sum() - n) / 2
return high / pairs
# before: lockstep_score(df) -> 1.000 (every pair correlated)
# after: lockstep_score(df) -> 0.003 (near-zero; resample weekly)
The "after" number won't be exactly zero and shouldn't be: absolute zero correlation across a large fleet is itself slightly unnatural. What you want is a value in the noise floor of genuinely independent users — and you want it stable under weekly resampling.
One shared event can re-correlate everything
The subtlest part of the problem is that your scheduler can be perfect and your fleet still re-correlates during incidents. When the proxy gateway hiccups, every worker experiences it in the same minute, and if every worker responds with the same retry policy, thirty accounts log an identical pause-then-burst at the same timestamp. Mitigations:
- Randomize retry backoff per account, not just per request — jitter the retry curve itself (e.g., multiply the base delay by a per-account factor drawn from the same seeded RNG).
- Absorb shared events into the per-account rhythm — on a detected shared incident, resume each worker at its own next rhythm gap rather than simultaneously.
- Rate-limit scheduler restarts. After a crash or deploy, resist restarting every worker at once; honor each account's phase plan (the deterministic seed makes this trivial — the plan for the day already exists, just skip forward to the current position in it).
Lockstep is, in the end, an artifact of convenience: one cron line and one shared sleep constant are convenient, and the origin's clustering job is built on precisely that convenience. Spreading your accounts across phase, rhythm, and envelope costs you one scheduler module and roughly nothing in throughput — thirty accounts starting within a four-hour beta-shaped window still finish their daily work in the same day — and it removes the most legible signal a linking attack can find after all your payload-level work is done.
Disclosure: I use Thordata's residential proxies for this project. New users get 500MB free — code thor020 (10% off): https://www.thordata.com/?ls=uXcSHJzx&lk=02-tele
Top comments (0)