| # 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()`: |
| ```cpp |
| 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()` β `MergeDenseVarLenMinibatches` lambda: |
| ```cpp |
| const size_t stride_size = config.dense[d].elements_per_stride; |
| const size_t max_num_elements = max_num_features / stride_size; // <-- div by 0 |
| ``` |
| This 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* `Example` |
| bytes with the feature entirely missing). |
|
|
| * Per-example path, `FastParseSerializedExample()`: |
| ```cpp |
| if (out.int64_list.size() % num_elements != 0) { ... } // num_elements == elements_per_stride |
| ``` |
| (analogous lines for `float_list` / `bytes_list`). |
|
|
| * Single-example path, `FastParseSingleExample()`: |
| ```cpp |
| if (num_elements % num_elements_divisor != 0) { ... } // num_elements_divisor == elements_per_stride |
| ``` |
|
|
| * `SequenceExample` dense path, `ParseSequenceDenseFeatures()`: |
| ```cpp |
| 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 via `tf.io.parse_sequence_example`. |
| * `poc_tfrecord_sigfpe.py` β the realistic end-to-end path: writes one |
| perfectly ordinary `tf.train.Example` (`{"label": int64_list([1])}`) to a |
| real `.tfrecord` file with `tf.io.TFRecordWriter`, then reads it back with |
| the canonical `tf.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 via `tf.io.parse_example`. |
| - `poc_parse_sequence_example.py` β minimal repro via `tf.io.parse_sequence_example`. |
| - `poc_tfrecord_sigfpe.py` β realistic end-to-end repro starting from a real, valid `.tfrecord` file. |
| - `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 |
| ``` |
|
|