Techniques18-Aug-26|11 min read

Decode a Python .pyc or Marshal Blob When the Decompiler Fails

Identify the interpreter version, pick a decompiler that still supports it, and read names, constants, and imports straight out of the code object when decompilation does not complete

Definition:

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.

Three stages left to right on a dark background: a dense wall of unreadable compiled bytecode shown as grey hex byte pairs, an attempted reconstruction into Python source that fractures and dissolves mid-line in amber, and a column of intact, sharply legible tokens in green and cyan listing recovered function names such as load_config and validate_user alongside recovered string constants such as session_token and an API path, illustrating that names and constants stay readable even when decompilation fails

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.

text
.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 all

That 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.

Key Examples
3.6
Magic3379
First four bytes33 0d 0d 0a
3.7
Magic3394
First four bytes42 0d 0d 0a
3.8
Magic3413
First four bytes55 0d 0d 0a
3.9
Magic3425
First four bytes61 0d 0d 0a
3.10
Magic3439
First four bytes6f 0d 0d 0a
3.11
Magic3495
First four bytesa7 0d 0d 0a
3.12
Magic3531
First four bytescb 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.

Key Examples
uncompyle6
CoverageUp to Python 3.8
NotesThe tool most guides still recommend. Installs from PyPI. Does not support 3.9 or later.
decompyle3
CoverageAround 3.7 to 3.9
NotesSame lineage, carries a little further, narrower version range overall.
pycdc (Decompyle++)
CoverageBroadest, still incomplete on recent versions
NotesActively maintained. Distributed as C++ source to build, not a package install.
Manual disassembly
CoverageAny version
NotesThe 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.

python
# 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.

Key Examples
co_names
HoldsGlobals, modules, attributes referenced
Why it matters in triageThe API and import surface. Usually the fastest read on intent.
co_consts
HoldsLiteral constants, and nested code objects
Why it matters in triageURLs, paths, keys, and every inner function.
co_varnames
HoldsArgument and local variable names
Why it matters in triageAuthor intent survives here when it survives nowhere else.
co_name / co_qualname
HoldsThe function's own name
Why it matters in triageco_qualname exists from 3.11 and gives the full dotted path.
co_filename
HoldsPath recorded at compile time
Why it matters in triageOften leaks the build machine's directory layout.
co_flags
HoldsGenerator, coroutine, nested, varargs bits
Why it matters in triageCheap structural signal about what kind of callable this is.
Anatomy of a single Python code object drawn as a labelled container. One opaque band at the top holds the bytecode as unreadable grey hex. Below it, bands labelled names, constants, varnames and argcount hold short readable green tokens: function names, quoted strings, local variable names and an argument count. Two smaller containers nested inside the constants band repeat the same structure, showing that inner functions are themselves code objects carrying their own readable names and constants
The bytecode is the opaque part. Everything below it is stored beside the instruction stream, not inside it, which is why it survives a failed decompile.

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

Q

Why does uncompyle6 fail on my .pyc?

Most likely the file is from Python 3.9 or later. uncompyle6 supports up to 3.8 and does not cover the versions after it, so a newer .pyc produces an error rather than source. Read the first four bytes of the file to confirm which interpreter wrote it, then try decompyle3 for the versions just above, or pycdc (Decompyle++), which is the actively maintained option and reaches further. If none of them reconstruct the file, the names and constants in the code object are still readable.
Q

How do I tell which Python version a .pyc was compiled with?

The first four bytes are the magic number, a small integer stored little-endian followed by a carriage return and line feed. 3413 is Python 3.8, 3425 is 3.9, 3439 is 3.10, 3495 is 3.11 and 3531 is 3.12. A marshal blob embedded in a loader usually has no header at all, in which case the version is inferred from the bytecode's structure instead: parsing under the wrong version tends to produce opcodes that do not exist or jump targets outside the code.
Q

Is it safe to run marshal.loads() on a suspicious payload?

It is better avoided. Python's own documentation notes that marshal is not designed to be secure against erroneous or maliciously constructed data, and loading a blob constructs objects inside your process. Treat an unknown payload as hostile input to be parsed or scanned as bytes, or handle it in a disposable sandbox, rather than deserialising it in the interpreter you are working in.
Q

What can I recover if the decompiler fails completely?

More than most write-ups suggest. A code object stores the names it references, its constants, its local variable names, its argument count and the filename recorded at compile time, all alongside the bytecode rather than inside it. Those fields give the API surface, the literal strings including URLs and paths, and the shape of each function. Inner functions appear as further code objects inside the parent's constants, so walking that tree recursively enumerates the whole program. For triage this is frequently enough to establish intent.
Q

Can decompiled Python be trusted as the attacker's original code?

It is worth reading and worth treating as reconstruction rather than as the original. A decompiler can produce output that parses as valid Python but differs from the source in meaning, and no automated check separates that case from a correct reconstruction. Constants and names are read directly out of the code object rather than inferred, so they make a good cross-check for anything a decision depends on.

Found this useful? Sharing is caring!

Ready to decode?

See KlaroSkope transform obfuscated scripts into actionable intelligence.

Try It Free