Skip to content

Leçon 7.3 · Comportements malveillants· 50 min

Commande et contrôle

Repérer le C2 dans les journaux réseau (rythme des beacons, anomalies DNS, pivots TLS), l’extraire des échantillons et le contenir sans perdre de preuves.

Objectifs

  • Expliquer ce que transporte un canal de C2, pourquoi c’est la phase la plus observable d’une intrusion et comment elle s’inscrit dans la tactique ATT&CK TA0011
  • Décrire les artefacts observables du polling HTTP(S), des canaux DNS, du C2 via des services légitimes, du pair-à-pair et des DGA
  • Extraire les paramètres de C2 d’un échantillon et les consigner sous une forme exploitable par les ingénieurs détection et les intervenants
  • Chasser le C2 dans les journaux Zeek par score de beacon, connexions longues, destinations rares, pivots DNS et TLS, et raisonner sur les sosies bénins
  • Choisir entre sinkholing, blocage et isolement de l’hôte en tenant compte des compromis sur les preuves et la délimitation du périmètre

Mécanismes de persistance traitait de ce qu’une intrusion laisse sur le disque. La commande et contrôle (C2), c’est ce qu’elle fait sur le réseau. Un implant qui s’est installé puis s’est tu attend toujours des instructions, et pour les recevoir il doit sortir par votre réseau, encore et encore, aussi longtemps que dure l’intrusion.

Vous avez déjà vu les pièces depuis l’établi de l’analyste. Simuler et capturer le trafic réseau gardait le trafic d’un échantillon à l’intérieur du laboratoire et lisait les intervalles de beacon et les empreintes TLS dans une capture ; Extraire les configurations de malwares sortait la liste des C2 du binaire ; Écrire des signatures réseau transformait le format des requêtes en règles Suricata. Cette leçon relie ces compétences à l’autre versant du métier : trouver le C2 dans des journaux que vous n’avez pas générés, le bloquer, et s’en servir pour déterminer jusqu’où s’étend un incident.

À quoi sert le C2

Du point de vue du défenseur, un canal de C2 transporte trois types de trafic :

SensCe qu’il transporteCe que vous voyez en général
Check-in (de l’implant vers le serveur)Identifiant de l’hôte, utilisateur, OS, privilèges, « quelque chose pour moi ? »Petites requêtes régulières ; souvent de la même taille à chaque fois
Tâches (du serveur vers l’implant)Commandes : exécuter une commande shell, lister des fichiers, dormir plus longtemps, se désinstallerRéponses généralement vides ou minuscules ; de temps à autre, une plus grosse
Résultats et staging (dans les deux sens)Sortie des commandes, données volées qui sortent ; outils et étapes suivantes qui entrentRafales qui cassent le motif : gros envois, gros téléchargements

ATT&CK regroupe ces techniques sous la tactique TA0011, avec des sous-techniques pour le protocole (T1071, Application Layer Protocol), la façon de trouver le serveur (T1568, Dynamic Resolution), l’usage de services tiers (T1102, Web Service), le chiffrement (T1573), les proxys (T1090) et les canaux de repli (T1008). L’exfiltration et le transfert d’outils empruntent le même canal mais relèvent d’autres tactiques, ce qui explique qu’un seul implant corresponde souvent à une demi-douzaine d’identifiants.

Le C2 est la phase la plus détectable pour une raison simple : il se répète. L’accès initial a lieu une fois. La persistance est écrite une fois. Le vol d’identifiants peut avoir lieu une fois par hôte. Mais un implant interroge son serveur toutes les quelques secondes, minutes ou heures pendant des semaines, et chaque interrogation franchit une frontière que vous pouvez instrumenter : un résolveur DNS, un proxy, un pare-feu, une sonde Zeek. Chaque répétition est une occasion de plus de l’attraper, et la répétition elle-même est une signature. C’est aussi la phase qui offre le plus de levier : coupez le canal et l’opérateur perd d’un coup le contrôle de tous les hôtes infectés, même ceux que vous n’avez pas encore trouvés.

Familles de canaux et leur apparence

Les auteurs choisissent un canal pour sa fiabilité et sa capacité à se fondre dans le décor. Les familles ci-dessous sont présentées telles que vous les rencontrez dans les journaux, pas telles que vous les construiriez.

Polling HTTP(S)

La conception la plus courante : l’implant dort, se réveille, envoie une requête, lit la réponse et se rendort. Le serveur ne peut pas pousser d’informations : la latence est donc égale à l’intervalle de sommeil, et la plupart des frameworks ajoutent un jitter aléatoire à ce sommeil. Les frameworks à profils malléables permettent à l’opérateur de déguiser la requête en trafic web ordinaire.

Artefacts observables : connexions régulières d’un hôte vers une destination ; tailles de requêtes et de réponses constantes pendant les périodes d’inactivité ; un user-agent qui ne correspond pas au navigateur de l’hôte ; des URI qui se répètent ou suivent un même motif ; du TLS vers un domaine récent ou une IP nue ; des certificats auto-signés, fraîchement émis ou réutilisés sur des domaines sans rapport. Avec du domain fronting ou un CDN en frontal, le SNI et l’IP de destination appartiennent à un fournisseur réputé, et seuls le rythme et le contexte au niveau de l’hôte le trahissent.

Canaux DNS

Le DNS est autorisé en sortie de presque tous les réseaux, directement ou via un redirecteur. Le tunnel DNS encode les données dans le nom interrogé (<encoded-chunk>.t.example.net) et reçoit les réponses dans des enregistrements TXT, CNAME, NULL ou A. L’opérateur contrôle le serveur faisant autorité pour le domaine parent : chaque requête lui parvient donc à travers vos propres résolveurs.

Artefacts observables : de nombreux sous-domaines uniques sous un même domaine parent ; des labels longs (jusqu’à la limite de 63 caractères) composés d’alphabets base32, base64 ou hexadécimal ; un volume de requêtes élevé d’un client vers ce domaine ; des types d’enregistrements inhabituels comme TXT ou NULL depuis des postes de travail ; des réponses aux TTL courts. Le DNS over HTTPS déplace le même motif dans du HTTPS vers un résolveur public, où il ressemble à du TLS vers ce résolveur depuis un processus qui ne devrait pas l’utiliser.

Services cloud et collaboratifs légitimes

Publier des commandes via l’API d’un bot de messagerie, un dépôt d’hébergement de code, un site de paste ou un dossier de stockage cloud donne à l’implant une destination qu’aucune liste de blocage ne signalera (C2 via service web). Une variante, le dead drop resolver, ne stocke que l’adresse du vrai C2 dans un profil ou une publication publics.

Artefacts observables : des endpoints d’API (et non le front-end web) contactés par des hôtes ou des processus qui n’ont rien à y faire ; un serveur ou un compte de service qui parle à une API de messagerie ; l’interrogation régulière d’un même chemin de stockage ; le bon domaine avec le mauvais processus, ce que vous ne voyez que si la télémétrie du proxy ou de l’EDR enregistre le nom du processus. Les analyses purement réseau peinent ici ; c’est là que la jointure entre données endpoint et données réseau porte ses fruits.

Pair-à-pair

Dans une conception pair-à-pair, il n’y a pas de serveur unique : les hôtes infectés se relaient les commandes, à partir de listes de pairs que l’implant transporte et met à jour. En interne, certains frameworks chaînent les implants via des pipes nommés SMB ou du TCP pour qu’un seul hôte parle à l’extérieur.

Artefacts observables : des postes de travail qui acceptent des connexions entrantes d’autres postes de travail, souvent sur des ports élevés ou inhabituels ; de l’UDP ou du TCP sortant vers de nombreuses adresses IP résidentielles sans résolution DNS préalable ; le même port fixe chez de nombreux pairs externes. Le démantèlement est difficile faute de domaine unique à saisir ; la détection est généralement plus facile, car le trafic de poste à poste est rare dans un réseau bien segmenté.

Algorithmes de génération de domaines

Un DGA dérive une liste de domaines candidats à partir d’une graine et, généralement, de la date du jour. L’implant les essaie tour à tour ; l’opérateur n’a besoin d’en enregistrer qu’un. Bloquer un domaine ne sert à rien, car la liste de demain est différente. Le fast flux est l’astuce complémentaire côté IP, qui fait tourner de nombreuses adresses derrière un même nom.

Artefacts observables : des rafales de réponses NXDOMAIN depuis un hôte, des dizaines à des milliers de noms non enregistrés sur une courte fenêtre ; des noms sans mots du dictionnaire, à forte entropie de caractères, de longueur et de domaine de premier niveau constants ; finalement un nom qui se résout, suivi des connexions qui comptent. Retrouvez l’algorithme dans l’échantillon et vous pourrez calculer tous les domaines futurs, les préenregistrer ou les sinkholer, et confronter les journaux DNS passés aux dates passées.

Extraire les détails du C2 d’un échantillon

La chasse démarre plus vite quand vous savez exactement ce que vous cherchez. Passez l’échantillon au crible des sources que vous connaissez déjà :

  • Extraction de configuration. La configuration contient généralement la liste des C2, les ports, les URI, le sommeil et le jitter, l’identifiant de campagne et une éventuelle clé de chiffrement. Un extracteur construit comme dans Extraire les configurations de malwares vous donne tout d’un coup, et pour chaque échantillon apparenté.
  • Chaînes. L’analyse des chaînes et le déchiffrement scripté des chaînes retrouvent les user-agents, les modèles d’URI, les noms d’en-têtes et les chaînes de format telles que %s/%08x/submit.
  • Code réseau. Les imports comme WinHttpOpen, InternetConnectA, DnsQuery_A ou getaddrinfo, et les fonctions qui les entourent, montrent quels champs sont codés en dur, lesquels viennent de la configuration et lesquels sont calculés par hôte. Cherchez une boucle autour de la séquence envoi-sommeil-réception : elle révèle l’appel de sommeil, l’arithmétique du jitter et l’éventuel ordre de repli.
  • Capture dynamique. Une exécution contrôlée montre le trafic tel que le serveur le voit. Laissez-la tourner assez longtemps pour observer plusieurs check-ins, et souvenez-vous que les sandboxes (bacs à sable) qui accélèrent les sommeils faussent le rythme.

Consignez le résultat dans un tableau, une ligne par canal, car chaque champ alimente un consommateur différent :

ChampExempleQui l’utilise
Hôtes et ports, dans l’ordre de replicdn-static.example[.]org:443, puis 203.0.113[.]50:8443Blocage, recherches DNS et proxy
Méthode de résolutionListe statique ; DGA à graine datée ; dead drop sur un site de pastePlan de sinkholing, chasse rétroactive
URI et méthodesGET /assets/<16 hex>.js, POST /assets/uploadRecherches proxy, Suricata
Intervalle et jitter60 s, jitter de 10 %Seuils des analyses de beacons
User-agent et en-têtesVieille chaîne de navigateur avec une coquilleRecherches proxy, Suricata
Détails TLSSNI, JA4 du client, sujet, émetteur, numéro de série, validité et SHA-256 du certificatPivots dans les journaux TLS, recherches CT
Encodage et cryptoClé XOR, clé RC4, alphabet base64Décodage du trafic capturé
Observé ou code seul« chemin POST vu dans le code, jamais observé »Niveau de confiance du rapport

Astuce : Consignez l’intervalle et le jitter tels que l’échantillon les définit, et non tels que votre sandbox les a observés. Les valeurs configurées sont ce que vos analyses doivent chercher en production ; les valeurs observées ne sont justes que si rien n’a accéléré ou retardé l’exécution.

Chasser le C2 dans les journaux réseau

L’échantillon vous donne des indicateurs. La chasse trouve les infections que vos indicateurs manquent : autres builds, autres familles, autres canaux. Zeek est la source de données de référence pour ce travail : conn.log enregistre chaque connexion avec ses horodatages, ses nombres d’octets et sa durée ; dns.log chaque requête et réponse ; ssl.log le SNI et les détails de la négociation ; x509.log les certificats ; http.log les requêtes qu’il peut voir. Les journaux de proxy et de pare-feu portent des champs similaires sous d’autres noms.

Périodicité et jitter des beacons

Un beacon est une paire hôte-destination dont les connexions arrivent à intervalles réguliers. Prenez les temps inter-arrivées (les écarts entre connexions successives) et demandez-vous à quel point ils sont resserrés :

  • Le coefficient de variation (écart-type divisé par la moyenne) est proche de 0 pour une minuterie fixe et proche de 1 ou au-delà pour une activité humaine. Un jitter uniforme de plus ou moins J pour cent donne un CV d’environ J divisé par la racine carrée de 3 : un jitter de 10 % tombe donc vers 0,058.
  • L’écart absolu médian (MAD), rapporté à l’écart médian, est robuste : une poignée de beacons manqués pendant la mise en veille d’un portable ne le fausseront pas.
  • Le nombre de connexions compte, car deux écarts réguliers ne prouvent rien. Calculez le score sur une longue fenêtre, 24 heures ou plus en production, pour que les beacons lents aient le temps d’apparaître.
  • La constance des tailles : un implant inactif envoie la même requête et reçoit la même réponse « aucune tâche », si bien que les nombres d’octets bougent à peine.

Aucune mesure ne suffit à elle seule. Le lab montre pourquoi : la navigation par rafales produit un écart médian de quelques secondes au sein des rafales, ce qui donne au MAD une apparence régulière alors que le CV la démasque. RITA, l’outil open source d’Active Countermeasures, note les beacons selon les mêmes principes sur des journaux Zeek à grande échelle ; c’est l’étape suivante naturelle après le script de cette leçon.

Connexions longues, destinations rares et anomalies DNS

Tous les canaux ne fonctionnent pas par polling. Certains maintiennent une connexion ouverte pendant des heures et y poussent les tâches. Additionnez la durée des connexions par paire et regardez le haut du classement : vous y trouverez des clients de visioconférence, des VPN et du streaming, et parfois quelque chose qui n’est rien de tout cela.

La rareté est le filtre le plus utile que vous puissiez ajouter à n’importe laquelle de ces analyses. Une destination contactée par tous les hôtes de l’entreprise est un produit ; une destination contactée par un seul hôte, vue pour la première fois récemment et avec un domaine récent mérite un coup d’œil. Pour le DNS, calculez par client le nombre de réponses NXDOMAIN, de sous-domaines uniques par domaine parent et de requêtes TXT ou NULL, et notez les labels selon leur longueur et leur entropie. Attendez-vous à des faux positifs dus aux CDN, qui placent des labels ressemblant à des hashs dans les noms d’hôtes, et aux produits de sécurité, qui encodent des recherches dans le DNS.

Pivots TLS : JA3, JA4 et certificats

Quand vous ne pouvez pas lire la charge utile, la négociation sert d’empreinte. Un hash JA3 ou JA4 du client identifie la pile TLS et sa configuration ; le certificat du serveur identifie la façon dont l’opérateur a configuré le serveur. Une fois un hôte confirmé, pivotez vers l’extérieur : quels autres hôtes internes ont présenté le même JA4 client à une destination que personne d’autre n’utilise ? Quelles autres destinations ont servi un certificat de même sujet, émetteur, numéro de série ou clé publique ? JA3 est devenu bruité pour les navigateurs, qui randomisent désormais l’ordre des extensions ; JA4 trie ces champs et y résiste. Aucun des deux n’identifie une famille à lui seul : ils ne sont forts que lorsqu’ils sont rares dans votre environnement et combinés à un signal de destination ou de rythme.

Pivoter sur l’infrastructure, avec OPSEC

À partir d’un seul domaine ou certificat de C2 confirmé, vous pouvez souvent retrouver le reste de l’infrastructure de l’opérateur, et la chasser en entier dans vos journaux avant que l’implant ne bascule vers une solution de secours.

  • Le DNS passif enregistre quels noms se sont résolus vers quelles IP au fil du temps. Pivotez du domaine vers ses IP, de ces IP vers tous les autres domaines vus dessus, et écartez l’hébergement mutualisé.
  • Les journaux de Certificate Transparency publient tous les certificats reconnus publiquement. Les interroger par domaine, par chaîne d’organisation ou par motif de nommage permet de trouver des certificats émis pour des domaines apparentés, parfois avant que ces domaines ne soient utilisés.
  • Vos propres journaux sont la source passive la plus riche de toutes : tout hôte interne ayant un jour résolu ou contacté l’infrastructure entre dans le périmètre.

Attention : Restez passif. Résoudre un nom de C2 suspecté depuis le résolveur de l’entreprise, le visiter ou le scanner signale à un opérateur qui surveille son DNS faisant autorité ou son serveur web que quelqu’un regarde. Soumettre un échantillon ciblé à une sandbox publique peut avoir le même effet, tout comme rechercher un indicateur unique sur un service tiers qui partage les requêtes. Utilisez des jeux de données passifs, une infrastructure de recherche approuvée et les règles de votre équipe, comme indiqué dans la section OPSEC de la leçon sur le laboratoire réseau.

Le confinement et ses compromis

Toute action de confinement modifie aussi ce que vous pouvez encore apprendre. Décidez avec le responsable de l’incident, et dans le bon ordre : délimitez d’abord le périmètre là où c’est sans risque, puis coupez.

ActionCe qu’elle vous apporteCe qu’elle coûte
Sinkhole DNS (RPZ ou surcharge du résolveur) redirigeant les noms de C2 vers un serveur interneChaque hôte infecté se signale en interrogeant ou en contactant le sinkhole ; couvre les hôtes que vous n’avez pas trouvésLes IP codées en dur, le DoH et le P2P le contournent ; l’opérateur voit les check-ins s’arrêter
Blocage au proxy ou au pare-feuCoupure immédiate des canaux connus sur tout le parcL’implant peut basculer vers un canal de secours ou un DGA que vous n’avez pas cartographié ; vous perdez la visibilité sur ce qu’il aurait fait ensuite
Isolement de l’hôte via l’EDRBloque tout le trafic sauf vers la console EDR ; garde la mémoire et les processus en vie pour la collecteUn hôte à la fois ; l’opérateur remarque que cet hôte s’éteint
Réinstallation ou mise hors tensionRetire l’implant de cet hôteDétruit les preuves en mémoire (clés, charges utiles dépaquetées, connexions actives) si elles n’ont pas été collectées avant

Avant d’agir, collectez : une image mémoire d’au moins un hôte infecté, les fichiers et la persistance de l’implant, et les fenêtres de journaux pertinentes. Cartographiez les canaux de repli à partir de la configuration pour que le blocage du canal principal ne pousse pas l’implant vers un canal que vous ne surveillez pas. Dans la mesure du possible, contenez tous les canaux et tous les hôtes connus dans une seule fenêtre coordonnée, selon la même logique que le nettoyage de la persistance.

Rapporter le C2

Le rapport de triage liste les indicateurs réseau ; pour un incident, développez le C2 dans sa propre section :

  • Chaque canal du tableau d’extraction ci-dessus, avec les identifiants ATT&CK, les indicateurs neutralisés (defanged) et, pour chacun, s’il a été observé ou seulement vu dans le code.
  • Les détections qui se sont déclenchées ou qui ont été écrites : identifiants de règles, noms d’analyses, requêtes.
  • Le périmètre : quels hôtes ont émis des beacons, première et dernière observation, et comment cela a été établi (hits sur le sinkhole, journaux DNS, analyses de beacons).
  • Les actions de confinement menées, quand, et ce qu’elles ont pu masquer.
  • Le niveau de partage de chaque indicateur, comme dans partager les configurations de manière responsable.

Lab : noter les beacons et les labels DNS dans des journaux Zeek synthétiques

Ce lab se place uniquement du côté détection. Vous générez quatre heures de données conn.log et dns.log synthétiques pour six postes de travail au format JSON de Zeek, avec un hôte qui émet des beacons vers une destination de démonstration, puis vous classez chaque paire hôte-destination selon sa probabilité d’être un beacon. Toutes les adresses proviennent des plages réservées à la documentation et tous les noms sont des noms d’exemple réservés. Rien ne se connecte à quoi que ce soit.

  1. Préparez un dossier de travail et un environnement virtuel (bibliothèque standard uniquement) :

    bash
    mkdir m7c && cd m7c
    python3 -m venv .venv
    .venv/bin/python --version
    text
    Python 3.14.7
  2. Générez les journaux. Le générateur écrit des rafales de navigation irrégulières pour chaque hôte, une vérification de mise à jour logicielle toutes les 15 minutes sur chaque hôte, une longue session de visioconférence, et un implant sur 10.20.0.17 qui dort 60 s avec 10 % de jitter :

    python
    # gen_logs.py - write synthetic Zeek-style conn.log and dns.log (JSON lines)
    import json, random
    
    random.seed(7)
    START = 1790668800              # 2026-09-29 08:00:00 UTC
    END = START + 4 * 3600          # four hours of traffic
    HOSTS = [f"10.20.0.{n}" for n in (11, 12, 13, 14, 15, 17)]
    BEACON_HOST = "10.20.0.17"
    
    # Destinations: name -> IP (documentation ranges only)
    SITES = {f"site{i:02d}.example.com": f"198.51.100.{i + 10}" for i in range(1, 25)}
    UPDATES = ("updates.example.net", "192.0.2.80")
    C2 = ("cdn-static.example.org", "203.0.113.50")
    MEET = ("meet.example.com", "198.51.100.200")
    
    conns, dns, uid = [], [], 0
    
    def conn(ts, src, dst, port, dur, ob, rb, service="ssl", proto="tcp"):
        global uid
        uid += 1
        conns.append({"ts": round(ts, 6), "uid": f"C{uid:06d}",
                      "id.orig_h": src, "id.orig_p": random.randint(49152, 65535),
                      "id.resp_h": dst, "id.resp_p": port, "proto": proto,
                      "service": service, "duration": round(dur, 6),
                      "orig_bytes": ob, "resp_bytes": rb, "conn_state": "SF"})
    
    def lookup(ts, src, name, ip):
        dns.append({"ts": round(ts - 0.02, 6), "id.orig_h": src, "query": name,
                    "qtype_name": "A", "rcode_name": "NOERROR", "answers": [ip],
                    "TTLs": [300.0]})
    
    # 1. Human browsing: bursts of connections at irregular times
    for h in HOSTS:
        t = START + random.uniform(0, 600)
        while t < END:
            name = random.choice(list(SITES))
            lookup(t, h, name, SITES[name])
            for _ in range(random.randint(1, 6)):
                conn(t, h, SITES[name], 443, random.uniform(0.2, 30),
                     random.randint(400, 3000), random.randint(2000, 400000))
                t += random.uniform(0.1, 5)
            t += random.expovariate(1 / 240)          # mean 4 min between bursts
    
    # 2. Benign periodic: software update check every 15 min, all hosts, no jitter
    for h in HOSTS:
        t = START + random.uniform(0, 900)
        while t < END:
            lookup(t, h, *UPDATES)
            conn(t, h, UPDATES[1], 443, 0.35, 517, 1420)
            t += 900
    
    # 3. Benign long connection: one meeting client holding a session open
    lookup(START + 3600, "10.20.0.12", *MEET)
    conn(START + 3600, "10.20.0.12", MEET[1], 443, 5400, 38_000_000, 41_000_000)
    
    # 4. The implant: 60 s sleep with 10 % jitter, small consistent requests
    t = START + 1234.5
    while t < END:
        lookup(t, BEACON_HOST, *C2)
        conn(t, BEACON_HOST, C2[1], 443, random.uniform(0.25, 0.4),
             random.randint(410, 430), random.randint(250, 262))
        t += 60 * random.uniform(0.9, 1.1)
    
    conns.sort(key=lambda c: c["ts"])
    dns.sort(key=lambda d: d["ts"])
    with open("conn.log", "w") as f:
        f.writelines(json.dumps(c) + "\n" for c in conns)
    with open("dns.log", "w") as f:
        f.writelines(json.dumps(d) + "\n" for d in dns)
    print(f"conn.log: {len(conns)} connections, dns.log: {len(dns)} queries")
    bash
    .venv/bin/python gen_logs.py
    head -1 conn.log
    text
    conn.log: 1422 connections, dns.log: 637 queries
    {"ts": 1790668949.570667, "uid": "C000391", "id.orig_h": "10.20.0.13", "id.orig_p": 55295, "id.resp_h": "198.51.100.24", "id.resp_p": 443, "proto": "tcp", "service": "ssl", "duration": 19.054157, "orig_bytes": 1892, "resp_bytes": 59160, "conn_state": "SF"}

    Les noms de champs sont ceux de Zeek : le script de notation de l’étape suivante lit donc aussi un vrai conn.log écrit avec la journalisation JSON de Zeek activée.

  3. Écrivez le script de notation des beacons. Il regroupe les connexions par source, destination et port, calcule quatre sous-scores entre 0 et 1 et en fait la moyenne. Il compte aussi combien d’hôtes internes parlent à chaque destination, et liste les paires au temps de connexion cumulé le plus élevé :

    python
    # beacon_score.py - rank host->destination pairs in a Zeek conn.log by beacon likelihood
    import json, statistics as st, sys
    from collections import defaultdict
    
    MIN_CONNS = 10          # too few connections -> no meaningful timing statistics
    COUNT_SATURATE = 100    # this many connections in the window earns the full count score
    
    def load(path):
        with open(path) as f:
            return [json.loads(line) for line in f if line.strip()]
    
    conns = load(sys.argv[1])
    names = {}                                   # resp IP -> last name that resolved to it
    for d in load(sys.argv[2]) if len(sys.argv) > 2 else []:
        for ip in d.get("answers") or []:
            names[ip] = d["query"]
    
    pairs, dur_sum, talkers = defaultdict(list), defaultdict(float), defaultdict(set)
    for c in conns:
        key = (c["id.orig_h"], c["id.resp_h"], c["id.resp_p"])
        pairs[key].append((c["ts"], c["orig_bytes"]))
        dur_sum[key] += c["duration"] or 0
        talkers[c["id.resp_h"]].add(c["id.orig_h"])
    
    def score(events):
        ts = sorted(t for t, _ in events)
        gaps = [b - a for a, b in zip(ts, ts[1:])]
        med = st.median(gaps)
        mad = st.median(abs(g - med) for g in gaps)
        cv = st.pstdev(gaps) / st.mean(gaps)
        sizes = [b for _, b in events]
        size_cv = st.pstdev(sizes) / st.mean(sizes) if st.mean(sizes) else 1.0
        parts = {
            "cv": max(0.0, 1 - cv),                  # regular gaps -> low CV
            "mad": max(0.0, 1 - mad / med) if med else 0.0,   # robust to a few outliers
            "count": min(1.0, len(events) / COUNT_SATURATE),
            "size": max(0.0, 1 - size_cv),           # same request size every time
        }
        return sum(parts.values()) / len(parts), med, cv, parts
    
    rows = []
    for (src, dst, port), ev in pairs.items():
        if len(ev) < MIN_CONNS:
            continue
        total, med, cv, p = score(ev)
        rows.append((total, src, f"{dst}:{port}", names.get(dst, "?"), len(ev), med,
                     cv, p, len(talkers[dst])))
    rows.sort(reverse=True)
    all_hosts = len({c["id.orig_h"] for c in conns})
    
    print(f"{'score':>5}  {'src':<11} {'dst':<19} {'name':<24} {'n':>4} {'median':>7} "
          f"{'cv':>5} {'madS':>5} {'cntS':>5} {'sizeS':>5} {'hosts':>5}")
    for total, src, dst, name, n, med, cv, p, hosts in rows[:8]:
        print(f"{total:5.3f}  {src:<11} {dst:<19} {name:<24} {n:4d} {med:6.1f}s "
              f"{cv:5.3f} {p['mad']:5.3f} {p['count']:5.3f} {p['size']:5.3f} {hosts:3d}/{all_hosts}")
    
    print("\nLongest cumulative connection time per pair:")
    for key, secs in sorted(dur_sum.items(), key=lambda kv: -kv[1])[:3]:
        src, dst, port = key
        print(f"  {src:<11} {dst + ':' + str(port):<19} {names.get(dst, '?'):<24} "
              f"{secs / 3600:5.2f} h over {len(pairs[key])} connection(s)")
  4. Exécutez-le :

    bash
    .venv/bin/python beacon_score.py conn.log dns.log
    text
    score  src         dst                 name                        n  median    cv  madS  cntS sizeS hosts
    0.971  10.20.0.17  203.0.113.50:443    cdn-static.example.org    220   60.1s 0.055 0.954 1.000 0.985   1/6
    0.790  10.20.0.17  192.0.2.80:443      updates.example.net        16  900.0s 0.000 1.000 0.160 1.000   6/6
    0.790  10.20.0.15  192.0.2.80:443      updates.example.net        16  900.0s 0.000 1.000 0.160 1.000   6/6
    0.790  10.20.0.14  192.0.2.80:443      updates.example.net        16  900.0s 0.000 1.000 0.160 1.000   6/6
    0.790  10.20.0.13  192.0.2.80:443      updates.example.net        16  900.0s 0.000 1.000 0.160 1.000   6/6
    0.790  10.20.0.12  192.0.2.80:443      updates.example.net        16  900.0s 0.000 1.000 0.160 1.000   6/6
    0.790  10.20.0.11  192.0.2.80:443      updates.example.net        16  900.0s 0.000 1.000 0.160 1.000   6/6
    0.398  10.20.0.11  198.51.100.14:443   site04.example.com         11    3.5s 2.957 0.889 0.110 0.591   6/6
    
    Longest cumulative connection time per pair:
      10.20.0.12  198.51.100.200:443  meet.example.com          1.50 h over 1 connection(s)
      10.20.0.13  198.51.100.22:443   site12.example.com        0.13 h over 26 connection(s)
      10.20.0.14  198.51.100.19:443   site09.example.com        0.12 h over 27 connection(s)
  5. Lisez le classement en analyste. L’implant arrive premier : 220 connexions avec un écart médian de 60,1 s et un CV de 0,055, proche des 0,058 que prédit un jitter uniforme de 10 %, et des tailles de requêtes qui bougent à peine. La vérification de mise à jour est plus régulière (CV de 0) et n’est classée plus bas que parce qu’elle compte 16 connexions en quatre heures ; sur une semaine, elle égalerait ou dépasserait l’implant. Ce qui les distingue, c’est le contexte, pas le rythme : la destination de mise à jour est contactée par les six hôtes selon un calendrier identique (6/6), se résout depuis un nom d’éditeur que vous pouvez vérifier dans votre inventaire logiciel, et chaque hôte la répète selon le même calendrier de 15 minutes. La destination de l’implant est contactée par un seul hôte (1/6). La paire de navigation de la dernière ligne montre pourquoi le MAD ne peut pas être utilisé seul : son écart médian est de 3,5 s, mesuré au sein des rafales, si bien que le score MAD atteint un flatteur 0,889, tandis que le CV de 2,957 la trahit. Dans la liste des durées, la session de 1,5 heure vers meet.example.com est la connexion longue bénigne ; confirmez-la avec le processus ou la catégorie du proxy avant de l’écarter.

  6. Ajoutez le filtre de rareté. Ne gardez que les destinations contactées par un seul hôte :

    bash
    .venv/bin/python beacon_score.py conn.log dns.log | awk '$NF == "1/6"'
    text
    0.971  10.20.0.17  203.0.113.50:443    cdn-static.example.org    220   60.1s 0.055 0.954 1.000 0.985   1/6

    En production, vous filtreriez selon la prévalence sur une baseline plus longue (une destination est rare si peu d’hôtes l’ont contactée au cours des 30 derniers jours), et non au sein d’une seule fenêtre de quatre heures.

  7. Notez les labels DNS. Placez quelques noms dans queries.txt : des noms d’hôtes normaux, un label haché de style CDN, trois noms de style DGA, un label de tunnel en base32 et un nom long mais lisible par un humain :

    text
    www.example.com
    mail.example.org
    login.example.net
    cdn-static.example.org
    d3k9x2mfq0z7ab.cdn.example.net
    xjqvkzrwpbtmnc.example
    kq7zf0p3xw9lm2vr.example
    tgbhuiplmwqzxa.example
    ovzwk4r5mrsw23z3nbxxg5b5o5ztany.t.example.net
    internationalization-docs.example.com
    python
    # label_score.py - score DNS labels by length and character entropy
    import math, sys
    from collections import Counter
    
    def entropy(s):
        counts = Counter(s)
        return max(0.0, -sum(n / len(s) * math.log2(n / len(s)) for n in counts.values()))
    
    def leftmost_label(name):
        return name.split(".")[0].lower()
    
    names = [line.strip() for line in open(sys.argv[1]) if line.strip()]
    print(f"{'query':<46} {'len':>3} {'H':>5} {'digits':>6}  verdict")
    for name in names:
        lab = leftmost_label(name)
        h = entropy(lab)
        digits = sum(ch.isdigit() for ch in lab) / len(lab)
        suspicious = len(lab) >= 12 and h >= 3.3
        print(f"{name:<46} {len(lab):3d} {h:5.2f} {digits:6.2f}  "
              f"{'DGA-like' if suspicious else '-'}")
    bash
    .venv/bin/python label_score.py queries.txt
    text
    query                                          len     H digits  verdict
    www.example.com                                  3  0.00   0.00  -
    mail.example.org                                 4  2.00   0.00  -
    login.example.net                                5  2.32   0.00  -
    cdn-static.example.org                          10  2.92   0.00  -
    d3k9x2mfq0z7ab.cdn.example.net                  14  3.81   0.36  DGA-like
    xjqvkzrwpbtmnc.example                          14  3.81   0.00  DGA-like
    kq7zf0p3xw9lm2vr.example                        16  4.00   0.31  DGA-like
    tgbhuiplmwqzxa.example                          14  3.81   0.00  DGA-like
    ovzwk4r5mrsw23z3nbxxg5b5o5ztany.t.example.net   31  4.09   0.26  DGA-like
    internationalization-docs.example.com           25  3.43   0.00  DGA-like

    Les trois noms de style DGA et le label base32 (qui se décode en user=demo;host=ws07) sont signalés, comme prévu. Deux noms bénins le sont aussi : le label haché du CDN et le long et lisible internationalization-docs. L’entropie d’une chaîne courte est plafonnée par sa longueur (un label de 14 caractères ne peut dépasser log2(14), soit environ 3,81 bits) : longueur et entropie sont donc corrélées, et un long label composé de vrais mots franchit le seuil. La notation des labels est un préfiltre. Associez-la aux signaux par hôte auxquels les DGA et les tunnels ne peuvent pas échapper : rafales de NXDOMAIN, nombreux sous-domaines uniques sous un même parent, et un domaine parent que personne d’autre dans l’entreprise ne résout.

Questions : Si l’implant dormait 8 heures avec 30 % de jitter, de quoi le script de notation aurait-il besoin (longueur de fenêtre, COUNT_SATURATE, pondérations) pour le classer encore au-dessus de la vérification de mise à jour ? Quel champ unique de conn.log ajouteriez-vous au score de taille pour repérer une réponse « aucune tâche » toujours de même longueur, et pourquoi un gros envoi pourrait-il casser le motif sans que l’hôte soit sain pour autant ? Comment confirmeriez-vous que updates.example.net est bénin sans le contacter ? Pour 10.20.0.17, listez dans l’ordre les actions de confinement que vous entreprendriez, et ce que vous collecteriez en premier. Quelle caractéristique supplémentaire empêcherait internationalization-docs d’être signalé sans perdre les noms DGA ?

À retenir

  • Le C2 transporte check-ins, tâches, résultats et staging ; il se répète aussi longtemps que dure l’intrusion, ce qui en fait la phase qui offre le plus d’occasions de détection et le plus de levier pour le confinement.
  • Chaque famille de canaux laisse ses propres traces : petites requêtes régulières pour le polling HTTP, sous-domaines longs à forte entropie pour les tunnels DNS, appels d’API depuis le mauvais processus pour le C2 via services cloud, trafic de poste à poste ou vers des pairs résidentiels pour le P2P, et rafales de NXDOMAIN pour les DGA.
  • Extrayez le C2 de l’échantillon via la configuration, les chaînes, le code réseau et une exécution contrôlée, et consignez les hôtes, l’ordre de repli, les URI, l’intervalle, le jitter, le user-agent, les détails TLS et de certificat, ainsi que ce qui a été observé ou seulement déduit.
  • Les analyses de beacons combinent régularité du rythme (CV et MAD), nombre de connexions et constance des tailles ; c’est la rareté à travers les hôtes qui distingue un implant d’une vérification de mise à jour tout aussi régulière.
  • Pivotez depuis un indicateur confirmé via JA4, les certificats, le DNS passif et Certificate Transparency, sans jamais toucher vous-même l’infrastructure.
  • Collectez les preuves et cartographiez les canaux de repli avant de contenir ; le sinkholing trouve les hôtes, le blocage les coupe, l’isolement préserve la mémoire, et chacun vous cache quelque chose.