
🌐 Auch auf: English · Français · Español
Dies ist die Fortsetzung des langen Beitrags von gestern, Defense in Depth: Neun Schichten zwischen dem Internet und meinen Servern. Dort bekam der Login-Alarm einen einzigen Satz. Hier steht, wie er funktioniert, warum er so gebaut ist – und was ein Angreifer erfährt, wenn er das liest.
Die Idee ist alt und einfach: Wenn sich jemand auf einem meiner Server anmeldet, will ich das wissen – innerhalb von Minuten, nicht erst, wenn ich eine Woche später zufällig ins Log schaue. Jede Anmeldung schickt mir eine Mail. Da ich weiß, wann ich mich selbst angemeldet habe, ist jede Mail, die ich nicht erwartet habe, sofort einen Blick wert. Es ist ein Stolperdraht: Er hält niemanden auf, er sorgt dafür, dass ein Einbruch nicht unbemerkt bleibt.
Es gab drei Anforderungen. Er darf einer Anmeldung niemals im Weg stehen – eine Sicherheitsfunktion, die mich aus meinem eigenen Server aussperrt, ist schlimmer als gar keine. Er muss bei allem Automatischen still bleiben, sonst gewöhnt er mich daran, ihn zu ignorieren. Und er muss auf allen drei Servern gleich funktionieren und wie alles andere per Ansible ausgerollt werden.
🔍 Woher die Anmeldungen kommen: wtmp
Linux protokolliert jede Anmeldesitzung, die ein Terminal bekommt, in /var/log/wtmp. Der Befehl last liest diese Datei, und mit ISO-Zeitstempeln lässt sie sich leicht auswerten:
$ last -w -i --time-format iso | head -3
root pts/0 203.0.113.25 2026-10-14T05:56:45+00:00 - 2026-10-14T05:56:46+00:00 (00:00)
root pts/0 203.0.113.25 2026-10-13T14:27:37+00:00 - 2026-10-13T16:23:34+00:00 (01:55)
root pts/0 203.0.113.25 2026-10-13T07:53:55+00:00 - 2026-10-13T07:53:55+00:00 (00:00)Das wichtige Detail ist, was dort nicht drinsteht: Sitzungen ohne Terminal. Wenn mein Backup-Rechner nachts die Sicherungen abholt, führt er einen Befehl über SSH aus, bekommt kein Terminal und hinterlässt keinen wtmp-Eintrag. Genau das ist der Filter, den ich will – interaktive Shells werden gemeldet, reine automatische Befehle nicht.
🧩 Das Skript
Alle zwei Minuten liest ein kleines Skript last, vergleicht die Ausgabe mit der neuesten Anmeldung, die es schon gemeldet hat, und verschickt eine Mail für alles Neue in diesem Zeitfenster – mehrere Anmeldungen ergeben eine Mail, nicht fünf. Der Zustand ist ein einziger Zeitstempel in /var/lib/ssh-login-alert/last-seen. Beim allerersten Lauf auf einem Host merkt es sich nur die aktuelle Position, damit es mir nicht die ganze Historie auf einmal schickt. Und der Zustand wird erst weitergeschrieben, wenn die Mail raus ist: Schlägt der Versand fehl, versucht es der nächste Lauf erneut.
#!/bin/bash
#
# Tripwire: mails every new interactive login since the previous run. Started
# every 2 minutes by ssh-login-alert.timer; all new logins of one window go
# out as ONE mail.
#
# Source is wtmp (read with "last"): only sessions that got a terminal are
# recorded there. The nightly backup pulls run plain commands without a
# terminal and never appear. Ansible requests a terminal for some steps, so a
# playbook run does show up (as short sessions) - you know when that was you.
set -u
MAILTO="you@example.com"
STATE_DIR=/var/lib/ssh-login-alert
STATE="$STATE_DIR/last-seen"
mkdir -p "$STATE_DIR"
# One line per login: start-time (ISO 8601 with the host's UTC offset) TAB
# user TAB tty TAB source IP. Sorted by time; the offset is the same for all
# entries of one host, so string comparison of the first field works.
logins=$(last -w -i --time-format iso 2>/dev/null \
| awk '$1 != "reboot" && $1 != "wtmp" && NF >= 4 { print $4 "\t" $1 "\t" $2 "\t" $3 }' \
| sort)
newest=$(printf '%s\n' "$logins" | tail -n 1 | cut -f 1)
# First run on this host: only remember the current position, so the alert
# does not mail the whole history at once.
if [ ! -s "$STATE" ]; then
printf '%s\n' "${newest:-1970-01-01T00:00:00+00:00}" > "$STATE"
exit 0
fi
seen=$(cat "$STATE")
new=$(printf '%s\n' "$logins" | awk -F '\t' -v seen="$seen" '$1 > seen')
[ -n "$new" ] || exit 0
count=$(printf '%s\n' "$new" | wc -l)
{
echo "New interactive login(s) on $(hostname -f):"
echo
printf '%s\n' "$new" | awk -F '\t' '{ printf " %s %-8s %-7s from %s\n", $1, $2, $3, $4 }'
echo
echo "Times carry the host's UTC offset. If none of these were you, check: last -i | head"
} | /usr/bin/mail -s "[$(hostname -f)] SSH login: $count new" "$MAILTO" || exit 1
# Only after the mail went out: a failed send is retried on the next run.
printf '%s\n' "$newest" > "$STATE"
Die Mail geht über dasselbe kleine SMTP-Relay (msmtp) raus, das schon die anderen Berichte der Server verschickt. Sie sieht so aus:
Subject: [www.example.org] SSH login: 1 new
New interactive login(s) on www.example.org:
2026-10-14T08:01:02+00:00 root pts/1 from 203.0.113.25
Times carry the host's UTC offset. If none of these were you, check: last -i | head⏱️ Ein Timer, kein PAM-Hook
Der naheliegende Weg, auf eine Anmeldung zu reagieren, ist ein PAM-Modul: pam_exec im SSH-PAM-Stack startet bei jeder Sitzung ein Skript. Ich habe mich aus zwei Gründen dagegen entschieden.
- Aussperr-Risiko. PAM ist die Anmeldung selbst. Ein Tippfehler oder ein hängendes Skript im SSH-PAM-Stack kann dazu führen, dass der Server jede Anmeldung verweigert – genau die Situation, in der ich dann die Konsole des Hosting-Anbieters brauche.
- SELinux. Ein von PAM gestartetes Skript läuft im Sicherheitskontext von sshd. Von dort Mails zu verschicken heißt Netzwerkverbindungen aus einer Domäne, der SELinux das zu Recht nicht erlaubt.
Ein systemd-Timer hat keines der beiden Probleme. Er läuft völlig getrennt von sshd in einer eigenen Unit, und das Schlimmste, was ein Fehler anrichten kann, ist eine nicht verschickte Mail. Der Preis ist eine Verzögerung von bis zu zwei Minuten – für einen Stolperdraht vertretbar.
# /etc/systemd/system/ssh-login-alert.service
[Unit]
Description=Mail new interactive SSH logins
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/ssh-login-alert
# /etc/systemd/system/ssh-login-alert.timer
[Unit]
Description=Check for new interactive SSH logins every 2 minutes
[Timer]
OnBootSec=2min
OnUnitActiveSec=2min
[Install]
WantedBy=timers.target🤖 Ansible ruhig stellen
Der erste Test brachte eine Überraschung: Jeder Ansible-Lauf tauchte in wtmp als ein paar kurze Sitzungen auf. Das SSH-Plugin von Ansible fordert für manche Schritte standardmäßig ein Terminal an. Keines meiner Playbooks braucht eines – Pipelining ist an, und nichts fragt je nach einem Passwort –, also schaltet eine Zeile in ansible.cfg das ab:
[ssh_connection]
pipelining = True
# No terminal for Ansible's own connections - nothing here needs one, and
# without it a playbook run leaves no entry in wtmp.
usetty = FalseDanach hinterließ ein kompletter Playbook-Lauf keine Spur mehr in wtmp, und der Alarm schlägt nur noch bei echten interaktiven Sitzungen an. In Ansible selbst sind es drei kleine Tasks – Skript aus einem Template, zwei Unit-Dateien, Timer aktivieren:
- name: SSH | Deploy the login alert script
ansible.builtin.template:
src: ssh-login-alert.sh.j2
dest: /usr/local/sbin/ssh-login-alert
owner: root
group: root
mode: "0750"
- name: SSH | Enable the login alert timer
ansible.builtin.systemd:
name: ssh-login-alert.timer
enabled: true
state: started
daemon_reload: "{{ _ssh_alert_units.changed }}"
# in --check the units were not written yet on a fresh host
when: not (ansible_check_mode and _ssh_alert_units.changed)🕵️ Hilft das einem Angreifer?
Dieser Beitrag beschreibt genau, was den Alarm auslöst und was nicht. Also die ehrliche Frage: Liefert er einem Angreifer ein Rezept, unsichtbar zu bleiben?
Teilweise ja – und das ist in Ordnung. Wer mit einem gestohlenen Schlüssel das hier liest, weiß, dass ssh host 'befehl' ohne Terminal keine Mail auslöst. Das ist aber keine geheime Lücke, sondern so funktioniert wtmp, und wer Linux kennt, weiß das. Wichtiger noch: Der Alarm ist eine Schicht von mehreren. Um überhaupt hineinzukommen, braucht es erst einmal einen gültigen Schlüssel – Passwörter sind abgeschaltet, SSH hört nur auf die Adressbereiche eines Landes, und Fehlversuche werden gesperrt. Und was jemand drinnen ohne Terminal tut, muss trotzdem an den anderen Stolperdrähten vorbei: Änderungen an Benutzerkonten, sudo, der SSH-Konfiguration, Kernel-Modulen oder den Secrets des Clusters werden auf anderen Wegen protokolliert und gemeldet, und veränderte Systemdateien fallen in der nächsten Nacht auf.
Wer root wird, kann den Timer auch abschalten. Aber die interaktive Anmeldung, die ihn dorthin gebracht hat, ist dann schon gemailt – innerhalb von zwei Minuten, lange bevor man typischerweise anfängt, Spuren zu verwischen. Womit wir wieder beim Prinzip von gestern sind: Der Aufbau darf öffentlich sein; der Schutz kommt von den Schlüsseln und davon, mehr als eine Schicht zu haben.
✅ Ergebnis
- Eine Mail pro interaktiver SSH-Anmeldung, innerhalb von zwei Minuten, auf allen drei Servern.
- Still bei Backups und bei Ansible.
- Keine Änderung an sshd oder PAM, kann also niemanden aussperren.
- Eine bewusste Grenze (Befehle ohne Terminal), abgedeckt durch andere Schichten.
Die erste echte Mail kam schon beim Testen, ein paar Minuten nach dem Ausrollen – eine Test-Anmeldung, die ich absichtlich ausgelöst hatte. Genau so soll es sich anfühlen: langweilig, wenn du es selbst warst, nicht zu übersehen, wenn nicht.




