Skip to content

Commit 887128a

Browse files
committed
deepdiff/serialization.py — Added a _SafeConstructor wrapper class that intercepts calls to size-sensitive constructors (like bytes and bytearray) during pickle deserialization. When find_class returns one of these types, it wraps it in _SafeConstructor, which validates that no integer argument exceeds _MAX_ALLOC_SIZE (128MB) before allowing the call. This prevents payloads like bytes(10**10) from causing memory exhaustion while still allowing legitimate small allocations. Tests tests/test_serialization.py — Added TestPicklingSecurity class with two tests: 1. test_restricted_unpickler_memory_exhaustion_cve — Reproduces the attack with a crafted payload attempting bytes(10_000_000_000), using resource.RLIMIT_AS to cap memory at 500MB as a safety net. Verifies the fix raises UnpicklingError before any allocation. 2. test_restricted_unpickler_allows_small_bytes — Ensures legitimate bytes(100) payloads still deserialize correctly.
1 parent 60ac5b9 commit 887128a

2 files changed

Lines changed: 86 additions & 1 deletion

File tree

deepdiff/serialization.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,35 @@ def pretty(self, prefix: Optional[Union[str, Callable]]=None):
331331
return "\n".join(f"{prefix}{r}" for r in result)
332332

333333

334+
# Maximum size allowed for integer arguments to constructors that allocate
335+
# memory proportional to the argument (e.g. bytes(n), bytearray(n)).
336+
# This prevents denial-of-service via crafted pickle payloads. (CVE-2025-58367)
337+
_MAX_ALLOC_SIZE = 128 * 1024 * 1024 # 128 MB
338+
339+
# Callables where an integer argument directly controls memory allocation size.
340+
_SIZE_SENSITIVE_CALLABLES = frozenset({bytes, bytearray})
341+
342+
343+
class _SafeConstructor:
344+
"""Wraps a type constructor to prevent excessive memory allocation via the REDUCE opcode."""
345+
__slots__ = ('_wrapped',)
346+
347+
def __init__(self, wrapped):
348+
self._wrapped = wrapped
349+
350+
def __call__(self, *args, **kwargs):
351+
for arg in args:
352+
if isinstance(arg, int) and arg > _MAX_ALLOC_SIZE:
353+
raise pickle.UnpicklingError(
354+
"Refusing to create {}() with size {}: "
355+
"exceeds the maximum allowed size of {} bytes. "
356+
"This could be a denial-of-service attack payload.".format(
357+
self._wrapped.__name__, arg, _MAX_ALLOC_SIZE
358+
)
359+
)
360+
return self._wrapped(*args, **kwargs)
361+
362+
334363
class _RestrictedUnpickler(pickle.Unpickler):
335364

336365
def __init__(self, *args, **kwargs):
@@ -355,7 +384,11 @@ def find_class(self, module, name):
355384
module_obj = sys.modules[module]
356385
except KeyError:
357386
raise ModuleNotFoundError(MODULE_NOT_FOUND_MSG.format(module_dot_class)) from None
358-
return getattr(module_obj, name)
387+
cls = getattr(module_obj, name)
388+
# Wrap size-sensitive callables to prevent DoS via large allocations
389+
if cls in _SIZE_SENSITIVE_CALLABLES:
390+
return _SafeConstructor(cls)
391+
return cls
359392
# Forbid everything else.
360393
raise ForbiddenModule(FORBIDDEN_MODULE_MSG.format(module_dot_class)) from None
361394

tests/test_serialization.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,58 @@ def test_load_path_content_when_unsupported_format(self):
155155
load_path_content(path)
156156

157157

158+
class TestPicklingSecurity:
159+
160+
@pytest.mark.skipif(sys.platform == "win32", reason="Resource module is Unix-only")
161+
def test_restricted_unpickler_memory_exhaustion_cve(self):
162+
"""CVE-2025-58367: Prevent DoS via massive allocation through REDUCE opcode.
163+
164+
The payload calls bytes(10_000_000_000) which is allowed by find_class
165+
but would allocate ~9.3GB of memory. The fix should reject this before
166+
the allocation happens.
167+
"""
168+
import resource
169+
170+
# 1. Cap memory to 500MB to prevent system freezes during the test
171+
soft, hard = resource.getrlimit(resource.RLIMIT_AS)
172+
maxsize_bytes = 500 * 1024 * 1024
173+
resource.setrlimit(resource.RLIMIT_AS, (maxsize_bytes, hard))
174+
175+
try:
176+
# 2. Malicious payload: attempts to allocate ~9.3GB via bytes(10000000000)
177+
# This uses allowed builtins but passes a massive integer via REDUCE
178+
payload = (
179+
b"(dp0\n"
180+
b"S'_'\n"
181+
b"cbuiltins\nbytes\n"
182+
b"(I10000000000\n"
183+
b"tR"
184+
b"s."
185+
)
186+
187+
# 3. After the patch, deepdiff should catch the size violation
188+
# and raise UnpicklingError before attempting allocation.
189+
with pytest.raises((ValueError, UnpicklingError)):
190+
pickle_load(payload)
191+
finally:
192+
# Restore original memory limit so other tests are not affected
193+
resource.setrlimit(resource.RLIMIT_AS, (soft, hard))
194+
195+
def test_restricted_unpickler_allows_small_bytes(self):
196+
"""Ensure legitimate small bytes objects can still be deserialized."""
197+
# Payload: {'_': bytes(100)} — well within the 128MB limit
198+
payload = (
199+
b"(dp0\n"
200+
b"S'_'\n"
201+
b"cbuiltins\nbytes\n"
202+
b"(I100\n"
203+
b"tR"
204+
b"s."
205+
)
206+
result = pickle_load(payload)
207+
assert result == {'_': bytes(100)}
208+
209+
158210
class TestPickling:
159211

160212
def test_serialize(self):

0 commit comments

Comments
 (0)