my shellcode guide

reinhard@finalmedia.de Thu 15 Feb 2024 07:58:14 AM CET

Vorab

Externe Referenzen und Anlehnung an IEEE Std 1003.1-2017

Regel 1

Traue keinem Input. Führe für jede übergebenen und prozessierten Content ein explizites character cleaning durch! (z.b mit tr -dc um komplementäres charset zu löschen)

Fange leere Strings ab. Fange Backspaces \b ab. Fange Backticks ` oder Dollarzeichen $ und Commandsubtitution sowie Pipezeichen | und Umleitungen wie < und > bei deiner Inputvalidierung ab. Vermeide Ausrufezeichen in Strings in Scripten. Vermeide Asterisk. Denke an mögliche (versehentliche injection von Parametern durch Minuszeichen). Bedenke mögliche Leerzeichen in Parametern und Dateinamen. Bedenke Kombinationen.

Aufgrund der vielen Problemfälle ist es daher besser, generell nur den erlaubten Zeichensatz zu definieren und damit alle anderen Zeichen herausfiltern. Alles verboten. Ausnahmen erlauben. Nicht umgekehrt. Du kannst sonst eher etwas vergessen haben. Heisst auch: Nach dem Filtern verodert(!) prüfen ob nonzero, sonst Abbruch:

input="$(echo "${variable}" | tr -dc "abcdefg")"
test -n "${variable}" || exit 1

Regel 2

Arbeite nach Unix-Philosophie mit Pipes und schreibe deine Scripte als Filter. Nutze nicht nur exitcodes sondern die Standardströme. Verkette diese in anderen Scripten. Mach ein Ding und mach es richtig. Vermeide Args und Parameter. Wähle deine Filternamem selbsterklärend. Die richtige Namensfindung dauert manchmal länger als das eigentliche Programmieren.

Vermeide: programm -o output -i datei; weiter output; abschluss; report 
Nutze: programm < datei | weiter && abschluss && report || problem

der aufruf “problem” findet statt, sobald irgendein element in der pipe failed. abschluss und report werden hingegen nur ausgeführt, wenn die vorhergien aufrufe auch erfolgreich waren, also exitcode 0 hatten.

Regel 3

Vermeide Wildcards, die als Shellparameter Verwendung finden und der Wildcard Expansion unterliegen. Man kann diese sonst zur Parameter Injection über geschickt gewählte Dateinamen verwenden. (Bei einer tar zeile führt das mittels bandwechselbefehl zur code execution und man kann darüber auf einem system je nach kontext root reche erschleichen, wenn entsprechende cronjobs über Dateien laufen, die zuvor ein Nutzer mit niedrigeren Rechten eigenen Dateien angelegt hat, die Minuszeichen enthalten). Verständlicheres Beispiel

Niemals: find *.xyz -type f
Nutze: find -type f -name "*.xyz"

Der wildcard * wird im ersten Fall von der Shell selbst evaluiert, sie schaut also erst nach allen Dateien, die auf .xyz enden und schreibt diese als Parameter. also wird daraus dieser Befehl

find datei1.xyz datei2.xyz datei3.xyz …

und das führt die Shell im Anschluss aus. Das ist ein Problem, da Dateinamen ein Minus und Leerzeichen sowie Anführungszeichen enthalten können. Eine Datei namens

'"bullshit" -type d "exe" nix.xyz'
würde also einen parameter -type d injecten, der auch Verzeichnisse findet und zudem andere Dateitypen und Namen wie bullshit und exe

Im zweiten Fall ist das hingegen nicht so, da Anführungszeichen verwendet werden und der String "*.xyz" als Argument an find übergeben wird, dass dann intern im Dateisystem nach den Dateien sucht, die dieses Suffix haben und diese dann clean prozessiert, auch wenn sie exotische Dateinamen haben.

Regel 4

Nutze jede Möglichkeit, Dateinamen und Verzeichnisnamen selbst vorzugeben und auf ungefährlicheren Zeichenumfang zu beschränken: a-z 0-9 und den Punkt (vermeide Minus und Spaces)

Wenn Nutzer den Dateinamen vorgeben dürfen, ist Vorsicht geboten. Daher auch besser mit stdin arbeiten.

Vermeide: meintool dateiname
Nutze: meintool < dateiname

Regel 5

Schreibe keinen shell-spezifischen code, wenn du nicht sicher wissen kannst, ob die gewünschte shell in gewünschter Version mit gewünschten features auf dem Zielsystem verfügbar ist und bleibt. Daher nicht mit #!/bin/bash sondern mit #!/bin/sh einleiten und im gesamten Script den kleinsten gemeinsame Nenner bzgl. Syntax und Tooling nutzen.

deine shellscript soll also später unter ash, ksh, zsh, dash, bash oder busybox sh gleichermaßen lauffähig sein - und daher darfst du keine spezialfunktionaltäten dieser shells verwenden.

Vermeide: #!/bin/bash
Nutze: #!/bin/sh

Regel 6

Nutze posix shell kompatibele Befehle, also die coreutils zur string validation und substitution, sowie fallunterscheidungen. Vermeide interne Befehle und Functions der Shell, die das Gleiche ermöglichen würden, da ihre Verwendbarkeit versionsabhängig ist.

Regel 7

Nutze keine legacy Backticks, sondern geklammerten Aufruf wie $(befehl) und auch keine offenen variablennamen $name sondern stets ${name} da nur diese eindeutig Start und Ende des variablennamen selbst vorgeben. Auch wenn das in einigen Shells sauber erkannt wird, gilt es nicht für alle.

Vermeide: "$name"
Nutze: "${name}"

Regel 8

Nutze daher auch das tool “test” statt “[ und if then else fi” Verkette kurz und knapp mit && und || und nutze stets die Exitcodes. Arbeite mit Kontrollfluss. also continue, break und exit. Vermeide kaskadierte fallunterscheidung (heisst: kein spaghetticode if then)

Vermeide: if [ ...
Nutze: test "$name" -gt 3 && ...

Regel 9

Bevorzuge Oder-Verkettung, um auch den Fall abzufangen wenn das coreutils test tool nicht verfügbar ist oder unvorhergesehen arbeitet. Du brauchst das, um auch dann zum clean exit und einer Notabschaltung zu kommen. Beispie… Für den Fall “Abbruch, falls eine Datei nicht existiert” gilt


Vermeide: test ! -f "$filename" && exit 1
Nutze: test -f "$filename" || exit 1

Die zweite Variante beendet das Script bei jedem erdenklichen Fehler, der auch test selbst betrift, wie dessen potentielle Nichtverfügbarkeit oder einen Absturz, denn jeder exitcode ungleich 0 führt zum Beenden des restlichen scripts mit fehlercode 1.

Die erste Variante hingegen setzt zum Abbruch des Scripts einen Exitcode 0 des vorherigen tests voraus , um das Script zu beenden. Das ist aber nicht immer garantiert. Daher kann diese Zeile im Programmfluss unvorhergesehen übergangen werden und nachfolgende Fälle werden dennoch ausgeführt. Daher ist diese Variante zu vermeiden.

Regel 10

Willst du auf die Verfügbarkeit deines Toolings testen, tue das direkt zu Beginn des Scripts mit verodertem Sofortabbruch des Scripts. Nutze unterschiedlichen exitcodes ab 100 zur externen Diagnose. Beispiel

which toolA >/dev/null || exit 101
which toolB >/dev/null || exit 102
which toolC >/dev/null || exit 103

which testet nicht nur den pfad des tools und gibt diesen auf stdout aus, sondern auch ob das angegebene tool überhaupt existiert und nutzbar ist. Die jeweilige Zeile hat somit nur dann einen exitcode von 0, wenn which selbst verfügbar ist, und das entsprechend angefragte tool verfügbar und auch nutzbar ist. Ist also das gewünschte tool nicht nutzbar, beendet sich das Script.

Das ist wichtig, wenn du dich im späteren Verlauf des Scripts auf die Verwendbarkeit und Verfügbarkeit eher verlassen können möchtest. Natürlich kann ein Tool auch stets während der Ausführung eines Scripts verschwinden und gelöscht werden. Das ist aber ein anderer Fall.