Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Security fix: Prevent class pollution and remote code execution in Delta
- Add validation to prevent traversing dunder attributes via check_elem()
- Harden Delta class against malicious pickle payloads
- Make SAFE_TO_IMPORT a frozenset for immutability
- Add comprehensive security tests in test_security.py
- Prevent access to __globals__ and other dangerous attributes
  • Loading branch information
seperman committed Sep 3, 2025
commit c69c06c13f75e849c770ade3f556cd16209fd183
8 changes: 7 additions & 1 deletion deepdiff/delta.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
)
from deepdiff.path import (
_path_to_elements, _get_nested_obj, _get_nested_obj_and_force,
GET, GETATTR, parse_path, stringify_path,
GET, GETATTR, check_elem, parse_path, stringify_path,
)
from deepdiff.anyset import AnySet
from deepdiff.summarize import summarize
Expand Down Expand Up @@ -237,6 +237,11 @@ def _get_elem_and_compare_to_old_value(
forced_old_value=None,
next_element=None,
):
try:
check_elem(elem)
except ValueError as error:
self._raise_or_log(UNABLE_TO_GET_ITEM_MSG.format(path_for_err_reporting, error))
return not_found
# if forced_old_value is not None:
try:
if action == GET:
Expand Down Expand Up @@ -536,6 +541,7 @@ def _get_elements_and_details(self, path):
obj = self
# obj = self.get_nested_obj(obj=self, elements=elements[:-1])
elem, action = elements[-1] # type: ignore
check_elem(elem)
except Exception as e:
self._raise_or_log(UNABLE_TO_GET_ITEM_MSG.format(path, e))
return None
Expand Down
7 changes: 7 additions & 0 deletions deepdiff/path.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ def _path_to_elements(path, root_element=DEFAULT_FIRST_ELEMENT):

def _get_nested_obj(obj, elements, next_element=None):
for (elem, action) in elements:
check_elem(elem)
if action == GET:
obj = obj[elem]
elif action == GETATTR:
Expand All @@ -134,11 +135,17 @@ def _guess_type(elements, elem, index, next_element):
return {}


def check_elem(elem):
if isinstance(elem, str) and elem.startswith("__") and elem.endswith("__"):
raise ValueError("traversing dunder attributes is not allowed")


def _get_nested_obj_and_force(obj, elements, next_element=None):
prev_elem = None
prev_action = None
prev_obj = obj
for index, (elem, action) in enumerate(elements):
check_elem(elem)
_prev_obj = obj
if action == GET:
try:
Expand Down
4 changes: 2 additions & 2 deletions deepdiff/serialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ class UnsupportedFormatErr(TypeError):
DELTA_IGNORE_ORDER_NEEDS_REPETITION_REPORT = 'report_repetition must be set to True when ignore_order is True to create the delta object.'
DELTA_ERROR_WHEN_GROUP_BY = 'Delta can not be made when group_by is used since the structure of data is modified from the original form.'

SAFE_TO_IMPORT = {
SAFE_TO_IMPORT = frozenset({
'builtins.range',
'builtins.complex',
'builtins.set',
Expand Down Expand Up @@ -95,7 +95,7 @@ class UnsupportedFormatErr(TypeError):
'ipaddress.IPv4Address',
'ipaddress.IPv6Address',
'collections.abc.KeysView',
}
})


TYPE_STR_TO_TYPE = {
Expand Down
133 changes: 133 additions & 0 deletions tests/test_security.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import os
import pickle
import pytest
from deepdiff import Delta
from deepdiff.helper import Opcode
from deepdiff.serialization import ForbiddenModule


class TestDeltaClassPollution:

def test_builtins_int(self):

pollute_int = pickle.dumps(
{
"values_changed": {"root['tmp']": {"new_value": Opcode("", 0, 0, 0, 0)}},
"dictionary_item_added": {
(
("root", "GETATTR"),
("tmp", "GET"),
("__repr__", "GETATTR"),
("__globals__", "GETATTR"),
("__builtins__", "GET"),
("int", "GET"),
): "no longer a class"
},
}
)

assert isinstance(pollute_int, bytes)

# ------------[ Exploit ]------------
# This could be some example, vulnerable, application.
# The inputs above could be sent via HTTP, for example.


# Existing dictionary; it is assumed that it contains
# at least one entry, otherwise a different Delta needs to be
# applied first, adding an entry to the dictionary.
mydict = {"tmp": "foobar"}

# Before pollution
assert 42 == int("41") + 1

# Apply Delta to mydict
result = mydict + Delta(pollute_int)

assert 1337 == int("1337")

def test_remote_code_execution(self):
if os.path.exists('/tmp/pwned'):
os.remove('/tmp/pwned')

pollute_safe_to_import = pickle.dumps(
{
"values_changed": {"root['tmp']": {"new_value": Opcode("", 0, 0, 0, 0)}},
"set_item_added": {
(
("root", "GETATTR"),
("tmp", "GET"),
("__repr__", "GETATTR"),
("__globals__", "GETATTR"),
("sys", "GET"),
("modules", "GETATTR"),
("deepdiff.serialization", "GET"),
("SAFE_TO_IMPORT", "GETATTR"),
): set(["posix.system"])
},
}
)

# From https://davidhamann.de/2020/04/05/exploiting-python-pickle/
class RCE:
def __reduce__(self):
cmd = "id > /tmp/pwned"
return os.system, (cmd,)

# Wrap object with dictionary so that Delta does not crash
rce_pickle = pickle.dumps({"_": RCE()})

assert isinstance(pollute_safe_to_import, bytes)
assert isinstance(rce_pickle, bytes)

# ------------[ Exploit ]------------
# This could be some example, vulnerable, application.
# The inputs above could be sent via HTTP, for example.

# Existing dictionary; it is assumed that it contains
# at least one entry, otherwise a different Delta needs to be
# applied first, adding an entry to the dictionary.
mydict = {"tmp": "foobar"}

# Apply Delta to mydict
with pytest.raises(ValueError) as exc_info:
mydict + Delta(pollute_safe_to_import)
assert "traversing dunder attributes is not allowed" == str(exc_info.value)

with pytest.raises(ForbiddenModule) as exc_info:
Delta(rce_pickle) # no need to apply this Delta
assert "Module 'posix.system' is forbidden. You need to explicitly pass it by passing a safe_to_import parameter" == str(exc_info.value)

assert not os.path.exists('/tmp/pwned'), "We should not have created this file"

def test_delta_should_not_access_globals(self):

pollute_global = pickle.dumps(
{
"dictionary_item_added": {
(
("root", "GETATTR"),
("myfunc", "GETATTR"),
("__globals__", "GETATTR"),
("PWNED", "GET"),
): 1337
}
}
)


# demo application
class Foo:
def __init__(self):
pass

def myfunc(self):
pass


PWNED = False
delta = Delta(pollute_global)
assert PWNED is False
b = Foo() + delta

assert PWNED is False