| --- |
| license: apache-2.0 |
| tags: |
| - security |
| - proof-of-concept |
| - tensorflow-lite |
| - tflite |
| --- |
| |
| # TFLite `FlatBufferModel::GetMinimumRuntime()` out-of-bounds read (unchecked `Metadata.buffer` index) |
|
|
| **Gated PoC repository β access granted to protectai-bot / huntr triage only.** |
|
|
| ## Summary |
|
|
| `tflite::impl::FlatBufferModelBase<T>::GetMinimumRuntime()`, defined in |
| [`tensorflow/compiler/mlir/lite/core/model_builder_base.h`](https://github.com/tensorflow/tensorflow/blob/master/tensorflow/compiler/mlir/lite/core/model_builder_base.h) |
| (reached via the public `tflite::FlatBufferModel` class exposed in |
| `tensorflow/lite/model_builder.h`, the standard TFLite model-loading entry |
| point used by every embedder), indexes the model's `buffers` vector using an |
| attacker-controlled `uint32_t` field (`Metadata.buffer`) **without any bounds |
| check**: |
|
|
| ```cpp |
| std::string GetMinimumRuntime() const { |
| if (!model_ || !model_->metadata()) return ""; |
| |
| for (int i = 0; i < model_->metadata()->size(); ++i) { |
| auto metadata = model_->metadata()->Get(i); |
| if (metadata->name()->str() == tflite_metadata_min_runtime_version) { |
| auto buf = metadata->buffer(); |
| auto* buffer = (*model_->buffers())[buf]; // <-- unchecked index (line 476) |
| auto* array = buffer->data(); // <-- (line 477) |
| ... |
| ``` |
|
|
| `Metadata.buffer` is a plain `uint32` schema field |
| (`tensorflow/compiler/mlir/lite/schema/schema_generated.h`), fully attacker |
| controlled, and the generic flatbuffers structural verifier |
| (`Metadata::Verify()`) only checks that the field is present/well-typed β it |
| has no notion of the semantic constraint "must be `< Model.buffers.size()`". |
| `FlatBufferModelBase<T>::VerifyAndBuildFromAllocation()` (the "safe", |
| recommended load path β `tflite::VerifyModelBuffer()` + |
| `flatbuffers::Verifier`) does not check this relational constraint either, so |
| a model with an out-of-range `Metadata.buffer` is **accepted** by the |
| standard verified-load API. |
|
|
| This is a real, and in our reading of the current source, **unfixed** |
| regression relative to the sibling function `ReadAllMetadata()` in the same |
| file, which explicitly guards the identical field: |
|
|
| ```cpp |
| static std::map<std::string, std::string> ReadAllMetadata( |
| const ::tflite::Model* model) { |
| ... |
| for (size_t i = 0; i < model->metadata()->size(); ++i) { |
| auto metadata = model->metadata()->Get(i); |
| auto buf = metadata->buffer(); |
| if (buf >= model->buffers()->size()) continue; // <-- bounds check present here |
| ... |
| ``` |
|
|
| `InterpreterBuilder::ParseTensors` (`tensorflow/lite/core/interpreter_builder.cc`) |
| similarly bounds-checks the analogous `Tensor.buffer` field before indexing |
| `buffers`. `GetMinimumRuntime()` is the one place that indexes |
| `Model.buffers` with an attacker-controlled index and skips this check |
| entirely. |
|
|
| ## Attacker input β sink |
|
|
| 1. Attacker supplies a `.tflite` file (e.g. to a model-management/validation |
| service, an MLOps pipeline, an on-device model updater, or any code that |
| loads a third-party/untrusted model) containing: |
| - `buffers`: a single empty sentinel buffer (`size 1`, satisfies the |
| documented "0th buffer must be empty" convention). |
| - `metadata`: one entry `{ name: "min_runtime_version", buffer: <huge or |
| merely out-of-range uint32> }`. |
| 2. Victim code calls `tflite::FlatBufferModel::VerifyAndBuildFromBuffer(...)` |
| (or `BuildFromBuffer`/`BuildFromFile`, all funnel to the same internal |
| logic) β the model is **accepted**. |
| 3. Victim code calls `model->GetMinimumRuntime()` β a documented public API |
| method, commonly used to gate/log runtime-compatibility before deploying a |
| model β which dereferences `(*model_->buffers())[buf]` with the unchecked |
| index, reading out of the bounds of the underlying flatbuffer allocation. |
| |
| ## Real, reproduced evidence |
|
|
| We compiled the actual, unmodified TFLite source |
| (`tensorflow/compiler/mlir/lite/core/model_builder_base.h`, |
| `tensorflow/lite/model_builder.h`, `tensorflow/compiler/mlir/lite/allocation.cc`, |
| `tensorflow/compiler/mlir/lite/core/api/error_reporter.cc`, and the generated |
| `schema_generated.h`) together with a small driver (`harness.cc`, included in |
| this repo) that: |
|
|
| 1. Builds a malicious `.tflite` flatbuffer exactly as described above |
| (`evil_metadata_buffer_oob.tflite`, included, `Metadata.buffer = 100`, |
| `Model.buffers` size `1`). |
| 2. Loads it through the real, public, "safe" entry point: |
| `tflite::FlatBufferModel::VerifyAndBuildFromBuffer(...)` β confirmed |
| **accepted**. |
| 3. Calls `model->GetMinimumRuntime()`. |
|
|
| Compiled with Clang + AddressSanitizer, `-O2 -DNDEBUG` (the same |
| `NDEBUG`/optimized configuration TFLite release/production builds use β |
| `FLATBUFFERS_ASSERT` expands to a no-op `assert()` under `NDEBUG`, so the |
| flatbuffers library's own internal debug-only assertion, which "usually" |
| saves the day in a plain debug build, is compiled out in every real |
| production build (pip wheels, Android/iOS release builds, `bazel -c opt`)). |
|
|
| ### Result 1 β clean heap-buffer-overflow (`Metadata.buffer = 100`) |
|
|
| ``` |
| ==1151826==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x7c42421e0278 at pc ... |
| READ of size 4 at 0x7c42421e0278 thread T0 |
| #0 ... in flatbuffers::ReadScalar<unsigned int>(void const*) .../flatbuffers/base.h:428 |
| #1 ... in flatbuffers::IndirectHelper<flatbuffers::Offset<tflite::Buffer>,void>::Read(...) .../flatbuffers/buffer.h:138 |
| #2 ... in flatbuffers::Vector<flatbuffers::Offset<tflite::Buffer>,...>::Get(...) .../flatbuffers/vector.h:195 |
| #3 ... in flatbuffers::Vector<...>::operator[](...) .../flatbuffers/vector.h:198 |
| #4 ... in tflite::impl::FlatBufferModelBase<tflite::impl::FlatBufferModel>::GetMinimumRuntime() const |
| .../tensorflow/compiler/mlir/lite/core/model_builder_base.h:476 |
| #5 ... in main harness.cc:127 |
| |
| 0x7c42421e0278 is located 388 bytes after 180-byte region [0x7c42421e0040,0x7c42421e00f4) |
| allocated by thread T0 here: |
| ... std::vector<unsigned char>::vector<unsigned char const*,void>(...) |
| ... BuildMaliciousModel(unsigned int) harness.cc:92 |
| ... main harness.cc:99 |
| ``` |
|
|
| Full log: `asan_index100_heapoverflow.log`. |
|
|
| ### Result 2 β wild pointer dereference / SEGV (`Metadata.buffer = 0xFFFFFFF0`) |
|
|
| ``` |
| ==1148581==ERROR: AddressSanitizer: SEGV on unknown address 0x7cada71e00a8 ... |
| The signal is caused by a READ memory access. |
| #0 ... in flatbuffers::ReadScalar<unsigned int>(void const*) .../flatbuffers/base.h:428 |
| #1 ... in flatbuffers::IndirectHelper<flatbuffers::Offset<tflite::Buffer>,void>::Read(...) .../flatbuffers/buffer.h:138 |
| #2 ... in flatbuffers::Vector<...>::Get(...) .../flatbuffers/vector.h:195 |
| #3 ... in flatbuffers::Vector<...>::operator[](...) .../flatbuffers/vector.h:198 |
| #4 ... in tflite::impl::FlatBufferModelBase<tflite::impl::FlatBufferModel>::GetMinimumRuntime() const |
| .../tensorflow/compiler/mlir/lite/core/model_builder_base.h:476 |
| ``` |
|
|
| Full log: `asan_index_huge_segv.log`. |
|
|
| A debug build (assertions enabled, no `-DNDEBUG`) instead trips the |
| flatbuffers library's own internal `assert(i < size())` and aborts β which is |
| *only* a mitigation in debug builds and is absent from every optimized |
| release build. |
|
|
| ## Impact |
|
|
| - **Crash / Denial of Service**: any process that loads an untrusted |
| `.tflite` file and calls `GetMinimumRuntime()` (or exposes this via a model |
| validation/inspection service, a common pattern for model |
| marketplaces/MLOps pipelines that gate deployment on runtime-version |
| compatibility) can be crashed with a single ~180-byte file, in release |
| builds where the flatbuffers debug assert is compiled out. |
| - **Out-of-bounds heap read / potential info-disclosure primitive**: for |
| attacker-chosen small-to-moderate out-of-range indices (see Result 1), the |
| read lands within the process's mapped heap and is a controlled |
| heap-buffer-overflow read rather than a hard crash, i.e. a genuine |
| out-of-bounds memory disclosure primitive, not merely a DoS. |
| - The bug requires **no valid subgraph/tensor/operator content** β the |
| minimal repro model has an empty subgraph and a single empty buffer β so it |
| is trivially reachable by any file that merely passes the standard |
| flatbuffer structural verifier. |
|
|
| ## Why this isn't a duplicate |
|
|
| - Searched 1,200+ existing `tensorflow/tensorflow` GitHub Security Advisories |
| and GitHub issues for `GetMinimumRuntime`, `min_runtime_version`, |
| `model_builder`, `FlatBufferModel`, `ReadAllMetadata` β no match. |
| - The neighbouring function `ReadAllMetadata()` in the same file *does* |
| perform the correct bounds check on the identical `Metadata.buffer` field, |
| and `InterpreterBuilder::ParseTensors` bounds-checks the analogous |
| `Tensor.buffer` field β confirming this specific accessor is a genuine, |
| isolated gap rather than a previously-identified/fixed class of bug. |
| - Verified directly against `tensorflow/tensorflow` HEAD @ commit |
| `db3bb0b77151263d709e33d8264da24a6c23b373` (2026-07-06). |
|
|
| ## Suggested fix |
|
|
| Add the same guard `ReadAllMetadata()` already uses: |
|
|
| ```cpp |
| auto buf = metadata->buffer(); |
| if (!model_->buffers() || buf >= model_->buffers()->size()) { |
| TF_LITE_REPORT_ERROR(error_reporter_, "min_runtime_version metadata has out-of-range buffer index"); |
| break; |
| } |
| auto* buffer = (*model_->buffers())[buf]; |
| if (!buffer || !buffer->data()) break; |
| auto* array = buffer->data(); |
| ``` |
|
|
| ## Files in this repo |
|
|
| - `harness.cc` β driver source, compiles against the real, unmodified TFLite |
| headers/sources (paths shown in file comments). |
| - `evil_metadata_buffer_oob.tflite` β 180-byte malicious model |
| (`Metadata.buffer = 100`) that reproduces the heap-buffer-overflow. |
| - `asan_index100_heapoverflow.log` β full ASan report for the clean |
| heap-buffer-overflow repro. |
| - `asan_index_huge_segv.log` β full ASan report for the wild-pointer/SEGV |
| variant (`Metadata.buffer = 0xFFFFFFF0`). |
|
|
| ## Build/run instructions (for triage reproduction) |
|
|
| ```bash |
| # 1. Get tensorflow at the commit above (or later) and flatbuffers v25.9.23 |
| # (the pinned version in tensorflow/third_party/flatbuffers/workspace.bzl) |
| git clone --depth 1 https://github.com/tensorflow/tensorflow.git tf |
| curl -L https://github.com/google/flatbuffers/archive/refs/tags/v25.9.23.tar.gz | tar xz |
| |
| # 2. Compile the harness against the real TFLite source (no modifications): |
| clang++ -std=c++17 -g -O2 -DNDEBUG -fsanitize=address -fno-omit-frame-pointer \ |
| -I tf -I flatbuffers-25.9.23/include \ |
| harness.cc \ |
| tf/tensorflow/compiler/mlir/lite/allocation.cc \ |
| tf/tensorflow/compiler/mlir/lite/core/api/error_reporter.cc \ |
| -o harness_release |
| |
| # 3. Trigger: |
| ASAN_OPTIONS=detect_leaks=0:halt_on_error=1 ./harness_release 100 |
| ``` |
|
|