A .pyc file and a marshal blob embedded in a loader hold the same thing: a compiled Python code object, which is bytecode together with the names, constants, and structure the interpreter needs in order to run it. Turning that back into source is a decompilation step, separate from decoding, and it can fail while the payload stays readable in other ways. KlaroSkope renders a marshal payload to source where the interpreter version can be established, labels that output as reconstructed, and falls back to the code object's own fields when it cannot.
You peeled the outer layers of a Python loader and got bytes rather than source. Or a dropper wrote a .pyc into a temp directory and you want to know what it does before anything runs it. The next step is the same in both cases, and the tooling for it is thinner than the rest of the Python ecosystem would lead you to expect: uncompyle6, the tool most guides still name, stopped at Python 3.8.

Avoid calling marshal.loads() on a payload you are analysing. Python's documentation states that the module is not designed to be secure against erroneous or maliciously constructed data, and loading a blob builds objects inside your own process. Parse it, scan it as bytes, or open it in a disposable sandbox, rather than deserialising it in the interpreter you are working in. The same caution applies to importing a .pyc to see what happens.
Tell a .pyc From a Bare Marshal Blob
A .pyc file on disk starts with a 16-byte header, and the marshalled code object begins after it. Since PEP 552 landed in Python 3.7 that header is four bytes of magic number, four bytes of flags, and eight bytes that hold either a source timestamp and size or a source hash, depending on the flags. A marshal blob lifted out of a loader normally has none of this: the attacker called marshal.dumps() on a code object directly, so the bytes begin at the object itself.
.pyc file layout (Python 3.7 and later)
offset 0 4 bytes magic number, identifies the interpreter
offset 4 4 bytes flags (bit 0: hash-based rather than timestamp)
offset 8 8 bytes source mtime + size, or source hash
offset 16 ... the marshalled code object
Embedded marshal blob
offset 0 ... the marshalled code object, no header at allThat difference decides your first move. With a .pyc you read the version off the magic number and are done. With a bare blob there is no version field anywhere, and you have to infer it.
Reading the Magic Number
The magic number is a small integer followed by a carriage return and a line feed, stored little-endian. The trailing 0d 0a is deliberate: it corrupts under text-mode transfer, so a .pyc mangled by an FTP client or a copy-paste fails to load rather than loading wrong. The values below are the release magic for each version, taken from CPython's own importlib table.
| Python | Magic | First four bytes |
|---|---|---|
| 3.6 | 3379 | 33 0d 0d 0a |
| 3.7 | 3394 | 42 0d 0d 0a |
| 3.8 | 3413 | 55 0d 0d 0a |
| 3.9 | 3425 | 61 0d 0d 0a |
| 3.10 | 3439 | 6f 0d 0d 0a |
| 3.11 | 3495 | a7 0d 0d 0a |
| 3.12 | 3531 | cb 0d 0d 0a |
Development releases carry their own intermediate values, so a magic that does not appear above usually belongs to an alpha or beta of a nearby version rather than to something exotic. Reading the first four bytes of a suspicious .pyc is a useful triage step on its own: it tells you which interpreter the attacker expected to find on the target.
Identifying the Version With No Header
For a bare marshal blob the version has to be inferred from the object's structure. Bytecode layout shifts between releases: opcodes are added and removed, the argument encoding changes, and the code object's own fields are reordered or introduced. Parsing under the wrong assumption tends to announce itself, producing opcodes that do not exist in any version or jump targets that land outside the code. Working through the plausible versions and keeping the one that parses cleanly end to end is usually decisive, because a wrong guess rarely parses all the way through.
That version sensitivity constrains the attacker as much as the analyst. A blob compiled for 3.10 may fail to load on 3.12, so a loader that depends on one is betting on the victim's interpreter version. It is part of why marshal tends to appear alongside base64 and compression rather than as the only layer, and why some families ship several blobs and select at runtime.
What the Decompilers Actually Cover
Once the version is settled, turning bytecode back into source is a decompiler's job. This is where analysts coming from other ecosystems are usually surprised, because coverage thins out on recent releases rather than keeping pace with them.
| Tool | Coverage | Notes |
|---|---|---|
| uncompyle6 | Up to Python 3.8 | The tool most guides still recommend. Installs from PyPI. Does not support 3.9 or later. |
| decompyle3 | Around 3.7 to 3.9 | Same lineage, carries a little further, narrower version range overall. |
| pycdc (Decompyle++) | Broadest, still incomplete on recent versions | Actively maintained. Distributed as C++ source to build, not a package install. |
| Manual disassembly | Any version | The fallback that always applies. Slower, but nothing about it can silently stop working. |
An analyst holding a blob from a current interpreter may find that no decompiler reconstructs it cleanly. That is a real and fairly common outcome, and it is the point at which most write-ups stop. It is not where the analysis has to stop.
When Decompilation Fails, Read the Code Object
A code object carries considerably more than its instruction stream. The names it references, the constants it holds, its local variable names, its argument count, and the file it claims to come from are stored as ordinary fields beside the bytecode. Those fields survive whether or not a decompiler can reassemble control flow, because reconstructing control flow is the hard part and reading a tuple of strings is not.
# The fields are plain attributes on any code object.
# Shown on a benign function so the output is readable.
def demo(a, b=2, *rest, **kw):
import os
x = 'hello'
return os.path.join(x, str(a))
c = demo.__code__
c.co_names # ('os', 'path', 'join', 'str')
c.co_consts # (None, 0, 'hello')
c.co_varnames # ('a', 'b', 'rest', 'kw', 'os', 'x')
c.co_argcount # 2
c.co_filename # '<string>'co_names alone gives the external surface: every global, module, and attribute the code reaches for. On a real loader that is the list of API calls it makes. co_consts gives the literal strings and numbers, which is where URLs, registry paths, file names, and keys tend to live. Read together they answer the triage question directly. A payload naming VirtualAlloc, CreateThread, and a URL has disclosed its intent without a single line of reconstructed source.
| Field | Holds | Why it matters in triage |
|---|---|---|
| co_names | Globals, modules, attributes referenced | The API and import surface. Usually the fastest read on intent. |
| co_consts | Literal constants, and nested code objects | URLs, paths, keys, and every inner function. |
| co_varnames | Argument and local variable names | Author intent survives here when it survives nowhere else. |
| co_name / co_qualname | The function's own name | co_qualname exists from 3.11 and gives the full dotted path. |
| co_filename | Path recorded at compile time | Often leaks the build machine's directory layout. |
| co_flags | Generator, coroutine, nested, varargs bits | Cheap structural signal about what kind of callable this is. |

Inner Functions Are Nested Code Objects
Every function defined inside the code you are looking at appears as another code object inside its parent's co_consts. Walking that tree recursively enumerates the whole program's names and constants, including functions a partial decompile never reached. On a large payload this is the difference between reading the entry point and reading everything, and it is worth doing before concluding that a sample yields nothing.
It is also where output ordering starts to matter. A payload can carry hundreds of nested code objects, and the interesting one is not reliably near the front. Printing them in structural order and truncating at a size limit can bury the very indicator that would have identified the sample, which is a failure mode of the reporting rather than of the analysis.
Why a Decompiler's Exit Code Is Weak Evidence
Decompilers can exit with an error while emitting thousands of lines of perfectly usable output, and can exit cleanly while emitting something short and wrong. Judging the result by its status code therefore discards good output and accepts bad output in roughly equal measure. Judging it by evidence, whether the text parses and whether it contains real structure, is more reliable.
A syntax check is worth running and worth understanding the limits of. It catches output that is structurally broken, which is the common failure. It cannot catch output that parses cleanly but does not mean what the original meant, which is the dangerous one: a decompiler emitting an expression that is valid Python and refers to the wrong object produces something an analyst will read and believe. Nothing automated separates that from a correct reconstruction, which is the reason to label reconstructed source as reconstructed and to corroborate anything load-bearing against the constants and names, which are read rather than inferred.
Decode It Automatically
KlaroSkope handles the marshal layer as part of a full chain rather than as a separate tool, so a loader that wraps its payload in base64 and compression is peeled and rendered in one pass. Where the interpreter version can be established and reconstruction succeeds, the output is presented as reconstructed source and marked as such. Where it does not, the analysis falls back to the code object's structure, the names, constants, and imports, presented as what they are rather than dressed up as recovered source. Chains that end in a marshal stage are covered in decoding Python exec obfuscation.
Frequently Asked Questions
Why does uncompyle6 fail on my .pyc?
How do I tell which Python version a .pyc was compiled with?
Is it safe to run marshal.loads() on a suspicious payload?
What can I recover if the decompiler fails completely?
Can decompiled Python be trusted as the attacker's original code?
Continue Learning
Ready to decode?
See KlaroSkope transform obfuscated scripts into actionable intelligence.
Try It Free