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
=
4Now provide two output sizes:
[6, -2]The aggregate check succeeds:
6 + (-2)
=
4The number of split entries also matches the number of outputs.
two split sizes
two outputsBut 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 entriesand:
sum of split sizes
==
selected axis sizeThat 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 + 4An 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 sizeFor each split size:
reject if negative
reject if larger than remaining
otherwise subtract itAt the end:
remaining must be zeroThe 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 = 0The 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–3Another 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 >= 0and:
2 + 0 + 2
=
4But 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 axisThat 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
=
0After the first split:
0 + 6
=
6The partition has already moved beyond the end of the input.
The second entry brings the arithmetic total back:
6 + (-2)
=
4But it cannot retroactively make the first segment valid.
Final total:
valid-lookingFirst prefix:
already outside the inputThis is the difference between:
aggregate validityand:
prefix validityThe copy path consumes each split before seeing the final partition
The CPU implementation processes outputs sequentially.
For each output, it:
takes the corresponding
split_size,changes the selected output dimension to that size,
allocates the output tensor,
copies a region from the current input offset,
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 offsetThe 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] = 6the 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 failureThat 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 PrepareForComputeOther shared-path caller
→ shared PrepareForCompute
→ no guaranteed outer negative checkCUDA Compute
→ local PrepareForComputeLocal
→ no equivalent per-element validationA 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 defensePreparation-layer validation
→ required ownership boundaryThe 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 errorThat 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 >= 0while 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
=
4and:
split
=
[
6,
INT64_MAX,
INT64_MAX
]Every entry satisfies:
s >= 0The 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,807That 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:
4and 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 entriesand:
positive entries could exceed the accumulator’s rangeThe 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:
6The axis contains:
4Therefore:
6 > 4The 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 sizeThat 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 zeroIt validates the same structure the copy loop will consume.
The final algorithm maintains a bounded invariant
The merged logic begins with:
remaining
=
split_dim_sizeFor every split s:
if s < 0:
rejectif s > remaining:
rejectremaining -= sAt every successful iteration:
0 <= remaining <= split_dim_sizeThe subtraction cannot underflow because:
s <= remainingThe value cannot exceed the original axis size because it only decreases.
The final condition is:
remaining == 0along with:
number of split entries
==
number of outputsThis 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 = 4First entry:
s = 6Check:
6 > 4Result:
rejectThe 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
=
4The new validator sees:
s = -2and 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 negativeThe 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 = 4First entry:
s = 6Check:
6 > 4Result:
reject immediatelyNo 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 overflowExact total coverage is still checked
Rejecting oversized prefixes is not enough by itself.
Consider:
axis size = 4
split = [1, 1]Every entry is:
non-negativeEach entry fits in the current remainder.
The resulting state is:
remaining = 2The split list covers only half of the selected axis.
The final check:
remaining != 0rejects 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 = 3the operator contract still fails.
The final logic retains the count comparison.
remaining == 0does not prove:
one size was supplied for every outputThe complete validation is:
| Invariant | Meaning |
|---|---|
s >= 0 | No negative output extent |
s <= remaining | Current output fits in the unconsumed input region |
remaining == 0 | The selected axis is covered exactly |
split_count == output_count | Every 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 sumand 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 == 0Every 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 - remainingbecause 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 splitCUDA local preparation
→ perform the same validationFixing 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:
SplitAt runtime, providers may have distinct preparation and execution paths.
CPU
shared-provider users
WebGPU-related shared path
CUDAA 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 implementationThe 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 overflowThe final implementation never reaches that arithmetic.
It rejects:
split size 6
remaining axis size 4That error message captures the underlying partition violation directly.
Immediate semantic error
→ oversized first segmentrather than:
later implementation error
→ accumulator overflowThe 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 testedCUDA validation source
→ directly changed and presentHistorical CUDA OOB execution trace
→ not published in this PRThe 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 matchesFor 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 extentThe final sum hides both.
positive excess
+
negative deficit
→ apparently correct totalThis 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 successChecking 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 offsetThe new validator reasons in the same order.
validate split 0
reduce remaining capacity
validate split 1
reduce remaining capacityThis alignment makes the contract easier to inspect.
At each step:
validated consumed extent
+
remaining extent
=
original axis extentThe 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 sizeThe new loop proves, after every accepted split:
consumed size
<=
axis sizeand:
remaining size
>=
0At completion, it additionally proves:
consumed size
=
axis sizeA 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 allocationRejecting 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.ccWhat 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
Splitis 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 inputThe 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 survivesThe repaired path is:
remaining = 4
↓
first split = 6
↓
6 > remaining
↓
reject before subtraction,
allocation, mapping, or copyThe 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 consumedThe 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