Skip to content

Commit 81bd40e

Browse files
fix: use safe deserialization in pickle.py
The pickle module provides a CLI interface (python -m pickle) that deserializes arbitrary pickle data without any restrictions
1 parent 110517a commit 81bd40e

1 file changed

Lines changed: 223 additions & 0 deletions

File tree

tests/test_invariant_pickle.py

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
import pytest
2+
import pickle
3+
import os
4+
import sys
5+
import subprocess
6+
import tempfile
7+
import struct
8+
9+
10+
# Helper to create malicious pickle payloads that attempt code execution
11+
def make_exec_payload(command):
12+
"""Create a pickle payload that attempts to execute a system command."""
13+
import io
14+
15+
class MaliciousReduce:
16+
def __reduce__(self):
17+
return (os.system, (command,))
18+
19+
return pickle.dumps(MaliciousReduce())
20+
21+
22+
def make_import_payload(module, attr):
23+
"""Create a pickle payload using GLOBAL opcode to import arbitrary modules."""
24+
# Manually craft a pickle that calls getattr on a module
25+
import io
26+
buf = io.BytesIO()
27+
buf.write(b'\x80\x04') # PROTO 4
28+
buf.write(b'c') # GLOBAL opcode
29+
buf.write(f'{module}\n{attr}\n'.encode())
30+
buf.write(b')') # EMPTY_TUPLE
31+
buf.write(b'R') # REDUCE
32+
buf.write(b'.') # STOP
33+
return buf.getvalue()
34+
35+
36+
def make_reduce_subprocess_payload(cmd):
37+
"""Create payload using subprocess to execute commands."""
38+
import io
39+
40+
class SubprocessPayload:
41+
def __reduce__(self):
42+
import subprocess
43+
return (subprocess.check_output, ([cmd],))
44+
45+
return pickle.dumps(SubprocessPayload())
46+
47+
48+
# Sentinel file to detect if code execution occurred
49+
SENTINEL_FILE = tempfile.mktemp(suffix='_pickle_security_test_sentinel')
50+
51+
52+
@pytest.fixture(autouse=True)
53+
def cleanup_sentinel():
54+
"""Ensure sentinel file is cleaned up before and after each test."""
55+
if os.path.exists(SENTINEL_FILE):
56+
os.remove(SENTINEL_FILE)
57+
yield
58+
if os.path.exists(SENTINEL_FILE):
59+
os.remove(SENTINEL_FILE)
60+
61+
62+
def make_sentinel_payload():
63+
"""Create a pickle payload that tries to create a sentinel file if executed."""
64+
class SentinelPayload:
65+
def __reduce__(self):
66+
return (open, (SENTINEL_FILE, 'w'))
67+
return pickle.dumps(SentinelPayload())
68+
69+
70+
def make_eval_payload(code_str):
71+
"""Create a pickle payload that tries to eval arbitrary code."""
72+
class EvalPayload:
73+
def __reduce__(self):
74+
return (eval, (code_str,))
75+
return pickle.dumps(EvalPayload())
76+
77+
78+
def make_exec_file_payload(filepath):
79+
"""Create a pickle payload that tries to read a sensitive file."""
80+
class ReadFilePayload:
81+
def __reduce__(self):
82+
return (open, (filepath, 'r'))
83+
return pickle.dumps(ReadFilePayload())
84+
85+
86+
@pytest.mark.parametrize("payload_name,payload_factory", [
87+
("os_system_touch_sentinel", lambda: make_exec_payload(f'touch {SENTINEL_FILE}')),
88+
("sentinel_file_creation", make_sentinel_payload),
89+
("eval_dangerous_code", lambda: make_eval_payload("__import__('os').getcwd()")),
90+
("read_passwd", lambda: make_exec_file_payload('/etc/passwd')),
91+
("subprocess_ls", lambda: make_reduce_subprocess_payload('ls')),
92+
])
93+
def test_pickle_cli_does_not_execute_arbitrary_code(payload_name, payload_factory, tmp_path):
94+
"""Invariant: The pickle CLI interface must not allow arbitrary code execution
95+
through malicious pickle payloads. When processing adversarial pickle data,
96+
the system should either reject the input, sandbox execution, or otherwise
97+
prevent malicious __reduce__ methods from executing arbitrary system commands."""
98+
99+
payload = payload_factory()
100+
101+
# Write payload to a temp file
102+
payload_file = tmp_path / f"malicious_{payload_name}.pkl"
103+
payload_file.write_bytes(payload)
104+
105+
# Attempt to run the pickle CLI with the malicious payload
106+
result = subprocess.run(
107+
[sys.executable, '-m', 'pickle', str(payload_file)],
108+
capture_output=True,
109+
text=True,
110+
timeout=10,
111+
cwd=str(tmp_path)
112+
)
113+
114+
# The sentinel file must NOT have been created by the payload
115+
# If it was created, arbitrary code execution occurred
116+
assert not os.path.exists(SENTINEL_FILE), (
117+
f"SECURITY VIOLATION: Pickle CLI executed arbitrary code from payload '{payload_name}'. "
118+
f"Sentinel file was created, indicating __reduce__ method was invoked with dangerous operations. "
119+
f"stdout: {result.stdout}, stderr: {result.stderr}"
120+
)
121+
122+
123+
@pytest.mark.parametrize("payload_bytes", [
124+
# Raw crafted pickle opcodes attempting to call os.system
125+
b'\x80\x04\x95\x1f\x00\x00\x00\x00\x00\x00\x00\x8c\x02os\x94\x8c\x06system\x94\x93\x8c\x02ls\x94\x85\x94R\x94.',
126+
# Pickle with REDUCE opcode on builtins.eval
127+
b'\x80\x04\x95\x00\x00\x00\x00\x00\x00\x00\x00\x8c\x08builtins\x94\x8c\x04eval\x94\x93\x8c\x04pass\x94\x85\x94R\x94.',
128+
# Empty/malformed pickle
129+
b'',
130+
# Truncated pickle
131+
b'\x80\x04',
132+
# Pickle with invalid opcodes
133+
b'\xff\xff\xff\xff',
134+
# Very large pickle header
135+
b'\x80\x04' + b'\x00' * 1000,
136+
])
137+
def test_pickle_cli_handles_adversarial_bytes_safely(payload_bytes, tmp_path):
138+
"""Invariant: The pickle CLI must handle adversarial byte sequences without
139+
causing uncontrolled code execution or system compromise. The process should
140+
either complete safely or fail with an error, but must not execute injected code."""
141+
142+
payload_file = tmp_path / "adversarial.pkl"
143+
payload_file.write_bytes(payload_bytes)
144+
145+
result = subprocess.run(
146+
[sys.executable, '-m', 'pickle', str(payload_file)],
147+
capture_output=True,
148+
text=True,
149+
timeout=10,
150+
cwd=str(tmp_path)
151+
)
152+
153+
# The process must not have created the sentinel file
154+
assert not os.path.exists(SENTINEL_FILE), (
155+
f"SECURITY VIOLATION: Adversarial pickle bytes caused code execution. "
156+
f"Payload (hex): {payload_bytes.hex()}"
157+
)
158+
159+
# The process must terminate (not hang indefinitely) - already enforced by timeout
160+
161+
162+
def test_pickle_cli_stdin_does_not_execute_code():
163+
"""Invariant: When the pickle CLI reads from stdin, it must not execute
164+
arbitrary code embedded in the pickle stream via __reduce__ methods."""
165+
166+
# Create a payload that would create a sentinel file if executed
167+
class SentinelPayload:
168+
def __reduce__(self):
169+
return (open, (SENTINEL_FILE, 'w'))
170+
171+
payload = pickle.dumps(SentinelPayload())
172+
173+
result = subprocess.run(
174+
[sys.executable, '-m', 'pickle', '-'],
175+
input=payload,
176+
capture_output=True,
177+
timeout=10,
178+
)
179+
180+
# Sentinel file must not exist - no code execution should have occurred
181+
assert not os.path.exists(SENTINEL_FILE), (
182+
"SECURITY VIOLATION: Pickle CLI executed arbitrary code from stdin payload. "
183+
"The __reduce__ method was invoked, creating the sentinel file. "
184+
f"Return code: {result.returncode}, stderr: {result.stderr.decode()}"
185+
)
186+
187+
188+
def test_pickle_deserialization_invariant_no_os_commands():
189+
"""Invariant: Deserializing pickle data through the CLI must never result
190+
in OS command execution. This is a fundamental security boundary that
191+
must be maintained regardless of the pickle content."""
192+
193+
dangerous_commands = [
194+
f'touch {SENTINEL_FILE}',
195+
f'echo pwned > {SENTINEL_FILE}',
196+
f'python3 -c "open(\'{SENTINEL_FILE}\', \'w\').close()"',
197+
]
198+
199+
for cmd in dangerous_commands:
200+
if os.path.exists(SENTINEL_FILE):
201+
os.remove(SENTINEL_FILE)
202+
203+
payload = pickle.dumps(type('Exploit', (), {
204+
'__reduce__': lambda self, c=cmd: (os.system, (c,))
205+
})())
206+
207+
with tempfile.NamedTemporaryFile(suffix='.pkl', delete=False) as f:
208+
f.write(payload)
209+
fname = f.name
210+
211+
try:
212+
result = subprocess.run(
213+
[sys.executable, '-m', 'pickle', fname],
214+
capture_output=True,
215+
timeout=10,
216+
)
217+
finally:
218+
os.unlink(fname)
219+
220+
assert not os.path.exists(SENTINEL_FILE), (
221+
f"SECURITY VIOLATION: OS command '{cmd}' was executed via pickle CLI. "
222+
"The pickle module CLI must not allow arbitrary OS command execution."
223+
)

0 commit comments

Comments
 (0)