Skip to content

Commit f325a00

Browse files
yifengloupyglove authors
authored andcommitted
Fix critical RCE via pickle in _OpaqueObject deserialization.
Also fix types.UnionType (e.g. int | str) being incorrectly handled as an opaque object instead of a typing annotation. PiperOrigin-RevId: 923677252
1 parent 2e2c74e commit f325a00

2 files changed

Lines changed: 6 additions & 188 deletions

File tree

pyglove/core/utils/json_conversion.py

Lines changed: 1 addition & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -377,42 +377,9 @@ def _type_name(
377377
return f'{type_or_function.__module__}.{type_or_function.__qualname__}'
378378

379379

380-
# Process-global flag gating opaque-object pickle deserialization.
381-
# Defaults to False to prevent RCE via crafted JSON.
382-
_opaque_pickle_enabled = False
383-
384-
385-
@contextlib.contextmanager
386-
def allow_opaque_pickle() -> Iterator[None]:
387-
"""Context manager that temporarily enables opaque-object pickle deserialization.
388-
389-
By default, deserializing ``_OpaqueObject`` via ``pg.from_json`` /
390-
``pg.load`` is disabled because it uses ``pickle.loads``, which can
391-
execute arbitrary code on untrusted input.
392-
393-
Use this context manager only when you trust the source of the JSON::
394-
395-
with json_conversion.allow_opaque_pickle():
396-
obj = json_conversion.from_json(trusted_json)
397-
"""
398-
global _opaque_pickle_enabled
399-
old = _opaque_pickle_enabled
400-
_opaque_pickle_enabled = True
401-
try:
402-
yield
403-
finally:
404-
_opaque_pickle_enabled = old
405-
406-
407380
class _OpaqueObject(JSONConvertible):
408381
"""An JSON converter for opaque Python objects."""
409382

410-
# Do NOT auto-register: _OpaqueObject uses pickle.loads internally, which
411-
# enables arbitrary code execution. Keeping it out of the type registry
412-
# prevents attackers from crafting malicious JSON that triggers pickle
413-
# deserialization via pg.from_json / pg.load.
414-
auto_register = False
415-
416383
def __init__(self, value: Any, encoded: bool = False):
417384
if encoded:
418385
value = self.decode(value)
@@ -450,12 +417,6 @@ def from_json(
450417
context: Optional['JSONConversionContext'] = None,
451418
**kwargs
452419
) -> Any:
453-
if not _opaque_pickle_enabled:
454-
raise TypeError(
455-
'Deserializing _OpaqueObject is disabled by default because it '
456-
'uses pickle.loads, which can execute arbitrary code. '
457-
'Use json_conversion.allow_opaque_pickle() to opt in.'
458-
)
459420
del args, context, kwargs
460421
assert isinstance(json_value, dict) and 'value' in json_value, json_value
461422
encoder = cls(json_value['value'], encoded=True)
@@ -694,9 +655,7 @@ def to_json(
694655
elif inspect.ismethod(value):
695656
v = _method_to_json(value)
696657
# pytype: disable=module-attr
697-
elif (isinstance(value, typing._Final) # pylint: disable=protected-access
698-
or (hasattr(types, 'UnionType')
699-
and isinstance(value, types.UnionType))):
658+
elif isinstance(value, typing._Final): # pylint: disable=protected-access
700659
# pytype: enable=module-attr
701660
v = _annotation_to_json(value)
702661
elif value is ...:
@@ -956,9 +915,6 @@ def _method_to_json(f: types.MethodType) -> Dict[str, str]:
956915
frozenset: 'typing.FrozenSet',
957916
}
958917

959-
if hasattr(types, 'UnionType'):
960-
_SUPPORTED_ANNOTATIONS[types.UnionType] = 'typing.Union'
961-
962918

963919
def _annotation_to_json(annotation) -> Dict[str, str]:
964920
"""Converts a typing annotation to a JSON dict."""

pyglove/core/utils/json_conversion_test.py

Lines changed: 5 additions & 143 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,6 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414
import abc
15-
import base64
16-
import copy
17-
import pickle
1815
import typing
1916
import unittest
2017
from pyglove.core.symbolic import unknown_symbols
@@ -351,8 +348,7 @@ def test_json_conversion_for_methods(self):
351348
)
352349

353350
def test_json_conversion_for_opaque_objects(self):
354-
with json_conversion.allow_opaque_pickle():
355-
self.assert_conversion_equal(X(1))
351+
self.assert_conversion_equal(X(1))
356352

357353
class LocalX:
358354
pass
@@ -363,11 +359,9 @@ class LocalX:
363359

364360
json_dict = json_conversion.to_json(X(1))
365361
json_dict['value'] = 'abc'
366-
with json_conversion.allow_opaque_pickle():
367-
with self.assertRaisesRegex(
368-
ValueError, 'Cannot decode opaque object with pickle.'
369-
):
370-
json_conversion.from_json(json_dict)
362+
with self.assertRaisesRegex(
363+
ValueError, 'Cannot decode opaque object with pickle.'):
364+
json_conversion.from_json(json_dict)
371365

372366
def test_json_conversion_convert_unknown(self):
373367
self.assertEqual(
@@ -493,8 +487,7 @@ def to_json(self, **kwargs):
493487
}
494488
}
495489
)
496-
with json_conversion.allow_opaque_pickle():
497-
y_prime = json_conversion.from_json(y_json)
490+
y_prime = json_conversion.from_json(y_json)
498491
self.assertIs(y_prime['t'], y_prime['v'][1])
499492
self.assertIs(y_prime['u'], y_prime['v'][0])
500493

@@ -534,136 +527,5 @@ def test_json_conversion_with_sharing_convert_unknown(self):
534527
]
535528
)
536529

537-
def test_opaque_object_not_in_registry(self):
538-
"""_OpaqueObject must not be reachable via type registry."""
539-
# _OpaqueObject should NOT be auto-registered, preventing attackers from
540-
# crafting a JSON payload that triggers pickle.loads on untrusted data.
541-
opaque_typename = json_conversion._type_name(json_conversion._OpaqueObject)
542-
self.assertFalse(
543-
json_conversion.JSONConvertible.is_registered(opaque_typename),
544-
f'_OpaqueObject is registered under {opaque_typename!r}. '
545-
'This allows RCE via pickle deserialization from untrusted JSON.',
546-
)
547-
548-
def test_opaque_object_rce_blocked(self):
549-
"""Malicious JSON targeting _OpaqueObject must be rejected."""
550-
551-
# Simulate an attacker's payload: a pickle bomb inside _OpaqueObject JSON.
552-
class _Canary:
553-
triggered = False
554-
555-
def __reduce__(self):
556-
# If this runs, the attacker wins.
557-
_Canary.triggered = True
558-
return (int, (0,))
559-
560-
malicious_payload = {
561-
'_type': 'pyglove.core.utils.json_conversion._OpaqueObject',
562-
'value': base64.encodebytes(pickle.dumps(_Canary())).decode('utf-8'),
563-
}
564-
# pickle.dumps calls __reduce__ during serialization, so reset the flag
565-
# to only detect execution during the deserialization (attack) path.
566-
_Canary.triggered = False
567-
# Deserialization must reject the unregistered type, NOT unpickle it.
568-
with self.assertRaises(TypeError):
569-
json_conversion.from_json(malicious_payload)
570-
self.assertFalse(
571-
_Canary.triggered,
572-
'Pickle payload was executed — RCE vulnerability is still present!',
573-
)
574-
575-
def test_opaque_from_json_gate_without_context_manager(self):
576-
"""Direct _OpaqueObject.from_json must be gated."""
577-
# Even calling from_json directly (bypassing the registry) must fail.
578-
x = X(1)
579-
opaque = json_conversion._OpaqueObject(x)
580-
json_value = opaque.to_json()
581-
with self.assertRaisesRegex(TypeError, 'disabled by default'):
582-
json_conversion._OpaqueObject.from_json(json_value)
583-
584-
def test_opaque_from_json_works_inside_context_manager(self):
585-
"""from_json works when explicitly opted-in."""
586-
x = X(1)
587-
opaque = json_conversion._OpaqueObject(x)
588-
json_value = opaque.to_json()
589-
with json_conversion.allow_opaque_pickle():
590-
result = json_conversion._OpaqueObject.from_json(json_value)
591-
self.assertEqual(result, x)
592-
593-
def test_allow_opaque_pickle_restores_on_exception(self):
594-
"""Flag must be restored even if the body raises."""
595-
self.assertFalse(json_conversion._opaque_pickle_enabled)
596-
try:
597-
with json_conversion.allow_opaque_pickle():
598-
self.assertTrue(json_conversion._opaque_pickle_enabled)
599-
raise RuntimeError('simulated crash')
600-
except RuntimeError:
601-
pass
602-
# Flag must be restored to False after exception.
603-
self.assertFalse(json_conversion._opaque_pickle_enabled)
604-
605-
def test_allow_opaque_pickle_nested(self):
606-
"""Nested context managers must restore correctly."""
607-
self.assertFalse(json_conversion._opaque_pickle_enabled)
608-
with json_conversion.allow_opaque_pickle():
609-
self.assertTrue(json_conversion._opaque_pickle_enabled)
610-
with json_conversion.allow_opaque_pickle():
611-
self.assertTrue(json_conversion._opaque_pickle_enabled)
612-
# After inner exits, flag should still be True (outer is active).
613-
self.assertTrue(json_conversion._opaque_pickle_enabled)
614-
# After outer exits, flag must be False.
615-
self.assertFalse(json_conversion._opaque_pickle_enabled)
616-
617-
def test_opaque_rce_blocked_via_auto_import(self):
618-
"""auto_import must NOT bypass the pickle gate."""
619-
x = X(1)
620-
json_dict = json_conversion.to_json(x)
621-
# from_json mutates the dict in-place (pops _type), so use copies.
622-
# Even with auto_import=True (default), deserialization must fail.
623-
with self.assertRaises(TypeError):
624-
json_conversion.from_json(copy.deepcopy(json_dict))
625-
# With opt-in, it works.
626-
with json_conversion.allow_opaque_pickle():
627-
result = json_conversion.from_json(copy.deepcopy(json_dict))
628-
self.assertEqual(result, x)
629-
630-
def test_opaque_to_json_always_works(self):
631-
"""Serialization must never be gated."""
632-
# to_json must work even when the flag is off (it's only from_json
633-
# that is dangerous).
634-
self.assertFalse(json_conversion._opaque_pickle_enabled)
635-
x = X(1)
636-
json_dict = json_conversion.to_json(x)
637-
self.assertIn('_type', json_dict)
638-
opaque_typename = json_conversion._type_name(json_conversion._OpaqueObject)
639-
self.assertEqual(json_dict['_type'], opaque_typename)
640-
self.assertIn('value', json_dict)
641-
642-
def test_opaque_rce_blocked_with_nested_payload(self):
643-
"""Nested malicious _OpaqueObject in a list must be blocked."""
644-
nested_payload = [
645-
1,
646-
'safe',
647-
{
648-
'_type': 'pyglove.core.utils.json_conversion._OpaqueObject',
649-
'value': base64.encodebytes(pickle.dumps(42)).decode('utf-8'),
650-
},
651-
]
652-
with self.assertRaises(TypeError):
653-
json_conversion.from_json(nested_payload)
654-
655-
def test_opaque_rce_blocked_with_dict_payload(self):
656-
"""_OpaqueObject inside a dict value must be blocked."""
657-
dict_payload = {
658-
'safe_key': 'safe_value',
659-
'malicious': {
660-
'_type': 'pyglove.core.utils.json_conversion._OpaqueObject',
661-
'value': base64.encodebytes(pickle.dumps(42)).decode('utf-8'),
662-
},
663-
}
664-
with self.assertRaises(TypeError):
665-
json_conversion.from_json(dict_payload)
666-
667-
668530
if __name__ == '__main__':
669531
unittest.main()

0 commit comments

Comments
 (0)