A fleet hides behind a shared engine identity (JA4) but must look like distinct users. It has exactly two ways to do that, and the scorer catches each with a complementary signal:
- Randomize JS per instance (Camoufox) → the JS-divergence paradox: shared TLS, divergent JS.
- Clone one fingerprint profile (BotBrowser) → the fingerprint-collision: identical high-entropy
fp_hashacross distinct source IPs (where real machines each hash differently).
The verdicts below are real score_cluster output, not hand-authored, on real captured fleets plus a
few illustrative synthetic clusters where noted.
Four signals can carry a fleet label. Two are ambiguous (a real cohort has a benign twin that
produces the same shape, so they convict only when corroborated); two are unambiguous (no benign twin,
verified by construction). Rule IDs and the live corroboration sources are in
contracts/rules/registry.yaml; the full rule table is the generated
docs/detection-catalog.md.
| signal | ambiguous? | benign twin | corroboration to convict | grounded-by (real capture) |
|---|---|---|---|---|
fp_collision (one fp_hash across distinct IPs) |
yes | a standardized corporate fleet — locked OS+browser image hashes byte-identically | a per-session automation/headless tell on a member, a cluster-property signal (JA4_c randomization / shared origin), or datacenter/proxy IP reputation (rep.datacenter_asn / rep.known_proxy_exit) |
corpus/fleet-cloned (cn1·cn2·cn3) |
ja4c_divergent (per-launch TLS-ext randomization) |
yes | a multi-Chrome-version cohort — JA4_c varies across versions while the cipher prefix holds | same gate: an automation tell, or an unambiguous coordination signal (cloned trace / shared origin) | corpus/fleet-proxy (rp1·rp2·rp3) |
trace_collision (one behavioral.trace_hash across distinct IPs) |
no | — | solo-convicting: the trace hash is high-entropy (≥12 points, integer-pixel FNV over the whole trajectory); two real humans cannot collide | corpus/fleet-randfp-trace (ft1·ft2·ft3), corpus/fleet-replay (rt1·rt2·rt3) |
shared_real_ip (distinct proxy IPs fronting one WebRTC origin) |
no | — | solo-convicting: the collector emits only the srflx/PUBLIC candidate, so distinct real users each leak their OWN distinct origin | corpus/fleet-webrtc-leak (wr1·wr2·wr3), corpus/fleet-proxy |
The two ambiguous signals each have a benign twin a real cohort produces, so they convict only when corroborated; the two unambiguous signals have no benign twin (verified by construction below, not just asserted). The JS-divergence paradox, IP spread and timing lockstep are corroborating only — they are also the null hypothesis (distinct real users on one Chrome build) — see the conviction gate below.
Two Taskfile targets drive the coordination detector (run from the repo root; see
Taskfile.yml):
task coordination-eval # precision/recall across the legit + malicious coordination scenario battery
task coordination-live # grade coordination against the LIVE detector's session store (set KITSUNE_DETECTOR)coordination-evalrunskitsune_harness.coordination_scenarios— the offline battery that grades the malicious fleets (which must convict) against the legit cohorts (which must cap atcandidate), reporting precision/recall. This is what pins the FP-safety of the ambiguous signals.coordination-liverunskitsune_harness.live_coordinationagainst a running detector's store — the production-shaped path (setKITSUNE_DETECTORto the detector URL).
The real fleet captures below are also pinned offline in harness/tests/test_coordination.py.
The coordination detector was synthetic-only; it is now grounded on real fleet captures that came
through the edge→detector pipeline, each pinned in harness/tests/test_coordination.py. Every signal in the
matrix above now has at least one real capture exercising it in isolation:
| capture | members | verdict | convicting signal — why |
|---|---|---|---|
corpus/fleet-proxy/ (residential proxy) |
rp1·rp2·rp3 | fleet (1.00) |
JA4_c randomization + shared origin: per-launch JA4_c randomization (3 ext/sig-alg variants behind one cipher prefix) and one WebRTC-leaked origin 45.137.0.42 behind 3 distinct proxy IPs |
corpus/fleet-cloned/ (cloned-profile) |
cn1·cn2·cn3 | fleet (1.00) |
fp-collision: 3 concurrent instances of one evader image on distinct container IPs (172.22.0.4/.5/.6) share one deterministic fp_hash bf779223 — convicted by the collision (members carry automation tells), with stable JA4_c and no shared WebRTC origin |
corpus/fleet-randfp-trace/ (fp-randomizing, shared trace) |
ft1·ft2·ft3 | fleet |
trace-collision ALONE: 3 apify fingerprint-injector instances each sample a DISTINCT fp_hash (fp-collision defeated, cloned_fingerprint stays None) yet replay one canned trajectory → one trace_hash across 3 IPs. The botright/multilogin pattern; proves the behavioural-clone signal catches what fp-collision cannot |
corpus/fleet-replay/ (replayed trace) |
rt1·rt2·rt3 | fleet |
trace-collision: 3 stealth-evader REPLAY_TRACE instances inject the SAME recorded pointer path → identical trace_hash across distinct IPs. Grounds that a coordinate-based clone hash IS byte-identical across a real fleet (unlike a timing hash, which scheduler jitter perturbs) |
corpus/fleet-webrtc-leak/ (one origin behind proxies) |
wr1·wr2·wr3 | fleet |
shared_real_ip ALONE: 3 camoufox instances each route HTTPS through a DISTINCT CONNECT proxy (distinct observed_ip) while WebRTC UDP leaks ONE shared origin. Diverse fps + jittered traces (no fp/trace collision), so the only convicting signal is the shared origin — the FP-safe convicting twin of the per-session (corroborating-only) net.webrtc_ip_vs_observed leak |
corpus/fleet/ (camoufox) |
cf1·cf2 | candidate (0.42) |
trusted-but-verified surprise: the real camoufox capture has stable JA4_c (not divergent) and homogeneous JS — indistinguishable from two real users on one build, so the conviction gate correctly withholds fleet |
The camoufox result corrects an assumption baked into the synthetic fleet-ja4c-randomizer scenario: real
camoufox (at least the captured build/config) does not randomize its JA4_c per launch, so a small
homogeneous camoufox cohort reads as a legitimate same-build cohort. The JA4_c-randomization signal is still
validated on real traffic — by the residential-proxy fleet, which genuinely does randomize. Net: the
convicting set fires on the fleets that are genuinely distinguishable and stays silent on the one that is not
(FP-safety), confirmed against real captures rather than only synthetic constructions.
A fingerprint collision catches a fleet that clones one fingerprint; the trace collision catches a
fleet that clones one behaviour. The collector hashes the pointer trajectory's shape (behavioral.trace_hash,
timing excluded), and an identical trace_hash across distinct source IPs is one tool replaying a single
canned "humanised" mouse path — two real users never trace the same path. It convicts a fleet that
randomises its fingerprint per instance yet reuses one recorded trajectory, where the fp-collision stays
silent (corpus/fleet-randfp-trace is exactly this shape, grounded in isolation). It is a convicting
coordination signal (behind the same conviction gate as the unambiguous signals).
A fleet label requires a convicting coordination signal — one a real diverse cohort cannot
produce: JA4_c divergence (per-launch TLS-extension randomization; real Chrome's JA4_c is stable per launch),
a cloned-fingerprint collision across distinct IPs, a cloned-trace collision, or a shared WebRTC origin
behind distinct proxy IPs. The JS-divergence paradox, IP spread and lockstep are corroborating only,
because they are also the null hypothesis: distinct real users on one Chrome build share a JA4 (TLS is
per-build) yet legitimately differ in hardware_concurrency, device_memory and OS-platform (Win/Mac/Linux
Chrome share a JA4) and arrive from distinct IPs. Without the gate, a 4-user real cohort on one popular
build — diverse hardware, distinct IPs, distinct fp_hash, spread over 15 min — scored fleet 1.00 (a
botnet verdict on a browser's user base); it is now correctly capped at candidate. This mirrors the
detector's convicting-signal gate (scoring.label_for) on the per-session side.
Both fp_collision and ja4c_divergent looked convicting but each has a benign twin a real cohort produces
— so each is gated to require a corroborating signal a clean cohort lacks:
fp_collision↔ a standardized corporate fleet. The rationale "real machines each hash differently" is an overstatement: a locked OS+browser image hashes its canvas/WebGL/audio byte-identically, and on distinct work-from-home residential IPs that is the exact shape fp-collision convicted (a botnet verdict on a corporate cohort). It cannot be disambiguated from a cloned bot fleet by the fingerprint alone (a native anti-detect browser clones the fp with clean JS and can synthesize distinct behaviour). So fp-collision no longer solo-convicts: it needs a per-session automation/headless tell on a member, a cluster-property signal (JA4_c randomization / shared origin), or datacenter/proxy IP reputation. An uncorroborated identical-fp cluster caps atcandidatefor operator review.ja4c_divergent↔ a multi-Chrome-version cohort. "Real Chrome's JA4_c is stable" holds per-launch but NOT per-version: JA4_c changes across Chrome versions while the cipher prefix holds, so a normal mix of auto-update states diverges JA4_c. Soja4c_divergentgets the same corroboration gate.
The cloned-trace and shared-origin signals stay solo-convicting — neither can fire on a clean cohort (real humans never share a trace; there is no shared WebRTC origin).
Corroboration includes IP reputation (wired, not just documented). The detector classifies the source IP
against curated public datacenter/proxy/Tor-exit CIDR lists (ip_reputation.py, emitting
reputation.asn_is_datacenter / is_proxy_exit); those signals are a corroboration source for the ambiguous
gate. This is the real disambiguator: a cloned/randomizer bot fleet runs on datacenter or proxy
infrastructure (flagged); a corporate or multi-version real cohort runs on genuine residential IPs (never
flagged, and private IPs classify as neither). So a clean native clone (no automation tell) on datacenter IPs
convicts. The classification is not blocked (public CIDR data, no live proxy needed); only the
in-sandbox live exercise is limited — container IPs are private (172.x), so the live fleet captures
corroborate via their automation tells instead. A production deployment seeing real datacenter/proxy IPs gets
the flag. CIDR seed lists are non-exhaustive (refresh per docs/ip-reputation-data.md).
The blocker for the live proxy half (rep.datacenter_asn, rep.known_proxy_exit, net.webrtc_ip_vs_observed,
the proxy-topology coordination signals) was that the in-sandbox fleet egresses from private container IPs
(172.x), not real ASN-classifiable IPs. The routing plumbing to fix that is built:
- The stealth evader honours
KS_PROXY=<url>(http(s)://,socks5://) — routes the context through a proxy so the edge observes a REAL egress IP. The edge demonstrably attributes the session to the proxy's egress, not the client (validated with a local CONNECT-proxy stand-in; see Grounding history) — exactly the substitutionrep.datacenter_asn/rep.known_proxy_exitneed. fleet_capture.shhonoursPROXIES=url1,url2,url3— node i routes viaPROXIES[i](round-robin), so a fleet egresses from distinct real IPs.
# Turnkey: supply real residential/datacenter proxies (target stays the allow-listed edge; the proxy is only
# the egress path), then the rep.* + webrtc-leak + proxy-topology signals fire on real IPs:
IMAGE=kitsune-stealth:latest N=3 ENV="-e STEALTH=1" \
PROXIES="socks5://p1:1080,socks5://p2:1080,socks5://p3:1080" \
OUT=corpus/fleet-proxy harness/tools/fleet_capture.shSo this frontier is data-only-blocked (needs proxy endpoints, no code change) — the parallel of the
turnkey real-traffic prevalence prior (build_prior_from_dir, docs/prevalence-model.md).
Ethics: the proxy is the egress path only; the TARGET stays the allow-listed edge, so the allow-list is not
weakened.
The net.webrtc_ip_vs_observed half (a proxy-vs-origin IP mismatch) is the one piece that still needs real
connectivity to produce live — diagnosed precisely as a headless-Chromium ICE limitation, not a Kitsune one
(see Grounding history). The rule's logic is unit-tested; only its live collector→signal path needs a real
browser with real STUN reachability.
The clusters below are real score_cluster output illustrating the two primary fleet shapes end to end.
- severity: moderate (12 requests, 7.8/min)
- members: rp1, rp2, rp3
- 3 sessions share JA4 cipher prefix
t13d1717h2_5b57614c22b0 - cipher suites identical but JS divergent across members: hardware_concurrency x3, nav_platform_os x2, ua_platform x2
- cipher suites identical but JA4 extensions/sig-algs divergent (3 variants) — per-launch TLS randomization
- timing lockstep: all members arrived within 23s
- distributed across 3 distinct source IPs — residential-proxy fleet pattern (IP diversity masks one shared engine, defeating IP/ASN rules)
- 3 proxy IPs front one real IP
45.137.0.42(WebRTC) — same-origin fleet
The trap this closes: JS is homogeneous (the scorer notes it "consistent with a real cohort"), so the
JS-divergence paradox stays silent and the old scorer rated this only a candidate. The fingerprint
collision across distinct IPs is what convicts it.
- severity: moderate (6 requests, 8.2/min)
- members: bb1, bb2, bb3
- 3 sessions share JA4 cipher prefix
t13d1516h2_8daaf6152771 - JS traits homogeneous across members — consistent with a real cohort
- identical high-entropy fingerprint
7c3a9f12across 3 distinct source IPs — cloned-profile reuse (one anti-detect profile shared fleet-wide) - timing lockstep: all members arrived within 22s
- distributed across 3 distinct source IPs — residential-proxy fleet pattern (IP diversity masks one shared engine, defeating IP/ASN rules)
Cloned headless Chromium — score 1.00 · 2 sessions (live capture, corpus/sessions/chrome-clone-*.json)
Two stock headless Chromium instances are a cloned profile by construction (same build +
deterministic SwiftShader rendering → byte-identical fp_hash). Captured concurrently so the edge sees
distinct container IPs, each ran the in-browser collector and emitted the same hash — real scorer output:
- members: chrome-clone-1, chrome-clone-2
- 2 sessions share JA4 cipher prefix
t13d1516h2_8daaf6152771 - JS traits homogeneous across members — consistent with a real cohort
- identical high-entropy fingerprint
28718b97across 2 distinct source IPs — cloned-profile reuse (one anti-detect profile shared fleet-wide) - timing lockstep: all members arrived within 1s
- distributed across 2 distinct source IPs — residential-proxy fleet pattern (IP diversity masks one shared engine, defeating IP/ASN rules)
task coordination-eval grades the malicious fleets (must convict) against the legit cohorts (must cap at
candidate), reporting precision/recall (kitsune_harness.coordination_scenarios). The benign twins above
are each pinned by a legit scenario:
legit-corporate-fleet(standardized corporate fleet, residential IPs) →candidate(precision stays 1.0).legit-multi-version-cohort(clean 4-user cohort spanning 2 Chrome versions) →candidate.- The malicious
fleet-cloned-fingerprintandfleet-ja4c-randomizerscenarios were made AUTOMATED (realistic) so recall stays 1.0;fleet-cloned-datacenterexercises the IP-reputation corroboration of a clean native clone on datacenter IPs.
The live captures (corpus/fleet-*) are pinned offline in harness/tests/test_coordination.py and serve as
the real-traffic complement to the synthetic battery.
Dated lab-notebook entries, kept for provenance. Current behaviour is described above; these record how each piece was grounded.
The fp-collision rationale — "real machines each hash differently (GPU/driver/OS/font variance)" — is an
overstatement, and trusting it produced a real false positive. A standardized corporate fleet (the
same laptop model running a locked OS + browser image) hashes its canvas/WebGL/audio byte-identically;
on distinct work-from-home residential IPs that is the exact shape fp-collision convicted — grounded by
constructing it: 4 clean Windows laptops, one shared fp_hash, 4 residential IPs → fleet 1.00, evidence
reading "cloned-profile reuse" and "residential-proxy fleet." A botnet verdict on a corporate cohort. Fix:
fp-collision no longer solo-convicts (corroboration-gated, above); pinned by legit-corporate-fleet and
test_corporate_fleet_fp_collision_is_not_convicted. The cloned-fingerprint malicious scenario was made
AUTOMATED so recall stays 1.0, and the live fleet-cloned fixture still convicts (its stealth-evader members
carry automation tells).
The same lesson recurred on ja4c_divergent. Its rationale — "real Chrome's JA4_c is stable" — holds
per-launch but NOT per-version: JA4_c changes across Chrome versions while the cipher prefix
(JA4_a+JA4_b) stays, so a normal mix of auto-update states diverges JA4_c. Grounded in the fleet captures
themselves — one cipher prefix t13d1516h2_8daaf6152771 carries two real JA4_c (02713d6af862,
d8a2da3f94cd) across older vs newer captures, i.e. a Chromium version bump — and by construction: a clean
4-user cohort spanning 2 versions (distinct IPs/fps/traces, no automation) scored fleet 0.92, the evidence
wrongly reading "per-launch TLS randomization." Fix: same corroboration gate; pinned by
legit-multi-version-cohort (→ candidate) and a now-AUTOMATED fleet-ja4c-randomizer (→ fleet); the live
fleet-proxy fixture is unaffected (it convicts via its shared WebRTC origin).
trace_collisionis safe by construction: the trace hash is high-entropy (≥12 points, exact integer-pixel FNV over the whole trajectory), so two real humans cannot coincidentally collide. Grounded: three distinct stealth-evader configs (full-stealth,naive-tz-spoof,stealth-naive) all reuse the harness's one hardcoded mouse path → share tracea0214e60→ scorefleeton the trace collision; the curved-motionhuman-mousecontrol has a unique hash and never collides.shared_real_ipis safe by construction: the collector emits only the srflx/PUBLIC WebRTC candidate (host/private candidates excluded), so distinct real users each leak their OWN distinct public IP — they cannot share an origin (pinned bytest_distinct_public_ips_do_not_share_origin).
A fresh concurrent 3-instance cloned capture through the live edge→detector stack (new sids, fp_hash bf779223 across distinct container IPs 172.22.0.4/.5/.6), scored by the live coordination detector → fleet 1.00 via the fp-collision path. The corroboration source was verified by inspecting the captured
signals: each member carries automation tells (cdp_runtime_enabled, ch_he_headless,
chrome_object_missing) on a private IP with an empty reputation layer — so the conviction is carried
by _has_automation_tell, NOT an IP-reputation flag, exactly as the gate intends. This confirmed the
a4d1ac0 corroboration-gating did not break the live cloned-fleet conviction end-to-end. CI drift fixed in
the same pass (two ruff findings). No rule semantics changed → no ruleset bump.
The IP-attribution substitution was validated end-to-end: a local CONNECT-proxy stand-in container was run on
the lab network, then the same evader was scored twice — STEALTH=1 alone gave observed_ip = 172.22.0.5
(the evader's own container IP), while STEALTH=1 + KS_PROXY=http://proxy gave observed_ip = 172.22.0.4
(the proxy's IP). So the edge demonstrably attributes the session to the proxy's egress, not the client.
The net.webrtc_ip_vs_observed half is NOT demonstrable in-sandbox (diagnosed 2026-06-19). The rule fires
when the WebRTC-leaked srflx IP ≠ the proxy-observed IP, and WebRTC's UDP bypasses the HTTP proxy, so it
should leak the real container IP. A full attempt was made: a minimal STUN responder + the CONNECT proxy on
the lab net, with the evader's WebRTC STUN host redirected to the local responder via Chromium
--host-resolver-rules=MAP stun.l.google.com <ip>. The STUN responder works (a direct UDP probe gets a
reflected address) and UDP egress works, and the headless browser DOES gather host/mDNS candidates
(webrtc_unavailable never fires) — but it never sends a STUN binding request to the redirected responder, so
no srflx candidate and no webrtc_public_ip. So headless Chromium's WebRTC srflx gathering does not
honour the host-resolver redirect (its ICE stack resolves STUN outside the override) — a Chromium-headless
limitation, not a Kitsune one. The unvalidated KS_STUN evader hook was reverted — per "never ship a
signature you couldn't ground." The IP-attribution plumbing — the load-bearing piece — is proven; the WebRTC
leak is the icing that genuinely needs real connectivity.
Merged from the former coordination-scenarios.md (2026-06-26 docs consolidation). The measured
proof of the FP-safety prose above — regenerate via task coordination-eval.
- precision: 100% (no legitimate cohort labelled
fleet) - recall: 100% (every fleet shape caught)
| scenario | expected | label | ✓ | why |
|---|---|---|---|---|
legit-corporate-fleet |
not-fleet | candidate |
✓ | a STANDARDIZED corporate fleet: identical laptop model + locked image hashes byte-identically, on distinct WFH residential IPs, with DISTINCT human traces and NO automation tell — fp collides but the cohort is real; must cap at candidate, not fleet (the fp-collision-vs-corporate FP) |
legit-diverse-cohort |
not-fleet | candidate |
✓ | distinct real users on one Chrome build: diverse hw/OS, distinct IPs+fps+traces, spread timing |
legit-homogeneous-pair |
not-fleet | candidate |
✓ | two users, identical JS (same build+config) but distinct fps/traces — a benign same-build pair |
legit-large-cohort |
not-fleet | candidate |
✓ | 8 distinct users on one build — paradox + IP spread at scale must still not convict |
legit-multi-version-cohort |
not-fleet | candidate |
✓ | real Chrome users spanning auto-update versions: ONE cipher prefix but a few distinct JA4_c (JA4_c varies across Chrome versions), distinct IPs + fps + traces, NO automation — diverges JA4_c but is a real cohort; must cap at candidate, not fleet (the JA4_c-randomizer-vs-multi-version FP) |
legit-nat-cohort |
not-fleet | candidate |
✓ | 5 distinct users behind ONE NAT IP — collisions need distinct IPs, so a shared IP never convicts |
fleet-cloned-datacenter |
fleet | fleet |
✓ | a CLEAN native anti-detect clone (BotBrowser-style, no automation tell, no JS divergence) but on DATACENTER IPs — the IP-reputation flag corroborates the fp-collision as a bot fleet where no automation tell does; distinguishes it from a residential corporate cohort |
fleet-cloned-fingerprint |
fleet | fleet |
✓ | BotBrowser-style: homogeneous JS but one fp_hash cloned across distinct IPs, AUTOMATED (webdriver) — the automation tell corroborates the collision as a cloned bot fleet, not standardized hardware |
fleet-cloned-trace |
fleet | fleet |
✓ | behavioural clone: distinct fps but one canned pointer trace replayed across distinct IPs |
fleet-ja4c-randomizer |
fleet | fleet |
✓ | uTLS-style fingerprint randomizer: shared cipher prefix but per-launch JA4_c randomization (NOT Camoufox — real Camoufox emits a stable JA4_c per config, grounded 2026-06-20), diverse JS, distinct IPs, AUTOMATED (webdriver) — the automation tell corroborates the JA4_c divergence as a fleet, not a benign multi-browser-version cohort (which also diverges JA4_c, hence the corroboration gate) |
fleet-shared-origin |
fleet | fleet |
✓ | proxies fronting one origin: diverse JS, distinct proxy IPs, ONE shared WebRTC-leaked real IP |
Merged from the former coordination.md (2026-06-26 docs consolidation). A sample graded cluster
emitted by the coordination grader (task coordination-eval output) — a real cohort capped at
candidate, not convicted.
- members: cf1, cf2
- 2 sessions share JA4 cipher prefix
t13d1717h2_5b57614c22b0 - JS traits homogeneous across members — consistent with a real cohort
- timing lockstep: all members arrived within 10s