Von optimierten Asserts bis zur Normalisierung der IP-Adresse 10 unbekannte Sicherheitslücken in Python

Ein Gastbeitrag von Dennis Brinkrolf *

Anbieter zum Thema

Viele Entwickler nutzen Python und vertrauen darauf, dass es einen soliden Security-Level bietet. Dennoch gibt es Funktionen, die zu bestimmten Sicherheitslücken führen können. Zehn dieser eher unbekannteren Sicherheitslücken werden in diesem Artikel näher erläutert.

Python ist nicht zuletzt aufgrund seiner Robustheit beliebt, vor Sicherheitslücken gefeit ist man dennoch nicht.
Python ist nicht zuletzt aufgrund seiner Robustheit beliebt, vor Sicherheitslücken gefeit ist man dennoch nicht.
(Bild: Hitesh Choudhary (@hiteshchoudhary) / Unsplash )

1. Optimierte Asserts

Zuallererst möchte ich auf das optimierte Ausführen von Code in Python hinweisen, wodurch alle Assert-Anweisungen ignoriert werden können. Wird eine Assert-Anweisung z. B. als Teil einer Authentifizierungsprüfung verwendet, kann dies zu einer Sicherheitslücke führen.

1 def superuser_action(request, user):
2 assert user.is_super_user
3 # execute action as super user

In diesem Code-Snippet würde die Assert-Anweisung (Zeile 2) missachtet werden und jeder Nicht-Superuser könnte die nächsten Codezeilen erreichen. Daher sollten Assert-Anweisungen besser nicht für sicherheitsrelevante Prüfungen eingesetzt werden.

2. MakeDirs-Berechtigungen

Mit der Funktion os.makedirs werden Dateisystem erstellt, deren Standardberechtigungen mit dem Parameter mode angegeben werden. Im folgenden Code-Snippet werden die Ordner A/B/C mit der Berechtigung rwx------(0o700) eingerichtet. Wodurch nur der aktuelle User Lese-, Schreib- und Ausführungsrechte erhält.

1 def init_directories(request):
2 os.makedirs("A/B/C", mode=0o700)
3 return HttpResponse("Done!")

Es gibt jedoch erhebliche Unterschiede in den verschiedenen Python Versionen, derer sich viele nicht bewusst sind. So werden in Python<3.6 alle angegebenen Ordner mit der Berechtigung 700 erstellt, in Python>3.6 jedoch nur der Letzte. Die anderen Ordner erhalten die Standardberechtigung 755.

3. Absolute Pfadverknüpfungen

Eine weitere oft unbekannte Besonderheit ist das Zusammenführen mehrerer Dateipfadkomponenten zu einem kombinierten Dateipfad – mittels der Funktion os.path.join(path, *paths). Beginnt eine der Komponenten mit einem „/“, werden alle vorherigen Komponenten einschließlich des Basispfads entfernt und die Komponente als absoluter Pfad behandelt.

1 def read_file(request):
2 filename = request.POST['filename']
3 file_pfad = os.path.join("var", "lib", filename)
4 if file_path.find(".") != -1:
5 return HttpResponse("Failed!")
6 with open(file_path) as f:
7 return HttpResponse(f.read(), content_type='text/plain')

In Zeile 3 wird der resultierende Pfad aus dem Usergesteuerten Eingabedateinamen mithilfe der Funktion os.path.join erstellt und dann in Zeile 4 überprüft, um eine Sicherheitslücke durch Pfadüberquerung zu verhindern. Übergibt ein Angreifer jedoch den Parameter filename /a/b/c.txt, ist die resultierende Variable file_path in Zeile 3 ein absoluter Dateipfad. Die var/lib-Komponenten einschließlich des Basispfads werden nun von os.path.join ignoriert und ein Angreifer kann jede beliebige Datei lesen.

4. Beliebige Temp-Dateien

Mit der Funktion tempfile.NamedTemporaryFile werden temporäre Dateien mit bestimmten Namen erzeugt. Jedoch sind die Präfix- und Suffix-Parameter anfällig für Pfadüberquerungsangriffe (Path Traversal). Kontrolliert ein Angreifer einen dieser Parameter, kann er an einem beliebigen Ort im Dateisystem eine temporäre Datei erstellen.

1 def touch_tmp_file(request):
2 id = request.GET['id']
3 tmp_file = tempfile.NamedTemporaryFile(prefix=id)
4 return HttpResponse(f "tmp file: {tmp_file} created!", content_type='text/plain')

Im Beispiel wird die Usereingabe-ID (Zeile 3) als Präfix für die temporäre Datei genutzt. Verwendet ein Angreifer die Payload /.. /var/www/test als id-Parameter, wird die folgende tmp-Datei generiert: /var/www/test_zdllj17. Das klingt auf dem ersten Blick harmlos, bietet aber die Grundlage, um komplexe Sicherheitslücken auszunutzen.

5. Erweiterter Zip-Slip

In Python sind die Funktionen TarFile.extractall und TarFile.extract dafür bekannt, anfällig für Zip-Slip-Angriffe zu sein. Bei diesen werden Dateinamen im Archiv so manipuliert, dass sie Pfadüberquerungszeichen (.. /) enthalten – daher sollten Archiveinträge immer als nicht vertrauenswürdige Quellen betrachtet werden.

Verhindern lassen sich solche Path-Traversal-Schwachstellen mit den Funktionen zipfile.extractall und zipfile.extract, die Zip-Einträge bereinigen. Dennoch können solche Schwachstellen auch weiterhin innerhalb der ZipFile-Bibliothek auftreten.

4 def extract_html(request):
5 filename = request.FILES['filename']
6 zf = zipfile.ZipFile(filename.temporary_file_path(), "r")
7 for entry in zf.namelist():
8 if entry.endswith(".html"):
9 file_content = zf.read(entry)
10 with open(entry, "wb") as fp:11 fp.write(file_content)12 zf.close()13 return HttpResponse("HTML files extracted!")

In diesem Fallbeispiel kann der Angreifer einen Dateinamen mit beliebigem Inhalt schaffen. Der Inhalt der schädlichen Datei wird gelesen (Zeile 9) und in den vom Angreifer kontrollierten Pfad in den Zeilen 10-11 geschrieben. Dadurch kann dieser beliebige HTML-Dateien auf dem gesamten Server kreieren.

6. Unvollständige Regex-Übereinstimmung

Regex (Reguläre Ausdrücke) sind ein wesentlicher Bestandteil vieler Webanwendungen. Sie werden häufig von Userdefinierten Web Application Firewalls (WAF) für die Eingabevalidierung verwendet, um z. B. schädliche Zeichenfolgen zu erkennen. In Python gibt es einen feinen Unterschied zwischen re.match und re.search, wie im folgenden Code-Snippet demonstriert.

3 def is_sql_injection(request):
4 pattern = re. compile(r".*(union)|(select).*")
5 name_to_test = request.GET['name']
6 if re.search(pattern, name_to_test):
7 return True
8 return False

In Zeile 4 wird ein Muster definiert, um eine mögliche SQL Injection zu erkennen. Dies kann jedoch oft umgangen werden und ist daher nicht zu empfehlen. Um zu prüfen, ob der Name der Usereingabe in Zeile 5 einen schädlichen Wert enthält, wird in die Funktion re.match mit dem zuvor definierten Muster verwendet (Zeile 6). Anders als die Funktion re.search wird diese Funktion jedoch nicht auf neue Zeilen angewendet. Dadurch besteht die Möglichkeit, dass die Usereingabe nicht mit dem Regex übereinstimmt und die Prüfung umgangen werden kann.

Jetzt Newsletter abonnieren

Täglich die wichtigsten Infos zu Softwareentwicklung und DevOps

Mit Klick auf „Newsletter abonnieren“ erkläre ich mich mit der Verarbeitung und Nutzung meiner Daten gemäß Einwilligungserklärung (bitte aufklappen für Details) einverstanden und akzeptiere die Nutzungsbedingungen. Weitere Informationen finde ich in unserer Datenschutzerklärung.

Aufklappen für Details zu Ihrer Einwilligung

7. Umgehung des Unicode-Sanitizers

Mit Unicode können Zeichen in verschiedenen Darstellungen verwendet und Codepoints zugeordnet werden. In der Unicode-Norm sind vier Normalisierungen für verschiedene Unicode-Zeichen definiert, mit denen Daten auf einheitliche Weise unabhängig von der menschlichen Sprache gespeichert werden können. Das kann aber ausgenutzt werden und führte bereits zu einer Sicherheitslücke in Pythons urllib.

1 import unicodedata
2 from django.shortcuts import render
3 from django.utils.html import escape
4
5 def render_input(request):
6 user_input = escape(request.GET['p'])
7 normalized_user_input = unicodedata.normalize("NFKC", user_input)
8 context = {'my_input': normalized_user_input}
9 return render(request, 'test.html', context)

Dieses Code-Snippet demonstriert eine Cross-Site-Scripting-, also XSS-Schwachstelle, die auf der NFKC-Normalisierung basiert. In Zeile 6 wird die Usereingabe durch die Escape-Funktion von Django bereinigt, um eine XSS-Schwachstelle zu verhindern. Die bereinigte Eingabe wird über den NFKC-Algorithmus normalisiert (Zeile 7) und über die Vorlage test.html in den Zeilen 8-9 korrekt wiedergegeben.

1 <!DOCTYPE html>
2 <html lang="de">
3 <body>
4 {{ my_input | safe}}
5 </body>
6 </html>

In der Vorlage test.html ist die Variable my_input als safe markiert (Zeile 4). Durch die Verwendung des Schlüsselworts safe wird die Variable von Django jedoch nicht zusätzlich sanitized. Aufgrund der Normalisierung in Zeile 7 wird jedoch das Zeichen %EF%B9%A4 in „<“ und %EF%B9%A5 in „>“ umgewandelt. Das erlaubt Angreifern, beliebige HTML-Tags einzuschleusen und eine XSS-Schwachstelle auszulösen. Usereingaben sollten daher immer erst bereinigt werden, nachdem sie normalisiert wurden.

8. Unicode-Großbuchstabenkollision

Ein weiteres Problem, das in Python im Zusammenhang mit Unicode auftreten kann, ist das Vereinheitlichen vieler verschiedener menschliche Sprachen. Dies erhöht die Wahrscheinlichkeit, dass verschiedene Zeichen das gleiche „Layout“ aufweisen und dem gleichen Codepunkt in Großbuchstaben zugeordnet werden. Das hat bereits zu kritischen Sicherheitslücken geführt.

1 from django.core.mail import send_mail
2 from django.http import HttpResponse
3 from vuln.models import User
4
5 def reset_pw(request):
6 email = request.GET['email']
7 result = User.objects. filter(email__exact=email.upper()).first()
8 if not result:
9 return HttpResponse("User not found!")
10 send_mail('Reset Password','Your new pw: 123456.', 'from@example.com', [email], fail_silently=False)
11 return HttpResponse("Passwort reset email send!")

Dieser Code-Snippet zeigt die Funktion zum Zurücksetzen eines Passworts. In Zeile 6 wird die vom User eingegebene E-Mail-Adresse angegeben, und in den Zeilen 7-9 überprüft. Anschließend wird eine E-Mail an den User gesendet (Zeile 10). Die Prüfung (Zeile 7-9) wird dabei ohne Rücksicht auf Groß- und Kleinschreibung durchgeführt.

Existiert beispielweise in der Datenbank ein User mit der E-Mail-Adresse foo@mix.com könnte ein Angreifer foo@mıx.com in Zeile 6 angeben. In diesem Fall wurde das i durch das türkische ı ersetzt, da beide in Großbuchstaben als I dargestellt werden. In Zeile 7 wird die Adresse in Großbuchstaben umgewandelt, woraus sich FOO@MIX.COM ergibt. Somit wurde ein User erfolgreich ermittelt und eine E-Mail zum Zurücksetzen des Passworts gesendet, allerdings an die nicht konvertierte E-Mail-Adresse aus Zeile 6. Wird in Zeile 10 jedoch die E-Mail-Adresse des Users aus der Datenbank eingesetzt, kann dies umgangen werden.

9. Normalisierung der IP-Adresse

In Python<3.8 werden IP-Adressen von der ipaddress-Bibliothek so normalisiert, dass führende Nullen entfernt werden – was zu schweren Sicherheitslücken führen kann. Das kann dazu missbraucht werden, potenzielle Validierer für SSRF-Angriffe (Server-Side Request Forgery) zu umgehen, wie im folgenden Code-Snippet veranschaulicht.

1 import requests
2 import ipaddress
3
4 def send_request(request):
5 ip = request.GET['ip']
6 try:
7 if ip in ["127.0.0.1", "0.0.0.0"]:
8 return HttpResponse("Not allowed!")
9 ip = str(ipaddress.IPv4Address(ip))
10 except ipaddress.AddressValueError:
11 return HttpResponse("Error at validation!")
12 requests.get('https://' + ip)
13 return HttpResponse("Request send!")

In Zeile 5 wird eine IP-Adresse eines User angegeben und in Zeile 7 eine Denyliste verwendet, um zu prüfen, ob die IP-Adresse eine lokale ist, um so eine mögliche SSRF-Schwachstelle zu verhindern. Der Code prüft (Zeile 9), ob die angegebene IP eine IPv4-Adresse ist, gleichzeitig wird die IP normalisiert. Ist die Validierung abgeschlossen, wird die eigentliche Anfrage an die angegebene IP ausgeführt (Zeile 12). Es könnte stattdessen auch 127.0.00.1 als IP-Adresse übergeben werden, die nicht in der Denyliste in Zeile 7 enthalten ist. Die IP in Zeile 9 wird anschließend mit ipaddress.IPv4Address auf 127.0.0.1 normalisiert. So kann der SSRF-Validator umgangen und Anfragen an die lokalen Netzwerkadressen gesendet werden.

10. URL-Abfrage-Parsing

Die Funktion urllib.parse.parse_qsl erlaubt in Python<3.7 die Verwendung der Zeichen „;“ und „&“ als Trennzeichen für URL-Abfragevariablen. Von anderen Sprachen wird das Zeichen „;“ jedoch nicht als Trennzeichen erkannt. Das kann kritisch sein, wie folgendes Beispiel zeigt. Angenommen, eine betriebene Infrastruktur nutzt eine PHP-Anwendung als Frontend sowie eine weitere interne Python-Anwendung. Nun sendet ein Angreifer folgende GET-Anfrage an das PHP-Frontend:

GET https://victim.com/?a=1;b=2

Das PHP-Frontend kennt nur eine Abfragevariable: a mit dem Inhalt 1; b=2. PHP behandelt die Zeichen „;“ nicht als Trennzeichen für Abfragevariablen. Nun leitet das Frontend die Anfrage des Angreifers an eine interne Python-Anwendung mit der Abfragevariable a weiter:

GET https://internal.backend/?a=1;b=2

Wird urllib.parse.parse_qsl verwendet, verarbeitet die Python-Anwendung zwei Abfragevariablen: a=1 und b=2. Dieser Unterschied beim Parsen von Abfragevariablen kann zu fatalen Sicherheitslücken führen, wie beispielsweise die Web-Cache-Poisoning-Schwachstelle in Django.

Fazit

Dennis Brinkrolf
Dennis Brinkrolf
(Bild: SonarSource )

Diese zehn eher weniger bekannten Python-Sicherheitsfallen können leicht übersehen werden und haben in der Vergangenheit bereits zu verschiedenen Sicherheitslücken geführt. Diese Fallstricke können bei allen Arten von Transaktionen auftreten, von der Datenverarbeitung über Verzeichnisse, Archiven, URLs, IPs bis hin zu einfachen Strings. Ein häufiges Muster ist dabei die Verwendung von Bibliotheksfunktionen, die ein unerwartetes Verhalten aufzeigen können. Die Programme sollten daher immer auf die neueste Version aktualisiert und die Dokumentation sorgfältig gelesen werden.

* Dennis Brinkrolf ist Security Researcher bei SonarSource, der führenden Plattform für Clean Code. Zuvor war er bereits in gleicher Rolle beim Bochumer Technologie Unternehmens RIPS Technology, welches 2020 von SonarSource übernommen wurde, sowie der Ruhr-Universität Bochum tätig.

(ID:48459783)