A python in disguise: unpacking PyInstaller malware on macOS

Attackers are using PyInstallers to deploy infostealers on macOS. Jamf Threat Labs investigates this newly discovered technique.

May 12 2025 by

Jamf Threat Labs

By Allen Golbig and Thijs Xhaflaire

Introduction

Jamf Threat Labs recently uncovered new undetected macOS infostealer samples that bundle Python code into Mach-O executables using PyInstaller. While the concept of using PyInstaller to accomplish this task isn't new, this is the first time that we've encountered it with macOS infostealers.

PyInstaller is a legitimate open-source tool that allows developers to package Python scripts into standalone binaries. This means apps can run without requiring Python to be installed or worrying about version compatibility — especially useful since Apple removed system-installed Python starting with macOS 12.3. While it’s great for cross-platform development, attackers are now leveraging the same technique to deliver malicious payloads that execute smoothly on macOS.

VirusTotal entry for newly discovered malicious file

In this blog post, we’ll break down:

  • How attackers use PyInstaller to bundle malicious Python payloads into Mach-O executables.
  • Dynamic analysis of PyInstaller usage via Apple’s Endpoint Security API.
  • How to unpack and decompile them using open-source tools like Pyinstxtractor or PyLingual.

Analysis walkthrough

In April 2025, Jamf Threat Labs discovered a suspicious Mach-O on VirusTotal named stl. Upon inspecting its behavior, it was clear this was yet another infostealer. While this write-up won’t dive deep into the stealer’s functionality, some initial behaviors were noted in VirusTotal:

  • Triggering an AppleScript dialog, prompting the user for their password until the correct one is provided
  • Running tccutil reset AppleEvents
  • Running /usr/bin/osascript /tmp/osascr.scpt
  • Communicating with a domain that ends with /connect which is seen in other stealer samples

After finding this sample, we did some additional digging to identify others showing similar behavior. We discovered three fully undetected stealers that had been on VirusTotal — the earliest uploaded at the end of January 2025. All three are different, but they share a handful of similarities. For this write-up, we’re focusing on the previously mentioned stl sample.

Static analysis

Using the codesign command, we can confirm that this sample is ad-hoc signed and lacks a valid signing authority.

Using the/usr/bin/file binary we can confirm the architecture the Mach-O was compiled for.

Reviewing the output of the command, we can see that it’s a Mach-O FAT binary with support for both x86_64 and arm64 architectures. We wanted to validate our initial findings from VirusTotal, that indicated it was packaged using PyInstaller. Checking the output of /usr/bin/strings then using /usr/bin/grep reveals that the Mach-O contains strings that are known to be related to PyInstaller.

This relationship is based on the specificity of the strings and their presence in the PyInstaller repository.

PyInstaller works by embedding an archive of files it requires into a standalone Mach-O executable. Upon execution, the PyInstaller's bootloader (main executable) dynamically extracts the archive into a temporary directory named _MEIxxxxxx. This _MEIxxxxxx directory typically contains:

  • Python .pyc files (compiled bytecode)
  • Python standard libraries
  • Embedded shared libraries

The bootloader then launches the embedded Python interpreter. This interpreter runs the compiled Python bytecode from the extracted archive that serves as the entry point for execution and contains the primary logic of the malware.

One interesting detail worth mentioning is the structure of the FAT binary and how it relates to the PyInstaller archive. We noted this when checking the output of /usr/bin/lipo -detailed_info stl and the arm64 slice of the Mach-O is 8MB while the Intel slice is significantly smaller, around 70KB. Looking through the PyInstaller code, we can see that the PyInstaller archive is embedded near the end of the Mach-O binary. In the case of a FAT binary, this will be within the arm64 slice. We can verify this by extracting both slices from the Mach-O and checking for the magic number of the archive within.

As you can see, stl-intel does not contain that magic number (4D 45 49 0C 0B 0A 0B 0E) which results in the following error when executing the extracted Intel binary.

All of this to say, if executed on Intel, the universal binary itself must be present since the Intel binary does not contain the PyInstaller archive.

Dynamic analysis

After some static analysis, it’s time to detonate the stl sample and observe its behavior dynamically using tools like eslogger or Red Canary Mac Monitor. During detonation with Mac Monitor running, we observed several exec events for osascript being invoked by stl, including an osascript -e display dialog prompt for the user’s password, muting the system volume and other common commands.

Execution events after detonating stl

At first glance, none of the commands imply that Python code is being executed. We don’t see Python being launched nor any obvious signs that Python modules are in use. But when we look at the execution event for the stl binary itself, a few things stand out in the environment variables.

  • _PYI_APPLICATION_HOME_DIR: reveals the temporary home directory PyInstaller creates at runtime
  • _PYI_ARCHIVE_FILE: shows the path to the actual PyInstaller executable that was launched
  • _PYI_PARENT_PROCESS_LEVEL: indicates the process level within the PyInstaller execution chain. For the stl binary, this is set to 0, meaning it’s the top-level process. In contrast, something like the osascript -e command used to prompt the user would have a value of 1, indicating it’s a direct child of the main PyInstaller process.

During detonation, we also captured file create, rename and unlink (delete) events. By inspecting the initial exec event of the stl binary and correlating related activity, we can see that before any logic executes, the binary unpacks all bundled Python libraries into its temporary home directory. These files aren’t persistent; they only exist on disk for the lifetime of the stl process.

Events correlated with the initial execution of the stl binary

If we look at the second exec event of the stl binary and correlate the associated file create events, the files being written match exactly what we’d expect from an infostealer, pointing to clear data collection activity:

Events correlated with the second executed event, matching expected infostealer activity

Unpacking and decompiling PyInstaller executables

Now that we have a better understanding about how the malware is packaged and what its goals are, the next step is to unpack the Mach-O and decompile to see the true source of the executable (stl.py). To unpack the Mach-O, we can use Pyinstxtractor or pyinstxtrator-web, which are open-source tools used to extract the contents of a PyInstaller executable. Once the Mach-O is processed, an archive called stl_extracted.zip will be generated. Within that archive is the Python3.framework folder (Python3.9), associated dependencies, and stl_obf.pyc.

Pyinstxtractor unpacking the Mach-O

The .pyc file extension indicates a compiled Python file, Python source code that has been converted into bytecode. These files are what the Python interpreter runs, and they’re typically generated automatically when a script is executed.

There are many tools to decompile Python bytecode, such as Decompile++ and Uncompyle6, but we will use PyLingual. It offers a web portal but can also be built and run locally. A handy feature of PyLingual is that it automatically identifies which Python version is needed to decompile the bytecode. One note about PyLingual, if you use the web portal, anything you upload will be retained to support future research and development. Now that we’ve got that out of the way, let's see what the decompiled Python script looks like.

PyLingual decompiling the bytecode

The decompiled Python code reveals that the script is further obfuscated by a combination of:

  • string reversal
  • base85 encoding
  • XOR'd bytes (encryption key = 188)
  • zlib compression

Modifying the last line of the script to print the contents of the obfuscated payload instead of running exec(__(_)), reveals the original state of the Python script including the steps taken to build the PyInstaller binary.

Finally, a quick review of several key functions confirms the true nature of this malware — it's yet another stealer:

  • GetPasswordModal(): attempts to harvest user credentials by triggering deceptive password prompts
  • RunAppleScript(): executes arbitrary AppleScript payloads from attacker server
  • DumpKeychain(): extracts saved credentials and sensitive information directly from the macOS Keychain
  • CollectCryptowallets(): scans the filesystem for known cryptocurrency wallets to exfiltrate private keys and steal crypto assets

Conclusion

As infostealers continue to become more prevalent in the macOS threat landscape, threat actors will continue the search for new ways to distribute them. While the use of PyInstaller to package malware is not uncommon, this marks the first time we've observed it being used to deploy an infostealer on macOS. By combining PyInstaller with additional obfuscation, attackers can potentially execute malicious payloads on macOS systems without requiring a native Python installation, while also potentially evading traditional detection mechanisms. Jamf Threat Labs continues the hunt for these techniques in order to stay ahead of attackers and stop malicious activity on macOS devices.

IOCs

VirusTotal query