Instructions to use EnigmaConsultant/huntr-poc-modelscan-keras-vectorizedmap-lambda-carrier with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Keras
How to use EnigmaConsultant/huntr-poc-modelscan-keras-vectorizedmap-lambda-carrier with Keras:
# Available backend options are: "jax", "torch", "tensorflow". import os os.environ["KERAS_BACKEND"] = "jax" import keras model = keras.saving.load_model("hf://EnigmaConsultant/huntr-poc-modelscan-keras-vectorizedmap-lambda-carrier") - Notebooks
- Google Colab
- Kaggle
| # 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"`. | |