QVINTVS · SCRIBET

Vernünftige Daemons in Ruby (Update 10.03.14)

Die meisten Systemdienste (Dæmons) sind wohl in C/C++ geschrieben. Allerdings erfüllt auch Ruby bereits von Haus aus alle dafür erforderlichen Voraussetzungen, wenn auch mit kleineren Stolperfallen. In diesem Artikel will ich mich damit beschäftigen, wie man mit Ruby einen üblichen Dæmon schreibt, der aus Sicherheitsgründen sowohl seine Rechte aufgibt (privilege dropping) als auch unter einer anderen Dateiwurzel läuft (chroot).

Dæmons sind ein kompliziertes Thema. Die oberste und wichtigste Regel bei der Arbeit an einem Dæmon ist folgende: Sicherheit. Dæmons benötigen oft spezielle Rechte und werden daher (zunächst) als root-Nutzer ausgeführt; ein Sicherheitsleck in einem Dæmon kann es einem Angreifer ermöglichen, mit vollen root-Rechten beliebige Kommandos auf dem System durchzuführen. Es ist daher von zwingender Notwendigkeit, seinen Code möglichst intensiv auf Sicherheitslecks zu überprüfen bzw. am besten überprüfen zu lassen. Diese Artikel kann keine Anleitung dafür sein, wie man sicheren Ruby-Code schreibt, vielmehr beschäftigt er sich mit den Grundaufgaben eines jeden Dæmons und wie man zumindest diese sicher ausführt.

Was ist ein Dæmon?

Ein Dæmon ist ein Prozess, der sich durch spezielles Verhalten besonders auszeichnet. Hauptmerkmal eines Dæmons ist der Detach bzw. Fork, d.h. die „Verselbständigung“ des Prozesses als eigener, von einem Elternprozess unabhängiger Prozess, der direkt unterhalb von init läuft. Er tritt in den Hintergrund und benötigt keine Nutzerinteraktion.

Weitere typische Merkmale von Dæmons sind:

Aus Sicherheitsgründen sollte ein Dæmon zudem noch folgendes tun:

Ziel

In diesem Artikel wird ein Dæmon erstellt, der den folgenden Kriterien genügt:

Aufgabe das Dæmons wird es sein, auf TCP-Port 70 zu lauschen und jedem dorthin verbindenden Client die aktuelle Uhrzeit mitzuteilen. In einem späteren Artikel werde ich den bestehenden Code noch um Kommandozeilenargumente, eine Konfigurationsdatei und bessere Handhabung des Startens/Stoppens erweitern.

Gabeln

Hauptmerkmal des Dæmons ist die Abspaltung in den Hintergrund, der sog. Fork. Die grundlegende Funktion wird von jedem POSIX-konformem System (also nicht von Windows) über den fork(3p)-Syscall bereitgestellt, der in Ruby über die Methode Kernel#fork bereitgestellt wird. #fork „spaltet“ den aktuellen Prozess in zwei und gibt im bisherigen Prozess (dem Elternprozess) die PID des neuen Prozesses (des Kindprozesses) zurück. Im Kindprozess dagegen gibt #fork schlicht nil zurück. Folgt man der Ruby-Dokumentation, so sollte man im Elternprozess nach dem Aufruf von #fork noch klarstellen, welches Interesse der Elternprozess an seinem neuen Kind hat — im Falle eines Dæmons überhaupt keines. Der Elternprozess dient nur als Startrampe für den neuen Prozess und wird unmittelbar nach Aufruf von #fork beendet; fehlt besagte Klarstellung, wird er vom Kernel jedoch künstlich als „Zombie“ am Leben erhalten.

Der folgende Code forkt und schließt den Elternprozess sauber ab:

if pid = fork
  # Dies hier wird nur im Elternprozess ausgeführt
  Process.detach(pid)
  exit
end

# Ab hier beginnt der Kindprozess-Code

Process::detach teilt dem Kernel mit, dass dieser (Eltern-)prozess kein Interesse an dem Kind mit der angegebenen PID hat. Der Kernel darf den Prozess vollständig entfernen, sodass kein Zombieprozess zurückbleibt. Da der Elternprozess nicht weiter benötigt wird, wird er mithilfe von #exit beendet.

Boilerplate

Nun gilt es, im neugeschaffenen Kindprozess ein paar Sicherheitsgrundsätze umzusetzen. Es muss ein sicherer Zustand hergestellt werden und nutzlose Verbindungen zur Außenwelt werden gekappt (ein Dæmon läuft ohne Nutzerinteraktion).

  1. Eine umask muss festgelegt werden, d.h. die Standardzugriffsrechte für neu angelegte Dateien werden festgelegt.
  2. Das Syslog muss geöffnet werden.
  3. Der Dæmon muss Wurzel einer eigenen Prozessgruppe werden.
  4. Die Standard-IO-Streams werden geschlossen.

umask

Die umask legt fest, welche Zugriffsrechte für neu erstellte Dateien (einschließlich etwaiger Logfiles abseits vom Syslog) festgelegt werden. Zu beachten ist dabei, dass die hier festgelegte Maske von der hypothetischen „ich-darf-alles-Maske“ 0777 abgezogen wird. Eine umask von 0 würde folglich Lese-, Schreib- und Ausführrechte für alle neuen Dateien gewähren. Sinnvoller erscheint eine Reduktion auf rw-r-----, also Lese- und Schreibrechte für den Eigentümer, Leserechte für die Gruppe, ansonsten keine Rechte. Die erforderlichen numerischen Werte können chmod(1) entnommen werden, sodass sich dafür folgender Code ergibt (File::umask setzt die umask):

# Neue Dateien mit rw-r-----
File.umask(0137)

Syslog öffnen

Da ein Dæmon keinen Kontakt zur direkten Außenwelt mithilfe der Standard-IO-Streams pflegt, braucht es andere Kanäle. Der typische Weg dafür ist die Ausgabe in das Systemlog (Syslog), das mithilfe von journalctl eingesehen werden kann (Linux-Distributionen, die nicht auf Systemd + Journald setzen, führen meist die klassische Logfile /var/log/syslog). Ruby enthält bereits in der stdlib ein Modul für den Syslog-Zugriff:

# Syslog-Modul einbinden
require "syslog"

# ...

# Syslog öffnen. Syslog::open erfordert einen kurzen Bezeichner,
# unter dem alle Meldungen, die dieses Programm loggt, ausgegeben
# werden. Syslog::log sollte selbsterklärend sein.
Syslog.open("foo")
Syslog.log(Syslog::LOG_INFO, "Starting up.")

Selbstverwaltung

Ein geforkter Prozess übernimmt die Umgebung von seinem Elternprozess, insbesondere die Standard-IO-Streams und die Prozessgruppe. Der neue Prozess muss aber vollständig selbständig sein, was sich durch die Anforderung einer eigenen Session-ID vom Kernel über Process::setsid erreichen lässt:

# Neue Prozessgruppe anfordern, um sich vollständig vom
# Elternprozess zu lösen (Achtung! Ab hier kein
# Terminalzugriff mehr!)
Process.setsid

Aufräumen

Die nunmehr nutzlosen Standardstreams, die in Ruby als STDIN, STDOUT und STDERR zur Verfügung stehen, können nun der Ordentlichkeit halber geschlossen werden. Ich möchte an dieser Stelle wegen eines weiterverbreiteten Missverständnisses noch einmal betonen, dass diese Konstanten dafür gedacht sind, immer auf das kontrollierende Terminal zu zeigen. Sie sind nicht dafür gedacht, die Standardein- und -ausgabe in eine Datei umzulenken (deswegen sind es ja auch _Konstant_en). Dies ist durch Neuzuweisung der entsprechenden globalen Variablen $stdin, $stdout und $stderr zu erreichen.

# Standard-Dateideskriptoren schließen, weil wir sie
# ohnehin nicht verwenden können.
STDIN.close
STDOUT.close
STDERR.close

Prozessname

Eher kosmetisch ist die Zuweisung eines hübschen Prozessnamens. Durch Zuweisung an $0 kann festgelegt werden, wie der Prozess von Werkzeugen wie ps(1) angezeigt wird:

# Hübschen Prozessnamen festlegen
$0 = "foo"

PID File

Jeder Dæmon sollte ein PID-File schreiben, um gewährleisten zu können, dass Prozessmanager wie systemctl ihn richtig ansprechen können. Leider stellt sich dies bei Verwendung der auf jeden Fall aus Sicherheitsgründen zu empfehlenden Abgabe von root-Rechten als etwas problematisch heraus. PID-Files werden üblicherweise in /run (früher /var/run) abgelegt, einem Verzeichnis, das nur für root schreibbar ist. So ist es zwar kein Problem, die PID-File erst einmal anzulegen:

# Wenn bereits eine PID-File existiert, den Start verweigern. Zwei Dæmons,
# die miteinander kämpfen, sind gefährlich.
if File.exists?("/run/foo.pid")
  Syslog.log(Syslog::LOG_CRIT, "PID file /run/foo.pid exists. Exiting.")
  raise "PID file /run/foo.pid exists."
end

File.open("/run/foo.pid", "w"){|pidfile| pidfile.write($$)}

Problematisch wird es jedoch beim Entfernen. Nachdem man wie unten gezeigt die root-Rechte abgegeben hat, ist es nicht mehr möglich, die PID-File wieder zu löschen (auch nicht mithilfe von #at_exit). Es gibt zwar Tricks, dies zu umgehen, diese sind jedoch aus Sicherheitsgründen nicht zu empfehlen: Ein Angreifer, der in den Prozess einbricht, könnte eine beliebige PID in die PID-File schreiben und so Systemd oder andere Initmanager dazu bewegen, den falschen Dienst zu beenden (etwa sshd). Aus diesem Grunde werden viele Dæmons entweder mit Kontrollskripts ausgeliefert (apachectl), die ihrerseits selbst als root laufen und nach erfolgter Beendigung des eigentlichen Dæmons die PID-File selbst entfernen, oder der Dæmon übernimmt diese Aufgabe vor dem Fork als Resultat der Auswertung seiner Kommandozeile (postfix stop) und sieht vom Fork ab. Letztere Variante werde ich in einem Folgepost vorstellen.

Verzeichnisse

Ein Dæmon, der keinen Zugriff auf das Dateisystem benötigt, sollte per chroot(2) in einem leeren Verzeichnis eingesperrt werden. Dies ist aber auch für Dæmons sinnvoll, die nicht Zugriff auf alle Verzeichnisse benötigen, sondern nur auf bestimmte Verzeichnisse, die von ihnen selbst gemanaged werden (ein Beispiel dafür ist Postfix’ /var/spool/postfix). Diese können dann entweder schlicht (durch den Dæmon) im Chroot angelegt werden, oder per mount --bind in das Chroot eingehängt werden. Nach erfolgten Wechsel der Verzeichniswurzel muss immer noch ein separater Wechsel des Arbeitsverzeichnisses folgen, da ein Zugriff auf Dateien außerhalb des Chroots sonst immer noch mithilfe relativer Pfade möglich wäre. Nach der Ausführung von Dir::chroot beziehen sich alle absoluten Verzeichnisangaben auf die neue Wurzel, sodass der Aufruf von Dir::chdir mit / erfolgen muss1.

# Begrenze den Dateisystemzugriff dieses Prozesses
# (Verzeichnis muss existieren)
Dir.chroot("/run/empty")

# In das Wurzelverzeichnis der neuen Wurzel wechseln
Dir.chdir("/")

Letzte privilegierte Operationen

An dieser Stelle sind die letzten Operationen durchzuführen, für die root-Rechte erforderlich sind. Da unser Dæmon auf Port 70 – ein privilegierter Port (< 256), den man nur mit root-Rechten öffnen kann – lauschen soll, muss dieser nun geöffnet werden:

require "socket"

# ...

# Privilegierten Port öffnen
server = TCPServer.new(70)

Rechte abgeben

Nun ist der Zeitpunkt gekommen, die administrativen root-Rechte abzugeben. An sich ist der dafür erforderliche Code in Gestalt des Aufrufs von Process::Sys::setuid plus zusätzlicher Überprüfung reichlich unspektakulär (die Nutzer-ID 1000 sollte natürlich durch eine sinvolle wie etwa die von nobody ersetzt werden):

# Privilegien abgeben
Process::Sys.setuid(1000)

# Sicherstellen, dass sie wirklich weg sind und keine root-Rechte
# mehr möglich sind:
begin
  Process::Sys.setuid(0)
rescue Errno::EPERM
  Syslog.log(Syslog::LOG_INFO, "Successfully dropped privileges.")
else
  Syslog.log(Syslog::LOG_CRIT, "Regained root privileges! Exiting!")
  raise "Regained root privileges!"
end

Fatalerweise ist an dieser Stelle jedoch häufig ein Aufruf von Process::euid= zu finden. Dieser Aufruf garantiert jedoch nicht, dass der Prozess dauerhaft unterprivilegiert bleibt. Vielmehr hält er ein Hintertürchen offen, das das privilege dropping effektiv wertlos macht. Daher ist auf den Aufruf der richtigen Methode unbedingt zu achten.

Eigentlicher Code

Zuletzt folgt der eigentliche Dæmon-Code, den ich dem Leser zum Selbststudium überlassen möchte. Es handelt sich dabei um keine sonderlich spektakuläre Verwendung von Rubys Socket-Bibliothek:

$finish = false

Signal.trap("SIGTERM") do
  $finish = true
end

threads = []
loop do
  # Timeout zur Prüfung der Beendigungsbedingung
  if IO.select([server], nil, nil, 5)
    t = Thread.start(server.accept) do |client|
      Syslog.log(Syslog::LOG_INFO, "Accepted connection from #{client.remote_address.ip_address}")
      client.puts("Local time is #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}")
      client.close
    end
    threads << t
  end

  if $finish
    threads.each(&:join)
    break
  end
end

Syslog.log(Syslog::LOG_INFO, "Shutting down.")
Syslog.close

Testen

Der Dæmon benötigt root-Rechte einmal wegen der Bindung an Port 70, aber auch wegen der Aufrufe von chroot und setuid. Er muss folglich mit sudo gestartet werden:

$ sudo ruby foo.rb

Ein Blick ins Syslog sollte sich dabei lohnen.

Mär 09 17:22:57 atlantis sudo[10925]: quintus : TTY=pts/0 ; PWD=/home/quintus/cronfile ; USER=root ; COMMAND=/usr/bin/ruby foo.rb
Mär 09 17:22:57 atlantis sudo[10925]: pam_unix(sudo:session): session opened for user root by (uid=0)
Mär 09 17:22:57 atlantis foo[10928]: Starting up.
Mär 09 17:22:57 atlantis foo[10928]: Successfully dropped privileges.

Auch die Verbindung kann getestet werden:

$ telnet 70
Trying ::1...
Connection failed: Verbindungsaufbau abgelehnt
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
Local time is 2014-03-09 16:23:47
Connection closed by foreign host.
Mär 09 17:23:47 atlantis foo[10928]: Accepted connection from 127.0.0.1

Vollständiger Code

# -*- coding: utf-8 -*-
require "syslog"
require "socket"

# Process.detach verhindert Zombies
if pid = fork
  Process.detach(pid)
  exit
end

# Neue Dateien mit rw-r-----
File.umask(0137)

# Syslog öffnen. Syslog::open erfordert einen kurzen Bezeichner,
# unter dem alle Meldungen, die dieses Programm loggt, ausgegeben
# werden. Syslog::log sollte selbsterklärend sein.
Syslog.open("foo")
Syslog.log(Syslog::LOG_INFO, "Starting up.")

# Neue Prozessgruppe anfordern, um sich vollständig vom
# Elternprozess zu lösen (Achtung! Ab hier kein
# Terminalzugriff mehr!)
Process.setsid

# Standard-Dateideskriptoren schließen, weil wir sie
# ohnehin nicht verwenden können.
STDIN.close
STDOUT.close
STDERR.close

# Hübschen Prozessnamen festlegen
$0 = "foo"

# Wenn bereits eine PID-File existiert, den Start verweigern. Zwei Dæmons,
# die miteinander kämpfen, sind gefährlich.
if File.exists?("/run/foo.pid")
  Syslog.log(Syslog::LOG_CRIT, "PID file /run/foo.pid exists. Exiting.")
  raise "PID file /run/foo.pid exists."
end

File.open("/run/foo.pid", "w"){|pidfile| pidfile.write($$)}

# Begrenze den Dateisystemzugriff dieses Prozesses
# (Verzeichnis muss existieren)
Dir.chroot("/run/empty")

# In das Wurzelverzeichnis der neuen Wurzel wechseln
Dir.chdir("/")

# Privilegierten Port öffnen
server = TCPServer.new(70)

# Privilegien abgeben
Process::Sys.setuid(1000)

# Sicherstellen, dass sie wirklich weg sind und keine root-Rechte
# mehr möglich sind:
begin
  Process::Sys.setuid(0)
rescue Errno::EPERM
  Syslog.log(Syslog::LOG_INFO, "Successfully dropped privileges.")
else
  Syslog.log(Syslog::LOG_CRIT, "Regained root privileges! Exiting!")
  raise "Regained root privileges!"
end

########################################
# Eigentlicher Code

$finish = false

Signal.trap("SIGTERM") do
  $finish = true
end

threads = []
loop do
  # Timeout zur Prüfung der Beendigungsbedingung
  if IO.select([server], nil, nil, 5)
    t = Thread.start(server.accept) do |client|
      Syslog.log(Syslog::LOG_INFO, "Accepted connection from #{client.remote_address.ip_address}")
      client.puts("Local time is #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}")
      client.close
    end
    threads << t
  end

  if $finish
    threads.each(&:join)
    break
  end
end

Syslog.log(Syslog::LOG_INFO, "Shutting down.")
Syslog.close

Valete.

Update

Weil ich darauf hingewiesen worden bin: Selbstverständlich ist es nach dem Schließen der Standard-IO-Streams nicht mehr möglich, Methoden wie #puts zu verwenden. Stattdessen würde eine Exception ähnlich dieser geworfen:

x.rb:4:in `write': closed stream (IOError)
        from x.rb:4:in `puts'
        from x.rb:4:in `puts'
        from x.rb:4:in `<main>'

Dies kann insbesondere bei Gems (beispielsweise durch Deprecation Warnings) problematisch sein, über die man selbst keine Kontrolle hat. Es kann insofern sinnvoll sein — und sollte im Sinne der Nachprüfbarkeit auch empfohlen sein — die Standardausgabe und den Standard-Error-Stream in eine Logfile zu leiten. Eine rudimentäre Version ohne hübsche Zeitstempel sähe dabei einfach so aus:

$stdout = $stderr = File.open("/var/log/foo.log", "a")
  1. Tatsächlich sollte auch ein Dæmon, der ohne Chroot auskommt, sein Arbeitsverzeichnis auf / stellen – dies ist das einzige Verzeichnis, dessen Existenz auf jeder noch so exotischen Linux-Distribution garantiert ist.