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 attf.saved_model.load()time, no user function call required. Usestf.lookup.StaticHashTable+TextFileInitializer(the standard pattern used byTextVectorization,IndexLookup,StringLookup) which registers an initializer that runs duringload().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
- Arbitrary file read on the loading host (any absolute path the attacker chooses).
- SSRF via
file_io.join's URI branch (http://attacker.example/probeissues HTTP GET during load). - Cross-FS read via cloud-URI schemes (
gs://attacker-bucket/...authenticates with the victim's GCS credentials). - HF Hub supply-chain exposure -- standard SavedModel malware-scan pipelines don't inspect
asset_file_defsemantic fields. - Disclosure via standard inspection --
loaded.vocab_asset.asset_pathis 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.