Post

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.