A marshal-"encrypted" Python file is just exec(marshal.loads(...)) wrapped around base64'd (often zlib- or zstd-compressed) bytecode — and it reverses cleanly, because the interpreter has to run it. Three tested methods: 1) decode the blob statically (base64 → decompress → marshal.loads) and read it with dis + co_consts — recovers the logic and every string literal on any Python version, without executing anything; 2) shadow the exec call to capture the code object without running it — the safe way to open an untrusted sample; 3) rebuild a .pyc from that code object and decompile it to readable source with a .pyc decompiler. "Marshal encryption" is encoding, not encryption — it stops nobody.
One of the most common ways people try to "protect" or hide Python is to compile it, marshal.dumps() the code object, base64 it, and ship a tiny exec(marshal.loads(...)) loader. You'll meet it in packed scripts, licensing wrappers, and — often — in malware. It looks impenetrable: a wall of base64. It is not.
Marshaling is serialization, not encryption: there's no key, and the interpreter must turn that blob back into a runnable code object, so you can too. Below are three ways to reverse it, each run on real Python 3.14 — including one that recovers everything without executing the sample, which is how you safely open something you don't trust.
What "marshal encryption" actually is
Strip away the base64 and the loader is completely mechanical. Your source is compiled to a code object, that object is marshal.dumps()'d to bytes, usually compressed, base64-encoded, and handed to exec:
import marshal, zlib, base64exec(marshal.loads(zlib.decompress(base64.b64decode("eJxLy8xJ..." # your entire program, encoded))))
There is no key anywhere in that pipeline — base64 and zlib/zstd are reversible transforms, and marshal is the interpreter's own format. Every step that packed the code has a public inverse, so reversing it is just running the pipeline backwards. The compression is only there to shrink the blob (see zlib vs zstd for bytecode); it adds nothing to protection.
Only reverse code you own or are authorized to analyze. Recovering your own build, auditing a dependency, or inspecting a suspicious script for safety is normal reverse-engineering; bypassing someone else's protection to steal or relicense their work can breach their terms or copyright. Know which side of that line you're on.
Method 1 — decode it statically (never run it)
Copy the base64 string out of the loader and reverse the exact steps it would — but stop at marshal.loads, which gives you a code object you can inspect instead of execute:
import marshal, zlib, base64, disblob = "eJxLy8xJ..." # the base64 string from the loaderraw = zlib.decompress(base64.b64decode(blob)) # if the loader used zstd, use# compression.zstd.decompress herecode = marshal.loads(raw) # a code object — NOT executedprint([c for c in code.co_consts if isinstance(c, str)]) # the string literalsdis.dis(code) # the full logic
Run against a sample whose "secret" is an API key and a licence check, this is the real output — the strings the author thought were hidden come straight back, and the disassembly spells out the logic:
['sk-live-9f83a1', 'PRO-2026'] # the "encrypted" key + licence, in the clear1 LOAD_CONST 0 ('sk-live-9f83a1')STORE_NAME 0 (API_KEY)2 ...MAKE_FUNCTION... (check)4 LOAD_NAME (print) ... LOAD_CONST 2 ('PRO-2026') ...# inside check(pw): COMPARE_OP (pw == 'PRO-2026')
How do you know which decompressor to use? Just read the loader — it literally names it: zlib.decompress, compression.zstd.decompress, or neither (plain marshal.loads(base64.b64decode(...))). Match that one call and you're in.
Method 2 — capture the code object without executing it
Method 1 needs you to find and copy the blob. When the loader is messier — or when the sample is untrusted and you refuse to run it — there's a cleaner trick: run the loader in a namespace where exec is your function instead of the builtin. The loader does all its own decoding, then calls exec(code) — which lands in your capture instead of executing:
import disloader = open("suspicious.py").read() # the exec(marshal.loads(...)) filecaptured = []# a globals dict whose 'exec' shadows the builtin — the loader calls oursg = {"exec": lambda obj, *a, **k: captured.append(obj)}exec(loader, g) # base64/decompress/marshal.loads all run;# the final exec(code) is intercepted, not executedcode = captured[0] # the payload's code object — never ran a line of itprint([c for c in code.co_consts if isinstance(c, str)])dis.dis(code)
['sk-live-9f83a1', 'PRO-2026']nested functions: ['check']# captured a code object without running a single line of the sample
This is the safe way to open a suspicious marshal loader: the decode/decompress steps are harmless, and the one dangerous step — running the payload — is exactly the call you replaced. For deeper cases you can do the same with sys.addaudithook on the exec/compile events.
Method 3 — rebuild a .pyc and decompile to source
dis gives you logic; for readable `.py` you feed the code object to a decompiler. Decompilers eat .pyc files, so wrap your recovered code object in a 16-byte .pyc header and write it out:
import marshal, importlib.util, struct, time# `code` is the code object from Method 1 or 2.header = importlib.util.MAGIC_NUMBER # 4-byte version magicheader += struct.pack("<I", 0) # flagsheader += struct.pack("<I", int(time.time())) # timestampheader += struct.pack("<I", 0) # source sizewith open("recovered.pyc", "wb") as f:f.write(header + marshal.dumps(code))# then decompile it to real source:# pycdc recovered.pyc# or drop recovered.pyc into the online .pyc decompiler
From there it's an ordinary decompilation job: pycdc (Decompyle++) handles modern bytecode, uncompyle6/decompyle3 give the cleanest output on Python ≤3.9, and our in-browser .pyc decompiler runs pycdc via WebAssembly so you can do it with nothing installed. As always, full-source decompilers lag the newest Python releases — but even when they can't rebuild perfect .py, Methods 1 and 2 already handed you the strings and the control flow. (More on that in how to decompile a .pyc.)
Nested and multi-layer loaders
Some packers stack the trick — marshal inside marshal, or a loader that execs another loader. It changes nothing: you peel one layer at a time. With Method 1, each marshal.loads hands you a code object whose co_consts contains the next blob; with Method 2, the shadow-exec fires again for each inner exec, so you capture every layer as it's unwrapped. There's no depth that survives, because every layer still has to decode itself to run.
The recurring lesson: encoding your program more times doesn't add protection, it just adds steps to an automated peel. If a layer looks encrypted, look for where the loader decrypts it — that key or routine is always shipped alongside, because the interpreter needs it too.
Why marshal is not protection (and what is)
Everything above works because marshaling is a cache format, not a lock: no key, a public inverse for every step, and string literals stored verbatim. That's why shipping exec(marshal.loads(...)) protects nothing — and why it also trips antivirus, which flags that exact pattern. If you actually need to raise the cost of reverse-engineering:
- Obfuscate the source before you marshal it. Rename every identifier and encrypt the string literals at the AST level, so even a recovered code object is gibberish with no readable secrets. Our free Python Obfuscator does this — then marshaling the result is at least hiding *mangled* code.
- Keep real secrets off the client. As Method 1 shows, a hard-coded key survives every layer. Load keys from the environment or a server you control — see protecting API keys in Python.
- For the strongest bar, compile to a native module (
.pyd/.sovia Cython, or a Nuitka binary) so there's no marshaled bytecode toloadsat all.
Want to see the whole loop end to end? Build a marshal loader with our Marshal Encryptor, then run these methods on your own output — the fastest way to internalise that marshaling is a speed bump, not a wall.
See how weak marshal really is — free
Build a marshal loader with our free tool, then reverse your own output with the methods above. In your browser, nothing uploaded.
Open the Marshal EncryptorFree tools mentioned here
Related guides
Frequently asked questions
Can you decrypt marshaled Python code?
There's nothing to decrypt — marshal is serialization, not encryption, so there's no key. You reverse it: base64-decode the blob, apply the same decompressor the loader uses (zlib or zstd, if any), and marshal.loads() the result into a code object. From there dis and co_consts reveal the logic and strings, and a decompiler rebuilds source.
How do I reverse exec(marshal.loads(...)) obfuscation?
Two reliable ways. Statically: copy the base64 string, decode and decompress it, and marshal.loads() it — then inspect the code object with dis instead of exec. Or intercept it: run the loader in a namespace whose exec shadows the builtin, so the payload's code object is captured without ever executing. Both recover the strings and logic.
How do I analyze a marshal loader without running it?
Use Method 2: run the loader with a custom globals dict where exec is your own function that just stores its argument. The loader's own base64/decompress/marshal.loads steps run (they're harmless), but the final exec(code) is intercepted, so you get the code object without executing a single line of the payload. This is the safe way to open untrusted samples.
Can I get readable .py source back from a marshal blob?
Often, yes. Recover the code object, wrap it in a 16-byte .pyc header (importlib.util.MAGIC_NUMBER plus flags/timestamp/size fields), and run the resulting .pyc through a decompiler like pycdc, decompyle3, or an online .pyc decompiler. Full-source decompilers lag the newest Python versions, but dis and co_consts already give you the logic and strings on any version.
Does compressing the bytecode with zlib or zstd make it harder to reverse?
No. Compression only shrinks the blob; the loader has to decompress it to run, so you decompress it the same way — the call is right there in the loader (zlib.decompress or compression.zstd.decompress). It adds one reversible step, not protection.
Is marshal obfuscation used by malware?
Frequently — exec(marshal.loads(zlib.decompress(...))) is a common Python malware packing pattern, which is why analysts learn to reverse it and why antivirus flags the pattern. The safe approach is static decoding or shadow-exec interception so you recover the payload's logic and indicators without executing it.