Skip to content

gather: bounds-check runtime position values in Eval - #3645

Open
evilgensec wants to merge 1 commit into
tensorflow:mainfrom
evilgensec:fix/gather-position-bounds-check
Open

gather: bounds-check runtime position values in Eval#3645
evilgensec wants to merge 1 commit into
tensorflow:mainfrom
evilgensec:fix/gather-position-bounds-check

Conversation

@evilgensec

@evilgensec evilgensec commented Aug 1, 2026

Copy link
Copy Markdown

BUG=GATHER kernel reads out of bounds on out-of-range runtime position values (memory safety)

This reworks #3593 to follow the Error Handling & Defensive Programming Guide, which was the reason that PR was closed.

Problem

Gather() copies each output slice with

input_data + (((batch * outer_size) + outer) * axis_size +
              coords_data[batch * coord_size + coord]) * inner_size

The position values come from the positions input tensor. They are guarded only by

TFLITE_DCHECK_GE(coords_data[coord], 0);
TFLITE_DCHECK_LT(coords_data[coord], axis_size);

There are two problems with that guard.

  1. TFLITE_DCHECK compiles out under -DNDEBUG, so in a release build an out-of-range position is used directly as an offset into input_data and the memcpy reads outside the input tensor.

  2. The guard reads coords_data[coord], but the memcpy indexes coords_data[batch * coord_size + coord]. Once batch_size > 1 these are different elements, so the value actually used as the offset is not the value being checked, even in a debug build.

Prepare cannot cover this. It validates axis, batch_dims and the tensor shapes, but the position values are only known at invoke time.

Approach

The guide, section 2, Phase 2, lists this case directly:

Out-of-bounds Memory Access: If an input tensor contains indices or offsets generated at runtime (e.g., in GATHER, STRIDED_SLICE), these should be bounds-checked at runtime using a raw if (!valid) return kTfLiteError;.

So the check stays in Eval, but as a raw branch rather than TF_LITE_ENSURE. Per the macro cheat sheet, TF_LITE_ENSURE in Eval should be avoided for signal data out-of-bounds because each invocation embeds __FILE__, __LINE__ and the stringified condition in .rodata. The raw branch adds no string data.

This is the difference from #3593, which used TF_LITE_ENSURE and also added an axis re-check in Eval that duplicated the existing TF_LITE_ENSURE in Prepare. That duplicate is dropped here, since Prepare already guarantees it and the existing TFLITE_DCHECKs on axis are the correct choice under the guide.

Change

gather.cc: read the position value once into coord_value, reject it with a raw branch if it falls outside [0, axis_size), and use that same value in the memcpy. This also removes the mismatch in 2 above, because the value checked and the value used are now the same expression.

Cost in release is one compare and branch per copied slice, with no added .rodata.

gather_test.cc: three tests, all asserting InitAndPrepare() still returns kTfLiteOk and Invoke() returns kTfLiteError.

  • GatherOp_PositionAboveAxisSizeReturnsError, position 3 against axis size 3.
  • GatherOp_NegativePositionReturnsError, position -1.
  • GatherOp_OutOfRangePositionInSecondBatchReturnsError, batch_dims = 1 with the bad value at coords_data[1]. This is the case the old DCHECK never read, so it fails on the unpatched kernel in a debug build as well as a release build.

Testing

gmake -f tensorflow/lite/micro/tools/make/Makefile test_kernel_gather_test
gmake -f tensorflow/lite/micro/tools/make/Makefile BUILD_TYPE=release_with_logs test_kernel_gather_test

Run on macOS arm64. Note that the host make there is 3.81 and the Makefile requires 3.82 or later, so gmake is needed.

Both builds pass with this change, 21 tests in each. To confirm the new tests actually exercise the defect, I also ran them against the unpatched kernel:

Build Kernel Result
default patched 21 passed
default unpatched aborts, exit code 134, on the first new test when the DCHECK fires
release_with_logs patched 21 passed
release_with_logs unpatched 18 passed, the 3 new tests fail

The last row is the case that matters. With -DNDEBUG the DCHECKs are gone, so the unpatched kernel does not abort. It uses the out-of-range position as an offset, reads outside the input tensor, and still returns kTfLiteOk. That is the behaviour this change removes.

The GATHER kernel copies each output slice using a position value taken
from the positions input tensor:

    input_data + (((batch * outer_size) + outer) * axis_size +
                  coords_data[batch * coord_size + coord]) * inner_size

Those values were guarded only by TFLITE_DCHECK, which compiles out under
NDEBUG, so a release build used an out-of-range position directly as an
offset and read outside the input tensor. The DCHECKs also read
coords_data[coord] while the memcpy indexes
coords_data[batch * coord_size + coord], so once batch_size is greater
than 1 they did not guard the value actually used.

Read the position once, reject it with a raw branch when it falls outside
[0, axis_size), and use that same value in the memcpy. The error handling
guide calls for a raw branch rather than TF_LITE_ENSURE for runtime index
data in Eval, so no error string is added to .rodata. Cost in release is
one compare and branch per copied slice.

Adds three tests covering a position above the axis size, a negative
position, and an out-of-range position in the second batch, which is the
case the previous DCHECKs never read.
@evilgensec
evilgensec requested a review from a team as a code owner August 1, 2026 17:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant