Rewrite fleet.py to use a GNN-based approach: nodes are src_ip with ML feature vectors, edges connect IPs sharing (JA4, ASN) pairs, GraphSAGE (2 SAGEConv layers, in→64→32) produces 32D embeddings clustered by HDBSCAN. PyG NeighborLoader activates for >50k nodes. Update thesis docs (§5.2, §6.4, §2, §8) to reflect GraphSAGE architecture and PyG scalability. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
35 KiB
5. Techniques comportementales avancées
Introduction
Cette section présente huit techniques originales développées pour adresser les angles morts des systèmes de détection existants. Ces angles morts ont été identifiés par analyse systématique des faux négatifs observés en production : sessions mal classifiées comme humaines malgré un comportement automatisé, ou sessions légitimes pénalisées par des features trop sensibles au contexte réseau.
Les huit techniques couvrent l'intégralité de la chaîne de détection : analyse des séquences de navigation (§5.1), détection de flottes distribuées (§5.2), fingerprinting temporel (§5.3), arbre de dépendances de ressources (§5.4), dérive intra-session de fingerprint (§5.5), analyse DNS passive (§5.6, non implémentée), invariant de compression (§5.7, non implémentée), et session multi-domaine (§5.8).
Récapitulatif des statuts :
| Section | Technique | Statut |
|---|---|---|
| 5.1 | Session Transformer Embedding | [impl.] |
| 5.2 | Bipartite Bot Fleet Detection | [impl.] |
| 5.3 | Request Cadence Fingerprint | [impl.] |
| 5.4 | Resource Dependency Tree | [impl.] |
| 5.5 | Intra-Session JA4 Drift | [impl.] |
| 5.6 | DNS Shadow Analysis | [todo] |
| 5.7 | Compression Ratio Invariant | [todo] |
| 5.8 | Cross-Domain Session Linking | [impl.] |
5.1 Encodage contextuel des séquences de navigation (Session Transformer) [impl.]
Constat et motivation
La feature path_diversity_ratio mesure la diversité des chemins parcourus (nombre de chemins distincts / nombre total de requêtes), mais non leur ordre. La version précédente de cette technique (§5.1 v1) utilisait une chaîne de Markov du premier ordre pour capturer l'ordre des transitions, réduite à un scalaire unique (path_transition_entropy). Cette approche présentait une limitation fondamentale : la propriété markovienne (l'état suivant ne dépend que de l'état courant) rend impossible la capture d'un contexte long.
Or, la navigation humaine est intrinsèquement hiérarchique : une page produit n'a pas la même signification selon qu'elle est atteinte depuis la page d'accueil, depuis un moteur de recherche, ou depuis un lien de recommendation. Un crawler avancé peut reproduire les transitions bigrammes d'un humain (Markov O(1)), mais échouera à reproduire les dépendances à longue portée que seul un modèle attentionnel peut encoder.
Limites de l'approche Markov O(1)
L'approche précédente modélisait les transitions de chemins par une chaîne de Markov du premier ordre :
P(chemin_j | chemin_i) = count(i → j) / count(i)
L'entropie de Shannon sur cette matrice, H = -Σ p(a→b) × log₂(p(a→b)), condense toute la structure séquentielle en un scalaire unique. Trois insuffisances majeures :
- Perte du contexte long : la propriété markovienne impose que
P(path_t | path_{t-1})ignore tout l'historique au-delà det-1. Un bot qui alterne correctement les paires de transitions mais avec un pattern périodique (A→B→C→A→B→C…) sera indiscernable d'un humain. - Réduction scalaire : l'entropie de Shannon projette la matrice de transition en un seul nombre, détruisant la richesse structurelle de la distribution.
- Absence de signal temporel : la chaîne de Markov ignore les intervalles inter-requêtes, alors que le rythme de navigation est un discriminant puissant entre humains et bots (cf. §5.3 — cadence fingerprint).
Ces limites motivent le passage à un modèle attentionnel capable d'encoder conjointement la séquence des chemins, les méthodes HTTP et le rythme temporel.
Architecture du Session Transformer
Le modèle SessionTransformer est un encoder Transformer léger qui produit un embedding dense de dimension 32 pour chaque session. L'architecture comporte quatre étapes :
1. Tokenisation multi-signal
Chaque requête dans la séquence est représentée par la somme de trois embeddings :
| Signal | Encodage | Dimension |
|---|---|---|
| Chemin URL | hash(path) mod V → nn.Embedding(V, d_model) |
64 |
| Méthode HTTP | enum(GET=0, POST=1, …) → nn.Embedding(8, d_model) |
64 |
| Délai inter-requête Δt | log1p(Δt_ms) / 10 → nn.Linear(1, d_model) |
64 |
Le vocabulaire des chemins V est fixé à 65 536 (hash modulo). La normalisation log1p/10 compresse la distribution heavy-tailed des intervalles (quelques ms à plusieurs minutes) en une plage stable pour l'optimisation.
2. Transformer Encoder
- 2 couches
nn.TransformerEncoderLayer, 4 têtes d'attention,d_model=64,dim_feedforward=256 - Positional encoding sinusoïdal injecté en addition (Vaswani et al., 2017)
- Le mécanisme de self-attention permet à chaque position d'attendre sur toutes les autres positions de la séquence, capturant les dépendances longues que la chaîne de Markov ne pouvait pas modéliser
3. Mean Pooling
La sortie du Transformer (B, S, 64) est moyennée sur l'axe temporel : z = mean(outputs, dim=1) → (B, 64). Le mean pooling agrège l'information de toute la séquence en un vecteur de session fixe, indépendamment de la longueur.
4. Projection linéaire
head = nn.Linear(64, 32) projette le vecteur de session en 32 dimensions : emb = head(z) → (B, 32).
Les 32 dimensions sont exposées comme colonnes seq_emb_0 à seq_emb_31 dans le DataFrame de features.
Robustesse
| Condition | Comportement |
|---|---|
| Session ≤ 1 requête | Vecteur nul (32 zéros) — pas assez de contexte |
| Session > 512 requêtes | Troncation aux 512 dernières requêtes |
| Fichier de poids absent | Fallback à vecteurs nuls + avertissement journalisé |
| PyTorch non installé | Fallback à vecteurs nuls (déploiement minimal) |
Implémentation
Modèle : bot_detector.session_transformer.SessionTransformer (PyTorch)
Inference : bot_detector.session_transformer.extract_sequence_embeddings(df_sessions, client) — requête les logs bruts depuis ja4_logs.http_logs, groupe par (src_ip, ja4, host), encode les séquences, et retourne un DataFrame avec colonnes src_ip, ja4, host, seq_emb_0..seq_emb_31.
Intégration : les 32 colonnes seq_emb_* remplacent path_transition_entropy et cadence_cv dans les listes FEATURES et FEATURES_COMPLET du module preprocessing. L'embedding est injecté dans le cycle principal (cycle.py) avant l'appel à preprocess_df.
Poids : stockés au chemin configuré par SESSION_TRANSFORMER_PATH (défaut : {MODEL_DIR}/session_transformer.pt).
5.2 Détection de flottes par apprentissage de représentations de graphe (GraphSAGE) [impl.]
Constat et motivation
La feature ja4_asn_concentration mesure la concentration d'une paire (JA4, ASN) donnée — une valeur élevée signale un usage anormal d'une combinaison spécifique. Cependant, les botnets sophistiqués utilisent des dizaines de JA4 et d'ASN en rotation : chaque paire (JA4, ASN) individuelle est statistiquement anodine, mais le pattern global de co-occurrence révèle la coordination.
Un botnet distribuant ses requêtes sur 20 ASN différents, avec 5 JA4 distincts par ASN, produira 100 paires (JA4, ASN) différentes, chacune avec une concentration faible — indétectable par ja4_asn_concentration seul. Pourtant, ces 5 JA4 co-apparaissent systématiquement dans les mêmes ASN, formant une signature de flotte détectable par analyse de graphe.
Limite de Louvain : une approche classique consiste à construire un graphe bipartite JA4×ASN puis à projeter et détecter des communautés via l'algorithme de Louvain (Blondel et al., 2008). Cette méthode ne exploite que la topologie des arêtes (modularité) et ignore totalement les features des nœuds — le profil comportemental de chaque IP (nombre de requêtes, ratio POST, diversité TLS, etc.). Deux IPs aux comportements radicalement différents (un scraper agressif vs. un navigateur légitime derrière un CDN) peuvent être regroupées dans la même communauté simplement parce qu'elles partagent un (JA4, ASN) commun. De plus, Louvain opère sur des structures NetworkX en mémoire, ce qui limite la scalabilité (cf. §6.4).
Modèle mathématique : graphe d'IPs et plongements GNN
L'approche proposée modélise directement les IPs comme nœuds d'un graphe, où les features de chaque nœud sont le vecteur ML agrégé de l'IP (moyenne des features numériques sur les sessions du cycle courant, normalisées par min-max).
Construction du graphe G = (V, E) :
- Nœuds V : ensemble des
src_ipuniques dans la fenêtre temporelle - Features x ∈ ℝᵈ : vecteur ML de dimension d (features numériques du pipeline, typiquement d ≈ 30–60)
- Arêtes (u, v) ∈ E : une arête relie IP_u et IP_v si elles co-occurent dans au moins un groupe (JA4, ASN) comptant ≥ N IPs distinctes (N =
FLEET_MIN_IPS, défaut 3)
Cette modélisation permet au GNN de combiner deux sources d'information :
- Structure du graphe : qui communique avec qui (co-occurrence JA4×ASN)
- Features des nœuds : comment chaque IP se comporte (profil ML)
GraphSAGE (Hamilton et al., 2017) apprend une représentation latente h_v pour chaque nœud v en agrégeant itérativement les features de ses voisins :
h_v^(k) = σ(W^(k) · CONCAT(h_v^(k-1), AGGREGATE({h_u^(k-1) ∀ u ∈ N(v)})))
où N(v) est le voisinage de v, W^(k) sont les poids appris à la couche k, et AGGREGATE est la fonction d'agrégation (moyenne chez SAGEConv). L'implémentation utilise 2 couches SAGEConv (in_channels → 64 → 32) avec activation ReLU et Dropout(0.1) entre les couches, produisant un embedding 32D par IP.
Clustering dans l'espace latent : les embeddings 32D sont clusterisés par HDBSCAN (McInnes et al., 2017) ou DBSCAN en fallback. Contrairement à Louvain qui partitionne sur la seule topologie, ce pipeline segmente les IPs dans un espace latent conjointement informé par la structure du graphe et les features comportementales — deux IPs aux features similaires mais sans co-occurrence directe peuvent tout de même être rapprochées via leurs voisinages respectifs (effet de propagation du GNN).
Formule du score de flotte :
fleet_score = cluster_size × compactness / log2(n_asn + 2)
où :
cluster_size: nombre d'IPs dans le cluster détectécompactness= 1 / (1 + d̄) où d̄ est la distance euclidienne moyenne au centroïde du cluster dans l'espace latent 32Dlog2(n_asn + 2): terme de normalisation pour pénaliser les clusters dispersés sur de nombreux ASN (signal d'infrastructure légitime CDN)
Une valeur de fleet_score élevée (≥ FLEET_SCORE_THRESHOLD, défaut 2.0) déclenche un malus sur le score d'anomalie de toutes les IPs du cluster concerné.
Implémentation
Module : fleet.py dans bot_detector
Dépendance : torch_geometric (SAGEConv, NeighborLoader pour le batching)
from torch_geometric.nn import SAGEConv
import torch.nn as nn
class GraphSAGE(nn.Module):
def __init__(self, in_ch, hidden_ch=64, out_ch=32, dropout=0.1):
super().__init__()
self.conv1 = SAGEConv(in_ch, hidden_ch)
self.conv2 = SAGEConv(hidden_ch, out_ch)
self.drop = nn.Dropout(dropout)
def forward(self, x, edge_index):
x = self.conv1(x, edge_index).relu()
x = self.drop(x)
x = self.conv2(x, edge_index)
return x
def detect_fleet_communities(df: pd.DataFrame) -> dict:
"""Analyse le graphe d'IPs via GraphSAGE + HDBSCAN."""
# 1. Construire le graphe : nœuds=IP, features=vecteur ML, arêtes=co-occurrence
unique_ips, node_features, edge_index = _build_ip_graph(df)
# 2. Inférence GraphSAGE → embeddings 32D par IP
embeddings = _infer_embeddings(node_features, edge_index)
# 3. Clustering HDBSCAN sur les embeddings
labels = HDBSCAN(min_cluster_size=3, metric='euclidean').fit_predict(embeddings)
# 4. fleet_score = cluster_size × compactness / log2(n_asn + 2)
return {ip: {"cluster_id": cid, "fleet_score": score}
for ip, cid, score in results}
Scalabilité : pour les graphes de plus de 50 000 nœuds, le module active automatiquement le Neighbor Sampling via torch_geometric.loader.NeighborLoader (échantillonnage stochastique du voisinage, batch_size=4096), permettant de traiter des graphes de millions de nœuds sans charger l'intégralité en mémoire GPU (détail §6.4).
Sortie : les résultats de la détection de flotte sont intégrés directement dans le DataFrame du cycle courant via deux colonnes additionnelles :
fleet_score: score du cluster auquel appartient l'IP (0 si aucune flotte détectée)fleet_campaign_flag: 1 sifleet_score ≥ FLEET_SCORE_THRESHOLD(défaut : 2.0)
Les IPs appartenant à des clusters avec fleet_score élevé reçoivent un malus de score d'anomalie dans le pipeline final (ajout d'une constante au score brut EIF avant application de la fusion LR).
5.3 Fingerprinting par timing inter-requêtes (Request Cadence Fingerprint) [impl.]
Constat et motivation
Les features temporelles existantes — hit_velocity (vitesse moyenne de requêtes) et temporal_entropy (distribution horaire) — capturent la fréquence et la régularité macro-temporelle des requêtes. Elles manquent cependant le rythme microscopique : la distribution des intervalles entre requêtes consécutives (inter-request intervals, Δt) contient un signal discriminant riche.
Les humains produisent des intervalles irréguliers avec des rafales de chargement de page suivies de pauses de lecture. Les bots produisent des intervalles réguliers (basés sur sleep(N)) ou des intervalles exponentiels (backoff). Ces différences de structure statistique sont exploitées via quatre signaux complémentaires.
Signal 1 : Coefficient de variation (CV)
Le coefficient de variation est le rapport entre l'écart-type et la moyenne de la distribution des intervalles :
CV(Δt) = σ(Δt) / μ(Δt)
Valeurs caractéristiques :
- Navigation humaine : CV ≈ 1,5–3,0 (haute variabilité naturelle due aux temps de lecture variables)
- Bot régulier (sleep fixe N ms) : CV ≈ 0,01–0,3 (faible variabilité, intervalles quasi-constants)
- Bot avec jitter gaussien : CV ≈ 0,3–0,7 (variabilité modérée mais inférieure à l'humain)
Signal 2 : Autocorrélation de décalage 1 (Lag-1)
L'autocorrélation de décalage 1 mesure la corrélation entre des intervalles consécutifs :
ρ₁ = corr(Δt_n, Δt_{n+1}) = Cov(Δt_n, Δt_{n+1}) / (σ_n × σ_{n+1})
Valeurs caractéristiques :
- Navigation humaine : ρ₁ ≈ 0 (intervalles indépendants — la durée de lecture d'une page n'est pas corrélée à celle de la suivante)
- Bot avec jitter proportionnel : ρ₁ ≈ 0,8+ (corrélé — le jitter est généré proportionnellement à l'intervalle de base, créant une dépendance entre intervalles consécutifs)
Ce signal distingue spécifiquement les bots utilisant un jitter proportionnel (ex. sleep(N + random() × N)) des bots avec jitter additif indépendant.
Signal 3 : Ratio rafale/pause
Proportion des intervalles Δt classifiés comme :
- Rafale : Δt < 100 ms (chargement parallèle de ressources dans le même rendu de page)
- Pause : Δt > 5 000 ms (temps de lecture d'une page)
- Intermédiaire : 100 ms ≤ Δt ≤ 5 000 ms (navigation entre pages)
Patterns caractéristiques :
- Navigateur réel : alternance rafale (chargement de page) → pause (lecture) → rafale, avec ratio rafale/pause ≈ 0,3–0,6
- Scraper séquentiel : exclusivement intermédiaire (requêtes espacées uniformément, ni rafale ni pause)
- Playwright/Puppeteer : rafales très courtes (ordre de grandeur < 50 ms, à valider empiriquement) mais pauses quasi-absentes
Signal 4 : Déviation par rapport à la loi de Benford
La loi de Benford (aussi appelée loi du premier chiffre, Benford, 1938) stipule que dans de nombreux jeux de données naturels, la probabilité que le premier chiffre significatif d'un nombre soit d vaut :
P(premier chiffre = d) = log₁₀(1 + 1/d)
Table de la loi de Benford :
| Chiffre d | Probabilité attendue |
|---|---|
| 1 | 30,1 % |
| 2 | 17,6 % |
| 3 | 12,5 % |
| 4 | 9,7 % |
| 5 | 7,9 % |
| 6 | 6,7 % |
| 7 | 5,8 % |
| 8 | 5,1 % |
| 9 | 4,6 % |
Les jeux de données naturels (durées de visite, revenus fiscaux, séquences physiques mesurées) suivent cette distribution ; les jeux de données artificiels (timers sleep(1000ms)) ne la suivent pas — ils concentrent les premiers chiffres sur des valeurs spécifiques.
Par exemple, un bot utilisant sleep(1000ms) génère exclusivement des intervalles Δt autour de 1000 ms, avec premier chiffre 1 en quasi-totalité. Un bot utilisant sleep(2500ms + jitter±200ms) surreprésente les chiffres 2 et 3.
Métrique de déviation :
D_benford = Σ_{d=1}^{9} |P_observée(d) - P_benford(d)|
= mean absolute deviation
Valeurs caractéristiques :
- Navigation humaine : D_benford ≈ 0,02–0,08 (faible déviation)
- Bot sleep(N) : D_benford ≈ 0,30–0,80 (forte concentration sur un chiffre)
Caveat : l'applicabilité de la loi de Benford aux intervalles inter-requêtes est une hypothèse heuristique, non démontrée mathématiquement pour ce cas d'usage. Les données d'intervalles (quelques ms à quelques secondes) ne couvrent pas nécessairement les ordres de grandeur requis par la loi. Les seuils ci-dessus sont empiriques et nécessitent une validation sur les données de production.
Implémentation
Table ClickHouse : agg_request_timing_1h
- Colonnes :
(src_ip, ja4, session_id, groupArray(timestamp_ns) AS ts_array)
Vue : view_thesis_features_1h
- Colonnes :
cadence_cv,lag1_autocorrelation,burst_ratio,benford_deviation,pause_ratio
SQL :
WITH deltas AS (
SELECT
session_id,
arrayMap(
(t1, t2) -> (t2 - t1) / 1e6, -- nanoseconds → milliseconds
arraySlice(ts_array, 1, length(ts_array) - 1),
arraySlice(ts_array, 2)
) AS delta_ms
FROM agg_request_timing_1h
WHERE length(ts_array) >= 3
),
stats AS (
SELECT
session_id,
arrayReduce('avg', delta_ms) AS mu,
arrayReduce('stddevPop', delta_ms) AS sigma,
countIf(d -> d < 100, delta_ms) AS burst_count,
countIf(d -> d > 5000, delta_ms) AS pause_count,
length(delta_ms) AS n
FROM deltas
ARRAY JOIN delta_ms AS d
GROUP BY session_id
)
SELECT
session_id,
sigma / nullIf(mu, 0) AS cadence_cv,
burst_count / n AS burst_ratio,
pause_count / n AS pause_ratio
-- lag1_autocorrelation et benford_deviation calculés en Python post-processing
FROM stats;
5.4 Détection de navigation synthétique par arbre de dépendances (Resource Dependency Tree) [impl.]
Constat et motivation
La feature asset_ratio détecte les bots qui ne chargent pas les ressources statiques (CSS, JS, images). Les frameworks de scraping modernes — Playwright, Puppeteer, Selenium — chargent toutes les ressources pour paraître identiques à un navigateur réel. Le signal n'est plus la présence du chargement de ressources, mais son ordre temporel.
Un navigateur réel parse le HTML, construit le Document Object Model (DOM), puis déclenche le chargement des ressources selon leur type et priorité. Ce processus crée des cascades waterfall caractéristiques. Playwright simule ce processus mais avec des différences temporelles mesurables.
Concept : cascade de dépendances de ressources
Le processus de chargement d'une page par un vrai navigateur suit cette séquence déterministe :
- Requête HTML : t = 0 ms — le navigateur demande le document HTML
- Parsing HTML : t = 0–50 ms — construction du DOM, identification des ressources
- CSS bloquant : t = 50–100 ms — les feuilles de style CSS référencées dans
<link rel="stylesheet">sont chargées en priorité absolue (bloquant le rendu) - Construction CSSOM : t = 100–150 ms — le CSS Object Model est construit à partir des CSS téléchargés
- JS différé et images : t = 150–300 ms — après la construction du CSSOM, les scripts différés (
defer,async) et les images sont chargés en parallèle
Cette structure produit deux signaux mesurables :
Signal 1 : root_to_first_asset_delay
Le délai entre la requête HTML et la première requête de ressource (CSS ou JS) est caractéristique :
- Navigateur réel : 50–200 ms (temps de parsing HTML réel)
- Playwright headless : délai attendu faible (< 50 ms, hypothèse nécessitant validation empirique sur notre infrastructure)
- Scraper avec chargement d'assets : 0 ms (requêtes séquentielles immédiates)
Signal 2 : asset_load_stddev
L'écart-type des timestamps de chargement au sein d'une cascade (batch de ressources déclenchées en parallèle) :
- Navigateur réel : faible dans le batch (chargement effectivement parallèle sur HTTP/2), mais les batches successifs sont séparés par des gaps caractéristiques
- Playwright : quasi-nul (toutes les ressources déclenchées simultanément par le moteur JS simulé)
- Scraper séquentiel : élevé (les ressources sont chargées une par une)
Implémentation
Tables ClickHouse :
CREATE TABLE agg_resource_cascade_1h
(
session_id String,
src_ip IPv4,
resource_type Enum('html', 'css', 'js', 'image', 'font', 'other'),
timestamp_ns UInt64,
path String
)
ENGINE = MergeTree()
ORDER BY (session_id, timestamp_ns)
TTL toDateTime(timestamp_ns / 1e9) + INTERVAL 2 HOUR;
Vue : view_resource_cascade_1h
Fichier SQL : 12_thesis_features.sql
Colonnes : root_to_first_asset_delay (ms), asset_load_stddev (ms)
Identification des ressources : double-source pour robustesse :
- En-tête Accept :
Accept: text/css→ CSS ;Accept: */*avecSec-Fetch-Dest: script→ JS - Extension de chemin : regex
\.(css|js|png|jpg|webp|woff2|svg)$
WITH html_times AS (
SELECT session_id, min(timestamp_ns) AS html_ts
FROM agg_resource_cascade_1h
WHERE resource_type = 'html'
GROUP BY session_id
),
first_asset AS (
SELECT session_id, min(timestamp_ns) AS first_asset_ts
FROM agg_resource_cascade_1h
WHERE resource_type IN ('css', 'js')
GROUP BY session_id
),
asset_stats AS (
SELECT session_id,
stddevPop(timestamp_ns / 1e6) AS asset_load_stddev_ms
FROM agg_resource_cascade_1h
WHERE resource_type != 'html'
GROUP BY session_id
)
SELECT
h.session_id,
(f.first_asset_ts - h.html_ts) / 1e6 AS root_to_first_asset_delay_ms,
a.asset_load_stddev_ms
FROM html_times h
LEFT JOIN first_asset f USING (session_id)
LEFT JOIN asset_stats a USING (session_id);
5.5 Analyse de dérive de fingerprint TLS intra-session (Intra-Session JA4 Drift) [impl.]
Constat et motivation
La feature distinct_ja4_count mesure la diversité globale des JA4 par IP sur une fenêtre temporelle. Elle détecte les bots qui changent fréquemment de JA4. Cependant, un attaquant sophistiqué peut maintenir un JA4 stable pendant la majeure partie de la session et ne le changer qu'à la transition de phase d'attaque — une stratégie de discrétion qui contourne distinct_ja4_count.
Cette technique analyse la dynamique temporelle de la dérive JA4 plutôt que sa magnitude globale.
Technique : analyse de dérive temporelle
- Segmentation : la session est découpée en fenêtres de 10 minutes (W₁, W₂, …, W_k)
- JA4 dominant par segment : pour chaque fenêtre, le JA4 le plus fréquent (mode) est retenu comme JA4 représentatif du segment
- Comptage des transitions : une transition est comptée quand le JA4 dominant change entre deux segments consécutifs
- Calcul du ratio :
drift_ratio = nb_transitions / (nb_segments - 1) ∈ [0, 1]
Valeurs caractéristiques :
- Navigation humaine : drift_ratio = 0 (même navigateur = même JA4 tout au long de la session)
- Bot en rotation simple : drift_ratio ≈ 1 (JA4 changeant à chaque segment)
- Bot APT multi-phase : drift_ratio faible (0,1–0,3) avec une transition unique corrélée à un changement comportemental
Détection APT (Advanced Persistent Threat)
La combinaison drift_ratio + post_ratio + changement de path_prefix permet de détecter les transitions reconnaissance → exploitation :
- Phase 1 (reconnaissance) : JA4_A, requêtes GET, chemins
/,/robots.txt,/sitemap.xml - Transition : changement de JA4 (JA4_A → JA4_B)
- Phase 2 (exploitation) : JA4_B, requêtes POST, chemins
/admin/login,/wp-json/wp/v2/users
Ce pattern — faible drift_ratio global avec une transition ponctuelle coïncidant avec un changement de post_ratio et de path_prefix dominant — est un indicateur fort d'activité APT coordonnée.
Implémentation
Vue ClickHouse : view_thesis_features_1h
- Colonne :
ja4_drift_ratio(Float32)
SQL :
WITH segmented AS (
SELECT
src_ip,
ja4_fingerprint,
-- Segment de 10 minutes
toStartOfInterval(event_time, INTERVAL 10 MINUTE) AS segment_ts,
count() AS hits_in_segment
FROM ja4_processing.sessions
WHERE event_time >= now() - INTERVAL 1 HOUR
GROUP BY src_ip, ja4_fingerprint, segment_ts
),
dominant_ja4 AS (
SELECT
src_ip,
segment_ts,
argMax(ja4_fingerprint, hits_in_segment) AS dominant_ja4
FROM segmented
GROUP BY src_ip, segment_ts
),
ordered AS (
SELECT
src_ip,
segment_ts,
dominant_ja4,
lagInFrame(dominant_ja4) OVER (
PARTITION BY src_ip
ORDER BY segment_ts
) AS prev_ja4,
count() OVER (PARTITION BY src_ip) AS nb_segments
FROM dominant_ja4
)
SELECT
src_ip,
sum(dominant_ja4 != prev_ja4 AND prev_ja4 IS NOT NULL) /
nullIf(max(nb_segments) - 1, 0) AS ja4_drift_ratio
FROM ordered
GROUP BY src_ip
HAVING max(nb_segments) >= 3; -- Minimum 3 segments (30+ minutes)
La condition HAVING max(nb_segments) >= 3 exclut les sessions trop courtes pour un calcul fiable de drift_ratio (voir §6.5 pour les limites). Les sessions exclues reçoivent la valeur NULL dans le vecteur feature, traitée par imputation avec la médiane de la population.
5.6 Corrélation DNS passive × flux HTTP (DNS Shadow Analysis) [todo]
Constat et motivation
L'architecture actuelle ne capture pas les requêtes DNS. Or, chaque première visite d'un navigateur vers un domaine est précédée d'une résolution DNS — le client interroge un résolveur récursif pour obtenir l'adresse IP du serveur. Les bots qui ciblent directement des IPs, utilisent un fichier /etc/hosts personnalisé, ou recourent au DNS-over-HTTPS (DoH) ne génèrent aucune requête DNS observable sur le réseau local.
Cette asymétrie constitue un signal exploitable : un flux HTTP sans résolution DNS préalable observable est suspect.
Technique : ratio DNS shadow
Capture DNS passive via ja4ebpf étendu au port UDP/53, puis corrélation avec les flux HTTP :
dns_shadow_ratio = requêtes HTTP vers hôte X / résolutions DNS de l'hôte X observées
Valeurs caractéristiques :
- Navigateur réel : ratio ≈ 1 (une résolution DNS → de multiples requêtes HTTP via connexions Keep-Alive réutilisées)
- Bot utilisant /etc/hosts ou DoH : ratio → ∞ (requêtes HTTP sans résolution DNS observable)
- CDN/proxy : ratio variable mais cohérent dans le temps
DNS-over-HTTPS (DoH)
DNS-over-HTTPS (RFC 8484) est un protocole qui envoie les requêtes DNS encapsulées dans des connexions HTTPS plutôt qu'en clair sur UDP/53. Ceci prévient l'observation passive des résolutions DNS. Fournisseurs courants : Cloudflare 1.1.1.1, Google 8.8.8.8.
Les bots utilisant DoH contournent l'analyse DNS shadow. Cependant, les connexions HTTPS vers 1.1.1.1 ou 8.8.8.8 depuis le réseau serveur peuvent elles-mêmes être corrélées comme indicateur d'utilisation de DoH par le client (si observable au niveau réseau) — cette corrélation indirecte est une atténuation partielle.
Extension JA4D
Le fingerprinting JA4D/JA4D6 des requêtes DHCP/DHCPv6 pourrait compléter l'analyse en identifiant les dispositifs derrière NAT, ajoutant une couche d'identification au-delà de l'adresse IP. Cette extension est envisagée comme travail futur conjoint à DNS Shadow Analysis.
[todo] Non implémenté : nécessite l'extension de ja4ebpf pour la capture UDP/53. Travail futur priorité 1 (voir §6.6).
5.7 Détection par invariant de ratio de compression (Compression Ratio Invariant) [todo]
Constat et motivation
La feature missing_accept_enc_ratio détecte l'absence de l'en-tête Accept-Encoding dans les requêtes HTTP. Certains bots incluent cet en-tête (Accept-Encoding: gzip, deflate, br) sans pour autant traiter effectivement la compression dans les réponses reçues. L'en-tête est présent pour paraître légitime, mais le bot traite les données brutes sans décompresser.
Technique : invariant de ratio de compression côté serveur
Instrumentation Apache (module custom) :
- Le serveur compare la taille de la réponse compressée envoyée avec la taille
Content-Lengthnon compressée - Le ratio de compression effectif par session est calculé :
ratio = taille_compressée / taille_non_compressée - Ce ratio est corrélé avec les requêtes suivantes du client : un client qui décompresse correctement peut traiter des réponses de taille variable sans délai anormal ; un bot qui ne décompresse pas lit des données plus petites (la réponse compressée) mais échoue à les interpréter correctement
Signal subtil : timing différentiel Brotli vs gzip
Brotli (RFC 7932) est un algorithme de compression développé par Google, offrant 20 à 26 % de meilleure compression que gzip sur du contenu web, au prix d'un coût computationnel de décompression plus élevé. Il est activé via Accept-Encoding: br.
gzip (RFC 1952) est le format DEFLATE-based largement supporté depuis HTTP/1.1, avec une décompression rapide et faible consommation CPU.
Un client réel est légèrement plus lent à traiter une réponse Brotli (décompression coûteuse) qu'une réponse gzip de même contenu. Ce délai différentiel est mesurable en quelques dizaines de millisecondes sur les connexions à faible latence. Un bot qui n'effectue pas de décompression répond à vitesse constante indépendamment de l'encodage — l'absence de différentiel est le signal.
[todo] Non implémenté : nécessite une instrumentation Apache pour le suivi de compression. Interférence avec la compression CDN pour les sessions has_xff=1. Travail futur priorité 2 (voir §6.6).
5.8 Empreinte comportementale de session multi-domaine (Cross-Domain Session Linking) [impl.]
Constat et motivation
Dans un environnement multi-hôtes (plusieurs Apache VirtualHosts sur le même serveur), les features sont calculées indépendamment par triplet (src_ip, ja4, host). Un attaquant scannant plusieurs domaines depuis la même IP présente un pattern inter-domaine caractéristique invisible dans l'analyse mono-domaine.
Cette technique fusionne les informations de sessions multi-domaines pour détecter les comportements de scan d'infrastructure systématique.
Métrique 1 : host_diversity
host_diversity = uniqExact(host) OVER (PARTITION BY src_ip)
Valeurs caractéristiques :
- Navigation humaine : 1–3 hôtes (un utilisateur visite rarement plus de 3 domaines hébergés sur la même infrastructure)
- Scanner d'infrastructure : 10+ hôtes (balayage systématique de tous les VirtualHosts)
Métrique 2 : host_sweep_speed
host_sweep_speed = uniqExact(host) / session_duration_seconds
Un scanner parcourt rapidement de nombreux hôtes différents. Un utilisateur humain reste longtemps sur un même hôte. La dimension temporelle distingue un utilisateur occasionnel multi-domaine d'un scanner actif.
Métrique 3 : host_coverage_uniformity
host_coverage_uniformity = 1 − stddev(hits_per_host) / avg(hits_per_host)
Un scanner distribue ses requêtes uniformément entre les hôtes (chaque hôte reçoit approximativement le même nombre de requêtes de reconnaissance). Un utilisateur humain se concentre sur 1–2 hôtes avec une distribution très inégale.
Valeurs caractéristiques :
- Scanner : host_coverage_uniformity ≈ 0,8–1,0 (distribution uniforme)
- Utilisateur humain : host_coverage_uniformity ≈ 0,1–0,4 (très inégal)
Métrique 4 : cross_domain_path_similarity (coefficient de Jaccard)
Le coefficient de Jaccard mesure la similarité entre deux ensembles :
Jaccard(A, B) = |A ∩ B| / |A ∪ B|
où :
- |A ∩ B| = nombre de chemins apparaissant sur les deux hôtes A et B
- |A ∪ B| = nombre total de chemins distincts sur les deux hôtes réunis
- La valeur est dans [0, 1] : 0 = aucun chemin commun, 1 = ensembles identiques
Un scanner utilisant une wordlist commune (/admin, /wp-login.php, /.env, /phpinfo.php, /.git/config) sur tous les hôtes produit un Jaccard élevé (0,7–1,0). Une navigation humaine sur des sites distincts produit un Jaccard proche de 0.
Implémentation SQL :
WITH paths_by_host AS (
SELECT
src_ip,
host,
groupArray(DISTINCT path) AS paths
FROM ja4_processing.sessions
WHERE event_time >= now() - INTERVAL 1 HOUR
GROUP BY src_ip, host
),
host_pairs AS (
SELECT
a.src_ip,
a.host AS host_a,
b.host AS host_b,
arrayIntersect(a.paths, b.paths) AS common_paths,
arrayDistinct(arrayConcat(a.paths, b.paths)) AS union_paths
FROM paths_by_host a
JOIN paths_by_host b
ON a.src_ip = b.src_ip AND a.host < b.host
)
SELECT
src_ip,
avg(length(common_paths) / nullIf(length(union_paths), 0))
AS cross_domain_path_similarity
FROM host_pairs
GROUP BY src_ip;
Table récapitulative des features Cross-Domain :
| Feature | Type | Description | Statut |
|---|---|---|---|
host_diversity |
UInt16 | Nombre d'hôtes distincts par IP | [impl.] |
host_sweep_speed |
Float32 | Hôtes distincts / durée session (hôtes/s) | [impl.] |
host_coverage_uniformity |
Float32 | 1 − stddev/avg des hits par hôte | [impl.] |
cross_domain_path_similarity |
Float32 | Jaccard moyen entre paires d'hôtes | [impl.] |
Ces quatre features sont intégrées dans le vecteur feature du Modèle Complet (famille F8 — Multi-domaine).