Intrusion SSH sur Linux : les traces à surveiller dans les journaux, avec un script testé

Un serveur Linux exposé laisse, chaque jour, des milliers de tentatives dans ses journaux. Le plus dur n’est pas de les lire : c’est de repérer la poignée de lignes qui comptent, celles qui montrent qu’une tentative a réussi et ce que l’intrus a fait ensuite. Voici cinq comportements à surveiller, les lignes exactes qui les trahissent, et un script que nous avons testé.

Ce qu’il faut retenir

  • Une tentative qui échoue ne dit presque rien ; une réussite après des échecs dit beaucoup. Le signal utile est l’enchaînement : force brute, puis Accepted depuis la même adresse.
  • Ce qui suit la connexion compte plus que la connexion : création d’un compte (surtout UID 0), lecture de /etc/shadow ou de authorized_keys par sudo.
  • Piège actuel : le nom du processus a changé. Depuis OpenSSH 9.8, les lignes d’authentification sont émises par sshd-session et non plus sshd. Sur un journal réel de notre serveur Debian 13, 6 des 20 lignes SSH portent ce nom ; une règle écrite pour sshd les ignorerait.
  • Un attaquant root peut effacer le journal local. Envoyez-le hors de la machine (SIEM) pour que la preuve survive.
  • Peu de règles, peu de faux positifs : notre script en applique cinq et reste muet sur les connexions ordinaires de notre jeu de test (une faute de frappe, un sudo de maintenance, un compte de service).

Où sont les journaux

Selon la distribution, l’authentification est écrite dans /var/log/auth.log (Debian, Ubuntu), /var/log/secure (Red Hat et dérivés), ou seulement dans le journal de systemd (journalctl -u ssh). Le format « syslog » est le même : date, machine, processus, message. Le message est celui qui nous intéresse ; les lignes ci-dessous sont celles qu’écrivent OpenSSH, sudo et les outils de comptes (shadow-utils). Le manuel de sshd décrit l’option LogLevel : en VERBOSE, les connexions par clé ou par certificat enregistrent aussi l’empreinte de la clé, et l’identifiant du certificat s’il y en a un (voir notre article sur le bastion SSH).

Les cinq comportements

Cinq règles de détection dans les journaux Linux : force brute, succès après échecs, root par mot de passe, compte créé, sudo sur fichier sensible
Règles du script authlog_hunt.py. Seuils modifiables en ligne de commande.
ComportementLigne typePourquoi c’est important
Force bruteFailed password for invalid user admin from 203.0.113.9 port 40001 ssh2Plusieurs échecs rapprochés depuis la même adresse, avec des comptes différents : on devine des mots de passe.
Succès après échecsAccepted password for root from 203.0.113.9 port 40007 ssh2L’attaquant a trouvé. C’est la ligne qui sépare « un scan de plus » d’un incident.
Root par mot de passeAccepted password for root …Un accès root direct sans clé est rare dans un parc sain, et c’est exactement ce que visent les attaques par dictionnaire.
Compte crééuseradd: new user: name=support2, UID=0, GID=0, …Persistance classique. Un UID 0 est un deuxième administrateur.
sudo sensiblesudo: deploy : … USER=root ; COMMAND=/usr/bin/cat /etc/shadowVol d’empreintes de mots de passe, ajout de clés (authorized_keys), modification de sudoers.

Ce découpage reprend la logique de la Kill Chain et d’ATT&CK : accès initial, puis persistance, puis accès aux identifiants. Les trois premières lignes du tableau sont l’accès, la quatrième la persistance, la cinquième la récolte d’identifiants.

Le script

authlog_hunt.py (bibliothèque standard de Python, une centaine de lignes) lit un journal et affiche les alertes. Il ne dépend d’aucun logiciel tiers et accepte les deux noms de processus (sshd et sshd-session) :

python3 authlog_hunt.py /var/log/auth.log               # seuil 5 échecs en 600 s par défaut
python3 authlog_hunt.py /var/log/auth.log --seuil 8 --fenetre 120
journalctl -u ssh --no-pager -o short > /tmp/ssh.txt && python3 authlog_hunt.py /tmp/ssh.txt

Sur notre journal d’essai, qui reconstitue une attaque de soixante-dix secondes, il donne :

10-09 03:00:09  force brute            203.0.113.9 : 5 échecs en 600 s, 4 comptes essayés
10-09 03:00:14  succès après échecs    root depuis 203.0.113.9 (password) après 6 échecs
10-09 03:00:14  root par mot de passe  connexion root depuis 203.0.113.9 (password)
10-09 03:00:30  compte créé            support2 (UID 0) : UID 0 !
10-09 03:01:10  sudo sensible          deploy → root : /usr/bin/cat /etc/shadow
10-09 10:00:00  compte créé            svc-backup (UID 998)
11:00:08        force brute            198.51.100.7 : 5 échecs en 600 s, 1 comptes essayés   (lignes sshd-session)
7 alerte(s)

Le test automatique vérifie que ces sept alertes, et seulement elles, sont produites. Les connexions ordinaires du jeu d’essai ne déclenchent rien : la clé de l’administratrice, le sudo apt update, le redémarrage de nginx par un compte de déploiement, et une utilisatrice qui se trompe une fois de mot de passe avant de réussir (le seuil est de 3 échecs pour le « succès après échecs »).

Limites à connaître. Le journal d’essai est synthétique : les lignes suivent les formats d’OpenSSH, de sudo et de shadow-utils, mais elles ne viennent pas d’une attaque réelle. Nous avons en revanche exécuté le script sur le journal SSH réel de notre serveur Debian 13 (20 lignes, dont 6 de sshd-session) : il s’est lu sans erreur et n’a produit aucune alerte, ce qui est cohérent. Les formats peuvent varier selon la version de sudo ou la distribution : vérifiez les expressions régulières sur vos propres lignes avant de vous y fier. L’ordre « sudo sensible » dépend de la ligne COMMAND=, qui n’existe que si sudo journalise (c’est le cas par défaut).

Et avec un SIEM ?

Un script lancé à la main ne remplace pas une surveillance continue. Un SIEM comme Wazuh fournit déjà des règles proches : sur notre serveur, la règle 5710 signale une tentative sur un compte inexistant, 5712 et 5763 des tentatives de force brute (8 échecs en 120 secondes), 5716 un échec d’authentification, 5715 un succès, et 5902 l’ajout d’un utilisateur au système. Notre script sert à comprendre l’enchaînement et à analyser un journal après coup (copie d’un disque, journal exporté) quand le SIEM n’y a pas accès.

Ce qui échappe aux journaux d’authentification

  • Les clés ajoutées sans bruit. Un attaquant qui ajoute sa clé à authorized_keys ne laisse rien dans auth.log : il faut une surveillance de l’intégrité des fichiers (FIM) ou auditd sur ce chemin.
  • Les commandes tapées. sudo journalise ce qui passe par lui, pas un shell root ouvert. L’audit du noyau (auditd) ou l’enregistrement de session comblent ce vide.
  • Les journaux supprimés ou modifiés. Un attaquant root peut les effacer ; d’où l’envoi vers un serveur distant (un effacement de journaux est l’équivalent Linux de ce que nous décrivons pour Windows dans 15 événements Windows).
  • Les connexions par un autre canal (console, outil d’administration, service web) qui n’utilisent pas SSH.

Par où commencer

  1. Vérifier que SSH n’accepte pas le mot de passe pour root (PermitRootLogin prohibit-password ou no) : la règle « root par mot de passe » ne devrait alors jamais se déclencher.
  2. Passer à l’authentification par clé (ou par certificats) et désactiver les mots de passe quand c’est possible : plus de force brute qui aboutit.
  3. Envoyer auth.log (ou le journal systemd) vers un serveur distant et y appliquer ces règles.
  4. Surveiller authorized_keys, /etc/passwd, /etc/shadow et sudoers avec une surveillance d’intégrité.
  5. Tester : générer une fausse force brute depuis une machine du labo et vérifier que l’alerte arrive, avant d’en avoir besoin.

Ce que nous avons vérifié

Le script et son test (test_authlog_hunt.py) ont été exécutés : 7 alertes attendues obtenues, aucune sur les connexions ordinaires, seuil modifiable vérifié. Le changement de nom du processus (sshd-session) a été constaté sur le journal d’un serveur Debian 13 (OpenSSH 10.0). Les règles Wazuh citées ont été lues dans le jeu de règles installé sur notre serveur. Les lignes useradd et sudo du jeu d’essai sont construites d’après les formats habituels et n’ont pas été relevées sur une attaque réelle.

Transparence : la recherche, la rédaction et, lorsqu’il y en a, les tests de cet article ont été réalisés avec l’aide d’un assistant IA (Claude) ; l’éditeur du site relit et valide chaque article avant publication. Le choix des cinq comportements et les seuils sont notre synthèse.

Le script complet

Le code complet de authlog_hunt.py (bibliothèque standard de Python). Le jeu d’essai est volontairement synthétique : adaptez les expressions régulières à vos propres lignes.

#!/usr/bin/env python3
"""authlog_hunt.py FICHIER [--seuil 5] [--fenetre 600] : repère dans un journal d'authentification Linux (auth.log / secure, format syslog classique)
des comportements d'intrusion : force brute, succès après échecs, connexion root par mot de passe, comptes créés, sudo sur fichiers sensibles.
Bibliothèque standard seulement. Les formats de lignes sont ceux d'OpenSSH, sudo et shadow-utils ; adaptez-les à votre distribution."""
import re, sys, argparse, collections
from datetime import datetime

MOIS = {m: i for i, m in enumerate("Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec".split(), 1)}
LIGNE = re.compile(r"^(?P<m>[A-Z][a-z]{2})\s+(?P<j>\d+)\s+(?P<h>\d\d:\d\d:\d\d)\s+(?P<hote>\S+)\s+(?P<proc>[A-Za-z0-9_.-]+)(?:\[\d+\])?:\s+(?P<msg>.*)$")
FAIL = re.compile(r"Failed (?:password|publickey|keyboard-interactive/pam) for (?:invalid user )?(?P<u>\S+) from (?P<ip>[0-9a-fA-F:.]+) port")
INVALID = re.compile(r"Invalid user (?P<u>\S*) from (?P<ip>[0-9a-fA-F:.]+)")
OK = re.compile(r"Accepted (?P<meth>password|publickey|keyboard-interactive/pam) for (?P<u>\S+) from (?P<ip>[0-9a-fA-F:.]+) port")
NEWUSER = re.compile(r"new user: name=(?P<u>[^,]+), UID=(?P<uid>\d+)")
SUDO = re.compile(r"(?P<qui>\S+) : .*USER=(?P<cible>\S+) ; COMMAND=(?P<cmd>.*)$")
SENSIBLE = re.compile(r"/etc/(shadow|sudoers|passwd)|authorized_keys|/root/\.ssh|\bchmod\s+[0-7]*[4-7][0-7]{2}\b.*\bbash\b|\bnc\b.*-e|/dev/tcp/")

def ts(m):
    # le journal syslog n'a pas d'année : on prend l'année courante (suffisant pour mesurer des écarts)
    return datetime(datetime.now().year, MOIS[m["m"]], int(m["j"]), *map(int, m["h"].split(":")))

def analyser(lignes, seuil=5, fenetre=600):
    alertes, echecs, utilisateurs = [], collections.defaultdict(list), collections.defaultdict(set)
    signale_brute = set()
    for brut in lignes:
        m = LIGNE.match(brut.rstrip("\n"))
        if not m: continue
        t, msg, proc = ts(m), m["msg"], m["proc"]
        if proc in ("sshd", "sshd-session"):   # OpenSSH ≥ 9.8 : le processus d'authentification s'appelle sshd-session
            f = FAIL.search(msg)
            if f:
                ip = f["ip"]; echecs[ip].append(t); utilisateurs[ip].add(f["u"])
                recents = [x for x in echecs[ip] if (t - x).total_seconds() <= fenetre]
                if len(recents) >= seuil and ip not in signale_brute:
                    signale_brute.add(ip)
                    alertes.append((t, "force brute", f"{ip} : {len(recents)} échecs en {fenetre} s, {len(utilisateurs[ip])} comptes essayés"))
                continue
            o = OK.search(msg)
            if o:
                ip = o["ip"]
                avant = [x for x in echecs.get(ip, []) if (t - x).total_seconds() <= 3600]
                if len(avant) >= 3:   # une ou deux fautes de frappe sont normales
                    alertes.append((t, "succès après échecs", f"{o['u']} depuis {ip} ({o['meth']}) après {len(avant)} échecs"))
                if o["u"] == "root" and o["meth"] != "publickey":
                    alertes.append((t, "root par mot de passe", f"connexion root depuis {ip} ({o['meth']})"))
        elif proc == "useradd":
            n = NEWUSER.search(msg)
            if n:
                alertes.append((t, "compte créé", f"{n['u']} (UID {n['uid']})" + (" : UID 0 !" if n["uid"] == "0" else "")))
        elif proc == "sudo":
            s = SUDO.search(msg)
            if s and SENSIBLE.search(s["cmd"]):
                alertes.append((t, "sudo sensible", f"{s['qui']} → {s['cible']} : {s['cmd'][:80]}"))
    return alertes

if __name__ == "__main__":
    ap = argparse.ArgumentParser(); ap.add_argument("fichier"); ap.add_argument("--seuil", type=int, default=5); ap.add_argument("--fenetre", type=int, default=600)
    a = ap.parse_args()
    with open(a.fichier, errors="replace") as fh:
        res = analyser(fh, a.seuil, a.fenetre)
    for t, kind, det in res:
        print(f"{t:%m-%d %H:%M:%S}  {kind:<22} {det}")
    print(f"{len(res)} alerte(s)", file=sys.stderr)

Sources