EnigmaConsultant's picture
Upload README.md with huggingface_hub
9b87e29 verified
|
Raw
History Blame Contribute Delete
4.25 kB
# 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.