Skip to content

Commit 596abd0

Browse files
Add ScannerDeactivation analysis to detect scanner tampering (#249)
* Add ScannerDeactivation analysis to detect scanner tampering Pickles that import pickle security scanning libraries (fickling, picklescan, etc) are now flagged as OVERTLY_MALICIOUS. This catches pickles that attempt to deactivate or interfere with safety hooks during deserialization. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Update ScannerDeactivation to use the new shorten_code API --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d985f89 commit 596abd0

2 files changed

Lines changed: 134 additions & 1 deletion

File tree

fickling/analysis.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import json
44
from abc import ABC, abstractmethod
5-
from ast import unparse
5+
from ast import Import, ImportFrom, unparse
66
from collections import defaultdict
77
from collections.abc import Iterable, Iterator
88
from enum import Enum
@@ -465,6 +465,42 @@ def analyze(self, context: AnalysisContext) -> Iterator[AnalysisResult]:
465465
)
466466

467467

468+
class ScannerDeactivation(Analysis):
469+
"""Detects pickles that attempt to import pickle security scanning libraries."""
470+
471+
SCANNER_MODULES: frozenset[str] = frozenset(
472+
{
473+
"fickling",
474+
"picklescan",
475+
"modelscan",
476+
"model_unpickler",
477+
"saferpickle",
478+
"modelaudit",
479+
}
480+
)
481+
482+
def analyze(self, context: AnalysisContext) -> Iterator[AnalysisResult]:
483+
for node in context.pickled.properties.imports:
484+
module_name = self._get_top_level_module(node)
485+
if module_name and module_name in self.SCANNER_MODULES:
486+
shortened = context.shorten_code(node)
487+
yield AnalysisResult(
488+
Severity.OVERTLY_MALICIOUS,
489+
f"`{shortened}` imports a pickle security scanning library; "
490+
"this is an attempt to deactivate or interfere with security analysis",
491+
"ScannerDeactivation",
492+
trigger=shortened,
493+
)
494+
495+
@staticmethod
496+
def _get_top_level_module(node: Import | ImportFrom) -> str | None:
497+
if isinstance(node, ImportFrom) and node.module:
498+
return node.module.split(".")[0]
499+
if isinstance(node, Import) and node.names:
500+
return node.names[0].name.split(".")[0]
501+
return None
502+
503+
468504
class ExpansionAttackAnalysis(Analysis):
469505
"""Detects potential exponential expansion attacks (Billion Laughs style).
470506
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
from unittest import TestCase
2+
3+
import fickling.fickle as op
4+
from fickling.analysis import ScannerDeactivation, Severity, check_safety
5+
from fickling.fickle import Pickled
6+
7+
8+
class TestScannerDeactivationAnalysis(TestCase):
9+
def test_fickling_remove_hook(self):
10+
"""Pickle calling fickling.hook.remove_hook to strip safety hooks."""
11+
pickled = Pickled(
12+
[
13+
op.Proto.create(4),
14+
op.ShortBinUnicode("fickling.hook"),
15+
op.ShortBinUnicode("remove_hook"),
16+
op.StackGlobal(),
17+
op.EmptyTuple(),
18+
op.Reduce(),
19+
op.Stop(),
20+
]
21+
)
22+
res = check_safety(pickled)
23+
self.assertEqual(res.severity, Severity.OVERTLY_MALICIOUS)
24+
detailed = res.detailed_results().get("AnalysisResult", {})
25+
self.assertIsNotNone(detailed.get("ScannerDeactivation"))
26+
27+
def test_fickling_deactivate_safe_ml(self):
28+
"""Pickle calling fickling.hook.deactivate_safe_ml_environment."""
29+
pickled = Pickled(
30+
[
31+
op.Proto.create(4),
32+
op.ShortBinUnicode("fickling.hook"),
33+
op.ShortBinUnicode("deactivate_safe_ml_environment"),
34+
op.StackGlobal(),
35+
op.EmptyTuple(),
36+
op.Reduce(),
37+
op.Stop(),
38+
]
39+
)
40+
res = check_safety(pickled)
41+
self.assertEqual(res.severity, Severity.OVERTLY_MALICIOUS)
42+
detailed = res.detailed_results().get("AnalysisResult", {})
43+
self.assertIsNotNone(detailed.get("ScannerDeactivation"))
44+
45+
def test_fickling_remove_hook_via_global_opcode(self):
46+
"""Older Global opcode path should also trigger detection."""
47+
pickled = Pickled(
48+
[
49+
op.Global.create("fickling.hook", "remove_hook"),
50+
op.EmptyTuple(),
51+
op.Reduce(),
52+
op.Stop(),
53+
]
54+
)
55+
res = check_safety(pickled)
56+
self.assertEqual(res.severity, Severity.OVERTLY_MALICIOUS)
57+
detailed = res.detailed_results().get("AnalysisResult", {})
58+
self.assertIsNotNone(detailed.get("ScannerDeactivation"))
59+
60+
def test_benign_import_not_flagged(self):
61+
"""A benign stdlib import should not trigger ScannerDeactivation."""
62+
pickled = Pickled(
63+
[
64+
op.Proto.create(4),
65+
op.ShortBinUnicode("collections"),
66+
op.ShortBinUnicode("OrderedDict"),
67+
op.StackGlobal(),
68+
op.EmptyTuple(),
69+
op.Reduce(),
70+
op.Stop(),
71+
]
72+
)
73+
res = check_safety(pickled)
74+
detailed = res.detailed_results().get("AnalysisResult", {})
75+
self.assertIsNone(detailed.get("ScannerDeactivation"))
76+
77+
def test_all_scanner_modules_covered(self):
78+
"""Every module in SCANNER_MODULES should be detected by ScannerDeactivation."""
79+
for module in ScannerDeactivation.SCANNER_MODULES:
80+
with self.subTest(module=module):
81+
pickled = Pickled(
82+
[
83+
op.Proto.create(4),
84+
op.ShortBinUnicode(module),
85+
op.ShortBinUnicode("some_func"),
86+
op.StackGlobal(),
87+
op.EmptyTuple(),
88+
op.Reduce(),
89+
op.Stop(),
90+
]
91+
)
92+
res = check_safety(pickled)
93+
detailed = res.detailed_results().get("AnalysisResult", {})
94+
self.assertIsNotNone(
95+
detailed.get("ScannerDeactivation"),
96+
f"{module} was not detected by ScannerDeactivation",
97+
)

0 commit comments

Comments
 (0)