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
Accepteddepuis 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/shadowou deauthorized_keysparsudo. - Piège actuel : le nom du processus a changé. Depuis OpenSSH 9.8, les lignes d’authentification sont émises par
sshd-sessionet non plussshd. Sur un journal réel de notre serveur Debian 13, 6 des 20 lignes SSH portent ce nom ; une règle écrite poursshdles 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

| Comportement | Ligne type | Pourquoi c’est important |
|---|---|---|
| Force brute | Failed password for invalid user admin from 203.0.113.9 port 40001 ssh2 | Plusieurs échecs rapprochés depuis la même adresse, avec des comptes différents : on devine des mots de passe. |
| Succès après échecs | Accepted password for root from 203.0.113.9 port 40007 ssh2 | L’attaquant a trouvé. C’est la ligne qui sépare « un scan de plus » d’un incident. |
| Root par mot de passe | Accepted 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 sensible | sudo: deploy : … USER=root ; COMMAND=/usr/bin/cat /etc/shadow | Vol 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_keysne laisse rien dansauth.log: il faut une surveillance de l’intégrité des fichiers (FIM) ou auditd sur ce chemin. - Les commandes tapées.
sudojournalise 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
- Vérifier que SSH n’accepte pas le mot de passe pour root (
PermitRootLogin prohibit-passwordouno) : la règle « root par mot de passe » ne devrait alors jamais se déclencher. - 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.
- Envoyer
auth.log(ou le journal systemd) vers un serveur distant et y appliquer ces règles. - Surveiller
authorized_keys,/etc/passwd,/etc/shadowetsudoersavec une surveillance d’intégrité. - 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)
À lire aussi
- 15 événements Windows qui trahissent l’attaquant : le même exercice côté Windows, avec un script de détection testé.
- Bastion SSH avec OpenSSH : des connexions qui nomment la personne et la demande dans les journaux.
Sources
- Manuel de sshd (option LogLevel, journalisation) : man7.org
- Wazuh, jeu de règles : documentation.wazuh.com
- OpenSSH, notes de version 9.8 (séparation du processus sshd-session) : openssh.com