Aus Linux-Magazin 06/2022

Verteilte Systeme in der Bash schreiben

© Aleksandar Gligoric / 123rf.com

Die Bash ist besser als ihr Ruf. In der ersten Folge unser Shell-Serie öffnet der Autor seine Schatzkiste und holt ein paar Perlen hervor, die jedes Skript schmücken.

Bash-Code sei langsam, schwer zu lesen und für mehr als kurze Skripte nicht zu gebrauchen, so behaupten Kritiker. Nicht an allem, was man der Bash nachsagt, ist aber tatsächlich etwas dran. In Sachen Performance kann die Bash zwar nicht mit Python oder Perl mithalten, aber als natürliche Schnittstelle aller textbasierten Anwendungen in der Unix-Welt ist sie prädestiniert zum Integrieren von Anwendungen. Wer diese Stärke der Bash nutzt und einen großen Bogen um ihre zahlreichen Schwächen macht schreibt im Handumdrehen Skripte, die selbst passionierten Python-Profis die Sprache verschlagen. Diese Serie zeigt Schritt für Schritt, wie Sie die Fähigkeiten der Bash nutzen, um leicht verständliche und sichere verteilte Systeme zu entwickeln.

Außerhalb der Pufferzone

Alle Skriptsprachen haben eine Gemeinsamkeit: Sie werden nicht kompiliert, sondern ein Interpreter führt den Code zur Laufzeit aus. Hier ergeben sich aber schon die ersten Unterschiede: Starten Sie ein Python-Skript, lädt der Interpreter das gesamte Skript in den Speicher und beginnt erst dann, es zu interpretieren. Bei der Bash läuft das anders: Sie interpretiert das Skript schon beim Lesen. Das hat zur Folge, dass man ein Bash-Skript nicht einfach verändern kann, während es läuft.

Das Skript in Listing 1 demonstriert dieses Problem. Es gibt eine Nachricht aus und fügt dann Leerzeichen am eigenen Anfang ein, was den Inhalt des Skripts nach hinten verschiebt. Die Position, die die Bash sich gemerkt hat, stimmt nicht mehr mit dem Inhalt des Skripts überein, und die letzten zwei Zeilen werden immer wieder ausgeführt (Abbildung 1).

Listing 1

Endlos ohne Schleife

#!/bin/bash
echo "Diese Zeile wird nur einmal ausgegeben"
padded=$(printf '%*s' 256 ' ' | cat - "$0")
echo "$padded" > "$0"
Abbildung 1: Der Code aus <a href="#artRef-l1">Listing&nbsp;1</a> nimmt eine triviale Code Injection vor.

Abbildung 1: Der Code aus Listing 1 nimmt eine triviale Code Injection vor.

Das mag sich erst einmal harmlos anhören, doch für dieses Problem gibt es in einschlägigen Kreisen eine Bezeichnung, bei der jeder hellhörig werden sollte: Code Injection. Es geht also nicht nur um ein Fehlverhalten, das man selbst durch versehentliches Editieren verursacht, sondern auch darum, dass ein Angreifer bewusst Skripte ausnutzt, um die eigenen Rechte auszuweiten – beispielsweise die, die man im Home-Verzeichnis eines jeden Administrators findet.

Was nach einem Designfehler aussieht, ist aber durchaus beabsichtigt. Weil die Bash Skripte zeichenweise liest und interpretiert, kann sie sehr große Skripte auch auf Systemen mit wenig Speicher ausführen – zugegeben ein schwacher Trost für jeden, der seinen Computer nach der Jahrtausendwende gekauft hat. Da sich dieses Verhalten auch nicht konfigurieren lässt, gilt es also, einen anderen Weg zu finden, um laufende Skripte vor Code Injection zu schützen. Die Bash gibt uns dazu auch eine simple Lösung an die Hand.

Im Gruppenzwang

Mehrere von geschweiften Klammern eingeschlossene Befehle, sogenannte Befehlsgruppen, liest die Bash immer am Stück. Daher lässt sich das gesamte Skript in geschweifte Klammern setzen, um zu verhindern, dass die Befehlsgruppe während der Ausführung verändert wird. Das schafft das Problem allerdings noch lange nicht aus der Welt, da es nach wie vor möglich ist, Befehle an das Ende des Skripts anzuhängen, wo sie nach der Befehlsgruppe ausgeführt würden. Das lässt sich verhindern, indem man dafür sorgt, dass nach der Befehlsgruppe auch wirklich Schluss ist, indem man das Skript mit »exit« beendet. Listing 2 zeigt den auf diese Weise geschützten Code von Listing 1.

Listing 2

Geschütztes Skript

#!/bin/bash
{
  echo "Diese Zeile wird nur einmal ausgegeben"
  padded=$(printf '%*s' 256 ' ' | cat - "$0")
  echo "$padded" > "$0"
  exit 0
}

Da diese Technik die Bash dazu zwingt, das komplette Skript in den Arbeitsspeicher zu laden, können sich Veränderungen des Skripts im Dateisystem nicht auf eine laufende Instanz auswirken. Veränderungen im Dateisystem verhindert das aber nicht. Wollen Sie auch diese Möglichkeit ausschließen, schützen Sie Ihre Skripte mittels »chmod« vor Überschreiben und stellen sicher, dass das System nicht von der Dirty-Pipe-Schwachstelle [1] betroffen ist.

Neben Befehlsgruppen liest die Bash auch Funktionen immer am Stück. Daher bietet es sich an, Bash-Skripten eine »main«-Funktion zu spendieren, die von einer kurzen Befehlsgruppe aufgerufen wird. Listing 3 zeigt diese Grundstruktur.

Listing 3

Sicheres Fundament

#!/bin/bash
main() {
  return 0
}
{
  main "$@"
  exit "$?"
}

Funktionen haben auch den Vorteil, dass sie die Deklaration lokaler Variablen erlauben. Warum das wichtig ist, wird klar, wenn man sich die Regeln für die Gültigkeit von Variablen vor Augen führt, den Variablen-Scope. Anders als in Python haben Variablen in Bash einen dynamischen Scope. Variablen, die Sie nicht mittels »local« oder »declare« (ohne »-g«) deklarieren, gelten global, und Funktionen erben den Scope des Aufrufers.

Besonders der zweite Punkt sorgt häufig für Verwirrung, da sich dieses Verhalten grundlegend von dem anderer Sprachen unterscheidet. Wenn eine Bash-Funktion eine Variable verändert, die nicht sie selbst sondern der Aufrufer deklariert hat, wird die Variable des Aufrufers verändert. Das gilt selbst dann, wenn die Variable als lokal deklariert wurde. Das Skript in Listing 4 und die dazugehörige Ausgabe in Abbildung 2 zeigen, wie sich das in der Praxis auswirkt.

Listing 4

Dynamischer Scope

#!/bin/bash
servus_local() {
  local gruss="Servus"
  moin_local
  echo "[$FUNCNAME] $gruss $USER"
}
moin_local() {
  local gruss="Moin"
  echo "[$FUNCNAME] $gruss $USER"
}
servus_dynamic() {
  local gruss="Servus"
  moin_dynamic
  echo "[$FUNCNAME] $gruss $USER"
}
moin_dynamic() {
  gruss="Moin"
  echo "[$FUNCNAME] $gruss $USER"
}
servus_global() {
  gruss="Servus"
  moin_global
  echo "[$FUNCNAME] $gruss $USER"
}
moin_global() {
  gruss="Moin"
  echo "[$FUNCNAME] $gruss $USER"
}
main() {
  servus_local
  echo "[$FUNCNAME] $gruss $USER"
  servus_dynamic
  echo "[$FUNCNAME] $gruss $USER"
  servus_global
  echo "[$FUNCNAME] $gruss $USER"
}
{
  main "$@"
  exit "$?"
}
Abbildung 2: Ein dynamischer Scope erlaubt, Variablen des Aufrufers zu ver&auml;ndern.

Abbildung 2: Ein dynamischer Scope erlaubt, Variablen des Aufrufers zu verändern.

Dasselbe gilt auch für durch Sprachkonstrukte wie For-Schleifen gesetzte Variablen. Wer sich lange Debugging-Sessions ersparen will, dem sei deshalb geraten, alle Variablen peinlichst genau mit »local« oder »declare« zu deklarieren.

Für Verwirrung sorgt aber nicht allein der dynamische Scope, sondern auch die fehlenden Warnungen beim Lesen ungesetzter Variablen. Doch auch bei diesem Problem lässt die Bash den Entwickler nicht völlig im Stich. Um ungesetzte Variablen zu finden, kann man Skripte mit »bash -u Skript« ausführen oder im Skript selbst »set -u« aufrufen. Das bringt die Bash dazu, eine Fehlermeldung auszugeben, wenn eine ungesetzte Variable referenziert wird.

Wem das noch nicht reicht, der nutzt Shellcheck [2], um seine Skripte statisch zu analysieren. Shellcheck findet neben ungesetzten Variablen auch viele weitere Problemklassen, die latente Bugs darstellen. Da es Skripte statisch analysiert, ist es zwar kein Allheilmittel, aber wer auch nur gelegentlich Skripte schreibt, der wird in Shellcheck einen guten Freund finden.

Dinge beim Namen nennen

Variablen zu deklarieren und Funktionen zu verwenden führt aber nicht automatisch zu lesbaren Skripten. Funktionen in Bash-Skripten haben keine Argumente wie Funktionen in C oder Java, sondern Positionsparameter. Die tragen keine bezeichnenden Namen, sondern sind durchnummeriert, was Konsequenzen für den Entwickler hat.

Laut Millers Gesetz [3] kann der Mensch im Durchschnitt sieben (plus minus zwei) Informationseinheiten im Kurzzeitgedächtnis halten. Mit steigender Parameteranzahl erfordert also das Verständnis immer mehr Konzentration, und damit wächst die Wahrscheinlichkeit, dass sich Fehler einschleichen. Hier hilft auch keine noch so gute Dokumentation, da man sich die Bedeutung der Positionsparameter letztendlich doch irgendwie merken muss.

Ein kleiner Trick schafft jedoch schnell Abhilfe: Am Anfang jeder Funktion deklariert man lokale Variablen für jeden Positionsparameter (Listing 5). Das hat nicht nur den Vorteil, dass man in der Funktion mit deskriptiven Variablennamen auf Parameter verweisen kann, sondern man sieht auch direkt auf einen Blick, welche Parameter eine Funktion erwartet.

Listing 5

Gedächtnisstütze

email_senden() {
  local adresse="$1"
  local title="$2"
  local nachricht="$3"
  [...]
}

Mit Verweis auf Millers Gesetz sei an dieser Stelle noch ein weiterer Trick erwähnt, der das Kurzzeitgedächtnis entlastet. Wegen des dynamischen Scopes ist es zwar sinnvoll, alle lokalen Variablen am Anfang einer Funktion zu deklarieren, man kann das aber auch am Anfang jedes anderen Blocks tun – etwa in einer Schleife, wie in Listing 6.

Da die Bash keinen Block-Scope hat, macht es für sie keinen Unterschied, ob die Variable innerhalb oder außerhalb der Schleife deklariert wurde. Der Trick dient vielmehr als Hinweis an andere Entwickler, dass man die Variable am Ende der Schleife aus dem Kurzzeitgedächtnis werfen darf. Wird eine solche Variable außerhalb der Schleife referenziert, weiß man sofort, dass man einen Bug gefunden hat.

Listing 6

Fiktiver Block-Scope

zeilen_verarbeiten() {
  local zeilen=("$@")
  local zeile
  for zeile in "${zeilen[@]}"; do
    local spalten
    read -ra spalten <<< "$zeile"
    spalten_verarbeiten "${spalten[@]}"
  done
}

Möchten Sie noch einen Schritt weiter gehen, entfernen Sie die Variablen am Ende der Schleife mit »unset« aus dem Scope. Hier gilt aber dieselbe Warnung wie schon beim Setzen von Variablen: Stammt die Variable aus dem Scope des Aufrufers, wird sie auch dort entfernt.

Eins nach dem anderen

Als wären das nicht schon genug Warnungen vor Variablen, kommt hier noch eine: Vorsicht beim gleichzeitigen Deklarieren und Initialisieren einer Variable. Beide Aktionen erfolgen nämlich weder gleichzeitig noch in der Reihenfolge, in der man es erwarten würde. Bei direkten Zuweisungen ist das zwar unproblematisch, aber stammt der zugewiesene Wert aus einer Subshell – also einem Befehl oder einer Funktion –, dann hat das Konsequenzen.

Ein Beispiel dafür zeigt Listing 7. Bevor die Bash einen Befehl ausführt, führt sie alle Substitutionen aus – dazu zählen auch die Subshells. Erst danach führt sie den eigentlichen Befehl aus, in diesem Fall »local«. Das Bedeutet, dass es sich bei »$?«, dem Rückgabewert des letzten Befehls, in diesem Fall nicht um den Rückgabewert von Grep handelt, sondern um den von »local«. Der ist immer 0, sofern keine ungültigen Parameter übergeben wurden. Die If-Verzweigung wird also niemals ausgeführt.

Listing 7

Unerreichbar in if-Verzweigung

datei_enthaelt() {
  local datei="$1"
  local suchwort="$2"
  local ergebnis=$(grep -F "$suchwort" "$datei")
  if (( $? != 0 )); then
    # Nichts gefunden
    return 1
  fi
  return 0
}

Um dieses als Maskieren von Rückgabewerten bezeichnete Problem zu verhindern, sollten Sie Deklaration und Zuweisung von Variablen stets trennen. Nur bei direkten Zuweisungen, etwa bei denen von Positionsparametern – ist das nicht zwingend nötig. Shellcheck entdeckt aber das Maskieren von Rückgabewerten zuverlässig (siehe Kasten “Fusselrolle für Shell-Skripte”).

Fusselrolle für Shell-Skripte

Shellcheck, ein Werkzeug zum statischen Analysieren von Shell-Skripten, hat das erklärte Ziel, typische syntaktische und semantische Fehler zu erkennen und zu erklären. Neben der Bash und der Posix-Shell unterstützt es auch die Dash sowie die Korn-Shell und gibt auf die verwendete Shell zugeschnittene Warnungen aus. Das hilft besonders dann, wenn Sie Bash-spezifische Funktionen verwenden, im Shebang aber »/bin/sh« steht. Abbildung 3 zeigt die Warnungen, die Shellcheck liefert, wenn Sie es auf Listing 7 ansetzen. Neben dem fehlenden Shebang bemängelt Shellcheck zum einen, dass Deklaration und Zuweisung der Variable »ergebnis« im selben Befehl stattfinden, und zum anderen, dass die Variable nie verwendet wird. Auch die indirekte Prüfung des Rückgabewerts von Grep mittels »$?« ist Shellcheck ein Dorn im Auge, da das Konstrukt später zu Problemen führt, wenn man unbedacht Befehle zwischen den zwei Zeilen einfügt. Zu jedem gefundenen Problem gibt Shellcheck auch einen Fehlercode aus. Das Shellcheck-Wiki [5] enthält zu jedem Fehlercode einen Artikel, der erklärt, warum der fragliche Code problematisch erscheint. Sie finden Shellcheck in den Paketquellen aller gängigen Distributionen, können es aber auch direkt im Webbrowser testen.

Abbildung 3: Shellcheck findet in <a href="#artRef-l7">Listing&nbsp;7</a> gleich mehrere Probleme.

Abbildung 3: Shellcheck findet in Listing 7 gleich mehrere Probleme.

Fazit

Die Bash dient nicht nur als Interpreter für Skripte, sondern hält in den meisten Fällen auch als Shell für interaktive Sitzungen her. Da die meisten Anwender erste Erfahrungen mit der Bash in interaktiven Sitzungen sammeln, verwundert es wenig, dass viele Skripte eher an eine interaktive Sitzung erinnern als an ein strukturiertes Programm. Hierin liegt sicherlich auch die Ursache dafür, dass manche der Bash die Tauglichkeit für längere Skripte gänzlich absprechen [4].

Dieser Artikel hat eine Reihe von Techniken vorgestellt, mit denen Sie Ihren Bash-Skripten ein solides Fundament geben, Code Injection verhindern, die Lesbarkeit verbessern und Problemen mit Variablen vorbeugen. Shellcheck ersetzt quasi den Compiler und hilft dabei, Probleme schon vor dem Ausführen eines Skripts zu finden. In der nächsten Folge werden wir ein kleines Framework schreiben, mit dem wir unsere Shell-Skripte in Module unterteilen und testen können. (jcb)

DIESEN ARTIKEL ALS PDF KAUFEN
EXPRESS-KAUF ALS PDFUmfang: 4 HeftseitenPreis €0,99
(inkl. 19% MwSt.)
LINUX-MAGAZIN KAUFEN
EINZELNE AUSGABE Print-Ausgaben Digitale Ausgaben
ABONNEMENTS Print-Abos Digitales Abo
TABLET & SMARTPHONE APPS Readly Logo
E-Mail Benachrichtigung
Benachrichtige mich zu:
0 Kommentare
Älteste
Neuste Beste Bewertung
Inline Feedbacks
Alle Kommentare anzeigen
Nach oben