# 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"`.