SSH-Zwangsbefehle: ein Knopf im Intranet, der genau einen Befehl darf

Schema: Ein Knopf im Intranet ruft über einen Webdienst mit drei Sperren einen SSH-Schlüssel auf, der per Zwangsbefehl nur status und start ausführen darf

Verfasst von

in

Mein Intranet hat seit Kurzem einen Knopf „Website veröffentlichen“. Ein Klick, und der Stand meiner Testumgebung geht auf diese Website. Technisch heißt das: Ein kleiner Webdienst auf einem Server soll auf einem anderen Server einen Vorgang starten. Der naheliegende Weg wäre ein SSH-Schlüssel mit Root-Zugang. Dann hätte aber jeder, der den Webdienst übernimmt, eine Root-Shell auf dem zweiten Server. Mit einem Zwangsbefehl in authorized_keys geht es deutlich enger: Der Schlüssel darf genau zwei Dinge, nämlich den Status lesen und den Vorgang starten. Hier zeige ich, wie das aussieht, und was auf der Webseite dazugehört, damit der Knopf nicht von außen gedrückt werden kann.

Schema: Ein Knopf im Intranet ruft über einen Webdienst mit drei Sperren einen SSH-Schlüssel auf, der per Zwangsbefehl nur status und start ausführen darf

Ein eigener Schlüssel nur für diesen Zweck

Der Webdienst läuft als eigener Systembenutzer ohne Login-Shell. Für ihn lege ich ein neues Schlüsselpaar an, das nichts anderes kann. Der private Schlüssel liegt bei der Konfiguration des Dienstes, lesbar nur für diesen Benutzer. Den Host-Schlüssel des Ziels hinterlege ich in einer eigenen known_hosts-Datei, damit die Verbindung ohne Rückfragen und trotzdem geprüft aufgebaut wird.

ssh-keygen -q -t ed25519 -N "" -C "intranet veroeffentlichen" -f /etc/intranet/website_ed25519
chown intranet:intranet /etc/intranet/website_ed25519*
chmod 600 /etc/intranet/website_ed25519
ssh-keyscan -t ed25519 testserver.example.lan > /etc/intranet/website_known_hosts

Der Zwangsbefehl

Auf dem Zielserver steht der öffentliche Schlüssel in authorized_keys, aber mit Optionen davor. restrict schaltet alles ab, was SSH sonst noch kann: Portweiterleitung, Agent-Weiterleitung, Terminal. from= erlaubt den Schlüssel nur von der Adresse des Intranet-Servers. Und command= legt fest, welches Programm läuft, egal was der Aufrufer verlangt.

restrict,from="192.0.2.10",command="/usr/local/sbin/website-fernsteuerung" ssh-ed25519 AAAA… intranet veroeffentlichen

Was der Aufrufer eigentlich ausführen wollte, steht in der Variablen SSH_ORIGINAL_COMMAND. Das Programm prüft sie gegen eine feste Liste. Alles andere wird abgelehnt.

#!/bin/bash
# Zwangsbefehl: nur "status" und "start"
case "${SSH_ORIGINAL_COMMAND:-}" in
  status)
    cat /var/lib/website/status.json ;;
  start)
    if systemctl is-active -q website-veroeffentlichen.service; then
      echo '{"ok":false,"fehler":"läuft schon"}'; exit 0
    fi
    echo intranet > /run/website-ausloeser
    systemctl start --no-block website-veroeffentlichen.service && echo '{"ok":true}' ;;
  *)
    echo '{"ok":false,"fehler":"nicht erlaubt"}'; exit 1 ;;
esac

Zwei Details sind mir wichtig. Erstens startet start nur einen systemd-Dienst und kehrt sofort zurück (--no-block). Die eigentliche Arbeit läuft unabhängig von der SSH-Verbindung, mit eigenem Log und eigener Statusdatei. Derselbe Dienst läuft auch nachts per Timer. Zweitens nimmt das Script keine Parameter vom Aufrufer an. Es gibt keinen Weg, etwas in einen Befehl einzuschleusen, weil nichts vom Aufrufer in einen Befehl gelangt.

Den Gegentest mache ich immer: Ein beliebiger anderer Befehl mit diesem Schlüssel muss scheitern.

$ ssh -i website_ed25519 root@testserver status
{"ergebnis": "unveraendert", …}
$ ssh -i website_ed25519 root@testserver "cat /etc/shadow"
{"ok":false,"fehler":"nicht erlaubt"}

Der Knopf selbst: wer darf drücken?

Mein Intranet ist im Heimnetz ohne Anmeldung offen. Das ist für die Dienste-Übersicht, den Speedtest und das Übergabefach in Ordnung, aber nicht für einen Knopf, der etwas veröffentlicht. Der Webdienst prüft deshalb drei Dinge, bevor er den Zwangsbefehl aufruft.

Zuerst die Herkunft: Der Aufruf ist nur aus dem Adressbereich erlaubt, in dem die PCs liegen. Handys, Fernseher und Steckdosen sehen den Status, der Knopf bleibt für sie gesperrt. Dann die Häufigkeit: höchstens drei Aufrufe in zehn Minuten je Gerät, dazu eine Obergrenze pro Tag.

Die dritte Prüfung betrifft fremde Webseiten. Ohne sie könnte eine beliebige Seite im Internet deinen Browser dazu bringen, den Knopf im Hintergrund zu drücken (Cross-Site Request Forgery), denn ein einfacher POST braucht keine Erlaubnis. Mein Webdienst verlangt deshalb einen eigenen Header X-Intranet: 1. Eine fremde Seite darf ihn nicht setzen, ohne dass der Browser vorher nachfragt, und diese Nachfrage beantwortet mein Dienst nicht.

Weg eines Knopfdrucks: Browser, Webdienst mit drei Sperren, SSH-Schlüssel mit Zwangsbefehl, Skript auf dem Zielserver mit den erlaubten Befehlen status und start
if self.headers.get("X-Intranet") != "1":
    return self.send(403, {"fehler": "Anfrage nicht aus dem Intranet"})
if not erlaubtes_netz(client_ip):
    return self.send(403, {"fehler": "Nur von einem PC aus erlaubt"})
if not rate("website:" + client_ip, 3, 600):
    return self.send(429, {"fehler": "Zu oft. Bitte später noch einmal."})

Auf der Seite fragt der Knopf außerdem einmal nach, bevor er etwas tut. Das schützt nicht vor Angriffen, aber vor einem versehentlichen Klick.

Was im schlimmsten Fall passiert

Ich gehe bei solchen Dingen von der Frage aus, was jemand anrichten kann, der genau diesen Baustein übernimmt. Übernimmt jemand den Intranet-Server, kann er mit dem Schlüssel den Status lesen und eine Veröffentlichung starten. Die läuft ohnehin jede Nacht, wenn sich etwas geändert hat, und vorher wird die Live-Datenbank gesichert. Dateien lesen, Befehle ausführen oder einen Tunnel ins Netz bauen kann er nicht. Mit einem normalen Root-Schlüssel wäre es der ganze Testserver gewesen, samt der Zugangsdaten zum Hoster, die dort liegen.

Zwangsbefehle passen überall, wo ein Server auf einem anderen genau eine Sache auslösen soll: eine Sicherung abholen, ein Zertifikat einspielen, einen Dienst neu laden. Als Nächstes ist der Automat dran, der Zertifikate meiner eigenen CA auf die internen Webdienste verteilt. Er meldet sich heute noch mit vollen Rechten an, und das ist mehr, als er braucht.

Kommentare

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht, deine IP-Adresse wird nicht gespeichert. Kommentare erscheinen erst nach Freigabe. Was mit deinen Angaben passiert, steht in der Datenschutzerklärung. Pflichtfelder sind mit * markiert.