YAML Metadata Warning:empty or missing yaml metadata in repo card
Check out the documentation for more information.
TFLite READ_VARIABLE out-of-bounds read: kernel memcpy's output->bytes from an undersized resource-variable buffer with only a type check
Summary
READ_VARIABLE in TensorFlow Lite copies a resource variable's backing buffer into the op's
output tensor using memcpy(output->data.raw, variable_tensor->data.raw, output->bytes). The
only consistency guard is a scalar type check. The source length is never validated
against the destination length. Because the output tensor's byte length (output->bytes) is
derived from an attacker-controlled static shape in the untrusted .tflite, while the variable's
backing buffer is sized to a separate attacker-controlled ASSIGN_VARIABLE value tensor, a model
can force the kernel to memcpy an attacker-chosen large number of bytes out of a tiny (e.g.
4-byte) heap allocation, reading far out of bounds and crashing the process (SIGSEGV) β or
leaking adjacent heap memory into the readable output tensor.
- Target: TensorFlow Lite (
tensorflow-cpu) β verified on 2.21.0 (pip, x86-64 Linux). Code path unchanged on master (read_variable.ccline 81). - File:
tensorflow/lite/kernels/read_variable.cc,read_variable::Eval - Class: Out-of-bounds read (CWE-125) from untrusted model file
- Attack surface: Loading and invoking a malicious
.tflite. All three ops used (ASSIGN_VARIABLE,READ_VARIABLE, plus const initialization) are builtin ops present in the defaultBuiltinOpResolverβ no custom ops, no flex delegate required.
Root cause
read_variable::Eval (tensorflow/lite/kernels/read_variable.cc):
TfLiteTensor* variable_tensor = variable->GetTensor();
TfLiteTensor* output;
TF_LITE_ENSURE_OK(context,
GetOutputSafe(context, node, kOutputValue, &output));
TF_LITE_ENSURE_TYPES_EQ(context, variable_tensor->type, output->type); // scalar TYPE check only
// Only resize the output if the op produces dynamic output.
if (IsDynamicTensor(output)) {
TF_LITE_ENSURE_OK(context, context->ResizeTensor(
context, output,
TfLiteIntArrayCopy(variable_tensor->dims)));
}
memcpy(output->data.raw, variable_tensor->data.raw, output->bytes); // <-- no size check
Two independent problems combine:
Copy length is the destination's length, not the source's.
output->bytesis computed from the output tensor's declared shape at allocation time. It is never compared tovariable_tensor->bytes(the source). Ifoutput->bytes > variable_tensor->bytes, thememcpyreads past the end of the variable's buffer.The output is only re-shaped to the variable when it is dynamic. The
ResizeTensorthat would makeoutputmatch the variable's real dims runs only insideif (IsDynamicTensor(output)). A model can declare theREAD_VARIABLEoutput with a static shape (rank >= 1 with non-zero dims), which keeps its allocation typekTfLiteArenaRw(not dynamic). The resize is skipped, andoutput->bytesretains the attacker's large static size while the source stays tiny.
The variable's buffer is sized by ASSIGN_VARIABLE β ResourceVariable::AssignFrom
(tensorflow/lite/experimental/resource/resource_variable.cc), which reallocs the backing
buffer to exactly the assigned value tensor's byte size. So the source length is the size of the
attacker's tiny value tensor, and the destination length is the size of the attacker's large
output shape β both fully controlled fields in the untrusted flatbuffer.
Proof of Concept
A 488-byte .tflite, built with the bundled TFLite flatbuffer schema
(tensorflow.lite.python.schema_py_generated), containing one subgraph with two ops executed in
order:
ASSIGN_VARIABLE(resource_id = const int32[1] = 0, value = const int32[1] = 42)β stores a 4-byte buffer into resource variable id 0.READ_VARIABLE(resource_id = 0)whose output tensor is declared as a STATICint32[8000000]arena tensor (buffer index 0, uninitialized).
At invoke(), READ_VARIABLE memcpy's output->bytes = 32,000,000 bytes from the 4-byte
variable buffer β out-of-bounds read β SIGSEGV.
Loaded and invoked via:
tf.lite.Interpreter(
model_content=data,
experimental_op_resolver_type=tf.lite.experimental.OpResolverType.BUILTIN_REF,
)
Negative control
The identical model with the READ_VARIABLE output declared int32[1] (so
output->bytes == the variable's 4 bytes) exercises the exact same kernel/memcpy path and runs
cleanly: allocate_tensors OK, invoke OK, output = 42, exit 0. This isolates the crash to the
output-vs-variable size mismatch, not to the op path itself.
Captured evidence (verbatim)
Reproduced live on this run β tensorflow-cpu 2.21.0:
--- crash run (N=8000000: memcpy 32MB from 4-byte variable buffer) ---
[load] constructed
[load] allocate_tensors OK
crash exit=139 # 139 = 128 + 11 = SIGSEGV
--- negative control (N=1, output bytes == variable bytes) ---
[load] constructed
[load] allocate_tensors OK
[load] invoke OK
[load] output first/last = 42 42 len 1
neg exit=0
Crash reproduced 3/3 runs in the original capture (exit 139 each).
gdb backtrace (fault in the READ_VARIABLE memcpy)
Thread 1 "python" received signal SIGSEGV, Segmentation fault.
#0 0x00007ffff7dd0720 in ?? () from libc.so.6 (memmove/memcpy)
#1 tflite::ops::builtin::read_variable::Eval(TfLiteContext*, TfLiteNode*)
#2 tflite::Subgraph::InvokeImpl()
#3 tflite::Subgraph::Invoke()
#4 tflite::interpreter_wrapper::InterpreterWrapper::Invoke(int)
rsi(src)=0x462bd40 rdi(dst)=0x7fff9beaf080
rsi (memcpy source) = 0x462bd40 is the small heap-allocated variable buffer, being walked off
its end; rdi (destination) is the large arena output tensor.
Impact
Loading and invoking an untrusted .tflite triggers an out-of-bounds heap read of
attacker-controlled length. Minimally a denial of service (SIGSEGV). Because the read
destination is the model's own output tensor, out-of-bounds heap contents adjacent to the
undersized variable buffer can be copied into a tensor the caller reads back, giving a potential
heap information-disclosure primitive in addition to the crash. No custom ops or delegates are
needed; the whole thing is reachable through the default builtin resolver.
Reproduction steps
pip install tensorflow-cpu==2.21.0
python mk_readvar.py 8000000 readvar_crash.tflite # build crash model
python mk_readvar.py 1 readvar_neg.tflite # build negative-control model
python load.py readvar_crash.tflite # -> exit 139 (SIGSEGV)
python load.py readvar_neg.tflite # -> exit 0, output 42
Suggested fix
Before the memcpy, validate that the source and destination byte lengths agree (and/or always
resize the output to the variable's dims, not only when dynamic). For example:
TF_LITE_ENSURE_EQ(context, variable_tensor->bytes, output->bytes);
Dedup / prior-art note
This is distinct from the other TFLite kernel OOB findings in this account
(stablehlo-gather, sparse-densify, dequantize-qdim, getminimumruntime): the vulnerable
code is read_variable::Eval and the trigger is a resource-variable size mismatch between
ASSIGN_VARIABLE and a statically-shaped READ_VARIABLE output. No CVE currently tracks this
specific READ_VARIABLE output-vs-variable length mismatch; the missing size guard is still
present on master (read_variable.cc line 81) at the time of writing.
Artifacts
mk_readvar.pyβ flatbuffer model builder (crash + negative control)load.pyβ load/invoke harnessreadvar_crash.tfliteβ 488-byte crash PoC (staticint32[8000000]output)readvar_neg.tfliteβ negative control (staticint32[1]output)poc_crash_evidence.logβ captured run outputgdbcmds.txtβ gdb command script for the backtraceread_variable.ccβ vulnerable source (2.21.0; identical path on master)resource_variable.ccβAssignFrom(variable buffer sizing)
- Downloads last month
- -