YAML Metadata Warning:empty or missing yaml metadata in repo card
Check out the documentation for more information.
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-levelconfig.layers[]and appends a finding solely whenlayer.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_configcalls bareserialization_lib.deserialize_keras_object(config["standardize"])/(config["split"])with nomodule_objects.keras/src/saving/serialization_lib.py:660-670β routesclass_name == "__lambda__"topython_utils.func_loadwhensafe_mode=False.keras/src/utils/python_utils.py:54-100βfunc_loadβmarshal.loadsβFunctionType.text_vectorization.py:541-542/:566-567β_preprocessdoesif callable(self._standardize): inputs = self._standardize(inputs)(same forsplit) β 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)thenmodel(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.