Reverse Engineering

Python-basierte Executables entpacken: Von PE und ELF zurück zum Sourcecode

Ein praxisnaher Workflow zum Erkennen, Extrahieren und Dekompilieren von PyInstaller-Executables unter Windows und Linux – vom Binary bis zum möglichst ursprünglichen Python-Sourcecode.

Eine unbekannte .exe landet auf dem Tisch, Detect It Easy meldet Python oder PyInstaller und beim Öffnen in IDA oder Ghidra sieht man hauptsächlich Bootloader-Code.

Die gute Nachricht: Wenn es sich tatsächlich um eine mit PyInstaller erzeugte Anwendung handelt, müssen wir diesen nativen Stub meistens gar nicht vollständig reverse engineeren.

Der eigentliche Python-Code steckt in vielen Fällen weiterhin als CPython-Bytecode innerhalb der Executable.

Unser Ziel sieht vereinfacht so aus:

  1. sample.exe beziehungsweise ein Linux-Binary untersuchen
  2. PyInstaller erkennen
  3. Das interne Archiv extrahieren
  4. Den Entry Point wie main.pyc finden
  5. Den Python-Bytecode dekompilieren
  6. Den rekonstruierten Code als main.py analysieren

Unter Windows handelt es sich normalerweise um ein PE – Portable Executable. Unter Linux steckt dasselbe Prinzip in einem ELF – Executable and Linkable Format.

Dieser Artikel zeigt beide Varianten und beginnt bewusst bei null.

Am Ende solltest du auch ohne vorherige Erfahrung verstehen, wie du aus einer Python-basierten Executable wieder möglichst nah an den ursprünglichen Python-Sourcecode kommst.


Erst einmal: Was steckt überhaupt in einer Python-EXE?

Python wird normalerweise nicht wie C oder C++ direkt zu nativem Maschinencode kompiliert.

Aus:

print("Hello from AetherSec")

entsteht zunächst CPython-Bytecode. Vereinfacht läuft das in drei Schritten ab:

  1. script.py
  2. CPython-Compiler
  3. script.pyc

Eine .pyc enthält Bytecode für die Python Virtual Machine.

Tools wie PyInstaller nehmen anschließend unter anderem:

und bauen daraus eine eigenständig startbare Anwendung.

In einer solchen Datei befinden sich typischerweise der PyInstaller-Bootloader, die Python-Runtime, Module und Abhängigkeiten sowie ein CArchive oder PYZ-Archiv mit Dateien wie main.pyc, requests.pyc und config.pyc.

PyInstaller verwendet dafür eigene CArchive- und PYZ/ZlibArchive-Strukturen, in denen Python-Module gespeichert werden. Die offiziellen PyInstaller-Tools können diese Archive ebenfalls anzeigen.

Das ist ein wichtiger Unterschied:

PyInstaller ist primär ein Packaging-System und kein Schutzmechanismus gegen Reverse Engineering.

Eine mit PyInstaller erzeugte .exe sieht von außen wie ein kompiliertes natives Programm aus – intern kann der ursprüngliche Python-Bytecode aber weiterhin vorhanden sein.


Wichtig: Nicht jede Python-Executable ist PyInstaller

Bevor wir irgendetwas extrahieren, müssen wir herausfinden, womit die Anwendung gebaut wurde.

Neben PyInstaller existieren beispielsweise:

Der Workflow aus diesem Artikel konzentriert sich auf PyInstaller.

Besonders Nuitka und Cython sind ein anderes Thema, da Python dort deutlich stärker in nativen Code übersetzt werden kann.

Wenn pyinstxtractor also meldet:

Not a PyInstaller archive

bedeutet das nicht automatisch, dass das Tool kaputt ist. Vielleicht ist die Datei schlicht kein PyInstaller-Binary.


Vorbereitung: Nicht einfach unbekannte Executables starten

Bei Reverse Engineering gilt dasselbe wie bei Malware-Analyse:

Eine unbekannte Binary gehört nicht auf das Host-System.

Komponente Empfehlung
Umgebung isolierte VM
Snapshot vor der Analyse
Netzwerk deaktiviert oder kontrolliert
Shared Folders möglichst deaktiviert
Hash vor der Analyse dokumentieren
Ausführung für diesen Workflow normalerweise nicht notwendig

Unter Linux:

sha256sum sample.exe

Unter Windows PowerShell:

Get-FileHash .\sample.exe -Algorithm SHA256

Für das eigentliche Extrahieren müssen wir die Executable normalerweise nicht starten.


Phase 1: Herausfinden, womit wir es zu tun haben

Windows PE

Unter Linux, WSL oder REMnux:

file sample.exe

Eine typische Ausgabe könnte sein:

sample.exe: PE32+ executable (console) x86-64, for MS Windows

Zusätzlich lohnt sich Detect It Easy:

diec sample.exe

Je nach Sample kann DIE bereits Hinweise wie diese liefern:

Compiler: Microsoft Visual C/C++
Packer: PyInstaller
Language: Python

Alternativ können wir Strings durchsuchen:

strings -a sample.exe | grep -Ei 'pyinstaller|pyiboot|pyz|_MEI'

Unter Windows:

strings.exe sample.exe | findstr /i "pyinstaller pyiboot PYZ _MEI"

Interessante Strings sind beispielsweise:

PYZ-00.pyz
pyiboot01_bootstrap
pyimod01_archive
_MEIPASS

Ein einzelner String beweist noch nichts – mehrere typische PyInstaller-Artefakte sind aber ein ziemlich guter Hinweis.

Linux ELF

Unter Linux geht es genauso los:

file sample

Beispiel:

sample: ELF 64-bit LSB pie executable, x86-64, dynamically linked

Danach:

strings -a sample | grep -Ei 'pyinstaller|pyiboot|pyz|_MEI'

Optional:

readelf -S sample
readelf -d sample

Damit sehen wir zunächst nur die native ELF-Seite der Anwendung. Der Python-Code liegt normalerweise tiefer im eingebetteten PyInstaller-Archiv.


Phase 2: PyInstaller-Archiv untersuchen

Hier hat sich seit älteren Tutorials einiges verbessert.

Ein älterer Workflow bestand teilweise daraus, unter Linux zunächst eine eingebettete pydata-Section manuell aus dem ELF zu dumpen und erst anschließend den PyInstaller-Extractor darauf anzusetzen.

Das ist beim modernen Standardfall nicht mehr notwendig.

Die aktuelle Generation von pyinstxtractor-ng unterstützt sowohl Windows-PEs als auch Linux-ELFs direkt. Außerdem verwendet das Tool xdis, wodurch die Extraktion nicht mehr zwingend mit exakt derselben Python-Version ausgeführt werden muss, mit der das Zielprogramm erstellt wurde.

Variante A: pyinstxtractor-ng

Für einen modernen Workflow würde ich heute zuerst pyinstxtractor-ng verwenden.

Archiv zunächst nur untersuchen

Windows:

.\pyinstxtractor-ng.exe -i .\sample.exe

Linux:

./pyinstxtractor-ng -i ./sample

-i beziehungsweise --info zeigt Informationen über das gefundene PyInstaller-Archiv, ohne direkt alles zu extrahieren.

Eine Ausgabe kann sinngemäß so aussehen:

PyInstaller archive detected
Python version: 3.x
Package size: ...
Files: ...

Damit wissen wir bereits zwei wichtige Dinge:

  1. Ist es tatsächlich PyInstaller?
  2. Für welche Python-Version wurde der Bytecode erzeugt?

Archiv extrahieren

Windows:

.\pyinstxtractor-ng.exe .\sample.exe

Linux:

./pyinstxtractor-ng ./sample

Danach entsteht normalerweise ein Verzeichnis wie sample.exe_extracted oder sample_extracted.

Ein mögliches Ergebnis:

sample.exe_extracted/
  main.pyc
  pyiboot01_bootstrap.pyc
  pyimod01_archive.pyc
  python3.dll
  base_library.zip
  PYZ.pyz_extracted/
    os.pyc
    json.pyc
    requests/
    urllib/

Jetzt sind wir bereits aus der PE- beziehungsweise ELF-Ebene heraus. Ab diesem Punkt analysieren wir überwiegend Python-Bytecode.

Variante B: Klassisches pyinstxtractor

Der ursprüngliche pyinstxtractor.py funktioniert weiterhin und unterstützt moderne PyInstaller-Versionen sowie Linux-ELFs direkt. Beim klassischen Extractor sollte möglichst dieselbe Python-Version verwendet werden, mit der auch die Executable gebaut wurde.

Aufruf unter Windows:

python pyinstxtractor.py sample.exe

Oder unter Linux:

python3 pyinstxtractor.py sample

Typische Ausgabe:

[+] Processing sample.exe
[+] Pyinstaller version: ...
[+] Python version: ...
[+] Found ... files in CArchive
[+] Beginning extraction...
[+] Possible entry point: main.pyc
[+] Successfully extracted pyinstaller archive

Besonders interessant ist:

Possible entry point: main.pyc

Das ist häufig genau die Datei, die wir suchen.

Variante C: Offizielles PyInstaller-Tool

Wenn PyInstaller lokal installiert ist, existiert zusätzlich:

pyi-archive_viewer sample.exe

Beziehungsweise unter Linux:

pyi-archive_viewer sample

Der Viewer kann PyInstaller-Archive innerhalb von Windows-Executables und ELF-Binaries untersuchen.

Innerhalb des Viewers stehen unter anderem folgende Befehle zur Verfügung:

O <name>   eingebettetes Archiv öffnen
X <name>   Datei extrahieren
U          eine Ebene zurück
Q          beenden

Beispielsweise kann man PYZ-00.pyz öffnen und anschließend die enthaltenen Module untersuchen.

Für automatisierte Extraktion ist pyinstxtractor-ng meistens angenehmer. Für das manuelle Verständnis der Archivstruktur ist pyi-archive_viewer allerdings ziemlich praktisch.


Phase 3: Wo ist jetzt mein eigentliches Programm?

Nach der Extraktion liegen möglicherweise hunderte .pyc-Dateien vor.

Das sieht zunächst schlimmer aus, als es ist. Ein großer Teil davon gehört meistens zu:

Wir suchen dagegen die Module, die der Entwickler selbst geschrieben hat.

Alle PYC-Dateien anzeigen

Linux:

find sample.exe_extracted -type f -name '*.pyc'

Windows PowerShell:

Get-ChildItem .\sample.exe_extracted -Recurse -Filter *.pyc

Ein Ergebnis könnte so aussehen:

main.pyc
config.pyc
crypto.pyc
network.pyc
pyiboot01_bootstrap.pyc
pyimod01_archive.pyc
PYZ.pyz_extracted/requests/api.pyc
PYZ.pyz_extracted/json/__init__.pyc

Hier wären main.pyc, config.pyc, crypto.pyc und network.pyc deutlich interessanter als die Standardbibliotheken.

Der Entry Point

pyinstxtractor beziehungsweise pyinstxtractor-ng kann häufig bereits mögliche Entry Points identifizieren.

Beispiel:

Possible entry point: pyiboot01_bootstrap.pyc
Possible entry point: main.pyc

pyiboot01_bootstrap.pyc gehört zu PyInstaller. main.pyc sieht dagegen nach unserem eigentlichen Programm aus.

Eine typische Importstruktur könnte so aussehen:

Damit können wir uns vom Entry Point durch die Anwendung bewegen.


Phase 4: Aus .pyc wieder .py machen

Jetzt kommt der Teil, der bei älteren Tutorials oft unnötig kompliziert wird.

Das eigentliche Extrahieren von PyInstaller ist meistens relativ stabil. Das Dekompilieren des Python-Bytecodes ist schwieriger.

Der Grund: CPython-Bytecode verändert sich zwischen Python-Versionen teilweise erheblich. Besonders Python 3.10, 3.11, 3.12, 3.13 und 3.14 unterscheiden sich in verschiedenen Opcode- und Control-Flow-Details. CPython-Bytecode ist ein Implementierungsdetail und kann sich zwischen Releases verändern.

Deshalb:

Erst Python-Version bestimmen, dann passenden Decompiler auswählen.

Moderne Empfehlung: PyLingual

Für moderne Python-Versionen ist PyLingual eine der interessantesten Möglichkeiten.

PyLingual unterstützt nach Angaben des Projekts veröffentlichte CPython-Versionen ab Python 3.6 und kann sowohl über einen Webdienst als auch lokal verwendet werden.

Einfachster Weg: PyLingual im Browser

Wenn du eine extrahierte Datei wie main.pyc hast, kannst du sie auf pylingual.io hochladen.

Der Ablauf:

  1. PyInstaller-Binary mit pyinstxtractor-ng extrahieren
  2. main.pyc identifizieren
  3. Die Datei mit PyLingual dekompilieren
  4. Den rekonstruierten Inhalt als main.py analysieren

Gerade bei neueren Python-Versionen ist das oft deutlich angenehmer, als viele ältere Decompiler nacheinander auszuprobieren. Der Dienst versucht außerdem, die verwendete Python-Version automatisch zu erkennen.

Achtung bei vertraulichem Code

Bei Malware-Samples, Firmen-Code oder anderen sensiblen Dateien sollte man grundsätzlich überlegen, ob man die .pyc auf einen externen Dienst hochladen möchte.

Für vertrauliche Samples ist die lokale Variante sauberer.

PyLingual lokal verwenden

Das Projekt kann auch lokal installiert werden.

git clone https://github.com/syssec-utd/pylingual.git
cd pylingual
uv tool install .

Eine Datei dekompilieren:

pylingual main.pyc

Ergebnisse in ein eigenes Verzeichnis schreiben:

pylingual -o recovered main.pyc

Das Ergebnis befindet sich anschließend beispielsweise unter recovered/main.py.

Mehrere Dateien können ebenfalls verarbeitet werden:

pylingual -o recovered main.pyc config.pyc networking.pyc

Damit lässt sich nach der PyInstaller-Extraktion Stück für Stück das ursprüngliche Projekt rekonstruieren.

Was ist mit uncompyle6?

Viele ältere Tutorials verwenden:

pip install uncompyle6
uncompyle6 main.pyc

Für ältere Python-Versionen ist das weiterhin interessant.

Das Problem: Neuere CPython-Versionen werden von uncompyle6 nicht vollständig unterstützt. Insbesondere bei Python 3.9 und neuer sollte man sich nicht auf dieses Tool allein verlassen.

Ein grober Entscheidungsweg:

Bytecode Erste Wahl
ältere Python-Versionen uncompyle6 testen
Python 3.7 oder 3.8 decompyle3 möglich
moderne Python-Versionen PyLingual
unbekannte Version PyLingual Auto Detection oder Extractor-Info
Decompiler scheitert Bytecode manuell untersuchen

decompyle3 ist insbesondere auf Python-3.7- und Python-3.8-Bytecode ausgerichtet.


Warum funktioniert mein .pyc nicht?

Das ist wahrscheinlich der häufigste Punkt, an dem Einsteiger hängen bleiben.

Du hast main.pyc, aber der Decompiler meldet beispielsweise:

Unsupported Python version
Invalid magic number
Marshal error

Oder er erzeugt nur unvollständigen Code. Dafür gibt es mehrere mögliche Ursachen.

Problem 1: Falsche Python-Version

PYC-Dateien besitzen eine versionsabhängige Kennung. Bytecode von Python 3.8 ist nicht dasselbe wie Bytecode von Python 3.12.

Wenn dein Extractor beispielsweise meldet:

Python version: 3.12

und dein Decompiler nur Python 3.8 versteht, wird das nicht funktionieren.

Die Lösung:

  1. Python-Version feststellen
  2. Decompiler mit passendem Support auswählen
  3. Bei modernen Versionen zunächst PyLingual testen

Problem 2: Falscher PYC-Header

Ein aus einem PyInstaller-Archiv extrahiertes Codeobjekt entspricht nicht immer exakt einer normalen .pyc, wie sie im __pycache__-Verzeichnis liegt.

Extractor wie pyinstxtractor reparieren beziehungsweise rekonstruieren die notwendigen Header bereits für viele Fälle.

Deshalb solltest du nicht einfach mit binwalk beliebige Bytebereiche herausschneiden und sie anschließend in something.pyc umbenennen. Benutze zuerst einen PyInstaller-spezifischen Extractor.

Problem 3: Es ist gar nicht dein Hauptprogramm

Wenn du versuchst, pyiboot01_bootstrap.pyc zu dekompilieren, bekommst du PyInstaller-internen Code. Das ist meistens nicht das, was du suchst.

Interessanter sind Namen wie:

main.pyc
app.pyc
client.pyc
server.pyc
bot.pyc
gui.pyc
config.pyc

Auch andere projektspezifische Modulnamen können relevant sein.

Problem 4: Der Decompiler rekonstruiert nur Teile

Auch ein korrekt extrahiertes .pyc garantiert keine perfekte Dekompilation.

Komplexe Konstruktionen können Probleme verursachen, zum Beispiel:

try:
    process_data()
except ValueError:
    handle_error()
finally:
    cleanup()

Auch Generatoren, Pattern Matching, verschachtelte Closures oder stark veränderter Control Flow können die Rekonstruktion erschweren.

Dann kann das Ergebnis beispielsweise so aussehen:

def process_data(data):
    ...
    # decompilation failed

Das bedeutet nicht, dass der Bytecode verloren ist. Nur die automatische Rückübersetzung ist gescheitert.

Dann lohnt es sich:

  1. einen zweiten Decompiler zu testen
  2. PyLingual zu testen
  3. das betreffende Codeobjekt als Bytecode zu disassemblieren
  4. die Funktion manuell zu rekonstruieren

Bytecode statt Sourcecode betrachten

Python besitzt selbst das Modul dis für die Darstellung von CPython-Bytecode.

Ein einfaches Beispiel:

def check_password(password):
    return password == "AetherSec"

Der Bytecode kann sinngemäß Operationen wie diese enthalten:

LOAD_FAST
LOAD_CONST
COMPARE_OP
RETURN_VALUE

Selbst wenn kein Decompiler daraus wieder perfekten Python-Code baut, ist diese Repräsentation immer noch analysierbar.

Ein Python-Modul lässt sich beispielsweise so disassemblieren:

python -m dis recovered/main.py

Gerade bei Malware-Analyse reichen manchmal bereits Hinweise wie LOAD_GLOBAL requests, eine URL in LOAD_CONST und ein anschließender Aufruf, um zu verstehen, was eine Funktion macht.


Bekomme ich wirklich den originalen Sourcecode zurück?

Nicht exakt. Das ist wichtig.

Beim Erstellen des Bytecodes gehen Informationen verloren.

Beispielsweise:

# Diese Funktion prüft den Key
def check_key(key):
    return key == SECRET_KEY

Der Kommentar existiert normalerweise nicht mehr im Bytecode. Auch die ursprüngliche Formatierung ist nicht rekonstruierbar.

Was wir zurückbekommen, ist ein rekonstruierter Python-Sourcecode, der funktional möglichst nah am ursprünglichen Programm liegt.

Typischerweise bleiben jedoch sehr viele wertvolle Informationen erhalten:

Für Reverse Engineering ist das normalerweise mehr als ausreichend.


Windows: Komplettworkflow

Nehmen wir an, unsere Datei heißt sample.exe.

1. Hash erzeugen

Get-FileHash .\sample.exe -Algorithm SHA256

2. Strings prüfen

strings.exe .\sample.exe | findstr /i "pyinstaller pyiboot PYZ _MEI"

Optional mit Detect It Easy:

diec.exe .\sample.exe

3. PyInstaller-Archiv prüfen

.\pyinstxtractor-ng.exe -i .\sample.exe

4. Archiv extrahieren

.\pyinstxtractor-ng.exe .\sample.exe

5. PYC-Dateien suchen

Get-ChildItem .\sample.exe_extracted -Recurse -Filter *.pyc

6. Main-Modul identifizieren

Der Extractor nennt häufig einen möglichen Entry Point:

Possible entry point: main.pyc

Die relevante Datei wäre dann beispielsweise sample.exe_extracted\main.pyc.

7. Dekompilieren

Mit einer lokalen PyLingual-Installation:

pylingual -o .\recovered .\sample.exe_extracted\main.pyc

Alternativ kannst du nicht vertrauliche Dateien über pylingual.io dekompilieren.

8. Weitere eigene Module rekonstruieren

pylingual -o .\recovered .\sample.exe_extracted\config.pyc .\sample.exe_extracted\network.pyc

Linux: Komplettworkflow

Nehmen wir an, das ELF-Binary heißt sample.

1. Hash und Dateityp prüfen

sha256sum ./sample
file ./sample

2. Nach PyInstaller-Artefakten suchen

strings -a ./sample | grep -Ei 'pyinstaller|pyiboot|pyz|_MEI'

3. Archivinformationen anzeigen

./pyinstxtractor-ng -i ./sample

4. Archiv extrahieren

./pyinstxtractor-ng ./sample

5. PYC-Dateien suchen

find ./sample_extracted -type f -name '*.pyc'

6. Entry Point und eigene Module identifizieren

Achte in der Extractor-Ausgabe auf Possible entry point. Trenne anschließend projektspezifische Namen wie main.pyc, client.pyc oder config.pyc von PyInstaller- und Standardbibliotheksmodulen.

7. Dekompilieren

pylingual -o ./recovered ./sample_extracted/main.pyc

Mehrere Module lassen sich in einem Durchlauf verarbeiten:

pylingual -o ./recovered ./sample_extracted/main.pyc ./sample_extracted/config.pyc ./sample_extracted/network.pyc

Häufige Sonderfälle

One-file und One-folder

PyInstaller kann Anwendungen unter anderem im One-file- oder One-folder-Modus bauen.

Beim One-folder-Modus liegen viele Bestandteile bereits neben der Executable. Beim One-file-Modus stecken sie im Binary und werden beim Start normalerweise in ein temporäres Verzeichnis extrahiert.

Für die statische Analyse ist das kein Grund, das Sample zu starten: Ein PyInstaller-Extractor liest das eingebettete Archiv direkt.

Verschlüsselte oder obfuskierte Module

Einige Anwendungen verwenden zusätzliche Obfuskation, verschlüsselte PYZ-Inhalte oder Produkte wie PyArmor. Dann kann die Archivextraktion zwar funktionieren, der enthaltene Code ist aber nicht sofort sinnvoll dekompilierbar.

In diesem Fall sind weitere Schritte nötig, beispielsweise:

Das geht über den normalen PyInstaller-Workflow hinaus.

Kein PyInstaller gefunden

Wenn der Extractor kein Archiv erkennt, solltest du die ursprüngliche Annahme überprüfen:

  1. Erkennt Detect It Easy einen anderen Packager?
  2. Deuten Strings auf Nuitka, cx_Freeze, py2exe oder Cython hin?
  3. Wurde das Binary zusätzlich mit einem nativen Packer geschützt?
  4. Ist die Datei beschädigt oder unvollständig?

Bei Nuitka oder Cython ist eine klassische .pyc-Extraktion häufig nicht der richtige Ansatz.


Kompakte Checkliste

  1. Sample nicht auf dem Host-System starten.
  2. Hash dokumentieren und Dateityp bestimmen.
  3. Nach PyInstaller-Artefakten suchen.
  4. Archivinformationen mit pyinstxtractor-ng anzeigen.
  5. Archiv statisch extrahieren.
  6. Entry Point und projektspezifische .pyc-Dateien identifizieren.
  7. Python-Version des Bytecodes beachten.
  8. Moderne Dateien zuerst mit PyLingual dekompilieren.
  9. Bei sensiblen Samples ausschließlich lokal arbeiten.
  10. Fehlgeschlagene Funktionen über Bytecode und dis manuell untersuchen.

Fazit

Eine Python-basierte Windows- oder Linux-Executable ist häufig kein undurchsichtiger Block aus nativem Maschinencode. Wenn sie mit PyInstaller erstellt wurde, enthält sie in vielen Fällen weiterhin extrahierbaren CPython-Bytecode.

Der moderne Standardworkflow ist überschaubar:

  1. PE oder ELF identifizieren
  2. PyInstaller bestätigen
  3. Archiv mit pyinstxtractor-ng extrahieren
  4. Entry Point und eigene Module finden
  5. .pyc mit einem zur Python-Version passenden Tool dekompilieren
  6. unvollständige Ergebnisse bei Bedarf über Bytecode analysieren

Dabei erhältst du nicht die ursprünglichen Kommentare oder exakt dieselbe Formatierung zurück. Funktionsnamen, Klassen, Strings, Imports, Konstanten und große Teile der Programmlogik bleiben aber oft erhalten.

Damit wird aus einer vermeintlich geschlossenen Executable wieder ein nachvollziehbares Python-Projekt – oder zumindest eine sehr gute Grundlage für die weitere Analyse.

Nutze die beschriebenen Techniken ausschließlich für eigene Dateien, autorisierte Analysen, Forschung und legitime Incident-Response- oder Malware-Analyse-Szenarien.

Ich hoffe dir hat dieser Blog-Eintrag gefallen. Bis zum nächsten …