EnigmaConsultant's picture
Upload folder using huggingface_hub
f8ecd67 verified
|
Raw
History Blame Contribute Delete
6.53 kB
# modelscan Keras `.keras` RCE blindspot β€” `__lambda__` carrier in a `keras.ops.vectorized_map` (`VectorizedMap`) operation
**Severity:** High (arbitrary code execution; scanner reports a fully clean bill)
**Affected tool:** `modelscan` 0.8.8 β€” `KerasLambdaDetectScan` (native `.keras` v3 zip). Also applies to the H5 path (`H5LambdaDetectScan`).
**Victim loader:** `keras.models.load_model(path, safe_mode=False)` (keras 3.15.0 / TF 2.21.0) β€” the documented path for loading Lambda-bearing models.
**Category:** ModelScan scanner-bypass on Keras `.keras`/`.h5` (Model File Formats, in-scope).
## Summary
modelscan flags a Keras model as dangerous only when a **top-level `config.layers[]` entry has `class_name == "Lambda"`**. `keras.ops.vectorized_map(fn, x)` used inside a functional model serializes as a top-level layer entry whose **`class_name` is `"VectorizedMap"`** (a `keras.src.ops.core.Operation`), carrying the attacker's Python callable as a `{"class_name":"__lambda__", ...}` value in its `function` config field. Because the class name is not `"Lambda"`, modelscan never inspects it and returns **`total_issues: 0, errors: [], scanned: [evil]`** β€” a genuine clean result. At load, `VectorizedMap.from_config` **bare-deserializes** that field (no `module_objects`, so no short-circuit) via `serialization_lib.deserialize_keras_object` β†’ `__lambda__` branch β†’ `python_utils.func_load` β†’ `marshal.loads` β†’ a real `FunctionType`, which `VectorizedMap.call` then **invokes** (`backend.core.vectorized_map(self.function, elements)`) during model build/first inference = **arbitrary code execution**.
## Root cause
- **Scanner gap (both formats):**
- `modelscan/scanners/keras/scan.py` `_get_keras_operator_names` β€” only `layer.get("class_name") == "Lambda"` in top-level `config.layers`.
- `modelscan/scanners/h5/scan.py` `_get_keras_h5_operator_names` β€” same Lambda-only top-level check.
- `modelscan/settings.py` β€” `unsafe_keras_operators = {"Lambda": "MEDIUM"}` only. `"VectorizedMap"` is not listed and sub-fields are never inspected.
- **Exec sink (victim side, keras 3.15.0):**
- `keras/src/ops/core.py:1163-1167` β€” `VectorizedMap.from_config`: `config["function"] = serialization_lib.deserialize_keras_object(config["function"])` (**bare**, no `module_objects` β†’ does not short-circuit `__lambda__`).
- `keras/src/saving/serialization_lib.py:686` β€” `class_name == "__lambda__"` β†’ `python_utils.func_load(inner_config["value"])`.
- `keras/src/utils/python_utils.py` `func_load` β†’ `marshal.loads` β†’ `types.FunctionType`.
- `keras/src/ops/core.py:1139` β€” `VectorizedMap.call` β†’ `backend.core.vectorized_map(self.function, elements)` **calls** the reconstructed function.
## Distinct from previously-filed Keras/modelscan carriers
- **Not** the `TextVectorization` `standardize`/`split` carrier (different layer, different field).
- **Not** the `"module"`-key legacy→modern deserializer fork on `.h5` activation (this needs **no** injected `"module"` key; `VectorizedMap.from_config` bare-deserializes natively, and the class is not a `Dense`/activation layer).
- **Not** the nested-Functional-submodel real-`Lambda` carrier (there is **no** `Lambda` layer anywhere β€” `class_name` is `"VectorizedMap"`, a distinct built-in operation).
- **Not** the `training_config`/compile loss/metric blindspot (this is a forward-pass model op firing at build/inference, not a compile-time callable).
- Among all `keras.ops` operations, `VectorizedMap` is the one that both **stores** its function in `get_config` and **bare-deserializes + invokes** it (`Map`/`Scan`/`Cond`/`Switch`/… take functions as call-time args and do not round-trip a stored function through model config).
## Reproduce
Env: `pip install tensorflow keras 'modelscan==0.8.8'`, `KERAS_BACKEND=tensorflow` (Python 3.10–3.12).
```
python build_poc.py # writes evil_vectorizedmap.keras + control_lambda.keras
modelscan -p evil_vectorizedmap.keras # -> No issues found! (total_issues: 0, errors: [])
modelscan -p control_lambda.keras # -> 1 MEDIUM 'Lambda' (control: detection works)
python demo_load.py # load_model(safe_mode=False) + predict -> /tmp/VMAP_PWN.txt written
```
## Verified output (modelscan 0.8.8, keras 3.15.0, TF 2.21.0)
- **`evil_vectorizedmap.keras`** β€” modelscan JSON: `total_issues: 0`, `total_issues_by_severity` all 0, `errors: []`, `scanned_files: ["evil_vectorizedmap.keras"]` (see `modelscan_evil_result.json`). Top-level layer `class_name`s are `["InputLayer", "VectorizedMap"]` β€” no `"Lambda"`.
- **Execution:** `keras.models.load_model("evil_vectorizedmap.keras", safe_mode=False)` fires the payload **during load** (functional trace) and `predict()` completes normally (output shape `(2,3)`); marker `/tmp/VMAP_PWN.txt` = `VMAP_RCE_1000` (real `os.system` / `id -u`).
- **Control 1 (detection works):** `control_lambda.keras` (identical payload wrapped in a real `Lambda`) β†’ modelscan `total_issues: 1`, `MEDIUM`, `"Use of unsafe operator 'Lambda'"` (see `modelscan_control_result.json`).
- **Control 2 (exec gate):** default `load_model(...)` (`safe_mode=True`) raises `ValueError: Requested the deserialization of a Python lambda ...` and the marker is **not** written β€” payload only fires under the documented `safe_mode=False`.
## Impact
ACE against any pipeline that scans an untrusted `.keras` (or `.h5`) model with modelscan β€” receiving a clean, 0-issue, file-marked-scanned result β€” and then loads it via the standard `load_model(safe_mode=False)`. No victim-side custom class and **no `Lambda` layer present**, so it defeats modelscan's "look for Lambda layers" detection model with a benign-looking built-in Keras operation.
## Files
- `build_poc.py` β€” regenerates both models (payload is benign: writes `/tmp/VMAP_PWN.txt` with `id -u`).
- `evil_vectorizedmap.keras` β€” malicious model (`class_name` `VectorizedMap`, no `Lambda`).
- `control_lambda.keras` β€” negative control (same payload as a real `Lambda`; modelscan flags it).
- `demo_load.py` β€” loads the evil model and triggers the payload.
- `modelscan_evil_result.json` / `modelscan_control_result.json` β€” captured scanner output.
## Suggested fix
In both Keras scanners, recurse **all** layer/operation entries and normalize sub-fields, flagging any `class_name == "__lambda__"` anywhere in the config (and treat function-carrying operations like `VectorizedMap` as unsafe), rather than matching only a top-level `class_name == "Lambda"`.