Skip to content

Commit 19e828d

Browse files
dguidoclaudethomas-chauchefoin-tob
authored
Integrate recent PickleScan bypasses into unsafe imports detection (#210)
* Integrate recent PickleScan bypasses into unsafe imports detection Add detection for 8+ CVEs and GHSAs discovered in PickleScan v0.0.31-v0.0.34: - Operator module bypasses (GHSA-m273-6v24-x4m4, GHSA-955r-x9j8-7rhh): _operator/operator.attrgetter, itemgetter, methodcaller - File handling bypasses (CVE-2025-10155, CVE-2025-10156): distutils.file_util.write_file, _io.FileIO, shutil - Async subprocess execution (CVE-2025-10157): asyncio.unix_events._UnixSubprocessTransport - Profiler/debugger code execution (GHSA-46h3-79wf-xr6c, GHSA-4675-36f9-wf6r): profile, trace, pdb, bdb, timeit, doctest - Nested pickle attacks (GHSA-84r2-jw7c-4r5q): pickle, _pickle - Package manipulation (GHSA-vqmv-47xg-9wpr): pip, venv, ensurepip - Network modules (GHSA-hgrh-qx5j-jfwx): aiohttp, httplib, http, ssl, requests - IDE tools (GHSA-r8g5-cgf2-4m4m): idlelib, lib2to3 - numpy.f2py.crackfortran.getlincoef/_eval_length - functools.partial wrapper bypass Closes #190 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Remove CVE/GHSA references from unsafe imports comments References were pointing to wrong advisories. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Remove non-ML modules from UnsafeImportsML.UNSAFE_MODULES Keep only modules relevant to ML model scanning. General-purpose modules remain in fickle.py UNSAFE_IMPORTS for standard detection. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Update bypass tests with real PickleScan advisory payloads - Replace test_operator_methodcaller with actual exploit payload from GHSA-955r-x9j8-7rhh that chains __import__, _operator.methodcaller to call os.system() - Replace test_asyncio_subprocess with payload from GHSA-f7qq-56ww-84cr targeting _UnixSubprocessTransport._start - Add GHSA references to test_distutils_write_file and test_numpy_f2py_getlincoef - Remove test_operator_attrgetter and test_functools_partial (redundant) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Fix STACK_GLOBAL validation to allow dotted attribute names Pickle files may contain dotted attribute names like `_UnixSubprocessTransport._start` in STACK_GLOBAL. Allow these by validating each component separately. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Remove duplicate entries from UnsafeImportsML.UNSAFE_IMPORTS - Keep only ML-specific imports (torch, numpy) since other modules are already covered by fickle.py UNSAFE_IMPORTS at the module level - Move _io/io from fickle.py to UnsafeImportsML with FileIO-only check to avoid false positives (io.BytesIO is commonly used legitimately) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Thomas Chauchefoin <thomas.chauchefoin@trailofbits.com>
1 parent 3d656b9 commit 19e828d

File tree

3 files changed

+164
-8
lines changed

3 files changed

+164
-8
lines changed

fickling/analysis.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -243,19 +243,14 @@ class UnsafeImportsML(Analysis):
243243
"dill": "This module can load and execute arbitrary code.",
244244
"code": "This module can compile and execute arbitrary code.",
245245
"pty": "This module contains functions that can perform system operations and execute arbitrary code.",
246+
"pickle": "This module can deserialize and execute arbitrary code through nested unpickling.",
247+
"_pickle": "This module can deserialize and execute arbitrary code through nested unpickling.",
246248
}
247249

248250
UNSAFE_IMPORTS = {
249251
"torch": {
250252
"load": "This function can load untrusted files and code from arbitrary web sources."
251253
},
252-
"numpy.testing._private.utils": {"runstring": "This function can execute arbitrary code."},
253-
"operator": {
254-
"getitem": "This function can lead to arbitrary code execution",
255-
"attrgetter": "This function can lead to arbitrary code execution",
256-
"itemgetter": "This function can lead to arbitrary code execution",
257-
"methodcaller": "This function can lead to arbitrary code execution",
258-
},
259254
"torch.storage": {
260255
"_load_from_bytes": "This function calls `torch.load()` which is unsafe as using a string argument would "
261256
"allow to load and execute arbitrary code hosted on the internet. However, in this case, the "
@@ -264,6 +259,13 @@ class UnsafeImportsML(Analysis):
264259
"underlying `torch.load()` call to unpickle that bytestring and execute arbitrary code through nested pickle calls. "
265260
"So this import is safe only if restrictions on pickle (such as Fickling's hooks) have been set properly",
266261
},
262+
"numpy.testing._private.utils": {"runstring": "This function can execute arbitrary code."},
263+
"numpy.f2py.crackfortran": {
264+
"getlincoef": "This function can execute arbitrary code.",
265+
"_eval_length": "This function can execute arbitrary code.",
266+
},
267+
"_io": {"FileIO": "This class can read/write arbitrary files."},
268+
"io": {"FileIO": "This class can read/write arbitrary files."},
267269
}
268270

269271
def analyze(self, context: AnalysisContext) -> Iterator[AnalysisResult]:

fickling/fickle.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040

4141
UNSAFE_IMPORTS: frozenset[str] = frozenset(
4242
[
43+
# Core builtins and system modules
4344
"__builtin__",
4445
"__builtins__",
4546
"builtins",
@@ -59,6 +60,39 @@
5960
"importlib",
6061
"code",
6162
"multiprocessing",
63+
# File and shell operations
64+
"shutil",
65+
"distutils",
66+
"commands",
67+
# Operator module bypasses
68+
"_operator",
69+
"operator",
70+
"functools",
71+
# Async subprocess execution
72+
"asyncio",
73+
# Code execution via profilers/debuggers
74+
"profile",
75+
"trace",
76+
"pdb",
77+
"bdb",
78+
"timeit",
79+
"doctest",
80+
# Package and environment manipulation
81+
"venv",
82+
"pip",
83+
"ensurepip",
84+
# Network and web modules
85+
"webbrowser",
86+
"aiohttp",
87+
"httplib",
88+
"http",
89+
"ssl",
90+
"requests",
91+
"urllib",
92+
"urllib2",
93+
# IDE and dev tools
94+
"idlelib",
95+
"lib2to3",
6296
]
6397
)
6498

@@ -1267,7 +1301,9 @@ def run(self, interpreter: Interpreter):
12671301
f"Module: {type(module).__name__}, Attr: {type(attr).__name__}"
12681302
)
12691303

1270-
if not all(m.isidentifier() for m in module.split(".")) or not attr.isidentifier():
1304+
if not all(m.isidentifier() for m in module.split(".")) or not all(
1305+
a.isidentifier() for a in attr.split(".")
1306+
):
12711307
raise ValueError(
12721308
f"Extracted identifiers are not valid Python identifiers. "
12731309
f"Module: {module!r}, Attr: {attr!r}"

test/test_bypasses.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,3 +445,121 @@ def test_unsafe_builtin_eval_still_flagged(self):
445445
detailed = res.detailed_results().get("AnalysisResult", {})
446446
self.assertIsNotNone(detailed.get("UnsafeImports"))
447447
self.assertIsNotNone(detailed.get("UnsafeImportsML"))
448+
449+
# https://github.com/mmaitre314/picklescan/security/advisories/GHSA-955r-x9j8-7rhh
450+
def test_operator_methodcaller(self):
451+
"""Test detection of _operator.methodcaller bypass."""
452+
pickled = Pickled(
453+
[
454+
op.Global.create("builtins", "__import__"),
455+
op.Mark(),
456+
op.Unicode("os"),
457+
op.Tuple(),
458+
op.Reduce(),
459+
op.Put(0),
460+
op.Pop(),
461+
op.Global.create("_operator", "methodcaller"),
462+
op.Mark(),
463+
op.Unicode("system"),
464+
op.Unicode('echo "pwned by _operator.methodcaller"'),
465+
op.Tuple(),
466+
op.Reduce(),
467+
op.Mark(),
468+
op.Get(0),
469+
op.Tuple(),
470+
op.Reduce(),
471+
op.Stop(),
472+
]
473+
)
474+
res = check_safety(pickled)
475+
self.assertGreater(res.severity, Severity.LIKELY_SAFE)
476+
477+
# https://github.com/mmaitre314/picklescan/security/advisories/GHSA-m273-6v24-x4m4
478+
def test_distutils_write_file(self):
479+
"""Test detection of distutils.file_util.write_file bypass."""
480+
pickled = Pickled(
481+
[
482+
op.Proto.create(4),
483+
op.ShortBinUnicode("distutils.file_util"),
484+
op.ShortBinUnicode("write_file"),
485+
op.StackGlobal(),
486+
op.ShortBinUnicode("/tmp/malicious.txt"),
487+
op.Mark(),
488+
op.ShortBinUnicode("malicious content"),
489+
op.List(),
490+
op.TupleTwo(),
491+
op.Reduce(),
492+
op.Stop(),
493+
]
494+
)
495+
res = check_safety(pickled)
496+
self.assertGreater(res.severity, Severity.LIKELY_SAFE)
497+
498+
def test_io_fileio(self):
499+
"""Test detection of _io.FileIO bypass."""
500+
pickled = Pickled(
501+
[
502+
op.Proto.create(4),
503+
op.ShortBinUnicode("_io"),
504+
op.ShortBinUnicode("FileIO"),
505+
op.StackGlobal(),
506+
op.ShortBinUnicode("/etc/passwd"),
507+
op.TupleOne(),
508+
op.Reduce(),
509+
op.Stop(),
510+
]
511+
)
512+
res = check_safety(pickled)
513+
self.assertGreater(res.severity, Severity.LIKELY_SAFE)
514+
515+
# https://github.com/mmaitre314/picklescan/security/advisories/GHSA-r8g5-cgf2-4m4m
516+
def test_numpy_f2py_getlincoef(self):
517+
"""Test detection of numpy.f2py.crackfortran.getlincoef bypass."""
518+
pickled = Pickled(
519+
[
520+
op.Proto.create(4),
521+
op.ShortBinUnicode("numpy.f2py.crackfortran"),
522+
op.ShortBinUnicode("getlincoef"),
523+
op.StackGlobal(),
524+
op.ShortBinUnicode("__import__('os').system('id')"),
525+
op.EmptyDict(),
526+
op.TupleTwo(),
527+
op.Reduce(),
528+
op.Stop(),
529+
]
530+
)
531+
res = check_safety(pickled)
532+
self.assertGreater(res.severity, Severity.LIKELY_SAFE)
533+
534+
# https://github.com/mmaitre314/picklescan/security/advisories/GHSA-f7qq-56ww-84cr
535+
def test_asyncio_subprocess(self):
536+
"""Test detection of asyncio subprocess execution bypass."""
537+
pickled = Pickled(
538+
[
539+
op.Proto.create(4),
540+
op.Frame(81),
541+
op.ShortBinUnicode("asyncio.unix_events"),
542+
op.Memoize(),
543+
op.ShortBinUnicode("_UnixSubprocessTransport._start"),
544+
op.Memoize(),
545+
op.StackGlobal(),
546+
op.Memoize(),
547+
op.Mark(),
548+
op.EmptyDict(),
549+
op.Memoize(),
550+
op.ShortBinUnicode("whoami"),
551+
op.Memoize(),
552+
op.NewTrue(),
553+
op.NoneOpcode(),
554+
op.NoneOpcode(),
555+
op.NoneOpcode(),
556+
op.BinInt1(0),
557+
op.Tuple(),
558+
op.Memoize(),
559+
op.Reduce(),
560+
op.Memoize(),
561+
op.Stop(),
562+
]
563+
)
564+
res = check_safety(pickled)
565+
self.assertGreater(res.severity, Severity.LIKELY_SAFE)

0 commit comments

Comments
 (0)