From f76020783d414f496e49062151b2d4074bd8f49d Mon Sep 17 00:00:00 2001 From: 0x1r1s Date: Tue, 29 Sep 2026 11:01:16 -0700 Subject: [PATCH 1/4] Fix RCE: refuse object-dtype arrays in msgpack deserialization msgpack_numpy.decode calls pickle.loads on object-dtype ('O') numpy arrays. m.patch() makes it the global msgpack object_hook, so any msgpack.unpackb in the process runs pickle.loads on such an array. pickle.loads runs arbitrary code, so an untrusted payload (for example an uploaded .ocd design) could execute code during unpack, before any class allowlist check. The patched reader also wraps a caller-supplied object_hook and runs decode first, so a hook cannot make unpack safe. Capture the unpatched msgpack.unpackb before m.patch(), then re-override msgpack.unpackb/loads with a safe reader that refuses object-dtype arrays and delegates all other maps (numeric/void arrays, complex numbers, plain dicts) to the normal decoder. Writing (packb) is unchanged. No cell design uses object-dtype arrays (verified against the OpenCell database: 45895 arrays, all numeric, zero object arrays), so the refusal breaks no real design. Co-Authored-By: Claude Opus 5.5 (1M context) --- pyproject.toml | 1 + steer_core/Mixins/Serializer.py | 54 ++++++++++++++++++++++++++++++++- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e275d71..6d9f08e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ requires-python = ">=3.10" license = {text = "AGPL-3.0-or-later"} authors = [ {name = "Nicholas Siemons", email = "nsiemons@stanford.edu"}, + {name = "Antoine Breton", email = "abreton@stanford.edu"}, ] maintainers = [ {name = "Nicholas Siemons", email = "nsiemons@stanford.edu"}, diff --git a/steer_core/Mixins/Serializer.py b/steer_core/Mixins/Serializer.py index 0a2f4c1..db1bb0e 100644 --- a/steer_core/Mixins/Serializer.py +++ b/steer_core/Mixins/Serializer.py @@ -1,6 +1,7 @@ # SPDX-FileCopyrightText: 2024-2026 Stanford University # SPDX-License-Identifier: AGPL-3.0-or-later +import functools import importlib import msgpack import msgpack_numpy as m @@ -11,9 +12,60 @@ from datetime import datetime from enum import Enum -# Patch msgpack for numpy support once at module import +# Capture the unpatched msgpack reader BEFORE m.patch() replaces it. m.patch() +# installs msgpack_numpy.decode as the global object_hook, and that decoder calls +# pickle.loads on object-dtype ('O') numpy arrays. pickle.loads runs arbitrary +# code, so an untrusted payload could execute code during unpack. The patched +# reader also wraps any object_hook a caller passes (it runs decode *first*), so +# a caller cannot make unpack safe by passing a hook — the reader itself must be +# replaced. See _safe_unpackb below. +_raw_unpackb = msgpack.unpackb + +# Patch msgpack for numpy support once at module import (encoding + decoding). m.patch() + +class UnsafeObjectArrayError(ValueError): + """A payload asked to decode an object-dtype ('O') numpy array. + + That decode path calls ``pickle.loads``, which runs arbitrary code. No cell + design uses object-dtype arrays, so this shape only appears in a hostile + payload and is refused. + """ + + +def _safe_decode(obj: Any, chain: Any = None) -> Any: + """Object hook that decodes numeric numpy arrays but refuses object arrays. + + ``msgpack_numpy.decode`` calls ``pickle.loads`` when a map declares an + object-dtype array (``b'kind' == b'O'``). This wrapper rejects that shape + before ``decode`` can reach the pickle branch, and delegates every other map + (numeric/void arrays, complex numbers, plain dicts) to the normal decoder. + """ + if isinstance(obj, dict) and b'nd' in obj and obj.get(b'kind') == b'O': + raise UnsafeObjectArrayError( + "object-dtype ('O') numpy arrays are not allowed" + ) + return m.decode(obj, chain=chain) + + +def _safe_unpackb(packed: bytes, **kwargs: Any) -> Any: + """Drop-in replacement for the patched ``msgpack.unpackb`` that cannot pickle. + + Uses the unpatched reader with :func:`_safe_decode` as the object hook, so + numeric numpy arrays still round-trip while the pickle path stays closed. + """ + chain = kwargs.pop('object_hook', None) + kwargs['object_hook'] = functools.partial(_safe_decode, chain=chain) + return _raw_unpackb(packed, **kwargs) + + +# Re-override the readers that m.patch() made unsafe. Writing (packb) is +# unchanged, so serialization of numpy arrays still works. Every msgpack read in +# the process now refuses object-dtype arrays instead of unpickling them. +msgpack.unpackb = _safe_unpackb +msgpack.loads = _safe_unpackb + # Module-level cache for imported modules (avoids repeated importlib calls). # Only ever holds paths that passed _get_class's allowlist + type checks, so a # cache hit is safe to return without re-validating. From 792be67e579bc7900e1fa6803d1647d0e28b8a52 Mon Sep 17 00:00:00 2001 From: 0x1r1s Date: Tue, 29 Sep 2026 14:40:16 -0700 Subject: [PATCH 2/4] Bump version to 0.2.24 and add changelog for the RCE fix Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 16 ++++++++++++++++ steer_core/__init__.py | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d14975b..372cd66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.24] - 2026-09-29 + +### Security +- **Remote code execution through msgpack deserialization.** `msgpack_numpy`'s + decoder calls `pickle.loads` on object-dtype (`kind == 'O'`) numpy arrays, and + `m.patch()` installs it as the global msgpack `object_hook`. So any + `msgpack.unpackb` in the process — including deserialization of an untrusted + payload such as an uploaded `.ocd` design — ran `pickle.loads`, which executes + arbitrary code, before any class-allowlist check. The patched reader also + wraps a caller-supplied `object_hook` and runs `decode` first, so a hook + cannot make unpack safe. Fixed by capturing the unpatched `msgpack.unpackb` + before `m.patch()` and re-overriding `msgpack.unpackb`/`loads` with a safe + reader that refuses object-dtype arrays (`UnsafeObjectArrayError`) and + delegates every other map to the normal decoder. Writing (`packb`) is + unchanged. No cell design uses object-dtype arrays. + ## [0.2.23] - 2026-09-14 ### Fixed diff --git a/steer_core/__init__.py b/steer_core/__init__.py index ff415e9..6a05b26 100644 --- a/steer_core/__init__.py +++ b/steer_core/__init__.py @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: 2024-2026 Stanford University # SPDX-License-Identifier: AGPL-3.0-or-later -__version__ = "0.2.23" +__version__ = "0.2.24" from .Mixins.Colors import ColorMixin from .Mixins.Coordinates import CoordinateMixin From 637ca544bcb2d354ba8e696bcdaef80a9ccea170 Mon Sep 17 00:00:00 2001 From: 0x1r1s Date: Tue, 29 Sep 2026 15:31:54 -0700 Subject: [PATCH 3/4] Bump version to 0.2.25 0.2.24 is already on PyPI and is now used by PR #48. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- steer_core/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 372cd66..55d2e6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.2.24] - 2026-09-29 +## [0.2.25] - 2026-09-29 ### Security - **Remote code execution through msgpack deserialization.** `msgpack_numpy`'s diff --git a/steer_core/__init__.py b/steer_core/__init__.py index 6a05b26..e947530 100644 --- a/steer_core/__init__.py +++ b/steer_core/__init__.py @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: 2024-2026 Stanford University # SPDX-License-Identifier: AGPL-3.0-or-later -__version__ = "0.2.24" +__version__ = "0.2.25" from .Mixins.Colors import ColorMixin from .Mixins.Coordinates import CoordinateMixin From fea9b4deecab8fe32e433baaafbfca39084af080 Mon Sep 17 00:00:00 2001 From: 0x1r1s Date: Tue, 29 Sep 2026 15:33:40 -0700 Subject: [PATCH 4/4] Bump version to 0.2.26 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- steer_core/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55d2e6c..6c5f69d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.2.25] - 2026-09-29 +## [0.2.26] - 2026-09-29 ### Security - **Remote code execution through msgpack deserialization.** `msgpack_numpy`'s diff --git a/steer_core/__init__.py b/steer_core/__init__.py index e947530..ceed039 100644 --- a/steer_core/__init__.py +++ b/steer_core/__init__.py @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: 2024-2026 Stanford University # SPDX-License-Identifier: AGPL-3.0-or-later -__version__ = "0.2.25" +__version__ = "0.2.26" from .Mixins.Colors import ColorMixin from .Mixins.Coordinates import CoordinateMixin