Skip to content
AZ Tools

Python Pickle Inspector

A pickle is not really a data format. It is a program for a small stack machine, and pickle.load runs it: the opcodes can import any module, call any callable and construct any class before a value ever appears. That is why this tool never unpickles anything. It reads the byte stream the way pickletools.dis reads it — one opcode at a time, with its offset, its decoded argument and its effect on the stack — and rebuilds only the part of the value that can be rebuilt without calling into Python. Dicts, lists, tuples, sets, strings, byte strings, integers of any size, floats, booleans and None come back as themselves; anything that would require an import is shown as an explicit placeholder rather than a guess. The protocol version is reported twice, because the two answers can differ: protocol 2 and later open with a PROTO opcode that declares a version, while protocols 0 and 1 declare nothing, so the highest protocol any opcode actually needs is computed as well — and a file that claims to be older than it is gets flagged. The memo view shows which objects were stored and fetched again, which is the only way shared references and cycles are visible at all: a list that contains itself is a memo fetch of a key that was stored while that same list was still being built. Protocol 4 and 5 streams are framed, and the frame table shows where each block begins. The findings panel is the part worth reading first. It names every construct that would run code on load — GLOBAL and STACK_GLOBAL for the names imported, REDUCE for the call itself, INST, OBJ, NEWOBJ and NEWOBJ_EX for class construction, BUILD for __setstate__, PERSID for the loader’s own persistent_load hook, and EXT1/2/4 for the copyreg registry, where the name is not even in the file — and it lists every module and attribute the loader would look up, flagging the ones that appear in published exploits, such as os.system, subprocess, builtins.eval and posix.system. A pickle with none of those opcodes is data; a pickle with them is a program, and no amount of inspection makes loading it from an untrusted source safe. Malformed files are named rather than half-read: a truncated stream, bytes left over after STOP — a second pickle hiding behind the first — and a memo fetch of a key that was never stored are each reported as such.

How to use

  1. Drop a .pkl, .pickle or any pickled blob onto the box. Nothing is uploaded and nothing is unpickled; the file is only disassembled.
  2. Read the findings panel first. "Data only" means no opcode in the file can import or call anything. If it names imports, treat the file as untrusted code and do not load it.
  3. Compare the declared and the required protocol. A file that declares protocol 2 but uses protocol 4 opcodes was assembled by hand rather than written by pickle.dumps.
  4. Scan the opcode table. The indentation follows the MARK nesting, so each container reads as a block, and the stack column shows how many values are live after every step.
  5. Open the memo table to find shared objects: a key with a reuse count above zero is one object appearing in several places, and it is also how a cycle is written.

Frequently asked questions

Is it safe to open an untrusted pickle here?
Yes, because nothing here unpickles anything. The file is read opcode by opcode, exactly as pickletools.dis reads it, and the only thing that happens to a GLOBAL or REDUCE opcode is that its module and attribute names get printed. Nothing is imported and nothing is called, and the reconstruction stops at values that can be built from opcodes alone. The file also never leaves your browser: it is read with the File API and parsed in the page. What the tool cannot do is make the file safe afterwards — if the findings panel names imports, pickle.load on that same file will still perform them.
Why can a pickle run code at all? It looks like a serialization format.
Because serializing arbitrary objects needs a way to recreate them, and Python’s answer is __reduce__: an object says which callable, given which arguments, will rebuild it. The stream therefore stores a name to import and a call to make, and the unpickler performs both. That single mechanism is what makes datetime objects, numpy arrays and every custom class picklable, and it is the same mechanism an attacker uses by naming os.system instead of datetime.datetime. Nothing in the format separates the two cases, which is why reading the opcodes is the only way to know what a file will do.
What is the memo for, and how does a cycle show up in it?
The memo is the pickler’s table of objects it has already written. When the same object appears again, the second occurrence is a memo fetch — PUT and GET in protocol 0, BINPUT and BINGET later, and MEMOIZE from protocol 4, which numbers keys implicitly instead of writing them out. That is what preserves identity: two names that pointed at one list still point at one list after loading. A cycle is the same mechanism one step further. The container is memoized before its contents are written, so the contents can refer back to it, which is why a list containing itself is representable at all and why the reconstruction here shows the reference instead of expanding it forever.
What do the six protocol versions actually change?
Protocol 0 is ASCII: integers, floats and strings are written as text terminated by newlines, which is why a protocol-0 pickle is almost readable. Protocol 1 adds binary spellings of most of them. Protocol 2 adds efficient class construction with NEWOBJ, plus one-byte tuples and real booleans. Protocol 3 adds bytes objects, which Python 2 had no way to represent. Protocol 4 adds framing — the stream is cut into length-prefixed blocks so a reader can pull whole chunks at once — along with 64-bit sizes, STACK_GLOBAL, native set support and implicit memo keys. Protocol 5 adds out-of-band buffers, where a large array travels beside the stream rather than inside it, which is why NEXT_BUFFER can appear with no data behind it.
The file has bytes after STOP. What does that mean?
STOP ends one pickle, and everything after it belongs to whatever comes next. Writing several pickles into one file is an ordinary pattern — a loop of pickle.dump into an open file, read back with a loop of pickle.load — so trailing bytes often just mean the file holds a stream of records rather than one object. It is also how a second payload hides behind a harmless first one, since a program that calls load once sees only the first object and never looks at the rest. This tool disassembles the first pickle and reports how many bytes remain; to inspect the rest, cut the file at the offset given for STOP and open the tail separately.
Can it show me the contents of a numpy array or a pandas DataFrame?
Not the values, because those objects are rebuilt by calling into numpy and pandas: the stream names something like numpy.core.multiarray._reconstruct or a pandas class, hands it a byte string, and the array only exists after that call has run. What you do see is the shape of the file — the names it would import, the raw buffers it carries and their sizes — which is usually enough to tell whether a file is what it claims to be. In a protocol 5 file the array data may not be in the file at all: NEXT_BUFFER means the payload was passed out of band and has to be supplied by the caller.

Related tools