YAML Metadata Warning:empty or missing yaml metadata in repo card
Check out the documentation for more information.
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:β legacymodel_from_config(short-circuits the carrier);else:β modernserialization_lib.deserialize_keras_object(fully deserializes it). - Exec sink:
serialization_lib.py:660(class_name=="__lambda__") β:670python_utils.func_loadβpython_utils.py:54marshal.loads. - Entry:
keras/src/legacy/saving/legacy_h5_format.py:127-141(load_model_from_hdf5βmodel_from_configunderSafeModeScope(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 β markerPWN_FINAL.txtwritten (RCE_VIA_MODULE_FORK).- Differential proof (
t_dense_vs_act.py):Dense.activationNOMOD = 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 thecompile/training_configoptimizer path is blocked βload_model(safe_mode=False)does not propagatesafe_modethere (raises thesafe_modeValueError). 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, becausemodel_configis a root attribute, not link-resolvable.