Read a PyTorch checkpoint's pickle without running it
Input artifact: a checkpoint file. Drop a .pt, .bin,
.ckpt or .pkl and this page disassembles the pickle opcode stream inside it,
in your tab, with nothing uploaded and nothing executed. You get two things: the permanent
opcode and import inventory (every GLOBAL and STACK_GLOBAL
the file resolves, every REDUCE that calls one), and the answer people actually want
since PyTorch 2.6 flipped weights_only to True by default:
would torch.load(weights_only=True) accept this file, and which exact global
or opcode is the blocker.
1. Load a file
Or start with a fixture. Everything below is computed from the bytes you give it.Load sample
Samples are byte sequences built in this page. The first one is the exact proof-of-concept published in GHSA-9gvj-pp9x-gcfr. All example payload strings are inert placeholders.
Simulate torch.serialization.add_safe_globals
One dotted path per line, for example numpy.core.multiarray._reconstruct.
These are added to the allowlist for the verdict below, exactly as add_safe_globals would.
The four blocklisted modules cannot be re-allowed this way, and the tool shows that.
2. Container
What kind of file this is, and how the pickles were found.3. Would torch.load(weights_only=True) accept it?
Computed here from your bytes, against the dated reference tables in section 7.
4. Import and call inventory
Every import the file resolves, and every opcode that would call one.5. Opcode disassembly
Offsets matchpickletools.dis. The stack column is a real stack model, not a text window.
6. Regression fixture: CVE-2025-71325
The incumbent scanner's published off-by-one, reproduced as a permanent test.
GHSA-9gvj-pp9x-gcfr describes a parsing bug in picklescan's _list_globals.
Handling STACK_GLOBAL at op index n, the backwards scan for the two string
operands looped range(1, n), which reaches op index n-1 down to 1
and never reaches index 0. The advisory's own words:
"The loop only consider the range from1ton-1but forgets to consider the opcode at position0. The correct range should be0ton-1."
In a protocol 0 or 1 pickle there is no PROTO opcode, so index 0 is a real operand.
Put one of the two names there and the scan finds only one value, raises ValueError,
and the scan errors out instead of reporting. This page reimplements all three behaviours from the
published source and runs them on the advisory's exact 26-byte payload, so the hole cannot be
inherited here without the test going red.
7. Reference snapshot (dated vendor data)
torch v2.13.0Everything in this section is copied from PyTorch's source, not computed by this tool, and it is shown in a dashed amber shell for exactly that reason. The allowlist grows every release. If a row here is stale, the tool's logic is still correct and only this table needs refreshing. Read it as "the tables as of the pinned tag", never as "the truth forever".
- pinned tag
- v2.13.0
- tag released
- 2026-07-08
- transcribed
- 2026-08-09
- source file
- torch/_weights_only_unpickler.py
7a. Opcodes the weights_only unpickler implements
Read off the elif key[0] == ... chain in Unpickler.load().
Anything not on this list raises UnpicklingError("Unsupported operand {byte}").
This is why a file can be rejected with no dangerous import in it at all.
7b. Allowed globals
| dotted path | family | how torch builds it |
|---|
7c. Modules that can never be allowlisted
From _blocklisted_modules: refused even if you pass them to
add_safe_globals. Try it in the box in section 1.
8. How it works, and what it does not decide
Reading the file
- A
.ptsaved bytorch.saveis a ZIP. The page reads the last 66000 bytes throughFile.slice, which covers a maximum-length archive comment plus the ZIP64 locator, finds the end-of-central-directory record, walks the central directory, and then slices only the bytes of each pickle member. Tensor data is never read. A multi-gigabyte checkpoint costs a few kilobytes of transfer from disk. - ZIP64 is handled: when the classic record carries the 0xFFFF / 0xFFFFFFFF sentinels the page
follows the ZIP64 locator and record, and reads the 64-bit fields from the per-entry
0x0001extra field. - Every archive member whose name ends in
.pklor.debug_pklis walked, not justdata.pkl, because a TorchScript archive also carriesconstants.pkl,callstack_debug_map.pkland onecode/<name>.py.debug_pklper source file. Those debug records are pickles. - Stored members are read as-is.
PyTorchStreamWriter::writeRecorddeclaresbool compress = false, and every call on thetorch.savepath takes that default, so a plain checkpoint is stored rather than deflated. TorchScript is the exception:export_module.cpppassessize > kMinToCompresswithkMinToCompress = 200, so source files and.debug_pklrecords over 200 bytes are deflated. Those go throughDecompressionStream("deflate-raw"), which MDN marks as available across browsers since May 2023, so there is no bundled inflate and no wasm. Extracted bytes are CRC32-checked against the central directory value. - The legacy non-ZIP
torch.saveformat is refused, not guessed at. Its first record is a protocol 2 pickle of the magic number0x1950A86A20F9469CFC6C, which the page recognises by its exact 14-byte prefix, and the even older tar layout is recognised by theustarmarker at offset 257. Mis-parsing those quietly would be worse than saying no.
Resolving imports
GLOBALcarries module and name inline, so a text scan finds it.STACK_GLOBALdoes not: the module and the name are pushed as two separate stack items and never appear as one contiguous token. This page therefore runs a real pickle stack, including the memo, the mark stack and the metastack, and pops the two operands the way CPython does. That also means aSTACK_GLOBALfed fromBINGETresolves correctly.- The opcode table covers all 68 opcodes CPython's pickle module defines across protocols 0
through 5, so an argument length is never guessed. Strings are rendered as printable ASCII
with
\xNNand\uNNNNescapes, so a module name carrying control characters or bidi overrides cannot disguise itself in the output of a tool you are using to look for disguises. - Lookups use null-prototype maps, so an import literally named
__proto__orconstructoris reported as what it is instead of colliding with a JavaScript built-in. There is a self-test for it.
The weights_only decision
- Two finite tables from
torch/_weights_only_unpickler.py: the opcodes the loop implements, and the globals_get_allowed_globals()registers. Both are in section 7, dated and marked as vendor data. - Torch applies the Python 2 to 3 rename tables to a
GLOBALbefore checking the blocklist, so__builtin__.internbecomessys.internand is blocked by module. That rewrite is implemented here. - Torch raises on the first problem it hits. The page shows that one as the blocker and lists the rest as what you would hit next.
What this tool does not decide
These checks in torch's unpickler depend on runtime object types, which a static reader cannot know. If your file only trips one of these, this page will say "no blocker found" and torch will still refuse. That is a real gap and it is stated here rather than buried:
BUILDis restricted toTensor,Parameter,OrderedDictor an allowlisted type, judged by the runtime type of the instance.APPENDandAPPENDSare restricted tolistand to allowlisted list subclasses.SETITEMandSETITEMSare restricted todict,OrderedDictandCounter.BINPERSIDrequires the persistent id to be a tuple or int whose first element decodes to"storage".- Anything an allowlisted rebuild function does with the arguments it is handed.
And the wider point: a file that weights_only=True accepts is not
thereby a file you should trust with weights_only=False. The torch documentation is
blunt about the general case: "torch.load() uses an unpickler under the hood.
Never load data from an untrusted source."
Primary sources
- torch/_weights_only_unpickler.py at v2.13.0 - the allowed-globals table, the opcode chain, the blocklist and the exact error strings.
- torch/serialization.py at v2.13.0 -
MAGIC_NUMBER = 0x1950A86A20F9469CFC6C,_is_zipfilechecking for the local header magicPK\x03\x04, and thedata.pklrecord name. Note the signature isweights_only: bool | None = None, notTrue:_default_to_weights_only()resolvesNonetoTrueonly whenpickle_module is Noneand the build is not fbcode, so passing apickle_modulesilently opts you back out. - caffe2/serialize/inline_container.h at v2.13.0 -
PyTorchStreamWriter::writeRecorddeclaresbool compress = false, and thetorch.savepath takes that default. export_module.cpp is the one caller that overrides it, withstatic constexpr size_t kMinToCompress = 200, which is why TorchScript source and.debug_pklrecords can arrive deflated. - torch/csrc/utils/tensor_types.cpp at v2.13.0 -
all_declared_types()lists{CPU, CUDA, SparseCPU, SparseCUDA}as an unconditional literal, so thetorch.cuda.*Tensorrows are build-independent, and skips Bool for the sparse backends with the comment "there is no sparse bool type". - PyTorch 2.6.0 release notes - "Flip default torch.load to weights_only".
- GHSA-9gvj-pp9x-gcfr / CVE-2025-71325 - the picklescan STACK_GLOBAL range bug, its proof of concept and its disassembly offsets.
- picklescan scanner.py at v1.0.5 and at the vulnerable commit - the two
_list_globalsloops reimplemented in section 6. The 0.0.27 release changed both the range and the string-opcode list. - Python pickletools and CPython Lib/pickle.py - the opcode byte values and argument encodings.
- MDN DecompressionStream() -
deflate-rawis documented as "Decompress the stream using the DEFLATE algorithm without a header and trailing checksum", and the API is marked "available across browsers since May 2023". - PKWARE APPNOTE.TXT - ZIP end-of-central-directory, central directory and ZIP64 record layouts.
9. Self-tests
Assertions over the pure functions, run in your browser.Every fixture below was disassembled with Python's pickletools first, and the
expected offsets are that output. The CVE row's expected offsets come from the advisory text itself.