Das pickle-Modul in Python

Während der Anwendungsentwicklung müssen wir oft komplexe Daten (wie Objekte) für die Verwendung in verschiedenen Laufzeiten persistieren. Die Aufrechterhaltung der Persistenz in komplexen Datenstrukturen und Objekten ist jedoch alles andere als einfach. In Python kann man die integrierte pickle-Bibliothek verwenden, um diesen Prozess zu bewältigen. Mit pickle kann ein Python-Objekt in einen flachen Byte-Stream serialisiert (pickling) und ein Byte-Stream wieder in ein Python-Objekt zurückverwandelt (unpickling) werden.

Wenn man komplexe Daten persistiert, muss man sie als flachen Bytestrom darstellen, um sie auf der Festplatte zu speichern oder über das Netzwerk zu versenden. Dieser Prozess der Umwandlung einer Objektstruktur in einen Bytestrom wird Marshaling oder Serialisierung genannt. Wenn unsere Anwendung den Bytestrom liest oder empfängt, führt sie den umgekehrten Prozess durch und wandelt ihn in unsere Objektstruktur um. Dieser Vorgang wird unmarshaling oder deserializing genannt.

Hinweis: Da pickle Python-spezifisch ist, kann es problemlos in jeder Python-Anwendung verwendet werden. Das bedeutet aber auch, dass pickle nicht für den Datenaustausch zwischen Anwendungen verwendet werden kann, die in anderen Sprachen geschrieben wurden.

Obwohl pickle in vielen Situationen unglaublich praktisch ist, sollte man nur Daten „entpickeln“, denen man vertraut! Das Unpicklng von nicht vertrauenswürdigen Daten kann zur Ausführung von beliebigem Code führen und ist eine häufige Quelle für kritische Sicherheitslücken.

Das Modul pickle verwenden

Das Modul pickle ist einfach zu benutzen, alle relevanten Funktionen zum pickling und unpickling befinden sich im Modul selbst.

Wenn wir ein Python-Objekt picklen, können wir es entweder direkt in eine Datei oder in ein Byte-Objekt packen, das wir später in unserem Code verwenden können. In beiden Fällen ist nur ein einfacher Methodenaufruf erforderlich.

Um ein Objekt in eine Datei zu packen, ruft man pickle.dump(object, file) auf. Um nur die gepickelten Bytes zu erhalten, ruft man pickle.dumps(object) auf.

Es können viele Datentypen „gepickelt“ werden, wie zum Beispiel:

  • primitive Datentypen wie Integer, Float, String und Boolean
  • verschachtelte Collection-Strukturen wie Tupel, Listen, Sets und Dictionaries – solange die Dictionaries nur picklefähige Objekte enthalten
  • die meisten Klasseninstanzen

Die Python-Dokumentation enthält eine ausführliche Liste der Objekte, die gepickelt werden können.

Wenn man versucht, ein nicht picklebares Objekt zu pickeln, löst Python einen PicklingError aus.

Beispiele

Nachfolgend sehen wir eine Datenstruktur, die den Zustand eines hypothetischen Spiels darstellt. In diesem Spiel gibt es einen Spieler, der sich an einem bestimmten Ort in der Spielwelt befindet, der als Tupel von Koordinaten dargestellt wird. Die Welt hat auch Hindernisse an bestimmten Orten, die als Tupel dargestellt werden. Der Spieler besitzt Gegenstände, die einen Namen und einen Preis haben. Die Klassen sehen wie folgt aus:

class GameItem:
  def __init__(self, name, cost):
      self.name = name
        self.cost = cost

class GameState:
  def __init__(self, player_coordinates, obstacles, items):
      self.player = player_coordinates # tuple (x, y)
      self.obstacles = obstacles # set of tuples (x, y)
        self.items = items # list of GameItems

Nun konstruieren wir einen bestimmten Zustand und verwenden pickle, um ihn in eine Datei zu packen.

player = (3, 2)
obstacles = { (1, 1), (5, 6), (7, 4), (0, -1) }
items = [ GameItem("Sword", 500), GameItem("Potion", 150) ]

state = GameState(player, obstacles, items)

with open("state.bin", "wb") as file: # "wb" weil wir im binary mode schreiben wollen
    pickle.dump(state, file)

Pickle ist ein für Menschen unlesbares Binärformat. Wenn wir also den Inhalt unserer neu erstellten Datei state.bin untersuchen, wird er nicht viel Sinn ergeben. Wir werden nur einige Bezeichner erkennen, wie z. B. GameState und obstacles.

Nun, da wir den Spielzustand in einer Datei gespeichert haben, wollen wir versuchen, den Zustand aus dieser Datei zu laden, damit wir eine Funktion zum Speichern und Fortsetzen in unser Spiel einbauen können! Sowohl pickle.dump als auch pickle.dumps haben ihre Gegenstücke zum Unpickling: pickle.load(_file_) lädt ein gepickeltes Python-Objekt aus einer Datei, pickle.loads(_bytes_) tut dasselbe für die angegebenen Bytes.

with open("state.bin", "rb") as file: # "rb" weil wir im binary mode lesen wollen
    state = pickle.load(file)

print("Player coordinates:", state.player)
print("Obstacles:", state.obstacles)
print("Number of items:", len(state.items))

Output:

Player coordinates: (3, 2)
Obstacles: {(7, 4), (1, 1), (0, -1), (5, 6)}
Number of items: 2

Wie wir sehen können, sind die Grundlagen des Pickling sehr einfach. Man braucht nur einen Methodenaufruf, um ein Python-Objekt zu speichern oder zu laden.

Wie man pickle ausnutzt

Wie in der Einleitung erwähnt, sollte man nur Daten entpickeln, denen man vertraut, da das pickle-Modul nicht sicher ist. Wenn man den Bytestrom eines Angreifers entpackt, kann man beliebigen Code ausführen. Dies ist ein Nebeneffekt der Mächtigkeit des Pickle-Formats.

Ein Python-Objekt kann mit der speziellen Methode _reduce_ angeben, wie es gepickelt werden soll. Diese Methode sollte entweder eine Zeichenkette oder ein Tupel zurückgeben. Eine Zeichenkette stellt den Namen einer globalen Variablen dar. Ein Tupel steht für aufrufbaren Code (z. B. eine Funktion oder Klasse), die Argumente für den aufrufbaren Code und einige optionale Informationen, die für dieses Beispiel nicht relevant sind. Der Prozess des Unpicklings ruft den angegebenen aufrufbaren Code mit seinen Argumenten auf.

Mit diesem Wissen kann man ein pickle-Objekt konstruieren, das eine beliebige Funktion aufruft, die man während des entpickeln ausführen möchte. Man kann zum Beispiel einen Systembefehl ausführen, indem man die Funktion os.system während des Entpickelns ausführt, oder eval, um jeden beliebigen Python-Code auszuführen!

Hier ist ein (ungefährliches) Beispiel für diesen Angriff, bei dem die eval-Funktion verwendet wird (die einfach die print-Funktion aufruft, wenn sie geladen wird):

import pickle

class Attack:
  def __reduce__(self):
      return (eval, ("print(1+2)",))

malicious = pickle.dumps(Attack())

pickle.loads(malicious)

Wenn man diesen Code ausführt, wird er die Zahl 3 auf der Konsole ausgeben, denn das Entpickeln dieser Daten führt eval(„print(1+2)“) aus.

Wie man pickle sicher verwendet

Wenn man Daten von einem nicht vertrauenswürdigen Client akzeptieren muss, kann man pickle aufgrund der oben genannten Risiken nicht verwenden. Stattdessen sollte man ein anderes Daten-Serialisierungsformat wie JavaScript Object Notation (JSON) verwenden.

Es ist möglich, dies mit dem json-Modul von Python zu tun. Der Nachteil ist, dass das json-Modul viel weniger leistungsfähig ist als pickle, da es komplizierte Datentypen, wie z. B. benutzerdefinierte Objekte, nicht von Haus aus unterstützt. Außerdem sind die nativen Datentypen von JSON begrenzt. Zum Beispiel hat ein Set in Python keine Entsprechung in JSON, so dass man einen benutzerdefinierten Kodierer für Sets verwenden müsste. JSON ist jedoch ein sicheres Format, wenn es um nicht vertrauenswürdige Daten geht.

Angenommen, eine vertrauenswürdige Anwendung generiert gepickelte Daten, aber man kannn deren Integrität zwischen dem Zeitpunkt, an dem die Daten gepickt und ungepickt werden, nicht garantieren. Vielleicht kann man den Daten nicht vertrauen, weil man die gepickelten Daten über ein unsicheres Netzwerk sendet oder sie in einem dauerhaften Speicher ablegt, auf den ein Angreifer zugreifen könnte.

In beiden Fällen besteht eine Lösung darin, mit Hilfe eines HMAC eine kryptografische Signatur für die gepickelten Daten zu erstellen. Die gepickelten Daten werden dann zusammen mit der Signatur gesendet oder gespeichert. Vor dem Entpickeln kann der Empfänger die Signatur validieren, um die Integrität der gepickelten Daten zu überprüfen.

Man kann eine Signatur wie folgt erzeugen (mit SHA256):

SECRET_KEY = b"your secret key here"
obj = [ "test", (1, 2), [ "a", "b" ] ]
data = pickle.dumps(obj)
digest = hmac.new(SECRET_KEY, data, hashlib.sha256).hexdigest()

Hinweis: In der realen Welt würde der geheime Schlüssel sicher in einer serverseitigen Anwendung gespeichert werden, die niemals einer nicht vertrauenswürdigen Umgebung ausgesetzt ist. Es sollte niemals ein geheimer Schlüssel in der Codebasis eingetragen werden.

Der Empfänger kann den erwarteten Digest für die gepickelten Daten berechnen und prüfen, ob er mit dem angegebenen Digest übereinstimmt.

expected_digest = hmac.new(SECRET_KEY, data, hashlib.sha256).hexdigest()
if expected_digest != digest:
  print("Data integrity violated")
else:
  unpickled = pickle.loads(data)
  print(unpickled)

In diesem Szenario können Angreifer, selbst wenn sie die gepickelten Daten manipulieren, die Signatur nicht erfolgreich fälschen (oder den Empfänger dazu bringen, nicht vertrauenswürdigen Code auszuführen), wenn sie nicht über den geheimen Schlüssel verfügen.

Zusammenfassung

Wir wissen jetzt, wie wir das pickle-Modul von Python verwenden können, um komplexe Python-Objekte auf sichere Weise zu serialisieren und zu deserialisieren.

Dabei ist immer zu beachten: Da Pickle kritische Sicherheitslücken im Code verursachen kann, sollte man niemals Daten entpickeln, denen man nicht vertraut. Wenn man Daten von einem nicht vertrauenswürdigen Client akzeptieren muss, sollte man das sicherere JSON-Format verwenden. Und wenn man gepickelte Daten zwischen vertrauenswürdigen Anwendungen überträgt, aber zusätzliche Maßnahmen zum Schutz vor Manipulationen benötigt, sollte man eine HMAC-Signatur erzeugen, die man vor dem Entpickeln überprüfen kann.