Skip to content

Commit 23ee034

Browse files
committed
Add audit hooks for pickle.load, pickle.loads, and pickle.Unpickler
Add sys.audit() calls to pickle deserialization entry points so that audit hook callbacks can monitor or block untrusted pickle data before deserialization begins. This complements the existing pickle.find_class audit event which only fires when resolving globals during unpickling. New audit events: - pickle.load(file) - raised by pickle.load() - pickle.loads(data) - raised by pickle.loads() - pickle.Unpickler(file) - raised by pickle.Unpickler() These are added to both the pure Python (Lib/pickle.py) and C-accelerated (Modules/_pickle.c) implementations. https://claude.ai/code/session_01EonnbetfRuaXpkjG3KHw4f
1 parent 149c465 commit 23ee034

6 files changed

Lines changed: 86 additions & 0 deletions

File tree

Doc/library/pickle.rst

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,9 +240,14 @@ process more convenient:
240240
Arguments *file*, *fix_imports*, *encoding*, *errors*, *strict* and *buffers*
241241
have the same meaning as in the :class:`Unpickler` constructor.
242242

243+
.. audit-event:: pickle.load file pickle.load
244+
243245
.. versionchanged:: 3.8
244246
The *buffers* argument was added.
245247

248+
.. versionchanged:: 3.15
249+
Added the ``pickle.load`` audit event.
250+
246251
.. function:: loads(data, /, *, fix_imports=True, encoding="ASCII", errors="strict", buffers=None)
247252

248253
Return the reconstituted object hierarchy of the pickled representation
@@ -255,9 +260,14 @@ process more convenient:
255260
Arguments *fix_imports*, *encoding*, *errors*, *strict* and *buffers*
256261
have the same meaning as in the :class:`Unpickler` constructor.
257262

263+
.. audit-event:: pickle.loads data pickle.loads
264+
258265
.. versionchanged:: 3.8
259266
The *buffers* argument was added.
260267

268+
.. versionchanged:: 3.15
269+
Added the ``pickle.loads`` audit event.
270+
261271

262272
The :mod:`!pickle` module defines three exceptions:
263273

@@ -430,9 +440,14 @@ The :mod:`!pickle` module exports three classes, :class:`Pickler`,
430440
an :ref:`out-of-band <pickle-oob>` buffer view. Such buffers have been
431441
given in order to the *buffer_callback* of a Pickler object.
432442

443+
.. audit-event:: pickle.Unpickler file pickle.Unpickler
444+
433445
.. versionchanged:: 3.8
434446
The *buffers* argument was added.
435447

448+
.. versionchanged:: 3.15
449+
Added the ``pickle.Unpickler`` audit event.
450+
436451
.. method:: load()
437452

438453
Read the pickled representation of an object from the open file object

Lib/pickle.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1314,6 +1314,7 @@ def __init__(self, file, *, fix_imports=True,
13141314
default to 'ASCII' and 'strict', respectively. *encoding* can be
13151315
'bytes' to read these 8-bit string instances as bytes objects.
13161316
"""
1317+
sys.audit('pickle.Unpickler', file)
13171318
self._buffers = iter(buffers) if buffers is not None else None
13181319
self._file_readline = file.readline
13191320
self._file_read = file.read
@@ -1909,13 +1910,15 @@ def _dumps(obj, protocol=None, *, fix_imports=True, buffer_callback=None):
19091910

19101911
def _load(file, *, fix_imports=True, encoding="ASCII", errors="strict",
19111912
buffers=None):
1913+
sys.audit('pickle.load', file)
19121914
return _Unpickler(file, fix_imports=fix_imports, buffers=buffers,
19131915
encoding=encoding, errors=errors).load()
19141916

19151917
def _loads(s, /, *, fix_imports=True, encoding="ASCII", errors="strict",
19161918
buffers=None):
19171919
if isinstance(s, str):
19181920
raise TypeError("Can't load pickle from unicode string")
1921+
sys.audit('pickle.loads', s)
19191922
file = io.BytesIO(s)
19201923
return _Unpickler(file, fix_imports=fix_imports, buffers=buffers,
19211924
encoding=encoding, errors=errors).load()

Lib/test/audit-tests.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ def test_marshal():
137137

138138
def test_pickle():
139139
import pickle
140+
import io
140141

141142
class PicklePrint:
142143
def __reduce_ex__(self, p):
@@ -156,6 +157,50 @@ def __reduce_ex__(self, p):
156157
pickle.loads(payload_2)
157158

158159

160+
def test_pickle_load_audit():
161+
import pickle
162+
import io
163+
164+
payload = pickle.dumps(("a", "b", "c", 1, 2, 3))
165+
166+
# Test pickle.loads audit event
167+
with TestHook() as hook:
168+
pickle.loads(payload)
169+
actual = [e for e, a in hook.seen if e == "pickle.loads"]
170+
assertSequenceEqual(actual, ["pickle.loads"])
171+
172+
# Test pickle.load audit event
173+
with TestHook() as hook:
174+
f = io.BytesIO(payload)
175+
pickle.load(f)
176+
actual = [e for e, a in hook.seen if e == "pickle.load"]
177+
assertSequenceEqual(actual, ["pickle.load"])
178+
179+
# Test pickle.Unpickler audit event
180+
with TestHook() as hook:
181+
f = io.BytesIO(payload)
182+
pickle.Unpickler(f).load()
183+
actual = [e for e, a in hook.seen if e == "pickle.Unpickler"]
184+
assertSequenceEqual(actual, ["pickle.Unpickler"])
185+
186+
# Test that pickle.loads can be blocked
187+
with TestHook(raise_on_events="pickle.loads") as hook:
188+
with assertRaises(RuntimeError):
189+
pickle.loads(payload)
190+
191+
# Test that pickle.load can be blocked
192+
with TestHook(raise_on_events="pickle.load") as hook:
193+
with assertRaises(RuntimeError):
194+
f = io.BytesIO(payload)
195+
pickle.load(f)
196+
197+
# Test that pickle.Unpickler can be blocked
198+
with TestHook(raise_on_events="pickle.Unpickler") as hook:
199+
with assertRaises(RuntimeError):
200+
f = io.BytesIO(payload)
201+
pickle.Unpickler(f)
202+
203+
159204
def test_monkeypatch():
160205
class A:
161206
pass

Lib/test/test_audit.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,11 @@ def test_pickle(self):
6868

6969
self.do_test("test_pickle")
7070

71+
def test_pickle_load_audit(self):
72+
import_helper.import_module("pickle")
73+
74+
self.do_test("test_pickle_load_audit")
75+
7176
def test_monkeypatch(self):
7277
self.do_test("test_monkeypatch")
7378

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Added audit events ``pickle.load``, ``pickle.loads``, and
2+
``pickle.Unpickler`` that are raised when unpickling operations are initiated.
3+
These hooks allow audit hook callbacks to monitor or block deserialization of
4+
untrusted pickle data.

Modules/_pickle.c

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7514,6 +7514,10 @@ _pickle_Unpickler___init___impl(UnpicklerObject *self, PyObject *file,
75147514
const char *errors, PyObject *buffers)
75157515
/*[clinic end generated code: output=09f0192649ea3f85 input=ca4c1faea9553121]*/
75167516
{
7517+
if (PySys_Audit("pickle.Unpickler", "O", file) < 0) {
7518+
return -1;
7519+
}
7520+
75177521
BEGIN_USING_UNPICKLER(self, -1);
75187522
/* In case of multiple __init__() calls, clear previous content. */
75197523
if (self->read != NULL)
@@ -8044,6 +8048,11 @@ _pickle_load_impl(PyObject *module, PyObject *file, int fix_imports,
80448048
/*[clinic end generated code: output=250452d141c23e76 input=46c7c31c92f4f371]*/
80458049
{
80468050
PyObject *result;
8051+
8052+
if (PySys_Audit("pickle.load", "O", file) < 0) {
8053+
return NULL;
8054+
}
8055+
80478056
UnpicklerObject *unpickler = _Unpickler_New(module);
80488057

80498058
if (unpickler == NULL)
@@ -8105,6 +8114,11 @@ _pickle_loads_impl(PyObject *module, PyObject *data, int fix_imports,
81058114
/*[clinic end generated code: output=82ac1e6b588e6d02 input=b3615540d0535087]*/
81068115
{
81078116
PyObject *result;
8117+
8118+
if (PySys_Audit("pickle.loads", "O", data) < 0) {
8119+
return NULL;
8120+
}
8121+
81088122
UnpicklerObject *unpickler = _Unpickler_New(module);
81098123

81108124
if (unpickler == NULL)

0 commit comments

Comments
 (0)