TensorFlow SavedModel asset_file_def.filename path traversal -- PoC

Reproducer for arbitrary file read on tf.saved_model.load() via attacker-controlled asset_file_def.filename in a malicious SavedModel.

Bug summary

tf.saved_model.load(path) resolves asset files by joining the model's assets/ directory with asset_file_def[i].filename from the model's MetaGraphDef. The join is done via tf.io.gfile.join which (a) falls through to os.path.join for local paths and (b) takes a URI branch for "://"-prefixed strings.

  • os.path.join('/m/assets', '/etc/passwd') == '/etc/passwd' -- absolute right-hand drops the prefix (documented Python behavior).
  • The URI branch additionally enables SSRF / cross-FS read via file://, gs://, http://, etc. (TF's pluggable filesystem layer honors all of them).

Two reachable sinks on TF master HEAD (2026-05-20):

  • tensorflow/python/saved_model/loader_impl.py:154-171 (get_asset_tensors)
  • tensorflow/python/trackable/asset.py:77-88 (Asset._deserialize_from_proto)

Both consume asset_file_def.filename directly without sanitization (no realpath, no isabs, no commonpath).

Files

  • tf_asset_traversal_initop.py -- end-to-end PoC, init-op variant. Fires at tf.saved_model.load() time, no user function call required. Uses tf.lookup.StaticHashTable + TextFileInitializer (the standard pattern used by TextVectorization, IndexLookup, StringLookup) which registers an initializer that runs during load().
  • tf_asset_traversal_poc.py -- alternative variant, fires on first method call.

Reproduction

pip install tensorflow>=2.16
python tf_asset_traversal_initop.py

Expected output:

[+] Built SavedModel at: /tmp/tf_savedmodel_io_xxx
[+] meta_graph has 1 asset_file_def entries
    'vocab.txt' -> '/tmp/secret.txt'
[+] Calling tf.saved_model.load() on poisoned model...
[+] Loaded. asset_path = b'/tmp/secret.txt'
[+] table['PWNED_VIA_INITOP'] = 0
    Line 0 confirms the SECRET file was the vocab source.

*** CONFIRMED: init_op read attacker-pointed file at tf.saved_model.load() time ***

Impact

  1. Arbitrary file read on the loading host (any absolute path the attacker chooses).
  2. SSRF via file_io.join's URI branch (http://attacker.example/probe issues HTTP GET during load).
  3. Cross-FS read via cloud-URI schemes (gs://attacker-bucket/... authenticates with the victim's GCS credentials).
  4. HF Hub supply-chain exposure -- standard SavedModel malware-scan pipelines don't inspect asset_file_def semantic fields.
  5. Disclosure via standard inspection -- loaded.vocab_asset.asset_path is publicly readable after load.

Suggested fix

Validate the joined asset path stays within assets_directory:

fn = compat.as_str_any(asset_proto.filename)
if os.path.isabs(fn) or "://" in fn or ".." in fn.split(os.sep):
    raise ValueError(f"asset_file_def.filename {fn!r} is not a relative path inside the assets directory")
real_assets = os.path.realpath(assets_directory)
real_joined = os.path.realpath(joined_path)
if not real_joined.startswith(real_assets + os.sep):
    raise ValueError(...)

Same patch applies to both sinks (loader_impl.py and trackable/asset.py).

Disclosure

Reported to huntr.com under the Model File Formats program (TensorFlow Saved Model, $4K tier) on 2026-05-20. This PoC repo is intentionally narrowly-scoped -- the secret.txt file is created by the PoC itself in /tmp/ for demonstration purposes; the PoC does NOT read any real-world sensitive file.

Author

Filed by kais113 (huntr.com). For questions, open a discussion on this repo.

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