Skip to content

feat(stack-buffer): bound stack array indexes with ScalarEvolution on a promoted copy - #264

Merged
SizzleUnrlsd merged 6 commits into
mainfrom
feat/stack-buffer-index-bounds
Nov 8, 2026
Merged

SizzleUnrlsd merged 6 commits into
mainfrom
feat/stack-buffer-index-bounds

Conversation

@SizzleUnrlsd

@SizzleUnrlsd SizzleUnrlsd commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #262. The base branch is fix/const-from-value-memoryssa. Merge #262 first; this PR then retargets to main.
It needs #262 for two reasons. Without it, the access after a loop in after-loop-bad.c would get both "on every run" and the wrong "appears unreachable" note. And the never-runs check restores the correct note on zero-constant-ok.c, which #262 removed.

Summary

Stack-array overflow checks on buf[i] used syntactic heuristics on the -O0 IR, where loop
counters live in memory. This PR adds a pilot that bounds those indexes with ScalarEvolution.
ScalarEvolution runs on a promoted copy of each function. The analyzed IR does not change.

  • Shared infrastructure: PromotedValues (include/analysis/PromotedValues.hpp).

    • It holds a copy of the module's declarations. On a function's first query, it copies that one body, then promotes its local slots to registers.
    • The copy is sanitized so its facts hold for the original program:
      • each promotable slot starts from an opaque value, so an index that may be uninitialized stays unknown;
      • wrap and poison flags, optnone, mustprogress, willreturn and the loop metadata are dropped;
      • functions with lifetime markers are left out.
    • It answers three ways for an index at a given load or store:
      • unknown;
      • never runs;
      • a range [min, max], with a flag for each endpoint that the access takes on every run of the function that returns.
    • Ranges use the endpoint method: affine recurrences that ScalarEvolution proves do not wrap, in loops that exit only from their header, with every input bounded by the program and not by its type. Anything else is unknown.
  • StackBufferAnalysis consumes it on one scope only: a GEP [0, i] straight on a stack array's alloca. Aliases, struct fields, globals and VLAs keep the heuristics. The pilot answers for each load or store through the address, under the conditions that access runs on:

    Outcome Treatment
    Proven in bounds No report
    Never runs The report stays and gets the "appears unreachable" note
    Range past the bounds "potential" Warning; a solver proof still removes it
    Reached on every run that returns Warning under the new rules StackBufferOverflow.EveryRun and NegativeStackIndex.EveryRun, without "potential"
    Unknown or out of scope The heuristics decide, as before
  • Fix: a store of an element's address is not a write to it. The check took any store using the GEP for an access, including char* end = &buf[10] and the end pointer of a range-based for. Only a load or store whose pointer operand is the GEP counts now. Fixes False positive: StackBufferOverflow on the end of a range-based for over a local array #206.

Diagnostic changes

The analyzer's full output was compared on every fixture, default and smt-z3 passes, against #262's head (c59f696). Diagnostic blocks were compared regardless of their order. The two passes change in the same way.

Seven existing fixtures change. No warning disappears from them.

  • Nine reports are requalified at the same place, from "potential … may go up to N" to "… reaches N on every run". Each is a loop with a constant count whose access runs on every iteration:
    • bound-storage-for-statement.c (2);
    • indirection-profonde-aliasing.c (2; the access through ptr stays potential);
    • ne-loop-guard-last-value.c;
    • ranges_test.c;
    • smt-path-loop-counter-is-kept.c;
    • security/buffer-overflow/01_buffer_overflow.c.
  • One new detection: ranges_test.c:147, for (i = -3; i < 5; ++i) buf[i] = …, writes buf[-3]. The fixture's comment already said so.
  • One detail: bound-storage-if-statement.c reads index 11 instead of 10 on a dead access (i == 11).

One false positive disappears, from the address fix:
offset_of-container_of/range_for_pointer_slot_induction_no_invalid_base.cpp no longer reports the range-for end pointer &cExtensions[2]. That fixture does not pin stack-buffer diagnostics.

Tests

  • 28 fixtures in test/bound-storage/loop-index-bounds/:
    • 22 loop cases, 11 safe and 11 out of bounds;
    • 6 scope cases: an index that may be uninitialized, a trip count bounded only by its type, a loop going below zero, an access that does not run on every iteration, an index left by a previous loop, and an address formed one past the end beside a real write there.
  • test/bound-storage/element-address-is-not-a-write.c covers the end-pointer idiom and an address formed in a loop but written only below the bound.
  • Unit tests:
    • testPromotedValues checks the range, the endpoints, the proof, the lifetime-marker exclusion, and that the original IR keeps its slots, loads and flags.
    • testIndexBoundsPerAccess uses one address shared by three accesses with different guards. Only the two that may run with i == 10 are reported, and only the unguarded one as "on every run". A store of the address alone is not reported.
  • Each guard fails when it should:
    • querying the GEP instead of the access breaks three checks of testIndexBoundsPerAccess;
    • counting the address store breaks one of them and both address fixtures;
    • the new fixtures fail with c59f696.

Verification

  • Full suite: python3 run_test.py --jobs=4 --no-cache passes 2637/2637. It covers both passes and the unit tests, and ctest passes 2/2. Environment: Linux x86_64, LLVM 20.1.8, Z3, the CI's CMake options.
  • Commit 2 alone (the address fix without the pilot) was compared against c59f696 on the 79 fixtures of test/bound-storage and test/offset_of-container_of. Only reports on address stores disappear: the range-for end pointer and those in the new fixtures.
  • Build warnings: none new, with clang 20 (macOS) and GCC (Linux). clang-format is clean.

Cost

Measured per process (CPU time, peak RSS) on the 934 fixture runs, both passes:

c59f696 This PR
CPU total 60.9 s 55.6 s (noise: compilation dominates)
Median peak RSS 99.7 MiB 99.8 MiB
Largest RSS increase +33.6 MiB, param-bad.c with Z3 (the solver checks the new potential report)

On a synthetic module of 3000 functions (macOS, two runs), user CPU rises from 0.97 s to 1.23 s and peak RSS from 290–310 MB to 325–331 MB.

Limits

The six places that report an access repeated the same ten fields and
told loads from stores apart twice. Collect the loads and stores of the
GEP once and fill the common fields in one helper, so the next change
adds a field in one place. The reports are unchanged.
…write

The overflow check counted every store using the GEP as an access to
the array, including a store of the address itself, as in
`char* end = &buf[10]` or the end pointer of a range-based for. Forming
a pointer one past the end is valid, so these reports were false
positives. Only a load or store whose pointer operand is the GEP counts
now.

A write through such a pointer kept in a local variable is no longer
reported. It was reported only by this mistake, on the line that formed
the address: the check does not follow pointers through memory.
PromotedValues answers value questions about the original IR on a copy
of it where promotable locals live in registers, so ScalarEvolution sees
loop counters. The analyzed IR is not changed.

The copy is sanitized so its facts hold for the original program:
- each promotable slot starts from an opaque value, so a read that may
  be uninitialized stays unknown;
- wrap and poison flags, optnone, mustprogress, willreturn and the loop
  metadata are dropped;
- functions with lifetime markers are left out.

It holds the module's declarations, and copies one function body at a
time, on its first query.

Index ranges use the endpoint method: affine recurrences with a no-wrap
ScalarEvolution proves, in loops that exit only from their header, with
every input bounded by the program and not by its type. Anything else is
unknown.
…dexes

On `buf[i]` straight on a stack array, the pilot decides each load or
store through the address whenever the copy establishes the index values
where that access runs, so the access's own conditions count:
- an index proven in bounds gets no report;
- an index range past the end gets a potential report, as before;
- an index the access takes past the end on every run of the function
  that returns gets a Warning under StackBufferOverflow.EveryRun or
  NegativeStackIndex.EveryRun, without "potential";
- an access in a loop that runs no iteration keeps its report and gets
  the unreachable-code note.

The heuristics decide the other accesses, as before.

Seven fixtures change with it. Nine reports on loops with a constant
count become "on every run" at the same place, `buf[-3]` in
ranges_test.c is now found, and a dead access in
bound-storage-if-statement.c reads index 11, not 10.
The 22 loop cases the pilot was built on, one per file: 11 safe loops
and 11 out-of-bounds variants of them. Six more cover its scope: an
index that may be uninitialized, a trip count bounded only by its type,
a loop going below zero, an access that does not run on every
iteration, an index starting from the value a previous loop left, and an
address formed one past the end next to a write that goes there.
bool endpoints(ScalarEvolution& scev, const LoopInfo& loops, const SCEV* value,
const BasicBlock* block, SmallVectorImpl<const SCEV*>& out)
{
const auto* recurrence = dyn_cast<SCEVAddRecExpr>(value);
@SizzleUnrlsd
SizzleUnrlsd merged commit a1f6a7e into main Nov 8, 2026
21 checks passed
@SizzleUnrlsd
SizzleUnrlsd deleted the feat/stack-buffer-index-bounds branch November 8, 2026 20:07
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.

False positive: StackBufferOverflow on the end of a range-based for over a local array

2 participants