Pythonic Malware Part-2: Reversing Python Executables
Post Updates
09/2023: Starting in PyInstaller 6, bytecode encryption and the
--key
argument have been depreciated.
Overview
In Pythonic Malware Part-1, I demonstrated how Python executables can be used to bypass Windows Defender and successfully launch Meterpreter shells on a fully patched system. However, this raised an interesting question, why don’t more APT’s and threat groups use Python for malware development?
While highly effective, one reason is because compiled Python is easily reversed. Without manual intervention or converting to lower-level languages, the default ways of compiling Python could allow blue teams to recover the clear-text source.
This post demonstrates how to decompile the shellcode_loader.exe
file created in Part-1 and recover the source code being executed — even when employing PyInstaller’s bytecode obfuscation with AES256 encryption.
Decompiling Python Executables
1. Unpacking the Executable
The first step in decompiling the shellcode_loader.exe
file is to unpack the compiled binary using pyinstxtractor. This will create a new directory containing the original Python bytecode files and packaged resources:
1
2
3
4
5
6
7
8
>> python pyinstxtractor.py shellcode_loader.exe
[+] Processing shellcode_loader.exe
[+] Pyinstaller version: 2.1+
[+] Python version: 307
...
[+] Possible entry point: shellcode_loader.pyc
[+] Found 179 files in PYZ archive
[+] Successfully extracted pyinstaller archive: shellcode_loader.exe
Extracted files located in the newly created “shellcode_loader.exe_extracted” directory.
2. Converting Python Bytecode
Now that we have the bytecode (.pyc
) version of our source, we can use uncompyle6 to convert our shellcode_loader.pyc
back to human readable code:
Converting .pyc files back to .py with uncompyle6.
Defeating PyInstaller’s AES256 Encryption
After successfully reversing the shellcode loader script, I wanted to dig deeper and explore PyInstaller’s bytecode obfuscation with AES encryption. This can be implemented by adding the --key
argument during compilation, as shown below:
1
pyinstaller -F --key MySecretKey12345 shellcode_loader.py
Method 1: Unencrypted Source
When going back and unpacking the newly created executable, several errors were displayed that indicated encryption may be applied:
Unpacking the encrypted executable with pyinstxtractor.
However, it was still possible to convert the shellcode_loader.pyc
file back to its original source — without any decryption methods applied:
Code snippet from original shellcode_loader.py file
Reviewing the terminal messages and unpacked files, it appeared only resource files were encrypted and placed in the PYZ-00.pyz_extracted
directory. This means the scripts entry-point and primary file was NOT protected.
Method 2: Using the Decryption Key
Given only script resources are encrypted, I restructured the shellcode loader script to import the primary code as a resource. The final directory structure looked something like:
1
2
3
|_shellcode_loader.py (entrypoint)
|_scloader
|_ __init__.py (malicious code)
Once recompiling and unpacking, the same encryption error messages were shown. However, this time, I was unable to recover the source:
That’s when I found the pyimod00_crypto_key.pyc
file in the unpacked directory, which contained the static key used to decrypt the executable at runtime:
PyInstallers encryption key found in clear-text at “pyimod00_crypto_key.pyc”.
Using the script below, this key can be leveraged to decrypt the Python bytecode and recover the original file before using uncompyle6:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#!/usr/bin/env python3
# Author: @m8sec
# Description: Decrypt AES encrypted bytecode files by pyinstaller.
import os
import sys
import zlib
import tinyaes
key = sys.argv[1] # Pyinstaller encryption Key
encrypt_file = sys.argv[2] # Encrypted file path
struct_file = sys.argv[3] # Path to *exe_extracted\struct.pyc
decrypt_file = "decrypted.pyc" # .pyc output file
CRYPT_BLOCK_SIZE = 16
# Grab pyc headers from packaged struct.pyc file
with open(os.path.join(struct_file), 'rb') as head:
pyc_header = head.read()[:16]
# Decrypt file and decompress
with open(encrypt_file, 'rb') as d:
data = d.read()
cipher = tinyaes.AES(key.encode(), data[:CRYPT_BLOCK_SIZE])
decrypt = cipher.CTR_xcrypt_buffer(data[CRYPT_BLOCK_SIZE:])
plaintext = zlib.decompress(decrypt)
# Write to file
a = open(decrypt_file, 'wb')
a.write(pyc_header)
a.write(plaintext)
a.close()
Conclusion
Reversing Python executables without additional protection mechanisms can be trivial. In fact, PyInstaller’s documentation even mentions their AES256 encryption only prevents “casual” tampering. This is just one reason Python malware is not more common in modern enterprise environments.