Skip to content

Leçon 8.5 · Encodage, crypto & signatures· 50 min

Écrire des signatures réseau

Transformez le code réseau d’un échantillon et le trafic capturé en règles Suricata visant la structure durable du protocole, puis testez-les sur des pcap.

Objectifs

  • Classer les indicateurs réseau par durabilité et expliquer pourquoi la structure du protocole survit aux IP et aux domaines
  • Combiner preuves statiques et dynamiques pour classer chaque champ d’une requête comme codé en dur, issu de la configuration, dérivé de l’hôte ou aléatoire
  • Écrire des règles Suricata avec sticky buffers, flow, modificateurs de content, pcre, flowbits, seuils et métadonnées
  • Tester une règle sur un pcap du trafic malveillant et sur du trafic bénin, et la garder rapide
  • Décrire ce qui fonctionne encore quand le trafic est chiffré : SNI, JA3/JA4, champs de certificat et journaux Zeek

Dans Écrire vos premières règles YARA, vous avez transformé des artefacts présents dans un fichier en une règle qui trouve le fichier suivant. Les signatures réseau font le même travail une couche plus loin. Un malware (logiciel malveillant) qui communique avec son opérateur doit mettre des octets sur le réseau, et ces octets traversent des capteurs que vous contrôlez : une instance Suricata sur un port miroir, un cluster Zeek, un journal de proxy. Une bonne règle réseau détecte d’un coup tous les hôtes infectés, y compris ceux dont la copie de l’échantillon a été repackée, renommée ou n’a jamais touché le disque.

Cette leçon clôt le module 8. Les leçons précédentes vous ont donné les compétences de décodage et d’extraction de configuration pour découvrir ce que un échantillon envoie et d’où il tire chaque champ. Ici, vous transformez ce savoir en détections, vous les testez sur du trafic capturé et vous les préparez pour le partage.

Niveaux d’indicateurs : du fragile au durable

Les indicateurs réseau ne vieillissent pas tous de la même façon. La « Pyramid of Pain » de David Bianco le résume : moins un indicateur coûte à l’attaquant à changer, moins il a de valeur pour vous.

IndicateurExempleDurée de vieOù il a sa place
Adresse IP203.0.113[.]44Quelques jours ; hébergements cloud et proxys tournentListes de blocage, recherche rétrospective dans les journaux
Domaineupdate-check[.]exampleQuelques jours à quelques semaines ; les domaines sont bon marchéSinkholes DNS, règles dns.query, flux de renseignement
URL complète/lab/gate.php?id=ecefe37b...Souvent une seule requêteNulle part sous forme littérale : une partie est aléatoire
Structure de l’URI/lab/gate.php?id= + 16 caractères hexadécimauxDes mois ; la changer impose de changer aussi le serveurRègles Suricata
Ensemble et ordre des en-têtes, user-agent inhabituelLabAgent/1.0 plus X-Lab: 1Jusqu’à ce que l’auteur réécrive le clientRègles Suricata, scripts Zeek
Comportement du protocoleBeacons à intervalle fixe, handshake maison, empreinte TLSLe plus long ; lié à la base de codeRègles, listes JA4, analyses comportementales

Les IP et les domaines figurent quand même dans votre rapport de triage, neutralisés (defanged), car c’est ce qu’un SOC peut bloquer le plus vite aujourd’hui. Mais une règle qui dit seulement « trafic vers cette IP » est un IOC (indicateur de compromission) déguisé en règle. L’opérateur déplace son infrastructure et la règle se tait alors que l’implant continue de tourner. Les cibles durables sont celles qui sont figées dans le code du client : la forme de la requête, les champs que l’auteur a codés en dur, l’encodage de la charge utile. Le serveur doit analyser tout cela ; les changer implique donc de redéployer les deux extrémités.

Preuves statiques et dynamiques ensemble

Une seule capture issue d’une seule exécution est une base dangereuse pour une règle. Tout y paraît constant parce que vous n’avez qu’un exemple. Le travail de l’analyste consiste à classer chaque champ du trafic selon sa provenance :

ProvenanceExemple dans une requêteConstant d’une exécution à l’autre ?Constant d’un hôte à l’autre ?Usage en signature
Codé en dur dans le binaireLabAgent/1.0, /lab/gate.php, X-Lab: 1OuiOuiCorrespondance littérale
ConfigurationHôte C2, intervalle de beacon, identifiant de campagneOui, par buildOui, par buildCorrespondance par campagne, ou sur le format
Dérivé de l’hôteHash du nom d’hôte ou du numéro de série du volume dans l’identifiantOuiNonCorrespondance sur le format, jamais sur la valeur
Aléatoire ou horodatéNonce, clé de session, horodatageNonNonCorrespondance sur la longueur et le jeu de caractères uniquement
Fourni par une bibliothèqueAccept, Connection, ordre des en-têtes de WinINetOuiOuiFaible ; partagé avec des logiciels légitimes

L’analyse dynamique vous dit ce qui varie. Exécutez l’échantillon plusieurs fois au laboratoire, idéalement sur deux VM portant des noms différents, capturez avec le dispositif de Simuler et capturer le trafic réseau, et comparez les requêtes. Un champ qui change entre deux exécutions sur le même hôte est aléatoire. Un champ stable sur un hôte mais différent sur un autre est dérivé de l’hôte.

L’analyse statique vous dit pourquoi il varie et quelle est toute sa plage de valeurs. Trouvez le code réseau par les imports (InternetOpenA, HttpOpenRequestW, WinHttpSendRequest, send, getaddrinfo ; voir Lire les capacités à partir des imports), ou par les résolveurs de hachage d’API quand les imports sont cachés. Remontez les arguments. Le user-agent passé à InternetOpenA est souvent un littéral, éventuellement chiffré par XOR et récupérable avec les scripts de Scripter le déchiffrement des chaînes. Le paramètre id peut provenir d’un appel wsprintfA("%08x%08x", ...) alimenté par un générateur de nombres aléatoires, ce qui vous indique qu’il comporte toujours 16 caractères hexadécimaux en minuscules. C’est un fait que vous ne pourriez pas déduire sans risque de cinq requêtes capturées.

Astuce : Les exécutions dynamiques manquent des chemins de code. Un échantillon qui se contente d’émettre des beacons dans votre laboratoire peut aussi avoir des requêtes d’envoi de fichiers, d’erreur et de « résultat de tâche » avec des chemins ou des en-têtes différents. Lisez le code qui construit les requêtes et listez tous les formats qu’il peut produire, puis décidez lesquels votre jeu de règles doit couvrir. La leçon Commande et contrôle approfondit la conception des protocoles.

Anatomie d’une règle Suricata

Les règles Suricata descendent de la syntaxe de Snort, mais Suricata moderne inspecte des champs de protocole analysés au moyen de sticky buffers plutôt que des offsets bruts dans la charge utile. Une règle comporte une action, un en-tête et des options entre parenthèses :

text
alert http $HOME_NET any -> $EXTERNAL_NET any (
    msg:"MAL LabImplant HTTP beacon";
    flow:established,to_server;
    http.method; content:"GET";
    http.uri; content:"/lab/gate.php?id="; startswith; fast_pattern;
    pcre:"/^\/lab\/gate\.php\?id=[0-9a-f]{16}$/";
    http.user_agent; content:"LabAgent/"; startswith;
    threshold:type limit, track by_src, count 1, seconds 3600;
    reference:url,example.invalid/TR-2026-0929-01;
    classtype:trojan-activity;
    sid:1000001; rev:2;
    metadata:created_at 2026_09_29, updated_at 2026_09_29, confidence High;
)

Dans les fichiers de règles, chaque règle doit tenir sur une ligne (ou sur des lignes jointes par une barre oblique inverse finale). Elle est ici étalée pour la lecture. Les éléments :

ÉlémentSignification
alertAction. Les autres sont pass, drop et reject (les deux dernières exigent le mode IPS en ligne)
httpProtocole. Suricata identifie lui-même la couche applicative, donc http correspond à du HTTP sur n’importe quel port. Existent aussi tcp, udp, dns, tls, quic et d’autres
$HOME_NET any -> $EXTERNAL_NET anySource, port source, sens, destination, port de destination. Les variables proviennent de suricata.yaml
msgLe texte de l’alerte. Rédigez-le pour la personne qui triera l’alerte
flow:established,to_serverN’inspecter que les données client vers serveur d’une session TCP établie
http.method, http.uri, http.user_agentSticky buffers : chaque content ou pcre qui suit s’applique à ce champ jusqu’à ce qu’un autre buffer soit nommé
contentUn littéral à trouver. Les octets hexadécimaux se placent entre barres verticales (X-Lab suivi de 3a 20 pour « : ») ; un ! initial inverse la condition
startswith, endswith, nocase, bsizeModificateurs qui ancrent, assouplissent ou limitent en taille la correspondance précédente
pcreUne expression régulière compatible Perl appliquée au buffer courant
thresholdLimite le débit des alertes : ici une par source et par heure, quelle que soit la fréquence des beacons de l’implant
reference, classtype, metadataContexte pour les humains et les outils ; ignoré lors de la correspondance
sid, revIdentifiant unique de la règle et son numéro de révision

Autres buffers dont vous aurez besoin : http.host, http.header (tous les en-têtes de requête, normalisés, une ligne Name: value\r\n chacun), http.header_names (noms uniquement, ce qui révèle l’ordre des en-têtes), http.request_body, http.stat_code, dns.query, tls.sni, tls.cert_subject, tls.cert_issuer, ja3.hash et ja4.hash. Pour un protocole TCP brut sans parseur, vous vous rabattez sur content appliqué à la charge utile avec depth, offset, distance et within.

Flowbits : un état à travers une conversation

Certains protocoles ne deviennent distinctifs que sur plusieurs messages. Les flowbits posent un drapeau nommé sur un flux dans une règle et le testent dans une autre :

text
alert tcp $HOME_NET any -> $EXTERNAL_NET any (msg:"LAB handshake seen"; flow:established,to_server; content:"LABHELLO|00|"; depth:9; flowbits:set,lab.hello; flowbits:noalert; sid:1000010; rev:1;)
alert tcp $EXTERNAL_NET any -> $HOME_NET any (msg:"LAB server tasking after handshake"; flow:established,to_client; flowbits:isset,lab.hello; content:"TASK"; depth:4; sid:1000011; rev:1;)

La première règle n’alerte jamais ; elle se contente de marquer le flux. La seconde se déclenche sur une chaîne de quatre octets qui serait bien trop générique seule, mais qui devient spécifique après le handshake du client. xbits étend la même idée à plusieurs flux, suivis par hôte ou par paire d’IP.

Cibler les parties stables

Une fois les champs classés, l’écriture de règles devient mécanique :

  1. Ancrez-vous sur le littéral codé en dur le plus spécifique, dans le buffer le plus étroit. LabAgent/ dans http.user_agent vaut mieux que la même chaîne recherchée n’importe où dans la charge utile.
  2. Décrivez les champs variables par leur forme, pas par leur valeur. Un identifiant aléatoire devient [0-9a-f]{16} ; un blob base64 devient une classe de caractères et une plage de longueurs. Utilisez bsize ou urilen quand la longueur est fixe.
  3. Exigez au moins deux caractéristiques indépendantes. Le chemin seul finira tôt ou tard par coïncider avec une application légitime ; chemin plus user-agent plus en-tête personnalisé, non.
  4. Prévoyez les versions auxquelles vous vous attendez. Recherchez LabAgent/ plutôt que LabAgent/1.0 si la chaîne de version provient de la configuration et change à chaque build.
  5. Méfiez-vous de l’imitation. Les auteurs copient de vrais user-agents de navigateurs, parfois avec une faute de frappe, une version obsolète ou une espace manquante. La faute est la signature ; le reste de la chaîne est du camouflage.

Performances

Suricata n’évalue pas chaque règle sur chaque paquet. Il regroupe les règles par protocole et par port, puis exécute un moteur de recherche multi-motifs (MPM) sur chaque buffer, à la recherche d’un fast pattern par règle. Seules les règles dont le fast pattern apparaît sont entièrement évaluées. C’est la même idée que les atomes de YARA.

  • Par défaut, Suricata choisit lui-même le fast pattern, généralement le content le plus long dans le buffer de plus haute priorité. Désignez-en un meilleur avec fast_pattern quand vous connaissez la chaîne la plus distinctive, comme le fait la règle du lab.
  • Une règle sans aucun content, avec seulement un pcre, n’a rien sur quoi préfiltrer. Elle est évaluée pour chaque transaction qui correspond à son en-tête, ce qui coûte cher sur un lien chargé. Associez toujours une expression régulière à un littéral qui doit également être présent.
  • Les littéraux courts ou courants (GET, .php, Mozilla) font de mauvais fast patterns, car ils correspondent en permanence.
  • Gardez l’en-tête restrictif : alert http plutôt que alert ip, et flow:to_server chaque fois que vous inspectez des requêtes.

suricata --engine-analysis produit un rapport sur la façon dont chaque règle chargée sera préfiltrée et signale les règles dont le fast pattern est médiocre ou absent. Exécutez-le à chaque modification de règle.

Les réalités de l’ère TLS

La majeure partie du trafic malveillant actuel passe en HTTPS. Sans déchiffrement au niveau d’un proxy, la charge utile, l’URI et les en-têtes sont invisibles. Ce qui reste :

Champ visibleBuffer SuricataRemarques
Nom du serveur (SNI)tls.sniChoisi par le client ; fragile comme un domaine, mais visible. Encrypted Client Hello le masque là où il est déployé
Résolution DNS avant la connexiondns.querySauf si l’échantillon utilise DNS over HTTPS
Empreinte du clientja3.hash, ja4.hashRésume le ClientHello. JA3 a cessé de fonctionner quand les navigateurs ont commencé à mélanger l’ordre des extensions ; JA4 les trie et reste stable
Empreinte du serveurja3s.hashCaractérise la pile TLS du serveur
Sujet, émetteur et validité du certificattls.cert_subject, tls.cert_issuerLes certificats autosignés ou par défaut des frameworks de C2 sont des signaux forts
Chronologie et taillesconn.log de Zeek, enregistrements de fluxDes beacons réguliers de taille similaire ressortent sur plusieurs heures

Une empreinte JA3 ou JA4 identifie une bibliothèque TLS et sa configuration, pas un programme. Un échantillon qui utilise la pile TLS de Windows partage son empreinte avec tous les autres clients WinHTTP de l’hôte ; traitez-la donc comme une preuve d’appoint. Elle devient forte quand le malware embarque sa propre bibliothèque TLS, comme le font de nombreux implants en Go et en Rust.

Attention : Quand le canal est chiffré de bout en bout, la détection réseau se réduit aux métadonnées. Les détections durables se déplacent alors vers le poste : YARA sur la mémoire après déchiffrement, télémétrie EDR sur le processus qui établit la connexion, et artefacts hôte de votre rapport de triage. Consignez ce déplacement dans votre rapport plutôt que de livrer une règle SNI en déclarant la famille couverte.

Zeek : le même trafic sous forme de journaux

Suricata répond à la question « ce motif s’est-il produit ? ». Zeek répond à « que s’est-il passé ? » en transformant le trafic en journaux structurés : conn.log (chaque connexion, avec durées et nombres d’octets), http.log (méthode, hôte, URI, user-agent, statut), dns.log, ssl.log (SNI, version, chaîne de certificats ; JA3/JA4 via des paquets) et files.log. Exécuter zeek -r capture.pcap dans un répertoire vide produit ces journaux à partir d’une capture.

Les deux outils se complètent. Une règle Suricata est précise et se déclenche en temps réel. Les journaux Zeek vous permettent de chasser rétrospectivement sur des mois avec une requête du type « toutes les entrées de http.log dont le user-agent commence par LabAgent/ », ou de repérer des beacons en regroupant conn.log par source et destination et en examinant la dispersion des intervalles. Les scripts Zeek et son framework Intel peuvent aussi lever des notifications ; les petites détections n’ont donc pas toujours besoin d’une règle Suricata.

Partager et versionner les règles

Les règles sont du code, et un jeu de règles partagé est une base de code partagée :

  • SID. La plage 1000000 à 1999999 est par convention réservée aux règles locales. Les jeux publics comme Emerging Threats ont leurs propres plages. Ne réutilisez jamais un SID pour une autre règle.
  • rev. Incrémentez-le à chaque modification, afin que capteurs, corrélations SIEM et collègues sachent quelle version s’est déclenchée.
  • metadata. Des clés à la manière d’Emerging Threats comme created_at, updated_at, malware_family, confidence et signature_severity permettent aux outils de filtrer et d’acheminer les alertes.
  • Tests. Conservez à côté de chaque règle le pcap à partir duquel elle a été écrite, avec un pcap bénin, et exécutez suricata -r sur les deux dans la CI.
  • Distribution. suricata-update déploie les sources de règles sur les capteurs, et les règles voyagent dans la section détection de votre rapport, marquées du TLP du rapport.

Lab : signature pour un beacon de laboratoire

Vous allez générer un pcap inoffensif contenant des « beacons » HTTP émis par un client de démonstration vers 127.0.0.1, mêlés à du trafic de navigateur bénin, écrire une règle Suricata ciblant les champs stables du beacon, puis la tester. Aucun paquet n’est envoyé nulle part : le script construit les paquets en mémoire et les écrit dans un fichier. Il vous faut Python 3 et, idéalement, Suricata (brew install suricata, apt install suricata, ou le MSI Windows).

  1. Créez un répertoire de travail avec son propre environnement virtuel :

    bash
    mkdir m8c && cd m8c
    python3 -m venv .venv
    .venv/bin/pip install scapy dpkt
  2. Enregistrez make_pcap.py. Il écrit neuf sessions TCP complètes : cinq beacons à 60 secondes d’intervalle, chacun avec un nouvel identifiant aléatoire, plus quatre requêtes bénignes. L’une des requêtes bénignes utilise délibérément le même chemin que le beacon, comme pourrait le faire une vraie application web :

    python
    """make_pcap.py - write a harmless pcap of lab "beacons" plus benign traffic."""
    import random
    from scapy.all import Ether, IP, TCP, Raw, wrpcap
    
    random.seed(1337)                      # reproducible "random" IDs and ports
    CLIENT, SERVER = "127.0.0.2", "127.0.0.1"
    T0 = 1790676000.0                      # 2026-09-29 10:00:00 UTC
    BROWSER_UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
                  "(KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36")
    packets = []
    
    def session(t, path, headers, body=b"OK"):
        sport = random.randint(49152, 65535)
        cseq, sseq = random.getrandbits(32), random.getrandbits(32)
        c = Ether() / IP(src=CLIENT, dst=SERVER)
        s = Ether() / IP(src=SERVER, dst=CLIENT)
        req = f"GET {path} HTTP/1.1\r\nHost: {SERVER}\r\n"
        req += "".join(f"{k}: {v}\r\n" for k, v in headers) + "\r\n"
        req = req.encode()
        resp = (b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n"
                b"Content-Length: %d\r\nConnection: close\r\n\r\n" % len(body)) + body
        seq = [
            c / TCP(sport=sport, dport=80, flags="S", seq=cseq),
            s / TCP(sport=80, dport=sport, flags="SA", seq=sseq, ack=cseq + 1),
            c / TCP(sport=sport, dport=80, flags="A", seq=cseq + 1, ack=sseq + 1),
            c / TCP(sport=sport, dport=80, flags="PA", seq=cseq + 1, ack=sseq + 1) / Raw(req),
            s / TCP(sport=80, dport=sport, flags="PA", seq=sseq + 1,
                    ack=cseq + 1 + len(req)) / Raw(resp),
            c / TCP(sport=sport, dport=80, flags="FA", seq=cseq + 1 + len(req),
                    ack=sseq + 1 + len(resp)),
            s / TCP(sport=80, dport=sport, flags="FA", seq=sseq + 1 + len(resp),
                    ack=cseq + 2 + len(req)),
            c / TCP(sport=sport, dport=80, flags="A", seq=cseq + 2 + len(req),
                    ack=sseq + 2 + len(resp)),
        ]
        for i, p in enumerate(seq):
            p.time = t + i * 0.001
        packets.extend(seq)
    
    # Five beacons, one every 60 seconds, each with a fresh random 8-byte ID.
    for n in range(5):
        beacon_id = random.getrandbits(64).to_bytes(8, "big").hex()
        session(T0 + 60 * n, f"/lab/gate.php?id={beacon_id}",
                [("User-Agent", "LabAgent/1.0"), ("X-Lab", "1"), ("Accept", "*/*")],
                body=b"sleep=60")
    
    # Benign traffic, interleaved in time, for the false-positive check.
    session(T0 + 15, "/index.html", [("User-Agent", BROWSER_UA), ("Accept", "text/html")])
    session(T0 + 95, "/static/app.js", [("User-Agent", BROWSER_UA), ("Accept", "*/*")])
    session(T0 + 130, "/lab/gate.php?page=2",                       # path collision
            [("User-Agent", BROWSER_UA), ("Accept", "text/html")])
    session(T0 + 200, "/health", [("User-Agent", "curl/8.7.1"), ("Accept", "*/*")])
    
    packets.sort(key=lambda p: p.time)
    wrpcap("beacons.pcap", packets)
    print(f"wrote beacons.pcap: {len(packets)} packets, {len(packets) // 8} TCP sessions")
    bash
    .venv/bin/python make_pcap.py
    text
    wrote beacons.pcap: 72 packets, 9 TCP sessions
  3. Examinez le trafic comme vous le feriez pour une vraie capture. Dans Wireshark, filtrez sur http.request ; ou utilisez tshark pour lister les beacons et le temps écoulé depuis le précédent (tshark 4.6.4 ici) :

    bash
    tshark -r beacons.pcap -Y 'http.user_agent contains "LabAgent"' \
        -T fields -e frame.time_delta_displayed -e http.request.uri
    text
    	/lab/gate.php?id=ecefe37b9e250d03
    60.000000000	/lab/gate.php?id=2a6a7b5fbb5d75b8
    60.000000000	/lab/gate.php?id=5c1ed35fca2410fd
    60.000000000	/lab/gate.php?id=34775758b2767375
    60.000000000	/lab/gate.php?id=a29c4db3d2083355

    Classez les champs comme le fait le tableau vu plus haut dans cette leçon. Le chemin, le user-agent et l’en-tête X-Lab ne changent jamais ; l’identifiant change à chaque requête ; l’intervalle est fixe (dans un vrai malware, il proviendrait de la configuration, souvent avec une gigue).

  4. Écrivez lab.rules avec un premier jet naïf et une règle qui cible les parties stables. Chaque règle doit tenir sur une seule ligne dans le fichier :

    text
    # Naive first draft: the path alone.
    alert http $HOME_NET any -> $EXTERNAL_NET any (msg:"LAB naive gate.php request"; flow:established,to_server; http.uri; content:"/lab/gate.php"; classtype:trojan-activity; sid:1000000; rev:1;)
    
    # Targets the stable parts: method, URI shape, user-agent prefix, custom header.
    alert http $HOME_NET any -> $EXTERNAL_NET any (msg:"LAB LabAgent HTTP beacon to gate.php"; flow:established,to_server; http.method; content:"GET"; http.uri; content:"/lab/gate.php?id="; startswith; fast_pattern; pcre:"/^\/lab\/gate\.php\?id=[0-9a-f]{16}$/"; http.user_agent; content:"LabAgent/"; startswith; http.header; content:"X-Lab|3a 20|1|0d 0a|"; nocase; reference:url,example.invalid/TR-2026-0929-01; classtype:trojan-activity; sid:1000001; rev:1; metadata:created_at 2026_09_29, updated_at 2026_09_29, malware_family LabImplant, confidence High;)
  5. Exécutez Suricata sur le pcap. Le trafic du lab va de 127.0.0.2 vers 127.0.0.1 ; définissez donc HOME_NET sur le client, $EXTERNAL_NET valant par défaut tout le reste. -S ne charge que ce fichier de règles, et -k none désactive la validation des sommes de contrôle, ce qui est courant avec des captures générées ou issues d’un déchargement matériel (offload) :

    bash
    mkdir -p logs
    suricata -r beacons.pcap -S lab.rules -l logs -k none \
        --set 'vars.address-groups.HOME_NET=[127.0.0.2]'
    cat logs/fast.log

    Avec Suricata 8.0.7 (l’image Docker jasonish/suricata convient si vous ne voulez pas l’installer), fast.log contient une ligne par alerte, indiquant le SID, le message, la classification et la paire d’IP. Triée par date, et classification abrégée :

    text
    09/29/2026-10:00:00  [**] [1:1000000:1] LAB naive gate.php request [**] ... {TCP} 127.0.0.2:61140 -> 127.0.0.1:80
    09/29/2026-10:00:00  [**] [1:1000001:1] LAB LabAgent HTTP beacon to gate.php [**] ... {TCP} 127.0.0.2:61140 -> 127.0.0.1:80
    09/29/2026-10:01:00  [**] [1:1000000:1] LAB naive gate.php request [**] ... {TCP} 127.0.0.2:60056 -> 127.0.0.1:80
    09/29/2026-10:01:00  [**] [1:1000001:1] LAB LabAgent HTTP beacon to gate.php [**] ... {TCP} 127.0.0.2:60056 -> 127.0.0.1:80
    09/29/2026-10:02:00  [**] [1:1000000:1] LAB naive gate.php request [**] ... {TCP} 127.0.0.2:59232 -> 127.0.0.1:80
    09/29/2026-10:02:00  [**] [1:1000001:1] LAB LabAgent HTTP beacon to gate.php [**] ... {TCP} 127.0.0.2:59232 -> 127.0.0.1:80
    09/29/2026-10:02:10  [**] [1:1000000:1] LAB naive gate.php request [**] ... {TCP} 127.0.0.2:59337 -> 127.0.0.1:80
    09/29/2026-10:03:00  [**] [1:1000000:1] LAB naive gate.php request [**] ... {TCP} 127.0.0.2:61108 -> 127.0.0.1:80
    09/29/2026-10:03:00  [**] [1:1000001:1] LAB LabAgent HTTP beacon to gate.php [**] ... {TCP} 127.0.0.2:61108 -> 127.0.0.1:80
    09/29/2026-10:04:00  [**] [1:1000000:1] LAB naive gate.php request [**] ... {TCP} 127.0.0.2:62217 -> 127.0.0.1:80
    09/29/2026-10:04:00  [**] [1:1000001:1] LAB LabAgent HTTP beacon to gate.php [**] ... {TCP} 127.0.0.2:62217 -> 127.0.0.1:80

    logs/eve.json contient les mêmes alertes avec toutes les métadonnées HTTP de chaque transaction.

  6. Si vous n’avez pas Suricata, le script suivant peut le remplacer. Ce n’est pas un IDS : il analyse chaque requête avec dpkt et n’applique que les options content, nocase, startswith et pcre de chaque règle aux buffers correspondants, en ignorant tout le reste. Ses verdicts ci-dessous concordent, requête par requête, avec la sortie de Suricata de l’étape 5. Enregistrez-le sous match_rules.py :

    python
    """match_rules.py - a STAND-IN for Suricata. It understands only the sticky
    buffers http.method, http.uri, http.user_agent and http.header, and the
    options content (with |hex| bytes), nocase, startswith and pcre."""
    import re, sys
    import dpkt
    
    def parse_rule(line):
        opts = line[line.index("(") + 1: line.rindex(")")]
        parts, cur, quoted = [], "", False
        for ch in opts:                       # split on ';' outside quotes
            if ch == '"' and not cur.endswith("\\"):
                quoted = not quoted
            if ch == ";" and not quoted:
                parts.append(cur.strip()); cur = ""
            else:
                cur += ch
        rule, checks, buf = {}, [], None
        for p in filter(None, parts):
            key, _, val = p.partition(":")
            if key in ("http.method", "http.uri", "http.user_agent", "http.header"):
                buf = key
            elif key == "content":
                raw = val.strip('"')
                raw = re.sub(r"\|([0-9a-fA-F ]+)\|",
                             lambda m: bytes.fromhex(m.group(1)).decode("latin-1"), raw)
                checks.append({"buf": buf, "type": "content", "value": raw})
            elif key in ("nocase", "startswith"):
                checks[-1][key] = True
            elif key == "pcre":
                body = val.strip('"')
                pat, flags = body[1:body.rindex("/")], body[body.rindex("/") + 1:]
                checks.append({"buf": buf, "type": "pcre",
                               "re": re.compile(pat, re.I if "i" in flags else 0)})
            elif key in ("msg", "sid"):
                rule[key] = val.strip('"')
        rule["checks"] = checks
        return rule
    
    def buffers(req):
        hdr = "".join(f"{k}: {v}\r\n" for k, v in req.headers.items())
        return {"http.method": req.method, "http.uri": req.uri,
                "http.user_agent": req.headers.get("user-agent", ""),
                "http.header": hdr}
    
    def check(c, bufs):
        data = bufs[c["buf"]]
        if c["type"] == "pcre":
            return bool(c["re"].search(data))
        needle = c["value"]
        if c.get("nocase"):
            data, needle = data.lower(), needle.lower()
        return data.startswith(needle) if c.get("startswith") else needle in data
    
    rules = [parse_rule(l) for l in open(sys.argv[1]) if l.startswith("alert")]
    for ts, frame in dpkt.pcap.Reader(open(sys.argv[2], "rb")):
        tcp = dpkt.ethernet.Ethernet(frame).data.data
        if not (isinstance(tcp, dpkt.tcp.TCP) and tcp.dport == 80 and tcp.data):
            continue
        req = dpkt.http.Request(tcp.data)
        bufs = buffers(req)
        hits = [r["sid"] for r in rules if all(check(c, bufs) for c in r["checks"])]
        ua = bufs["http.user_agent"][:14]
        print(f"{ts - 1790676000:6.1f}s  {req.uri:36} {ua:14}  "
              f"{'ALERT sid ' + ','.join(hits) if hits else '-'}")
    bash
    .venv/bin/python match_rules.py lab.rules beacons.pcap
    text
       0.0s  /lab/gate.php?id=ecefe37b9e250d03    LabAgent/1.0    ALERT sid 1000000,1000001
      15.0s  /index.html                          Mozilla/5.0 (W  -
      60.0s  /lab/gate.php?id=2a6a7b5fbb5d75b8    LabAgent/1.0    ALERT sid 1000000,1000001
      95.0s  /static/app.js                       Mozilla/5.0 (W  -
     120.0s  /lab/gate.php?id=5c1ed35fca2410fd    LabAgent/1.0    ALERT sid 1000000,1000001
     130.0s  /lab/gate.php?page=2                 Mozilla/5.0 (W  ALERT sid 1000000
     180.0s  /lab/gate.php?id=34775758b2767375    LabAgent/1.0    ALERT sid 1000000,1000001
     200.0s  /health                              curl/8.7.1      -
     240.0s  /lab/gate.php?id=a29c4db3d2083355    LabAgent/1.0    ALERT sid 1000000,1000001
  7. Lisez le résultat en ingénieur détection. Les deux règles détectent les cinq beacons. La règle naïve se déclenche aussi sur le /lab/gate.php?page=2 du navigateur, un faux positif qui arriverait dans la file d’un SOC à chaque visite d’une application légitime utilisant ce chemin. Le SID 1000001 reste muet sur les quatre requêtes bénignes, car la requête du navigateur échoue à la fois aux contrôles du motif d’identifiant, du user-agent et de l’en-tête. Supprimez le SID 1000000.

  8. Rendez la règle prête pour la production. Ajoutez threshold:type limit, track by_src, count 1, seconds 3600; pour qu’un hôte qui émet un beacon chaque minute lève une alerte par heure plutôt que soixante, passez rev à 2 et relancez l’étape 5 (l’étape 6 ignore les seuils). Les cinq beacons ne produisent plus qu’une seule alerte :

    text
    09/29/2026-10:00:00  [**] [1:1000001:2] LAB LabAgent HTTP beacon to gate.php [**] ... {TCP} 127.0.0.2:61140 -> 127.0.0.1:80

    Exécutez ensuite suricata --engine-analysis -S lab.rules -l logs et ouvrez logs/rules_analysis.txt. Pour le SID 1000001, il indique Fast Pattern "/lab/gate.php?id=" on "http request uri (http_uri)" buffer, le content que vous avez marqué avec fast_pattern.

  9. Recherchez les faux positifs à grande échelle. Rejouez une capture bénigne plus volumineuse avec la même règle : un enregistrement de votre propre navigation dans la VM du laboratoire, ou des pcap bénins publics. Toute alerte à ce stade révèle une caractéristique que vous pensiez unique et qui ne l’est pas.

Questions : Quel champ du beacon classeriez-vous comme aléatoire, lequel comme codé en dur, et quelle analyse statique le confirmerait ? Si le build suivant envoyait LabAgent/1.1 et passait l’en-tête en minuscules (x-lab: 1), le SID 1000001 se déclencherait-il encore, et quels modificateurs en sont responsables ? Pourquoi fast_pattern est-il placé sur le content de l’URI plutôt que sur GET ? Si l’auteur faisait passer le beacon en HTTPS avec la pile TLS de Windows, quelles parties de cette règle fonctionneraient encore, par quoi pourrait-on les remplacer, et que transmettriez-vous plutôt à l’équipe en charge des postes ?

À retenir

  • Les IP et les domaines sont des IOC à courte durée de vie ; les règles réseau durables ciblent ce que le code du client code en dur : structure de l’URI, en-têtes, encodages et comportement du protocole.
  • Classez chaque champ comme codé en dur, issu de la configuration, dérivé de l’hôte, aléatoire ou fourni par une bibliothèque, en utilisant des exécutions dynamiques répétées pour savoir ce qui varie et l’analyse statique pour savoir pourquoi et dans quelle mesure.
  • Une règle Suricata se compose d’une action, d’un en-tête et d’options ; les sticky buffers tels que http.uri, http.user_agent, dns.query et tls.sni gardent les correspondances précises, et les flowbits ajoutent un état à travers les messages.
  • Exigez plusieurs caractéristiques indépendantes, décrivez les champs variables par leur forme et donnez à chaque règle un littéral distinctif comme fast pattern ; ne livrez jamais une règle reposant uniquement sur pcre.
  • Testez chaque règle sur un pcap du trafic malveillant et sur du trafic bénin, puis versionnez-la avec sid, rev, des métadonnées et ses captures de test.
  • Sous TLS, la détection se déplace vers le SNI, JA4, les certificats et la chronologie, et finalement vers le poste ; les journaux Zeek vous permettent de chasser rétrospectivement dans le même trafic.