# modelscan H5 bypass via attacker-injected `"module"` key — legacy/modern deserializer dispatch-fork executes `__lambda__` in a non-Lambda layer's activation **Severity:** High (RCE; defeats the "activation/initializer fields are safe" boundary our R5 finding documented) **Affected tool:** `modelscan 0.8.8` — `H5LambdaDetectScan`. Victim loader: `keras.models.load_model(safe_mode=False)` (keras 3.14.1). **Category:** ModelScan scanner-bypass on Keras `.h5` (in-scope). ## Summary modelscan's H5 scanner flags a layer only when top-level `config.layers[i]["class_name"] == "Lambda"`; it never inspects layer config sub-fields. Our prior R5 finding established that an `__lambda__` carrier in `Dense.activation` does **not** fire, because keras's **legacy** deserializer routes function-typed fields (activation/initializer/regularizer/constraint) through `module_objects` deserializers that short-circuit and neutralize the carrier. **This finding defeats that boundary:** since the layer dict in `model_config` is fully attacker-controlled, adding a single `"module": "keras.layers"` key to a benign `Dense` layer **flips keras into the modern deserializer**, which fully deserializes the activation field → reaches the `__lambda__` branch → `marshal.loads` → arbitrary `FunctionType`, executed on the first inference. modelscan sees only `['InputLayer','Dense']` (no "Lambda") and returns a clean bill. This **generalizes RCE from R5's single niche layer (TextVectorization) to essentially any layer with a function-typed field.** ## Root cause - **Scanner gap:** `modelscan/scanners/h5/scan.py:105-130` (`_get_keras_h5_operator_names`, Lambda-only top-level check) + `settings.py` (`unsafe_keras_operators={"Lambda":"MEDIUM"}`). - **Victim dispatch fork:** `keras/src/models/functional.py:566` (`process_layer`): `if "module" not in layer_data:` → **legacy** `model_from_config` (short-circuits the carrier); `else:` → **modern** `serialization_lib.deserialize_keras_object` (fully deserializes it). - **Exec sink:** `serialization_lib.py:660` (`class_name=="__lambda__"`) → `:670` `python_utils.func_load` → `python_utils.py:54` `marshal.loads`. - **Entry:** `keras/src/legacy/saving/legacy_h5_format.py:127-141` (`load_model_from_hdf5` → `model_from_config` under `SafeModeScope(False)`). ## Reproduce `python poc/poc_final.py` (env: modelscan 0.8.8, keras 3.14.1, TF 2.21.0, h5py 3.14.0). Builds `evil_module_fork.h5` (benign `Input→Dense(relu)`; the `Dense` layer given `"module":"keras.layers"` + an `activation=__lambda__` payload). Output: - "Layers as modelscan sees them: `['InputLayer','Dense']` (no 'Lambda')"; modelscan → `issues:0, errors:0, skipped:0, scanned:1`. - `load_model(safe_mode=False)` + one inference → marker `PWN_FINAL.txt` written (`RCE_VIA_MODULE_FORK`). - **Differential proof** (`t_dense_vs_act.py`): `Dense.activation` NOMOD = does-not-fire (matches R5); `Dense.activation +module` = **FIRES**; `Activation`-layer activation fires even NOMOD (a 2nd carrier R5 never tested). All four = modelscan 0 issues. ## Impact ACE on a victim who scans a malicious legacy `.h5` with modelscan (fully clean: 0 issues, file marked scanned) and then loads via the standard `load_model(safe_mode=False)` + one inference. No victim-side custom class, **no Lambda layer present** (defeats the "look for Lambda layers" detection model). The `"module"`-key flip expands the exploitable carrier set from R5's single niche to any layer exposing a function-typed config field. ## Dup-check Clean for this mechanism. **Distinct from our R5** (TextVectorization carrier on the *legacy* path; R5 explicitly states activation/initializer/regularizer are *safe* — this finding defeats exactly that boundary via the `"module"`-key fork). Distinct from R6 (nested Functional submodel, real `class_name=="Lambda"` layers) and R2 (training_config blindspot). Distinct from CVE-2025-1550/9905/9906 (those target the `.keras` v3 zip with an actual Lambda layer that modelscan *would* flag, and are about keras honoring exec, not the scanner missing it). No public source links the `"module"`-key legacy/modern fork to a modelscan H5 false-clean. > Honest note: shares the H5-Lambda-carrier *class* with our R1/R2/R5/R6 h5 findings (one fix — recurse + normalize all layer fields — would harden several), but this is a genuinely distinct, undocumented primitive that **invalidates R5's stated mitigation boundary**, so it is the strongest representative of the carrier class. Expect huntr may group it with the other H5-carrier findings. ## Negative results (tested + falsified, for triage honesty) - A `__lambda__` in the `compile/training_config` optimizer path is **blocked** — `load_model(safe_mode=False)` does not propagate `safe_mode` there (raises the `safe_mode` ValueError). Not exploitable. - The HDF5 external/soft-link path-vs-stream divergence is real (keras resolves links by path; modelscan's `open(path,'rb')` stream read fails link resolution with KeyError) but **not** weaponizable for Lambda-hiding, because `model_config` is a root attribute, not link-resolvable.