TensorFlow tf.io.parse_example / parse_single_example / parse_sequence_example β SIGFPE (division-by-zero) Denial of Service
Status: gated PoC, private disclosure in progress via huntr.com (protectai). Do not use against production systems.
Summary
TensorFlow's fast Example/SequenceExample proto parser
(tensorflow/core/util/example_proto_fast_parsing.cc) computes an internal
per-row element count (elements_per_stride) directly from the inner shape
supplied for a variable-length dense feature
(tf.io.FixedLenSequenceFeature(shape=..., allow_missing=True), or the
equivalent dense_shapes attribute on the raw ParseExampleV2 /
ParseSingleExample / ParseSequenceExample ops). If that inner shape has a
0 in it, elements_per_stride is 0, and the parser later divides/modulos
by it with no zero-check, crashing the whole process with SIGFPE
(signal 8) β not a catchable Python exception, not a tf.errors.* status,
just an instant process kill.
The crash is independent of the actual serialized Example/SequenceExample
bytes β it fires even for a syntactically empty/valid proto, and even when
the feature in question is entirely absent from the data. It is purely a
function of the (attacker-controlled, when the parsing graph ships inside a
model file) feature-spec shape. Any model/graph that wires up
ParseExampleV2/ParseSingleExample/ParseSequenceExample with such a shape
turns every future invocation of that signature into an instant crash of the
hosting process β a persistent, config-driven Denial of Service against any
inference server that loads the model.
Root cause
tensorflow/core/util/example_proto_helper.cc, GetDenseShapes():
elements_per_stride->push_back(dense_shape.num_elements());
dense_shape is the caller-supplied shape with the (optional) leading
variable-length dimension stripped. If the remaining static dims multiply to
0 (e.g. shape=[0]), elements_per_stride is 0. Nothing rejects this.
tensorflow/core/util/example_proto_fast_parsing.cc:
Batch path,
FastParseExample()βMergeDenseVarLenMinibatcheslambda:const size_t stride_size = config.dense[d].elements_per_stride; const size_t max_num_elements = max_num_features / stride_size; // <-- div by 0This runs unconditionally once per configured variable-length dense feature, regardless of whether that feature appears anywhere in the batch's data (confirmed by crashing on a batch of empty
Examplebytes with the feature entirely missing).Per-example path,
FastParseSerializedExample():if (out.int64_list.size() % num_elements != 0) { ... } // num_elements == elements_per_stride(analogous lines for
float_list/bytes_list).Single-example path,
FastParseSingleExample():if (num_elements % num_elements_divisor != 0) { ... } // num_elements_divisor == elements_per_strideSequenceExampledense path,ParseSequenceDenseFeatures():if (!c.shape.AsTensorShape(&row_shape) || expected_max_elements != (expected_max_elements / row_shape.num_elements()) * row_shape.num_elements()) { ... } // div by 0 ... int64_t expected_max_rows = expected_max_elements / row_shape.num_elements(); // div by 0
None of the reachable call sites (tensorflow/core/kernels/example_parsing_ops.cc,
tensorflow/core/ops/parsing_ops.cc) validate that a configured dense shape
has a non-zero element count before it is used as a divisor.
Verified impact
Tested against a freshly pip install-ed tensorflow-cpu==2.21.0 (current
stable release at the time of testing, matching a fresh git clone of
tensorflow/tensorflow master). All three PoCs reproducibly kill the Python
interpreter with signal 8 (SIGFPE):
$ python3 confirm_signal.py
returncode: -8
Killed by signal: 8 SIGFPE
poc_parse_example_minimal.pyβtf.io.parse_example([b""], {...})crashes immediately, no TFRecord file, no real feature data at all.poc_parse_sequence_example.pyβ same crash viatf.io.parse_sequence_example.poc_tfrecord_sigfpe.pyβ the realistic end-to-end path: writes one perfectly ordinarytf.train.Example({"label": int64_list([1])}) to a real.tfrecordfile withtf.io.TFRecordWriter, then reads it back with the canonicaltf.data.TFRecordDataset(...).batch(1).map(tf.io.parse_example)pipeline. The data is 100% benign; only the feature-spec ("extra": tf.io.FixedLenSequenceFeature(shape=[0], dtype=tf.float32, allow_missing=True)) is malicious. The process is killed by SIGFPE the moment the first batch is materialized.
Attacker-controlled surface (malicious model file)
dense_shapes / the analogous shape attrs are ordinary attributes of the
ParseExampleV2, ParseSingleExample, and ParseSequenceExample ops. A
GraphDef/SavedModel is free to embed a pre-processing subgraph (e.g. an
input-parsing signature) that calls one of these ops with a zero-size inner
shape on a variable-length dense feature. Any consumer that loads that model
and invokes the corresponding signature β regardless of what input tensor it
passes β will crash on first use. This is a persistent Denial-of-Service
purely from loading and calling a malicious model file; no separately
malicious dataset/TFRecord is required (though, as poc_tfrecord_sigfpe.py
shows, this also fires naturally in the ordinary
TFRecordDataset β parse_example data-loading idiom if the feature spec is
ever malformed in this specific way).
Suggested fix
Reject a dense_shapes entry whose element count is 0 for a variable-length
(allow_missing=True / leading -1) dense feature in GetDenseShapes()
(example_proto_helper.cc), or guard every division/modulo by
elements_per_stride (and the analogous row_shape.num_elements() in the
SequenceExample dense path) with an explicit != 0 check that returns an
InvalidArgumentError instead of dividing.
Files
poc_parse_example_minimal.pyβ minimal repro viatf.io.parse_example.poc_parse_sequence_example.pyβ minimal repro viatf.io.parse_sequence_example.poc_tfrecord_sigfpe.pyβ realistic end-to-end repro starting from a real, valid.tfrecordfile.confirm_signal.pyβ runs the end-to-end PoC as a subprocess and prints the confirmed signal (SIGFPE / 8).
Environment used to verify
$ pip install tensorflow-cpu
$ python3 -c "import tensorflow as tf; print(tf.__version__)"
2.21.0