| # ModelScan H5 RCE blindspot β `__lambda__` carrier in a non-Lambda TextVectorization preprocessing field |
|
|
| **Severity:** High (arbitrary code execution; scanner false-clean) |
| **Affected tool:** `modelscan` (latest, `H5LambdaDetectScan`) β scanned file is a Keras **`.h5`**; victim loader is `tf.keras` / `keras.models.load_model(safe_mode=False)` (keras 3.14.1). |
| **Category:** ModelScan scanner-bypass on `.h5`/`.keras` (in-scope, Model File Formats). |
|
|
| ## Summary |
| modelscan's HDF5 scanner only treats a layer as dangerous when its `class_name == "Lambda"`. A serialized Python callable (`{"class_name":"__lambda__", ...}`) planted inside a **built-in, non-Lambda** `TextVectorization` layer's `standardize` or `split` field is therefore never inspected β modelscan reports **0 issues, 0 skipped, 0 errors** (a genuine clean bill). At load time, `TextVectorization.from_config` deserializes those fields through a path that honors `__lambda__` β `marshal.loads` β arbitrary `FunctionType`, which is then invoked on the first `predict()`/`adapt()` = **arbitrary code execution**. |
|
|
| ## Root cause (scanner gap) |
| - `modelscan/scanners/h5/scan.py:105-130` (`_get_keras_h5_operator_names`) iterates only top-level `config.layers[]` and appends a finding solely when `layer.get("class_name") == "Lambda"`. |
| - `modelscan/settings.py:43-45` β `unsafe_keras_operators = {"Lambda": "MEDIUM"}` only. Every other class and every config sub-field is ignored. |
|
|
| ## Exec sink (victim side, keras 3.14.1) |
| - `keras/src/layers/preprocessing/text_vectorization.py:491-505` β `from_config` calls **bare** `serialization_lib.deserialize_keras_object(config["standardize"])` / `(config["split"])` with **no** `module_objects`. |
| - `keras/src/saving/serialization_lib.py:660-670` β routes `class_name == "__lambda__"` to `python_utils.func_load` when `safe_mode=False`. |
| - `keras/src/utils/python_utils.py:54-100` β `func_load` β `marshal.loads` β `FunctionType`. |
| - `text_vectorization.py:541-542` / `:566-567` β `_preprocess` does `if callable(self._standardize): inputs = self._standardize(inputs)` (same for `split`) β executes attacker bytecode. |
|
|
| **Why this field and not the obvious ones:** planting the same `__lambda__` carrier in `activation`/`constraint`/`initializer`/`regularizer` does **not** work β those route through `module_objects` deserializers that short-circuit and return the bare `class_name` string. The `TextVectorization.from_config` path is distinct precisely because it calls `deserialize_keras_object` directly. (Control verified in the PoC: a `Dense.activation` carrier did **not** fire.) |
|
|
| ## Reproduce |
| `python poc/poc_final.py` (env: tensorflow 2.21.0, keras 3.14.1, h5py, modelscan). Builds `evil_standardize.h5` and `evil_split.h5` (6472 bytes each). For both: |
| - **[A1]** `ModelScan().scan()` β **0 issues / 0 skipped / 0 errors** (scanner blind, file recorded as *scanned*). |
| - **[A2]** `keras.models.load_model(path, safe_mode=False)` then `model(tf.constant(["hello world"]))` β marker file written with real exfil (`uid`/`host`). With a vocabulary shipped in the config, `predict()` completes **silently** while the payload fires. |
|
|
| ## Impact |
| ACE on anyone who scans a malicious `.h5` with modelscan (gets a clean result) and then loads + runs it via the standard `load_model(safe_mode=False)` + `predict()`. Defeats modelscan's core promise for a built-in layer with no victim-side custom class; two independent carrier fields (`standardize`, `split`). `safe_mode=False` is the standard/documented path for legacy `.h5` (and Keras ignores `safe_mode` for `.h5` per CVE-2025-1550/9905 anyway). |
|
|
| ## Dup-check |
| Clean. The dev.to "29 ways" article's only Keras/H5 bypass is *Lambda-in-TimeDistributed* (structurally different). modelscan docs describe H5 detection as "look for lambda layer" (Lambda-only). The Keras `safe_mode`-on-h5 CVEs are a different component (Keras honoring Lambda exec, not the scanner) and use actual Lambda layers. Distinct from our prior **R1 h5 activation-field lambda** (that field short-circuits via `module_objects`) and **R2 modelscan H5 training_config blindspot** (different sink). No public source ties a non-Lambda preprocessing-layer `__lambda__` carrier to a modelscan H5 miss. |
| |