ONNX Runtime #29461 — Why Split Sizes [6, -2] Could Pass the Sum Check and Read Past the Input

ONNX Runtime Split Validation — Negative cancellation, signed overflow, and why remaining capacity was safer than an aggregate sum

The split sizes added up to the input dimension.

The split was still invalid.

Consider an input whose selected axis has length four:

axis size
=
4

Now provide two output sizes:

[6, -2]

The aggregate check succeeds:

6 + (-2)
=
4

The number of split entries also matches the number of outputs.

two split sizes

two outputs

But the first output asks the kernel to take six rows from an axis containing only four.

The negative second entry does not make those first two extra rows valid.

It merely cancels them in the final sum.

Before ONNX Runtime PR #29461, the shared Split preparation logic and CUDA’s local copy primarily validated:

number of split entries

and:

sum of split sizes
==
selected axis size

That aggregate relationship was not enough to prove that each output segment described a valid region of the input.

The initial fix added a non-negative check.

Review then found a second bypass containing no negative values at all:

[
    6,
    INT64_MAX,
    INT64_MAX
]

On an axis of size four, the signed accumulation could overflow and appear to return to four under common machine behavior.

6
+
(2^63 - 1)
+
(2^63 - 1)

=
2^64 + 4

An aggregate equality check performed after that overflow could again accept an impossible first segment.

The final merged patch stopped asking only:

Does the final sum equal the axis size?

It instead tracked how much of the axis remained after every individual split.

remaining
=
axis size

For each split size:

reject if negative

reject if larger than remaining

otherwise subtract it

At the end:

remaining must be zero

The new validation proves both local and aggregate safety.

A partition is valid only when every segment fits inside the space left by the segments before it.

PR #29461 was merged into ONNX Runtime as commit abc74dc0. It updates the shared split preparation path, mirrors the validation in CUDA’s local implementation, and adds an overflow regression.


What Split is supposed to describe

The ONNX Split operator divides one tensor along a selected axis.

Suppose the input shape is:

[4, 2]

and the split axis is:

axis = 0

The selected axis contains four rows.

A valid split might be:

[1, 3]

The outputs have shapes:

output 0
→ [1, 2]

output 1
→ [3, 2]

The segments cover:

row 0

and

rows 1–3

Another valid split is:

[2, 0, 2]

because zero-length outputs are allowed by the non-negative contract implemented by the patch.

The segments still satisfy:

every size >= 0

and:

2 + 0 + 2
=
4

But a valid partition requires more than a matching final sum.

For each segment:

start offset must be inside the input

and

start offset + segment size
must not exceed the selected axis

That is a prefix condition.


The old validation checked the final aggregate

The old shared preparation code was effectively:

split_size_sum =
    accumulate(split_sizes);

if (
    split_count != output_count
    ||
    split_size_sum != axis_size
) {
    return error;
}

The validation asked two questions.

Does the list have the expected number of entries?
Do all entries add up to the selected dimension?

Those conditions are necessary.

They are not sufficient when the entries themselves have not been constrained.

The old shared code is visible in the PR’s base revision: for a non-empty split vector, it calculated or reused one aggregate sum and compared that value with split_dim_size.


A final sum says nothing about an invalid prefix

Take:

axis size = 4

split = [6, -2]

The cumulative positions are:

start
=
0

After the first split:

0 + 6
=
6

The partition has already moved beyond the end of the input.

The second entry brings the arithmetic total back:

6 + (-2)
=
4

But it cannot retroactively make the first segment valid.

Final total:
valid-looking
First prefix:
already outside the input

This is the difference between:

aggregate validity

and:

prefix validity

The copy path consumes each split before seeing the final partition

The CPU implementation processes outputs sequentially.

For each output, it:

  1. takes the corresponding split_size,

  2. changes the selected output dimension to that size,

  3. allocates the output tensor,

  4. copies a region from the current input offset,

  5. advances the input offset by that split size.

The core flow is:

split size i
        ↓
construct output shape i
        ↓
copy that many rows
        ↓
advance input offset

The copy loop does not wait until all outputs have been processed and then ask whether the final offset happened to equal the input length.

It trusts the prepared split sizes one at a time.

For:

split[0] = 6

the first iteration attempts to construct and fill an output covering six rows.

The selected input axis contains only four.

The later -2 entry cannot protect the first copy.

This is why aggregate cancellation can reach a memory-safety boundary.


The ordinary CPU path already had an outer negative check

The provider boundary needs to be stated precisely.

The ordinary CPU SplitImpl::Compute() path already read the optional split tensor and rejected any value below zero before calling PrepareForCompute().

any split value < 0
→ return failure

That check was already present in the PR’s base revision.

Therefore, the accurate conclusion is not:

Every ONNX Runtime CPU Split call accepted `[6, -2]`.

The missing invariant was lower in the shared preparation layer.

The PR author describes the split.h change as covering shared-provider users, including WebGPU-related paths, while CUDA maintained a separate PrepareForComputeLocal() copy that required the same repair.

The architecture before the patch was closer to:

CPU Compute
→ local negative check
→ shared PrepareForCompute
Other shared-path caller
→ shared PrepareForCompute
→ no guaranteed outer negative check
CUDA Compute
→ local PrepareForComputeLocal
→ no equivalent per-element validation

A safety property that matters to every consumer should not depend on one caller remembering an additional guard.


The shared routine was accepting more than its contract allowed

PrepareForCompute() was responsible for turning a split description into sizes safe for later allocation and copying.

Returning success effectively promised:

The output sizes can be used to partition the selected input axis.

An aggregate-only check did not establish that promise.

Even when one caller had already filtered negative values, the shared routine itself still accepted vectors whose local segments were not independently validated.

The patch moved the invariant into the layer that hands the sizes to the rest of the kernel.

Caller-specific validation
→ useful defense
Preparation-layer validation
→ required ownership boundary

The initial non-negative fix closed only the first counterexample

The first repair direction was straightforward.

for each s in split_sizes:

    if s < 0:
        return error

That rejects:

[6, -2]

before the copy path can use it.

It also aligns the input-tensor path with the split-attribute path, whose constructor already required non-negative entries.

But this check alone still allowed:

all entries >= 0

while the aggregate arithmetic itself could overflow.

Review caught that remaining path in both:

shared `SplitBase::PrepareForCompute`

and:

CUDA `PrepareForComputeLocal`

The overflow counterexample needed no negative value

Use:

axis size
=
4

and:

split
=
[
    6,
    INT64_MAX,
    INT64_MAX
]

Every entry satisfies:

s >= 0

The count can also match three outputs.

Now consider the intended mathematical total:

6
+
9,223,372,036,854,775,807
+
9,223,372,036,854,775,807

That value is larger than the maximum signed 64-bit integer.

The old path used signed int64_t accumulation.

The arithmetic therefore could not safely represent the mathematical total.

The review noted that, on common two’s-complement behavior, the calculation can wrap back to:

4

and satisfy the same aggregate equality the original negative vector exploited.

In standard C++, signed overflow is not a valid arithmetic result on which a safety check should depend.

The validation was therefore unsafe in two ways:

negative entries could cancel oversized entries

and:

positive entries could exceed the accumulator’s range

The first split was already impossible

The overflow vector does not need the second and third entries to expose the local problem.

The first value is:

6

The axis contains:

4

Therefore:

6 > 4

The vector is invalid immediately.

A correct validator should reject at the first entry.

It should never add the two INT64_MAX values.

This observation led to the final algorithm.

Do not first calculate an unbounded total.

Track bounded remaining capacity instead.

Why checked accumulation alone would not be the clearest contract

One possible repair would be:

reject negative values

use checked addition for the total

compare total with axis size

That would prevent signed overflow.

It would still delay discovery of an oversized prefix until after the complete accumulation.

A separate prefix check would remain necessary if each segment must be known to fit before later code uses it.

The remaining-capacity formulation expresses all required facts in one pass.

entry is non-negative

entry fits in remaining axis

subtraction remains safe

final remainder is zero

It validates the same structure the copy loop will consume.


The final algorithm maintains a bounded invariant

The merged logic begins with:

remaining
=
split_dim_size

For every split s:

if s < 0:
    reject
if s > remaining:
    reject
remaining -= s

At every successful iteration:

0 <= remaining <= split_dim_size

The subtraction cannot underflow because:

s <= remaining

The value cannot exceed the original axis size because it only decreases.

The final condition is:

remaining == 0

along with:

number of split entries
==
number of outputs

This logic is present in both the merged shared implementation and CUDA’s local preparation copy.


The negative counterexample now fails on its first invalid fact

For:

axis size = 4

split = [6, -2]

the new validation begins:

remaining = 4

First entry:

s = 6

Check:

6 > 4

Result:

reject

The validator does not need to reach -2.

It does not need to calculate the final sum.

The first segment already violates the partition boundary.


A reordered negative vector also fails directly

Consider:

split = [-2, 6]

The old aggregate is still:

-2 + 6
=
4

The new validator sees:

s = -2

and rejects it through the non-negative check.

The two orderings fail for different local reasons.

[6, -2]

→ first segment exceeds remaining capacity
[-2, 6]

→ first segment is negative

The final aggregate is no longer the primary source of truth.


The overflow counterexample never reaches overflow

For:

split
=
[
    6,
    INT64_MAX,
    INT64_MAX
]

the new flow is:

remaining = 4

First entry:

s = 6

Check:

6 > 4

Result:

reject immediately

No signed addition is performed.

The two maximum integers are never combined.

The patch author summarized the review resolution this way:

subtract each split from the remaining axis extent

reject oversized entries before subtraction

avoid signed accumulation overflow

Exact total coverage is still checked

Rejecting oversized prefixes is not enough by itself.

Consider:

axis size = 4

split = [1, 1]

Every entry is:

non-negative

Each entry fits in the current remainder.

The resulting state is:

remaining = 2

The split list covers only half of the selected axis.

The final check:

remaining != 0

rejects it.

The new validation therefore enforces both:

No prefix exceeds the axis.

and:

The complete list consumes the axis exactly.

Count and extent are separate contracts

Consider:

axis size = 4

split = [2, 2]

The sizes form a valid geometric partition.

But if the node has three outputs:

split count = 2

output count = 3

the operator contract still fails.

The final logic retains the count comparison.

remaining == 0

does not prove:

one size was supplied for every output

The complete validation is:

InvariantMeaning
s >= 0No negative output extent
s <= remainingCurrent output fits in the unconsumed input region
remaining == 0The selected axis is covered exactly
split_count == output_countEvery output has one declared extent

Each check protects a different boundary.


The attribute path also needed overflow-safe ordering

Older ONNX opsets can supply split sizes as an operator attribute rather than as the second input tensor.

That path already performed a non-negative validation in the SplitBase constructor.

But the old constructor calculated:

split_size_sum_ =
    std::accumulate(...)

before the non-negative assertion.

It also used an ordinary signed accumulator.

The merged patch changes the order:

first validate every attribute value is non-negative

then calculate the cached sum

and uses:

SafeInt<int64_t>{0}

as the accumulation seed.

This prevents the cached attribute sum from silently overflowing before later preparation uses it.


The final check no longer trusts the cached sum for safety

The attribute path retains split_size_sum_ for diagnostics and existing state.

But the decisive structural check in PrepareForCompute() is now:

remaining_split_size == 0

Every entry has already been validated against the remaining axis extent.

The cached aggregate is no longer the sole safety gate.

For input-tensor splits, where no constructor-time sum exists, the diagnostic total can be derived safely as:

axis size - remaining

because remaining is maintained inside the bounded interval.


Why the shared and CUDA implementations both changed

CUDA contains a local preparation function rather than calling the shared CPU implementation directly.

Its source explains that plugin builds cannot use SplitBase::PrepareForCompute() because that base path depends on CPU-provider internals.

The CUDA code therefore duplicates the relevant split preparation logic and must be kept in sync.

This creates a repeated correctness obligation.

Shared preparation
→ validate every split
CUDA local preparation
→ perform the same validation

Fixing only one copy would leave the other provider with a different accepted-input set.

PR #29461 changes both files in the same patch.


Duplicated preparation code can drift even when the operator name is shared

At the ONNX level, there is one operator:

Split

At runtime, providers may have distinct preparation and execution paths.

CPU

shared-provider users

WebGPU-related shared path

CUDA

A model-level test named Split does not guarantee that every execution provider passed through the same validation function.

This is why provider-local copies must be reviewed as separate consumers of the same operator contract.

Shared schema

≠

one physical implementation

The merged regression focuses on the overflow bypass

The test file already contained two CPU-only negative-entry regressions at the PR’s base revision.

They covered vectors such as:

[8, -2]

and:

[-1, 5]

and expected the ordinary CPU kernel’s local negative-input diagnostic.

After review identified the signed-overflow bypass, the merged PR added:

[6, INT64_MAX, INT64_MAX]

on an axis of size four.

The test expects failure with:

“exceeds the remaining size of the selected axis”

The final merged diff therefore pins the stronger remaining-capacity rule, not only the original non-negative check.


Why the expected failure mentions the first split

The overflow regression could have expected:

integer overflow

The final implementation never reaches that arithmetic.

It rejects:

split size 6

remaining axis size 4

That error message captures the underlying partition violation directly.

Immediate semantic error

→ oversized first segment

rather than:

later implementation error

→ accumulator overflow

The best validation failure is usually the earliest one that explains why the input is invalid.


The test uses CPU, but the source repair is broader

The newly added regression explicitly creates:

DefaultCpuExecutionProvider()

It therefore directly verifies the shared CPU-side preparation behavior.

The merged diff also changes CUDA’s local implementation, and the PR’s CI head completed successfully across CPU, CUDA, Web, ASAN, and related build/test workflows.

However, the patch does not add a dedicated CUDA runtime test that launches the historical invalid vector and captures an out-of-bounds device read.

The evidence should remain layered.

Shared CPU rejection regression
→ directly tested
CUDA validation source
→ directly changed and present
Historical CUDA OOB execution trace
→ not published in this PR

The patch confirms a memory-safety boundary, not an exploit

The source path shows how an oversized split can instruct a kernel to construct or copy an output segment beyond the selected input extent.

The PR explicitly describes the consequence as an out-of-bounds read.

The public evidence does not establish:

  • remote exploitability,

  • disclosure of specific adjacent data,

  • a CVE assignment,

  • control over a returned memory region,

  • or one production deployment in which the read was observed.

The accurate conclusion is:

Input validation allowed an impossible partition
that could reach an out-of-bounds read path.

Not:

A demonstrated remote data-exfiltration exploit existed.

Why aggregate validation is attractive

The old logic was simple.

count matches

and

sum matches

For trusted non-negative integers that cannot overflow, those two checks can be enough to establish a valid one-dimensional partition.

The hidden assumptions were:

Every entry is non-negative.
The sum is calculated without overflow.

Neither assumption belonged to the aggregate equality itself.

Once untrusted or runtime-provided integers enter the path, the proof must state those conditions explicitly.


Aggregate equality can hide opposite local errors

The [6, -2] vector contains two invalid local facts.

6
→ too large for remaining axis
-2
→ invalid negative extent

The final sum hides both.

positive excess

+

negative deficit

→ apparently correct total

This pattern appears in many systems:

  • tensor dimension lists,

  • byte ranges,

  • file offsets,

  • packet lengths,

  • memory-region partitions,

  • shard sizes,

  • and resource budgets.

A final total cannot prove that every intermediate region was valid.


Overflow can create a false aggregate without negative inputs

The second vector is more subtle.

All inputs are non-negative.

Yet the arithmetic used to validate them cannot represent their mathematical total.

The equality check receives a value produced outside its safe numeric domain.

Invalid arithmetic

→ plausible final total

→ false validation success

Checking input sign is therefore not a substitute for checking arithmetic range.

The remaining-capacity algorithm avoids both by keeping every intermediate value bounded by the known axis size.


Subtraction is safe because the bound is checked first

Subtraction alone is not automatically safer than addition.

This would still be wrong:

remaining -= s;
if (remaining < 0) fail;

If s can exceed the representable range relationship, the subtraction may already have overflowed before the comparison.

The merged code orders the operations correctly.

1. Check `s < 0`.

2. Check `s > remaining`.

3. Only then calculate `remaining - s`.

The proof of safe subtraction exists before the subtraction occurs.


The validator now mirrors the consumer

The copy path consumes splits sequentially.

copy output 0

advance offset

copy output 1

advance offset

The new validator reasons in the same order.

validate split 0

reduce remaining capacity

validate split 1

reduce remaining capacity

This alignment makes the contract easier to inspect.

At each step:

validated consumed extent
+
remaining extent
=
original axis extent

The validator and consumer share one monotonic model of progress.


Prefix invariants are stronger than end-state invariants

The old check proved only:

final accumulated value
=
axis size

The new loop proves, after every accepted split:

consumed size
<=
axis size

and:

remaining size
>=
0

At completion, it additionally proves:

consumed size
=
axis size

A prefix invariant contains more information than the final end state.

It protects every point at which the consumer can act.


Fail early also prevents dangerous allocations

Even before an actual copy occurs, an oversized split may be used to construct an output shape.

split size
→ output dimension
→ output allocation

Rejecting the first invalid segment prevents:

  • impossible shape construction,

  • oversized allocation attempts,

  • negative output dimensions,

  • invalid mapping arrays,

  • and later memory-copy errors.

The patch moves rejection before those side effects.


The final code remains in current main

The shared implementation in current ONNX Runtime main still:

  • initializes remaining_split_size,

  • rejects negative entries,

  • rejects entries larger than the remainder,

  • subtracts only after validation,

  • and requires a zero final remainder.

CUDA’s current local copy retains the same sequence.

The repair was not a temporary PR-only implementation.


What PR #29461 directly changed

The merged patch directly:

  • adds per-entry non-negative validation to the shared preparation path,

  • adds the same validation to CUDA’s local preparation copy,

  • rejects a split larger than the remaining selected-axis extent,

  • replaces unbounded input-tensor accumulation with monotonic remaining-capacity tracking,

  • moves attribute non-negative validation before sum calculation,

  • uses SafeInt<int64_t> for the cached attribute sum,

  • retains output-count validation,

  • requires exact exhaustion of the selected axis,

  • and adds the overflow regression.

It changes three files:

onnxruntime/core/providers/cpu/tensor/split.h

onnxruntime/core/providers/cuda/tensor/split.cc

onnxruntime/test/providers/cpu/tensor/split_op_test.cc

What the patch does not establish

The public evidence does not establish that:

  • every execution provider previously reached an out-of-bounds read,

  • the ordinary CPU path accepted negative input-tensor splits before this PR,

  • a production model used the malicious vectors,

  • a CUDA runtime OOB was captured by a new device-side regression,

  • adjacent memory contents were exposed,

  • every arithmetic-validation issue in Split is now impossible,

  • or the change affects valid-model performance.

The supported conclusion is narrower:

The shared and CUDA preparation routines could accept split vectors whose count and aggregate matched the selected axis even though an individual segment was invalid or the signed aggregate overflowed. The merged patch validates each segment against a bounded remaining extent before any output preparation uses it.


The complete failure chain

The negative path was:

axis size = 4

split = [6, -2]
        ↓
count matches output count
        ↓
aggregate sum = 4
        ↓
preparation accepts vector
        ↓
first output requests 6 rows
        ↓
selected input axis contains 4 rows
        ↓
copy can read beyond the input

The overflow path was:

axis size = 4

split =
[6, INT64_MAX, INT64_MAX]
        ↓
all entries appear non-negative
        ↓
signed aggregate exceeds int64 range
        ↓
aggregate can appear to equal 4
        ↓
same impossible first segment survives

The repaired path is:

remaining = 4
        ↓
first split = 6
        ↓
6 > remaining
        ↓
reject before subtraction,
allocation, mapping, or copy

The final lesson is that a partition must be valid at every prefix

A final sum is an end-state property.

A kernel consumes segments one at a time.

Those are different contracts.

Final total matches

does not prove

every segment was safe when consumed

The safer proof is incremental:

Every size is non-negative.

Every size fits in the remaining region.

Every subtraction is safe.

The final remainder is zero.

Validate the state the consumer will observe at each step, not only the total that happens to remain at the end.

ONNX Runtime #29461 fixed Split by replacing one aggregate assumption with a bounded sequence of local proofs.


Related material


Patch status: Merged into ONNX Runtime main as commit abc74dc0
Affected operator: ONNX Split
Affected input form: Runtime split-size tensor; attribute path also hardened against overflow
Original validation: Split count plus aggregate sum
Negative counterexample: [6, -2] for an axis of size four
Overflow counterexample: [6, INT64_MAX, INT64_MAX] for an axis of size four
Memory boundary: The first segment can request more rows than the selected input axis contains
Final validation: Non-negative entry, entry no larger than remaining extent, safe subtraction, zero final remainder
Provider scope: Shared preparation path and CUDA’s local copy
Regression added: CPU-only overflow-rejection test
Existing negative coverage: Two CPU-only negative-entry tests already existed at the PR base
Evidence boundary: Source-level OOB path and validation defect confirmed; no exploit or dedicated historical CUDA OOB trace was published

This is a standalone Resonetic Lab Code & Patch Analysis article.

#ONNXRuntime #ONNX #Split #TensorValidation #IntegerOverflow #OutOfBoundsRead #MemorySafety #CUDA #WebGPU #CompilerCorrectness #CodeAnalysis

Popular posts from this blog

AMD Is Trying to Turn Kernel Diversity Into One Software Asset

PyTorch #188031 — Why Did Forward Use 64-Bit Indexing While Backward Still Used uint32_t?

NVIDIA CUTLASS #3017 — Why load() and store() Mapped the Same Tile Coordinate to Different Addresses