Skip to content

fix(stack-buffer): check what callees access through a stack array's address - #267

Merged
SizzleUnrlsd merged 2 commits into
mainfrom
fix/stack-buffer-callee-access
Nov 9, 2026
Merged

SizzleUnrlsd merged 2 commits into
mainfrom
fix/stack-buffer-callee-access

Conversation

@SizzleUnrlsd

@SizzleUnrlsd SizzleUnrlsd commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #265, itself on #264 and #262. The base branch is fix/stack-buffer-access-through-pointer.
Merge this PR right after #265, before any release. With #264 and #265 alone, a write that a callee makes through a stack array's address gets no report (#266).

Summary

#264 removed the misplaced report on the store of &buf[8], and #265 restored the report for accesses in the same function. A write in a callee still got none (#266). This PR checks a call that receives the address of a stack array against what the callee is known to access through it:

void leaf(char* p) { *p = 42; }
void bridge(char* p) { char* q = p; leaf(q); }

void caller(void)
{
    char buf[8] = {0};
    char* p = &buf[8]; // no report: forming the address is valid
    bridge(p);         // reported here: bridge writes 1 byte at byte offset 8, in leaf
}
  • A summary per function (ParamAccessSummary). For each pointer parameter, it records the loads, stores and memory intrinsics of a constant length made through it. Each access is at a constant offset from the address the parameter holds on entry.

    • What the walk follows: GEPs with a constant offset, the variables the pointer is stored in, and the calls it is passed to. A callee's accesses are shifted by the offset.
    • What it keeps: for each parameter, the reads and the writes that reach furthest either way, with the place of the access.
  • Only accesses on every run of the callee count. An access counts when its block post-dominates the entry. An access behind a condition is left out of the summary, whatever the call passes; the guard is not evaluated at the call site. Take write_if, which writes *p only when its second argument is set:

    • write_if(&buf[8], 0) gives no report for that reason;
    • so does write_if(&buf[8], 1), although it writes past the end.
  • A summary lists accesses that happen. It never shows that a call is safe. Nothing is concluded from an access it does not list.

  • The check at the call. It applies when the address is computed from a stack array at a constant offset. The address may reach the call in three ways:

    An access outside the array is reported on the call as a potential overflow. The report gives the bytes, the function that makes the access, and its place. The constant index may be in bounds: &buf[7] passed to a function that writes p[1] overflows.

  • Across files. The summaries follow the pattern of the size-minus-one wrappers: rounds over the modules analyzed together, and publication only for a symbol defined once, by a definition the linker cannot replace. A weak definition and a static homonym publish nothing.

  • Budgets. Offsets past 2^32 bytes, and rounds over a cycle of calls (in a module or across modules) that do not settle within 8, make a summary incomplete. The accesses an incomplete summary lists still happen, and a caller that uses it becomes incomplete too.

  • Reading a variable back goes through FunctionFacts::loadsReadingStore, shared with fix(stack-buffer): check accesses through a local pointer to an array element #265's collectAccesses.

    • Shortcut: a local slot written by that store alone, whose address is only loaded from, needs no MemorySSA. Only the loads the store dominates read it.
    • Any other slot takes the MemorySSA clobber.

Diagnostic changes

The analyzer's full output was compared on every fixture, in the default and smt-z3 passes. Diagnostic blocks were compared regardless of their order.

Tests

  • test/bound-storage/callee-access-write.c and callee-access-read.c, with strict details. They cover the six criteria of StackBufferOverflow: a callee's write past the end of its caller's stack array is not reported #266 for writes and for reads:

    • an address passed without dereference;
    • buf + 7;
    • buf + 8;
    • copies in the caller and a relay that copies again;
    • a pointer reassigned to a large enough object;
    • a guard in the callee that the call turns off.

    They also cover:

    • a call under a guard that may hold;
    • p + 8 and end - 1 from pointer variables;
    • multi-byte reads past the end from a valid start: 4 bytes from buf + 6, and two ints from &values[3].
  • callee-access-calls.c:

    • the parameter moved back one byte in the callee (a write before the array, as a negative index);
    • a copy kept while the parameter changes;
    • mutual recursion, where the write is in the other function;
    • self recursion.
  • check_stack_buffer_callee_cross_tu in run_test.py, in both passes:

    • a chain over three files, in both orders;
    • a missing leaf;
    • a weak definition, alone and beside the strong one;
    • a static homonym.
  • testParamAccessSummary:

    • the slot shortcut against a call that gets the slot's address, an alias that rewrites the slot, and a load before the store;
    • accesses at a variable offset or on some runs only;
    • the offset budget and the round budget, and a summary that uses an incomplete one;
    • byval;
    • across modules: a chain, a weak and a twice-defined function, and a growing cycle.
  • Each guard fails a test when removed:

Verification

  • Full suite: python3 run_test.py --jobs=4 --no-cache passes 2696/2696. It covers both passes, the unit tests and the new cross-TU check, and ctest passes 2/2. Environment: Linux x86_64, LLVM 20.1.8, Z3, the CI's CMake options.
  • The first commit alone (the summaries and their unit tests) builds, and ctest passes 2/2.
  • Build warnings: none new; the only warning is the existing -Wunused-result in the unit tests. clang-format is clean.
  • CI: ci.yml only runs for pull requests into main, so the test jobs run here once the stack is retargeted.

Cost

  • Fixtures (952 runs, both passes, measured per process): CPU total 63.3 s for fix(stack-buffer): check accesses through a local pointer to an array element #265 and 58.8 s here, which is noise. Median peak RSS is 99.8 and 99.9 MiB; the largest RSS increase is +4.5 MiB.
  • A synthetic module of 3000 functions (leaves, relays in one cycle of calls, callers passing stack arrays; three runs each): CPU goes from 1.05 s to 1.08 s, and peak RSS from 132 to 134 MiB.
  • Across files (the audit's benchmark, 4/16/32 files in a cycle of calls, 1 and 4 jobs): times are unchanged, for example 0.797 s and 0.796 s for 32 files with 1 job.
  • What is built, and when:
    • in one file, the summaries are computed on the first call that receives a stack array's address;
    • MemorySSA only for parameter slots with more than one store;
    • the post-dominator tree only for functions with a candidate access.

Limits

  • Accesses on some runs of the callee only are left out of the summary. They are silent because of that exclusion, not because the guard is evaluated at the call site:

    • write_if(&buf[8], 1) writes past the end and gets no report, like write_if(&buf[8], 0), which does not write;
    • the same holds for if (out) *out = x; and for a recursive call under a condition.

    Evaluating the callee's guards with the call's arguments is left for a later change.

  • Variable offsets and lengths are not followed: p[n], memset(p, 0, n), or a length passed as an argument.

  • Pointers that leave the parameter are not followed: through a return value, a char** out-parameter, a struct field, or a function pointer.

  • A caller's guard proven false keeps the report with the "appears unreachable" note, as for a direct access. This PR keeps that policy; how dead code is reported is a separate decision.

  • Objects: stack arrays only, with constant addresses. Global arrays and VLAs are not checked. For an array field reached through a decayed pointer, the bound is the whole struct.

  • A step applied directly to an element address (f(&buf[2] + 6)) is not followed. f(buf + 8) and char* p = &buf[2]; f(p + 6); are.

  • A symbol defined in several files, or weakly, publishes no summary.

  • "On every run" (StackBufferOverflow.EveryRun) is not used for these reports yet. Every report is "potential".

Fixes #266

… parameters

A summary lists, for each pointer parameter of a function, the loads,
stores and memory intrinsics of a constant length made through it, at a
constant offset from the address it holds on entry, on every run of the
function that returns: the block of the access post-dominates the
entry. The walk follows GEPs with a constant offset, the variables the
pointer is stored in, and the calls it is passed to, whose own accesses
it shifts by the offset. For each parameter it keeps the reads and the
writes that reach furthest either way, with the place of the access.
An access on some runs only, at a variable offset, or through a
function pointer is left out: a summary lists accesses that happen, and
never shows that a call is safe. A byval parameter points to the
callee's own copy and gets no accesses.

Summaries are computed callees first, over each cycle of calls in
rounds, and across modules in rounds. A symbol defined once, by a
definition the linker cannot replace, publishes its summary. Offsets
past 2^32 bytes, and rounds that do not settle within their budget,
make a summary incomplete; the accesses it lists still happen.

The variables are read back through FunctionFacts::loadsReadingStore,
the loads that read exactly what a store wrote. A local slot written by
that store alone, whose address is only loaded from, needs no
MemorySSA: only the loads the store dominates read it. Any other slot
takes the MemorySSA clobber, as in #265.

Unit tests cover the slot shortcut against a call or an alias that
rewrites the slot and against a load before the store, the accesses
left out, the budgets, incomplete summaries, byval, and across modules
a chain, a weak and a twice-defined function, and a cycle. Nothing
reads the summaries yet.

Refs #266
…address

Since a store of an element's address stopped counting as a write
(#264), `char* p = &buf[8]; f(p);` got no report when f writes *p, and
#265 only follows the pointer to accesses in the same function.

A call that receives the address of a stack array, computed from the
array at a constant offset, is now checked against the callee's access
summary. The address may reach the call directly, through the
variables #265 follows, or at a constant step from such a variable
(`char* p = buf; f(p + 8);`), whose offset is counted in bytes; the
loads and stores through a step stay with their own GEP. An access
outside the array is reported on the call as a potential overflow,
with its bytes, the function that makes it and its place. The constant
index of the address may be in bounds: `&buf[7]` passed to a function
that writes p[1] overflows. Across the files analyzed together, the
summaries are published like the size-minus-one wrappers, and reach
the calls to declarations.

collectAccesses reads the variables back through
FunctionFacts::loadsReadingStore.

callee-access-write.c and callee-access-read.c cover the six criteria
of #266 for writes and for reads, steps from a variable, and multi-byte
reads past the end from a valid start. callee-access-calls.c covers moves and copies of
the parameter in the callee, a write before the array, and cycles of
calls. check_stack_buffer_callee_cross_tu runs a chain over three files
in both orders, a weak definition and a static homonym.

Fixes #266
Base automatically changed from fix/stack-buffer-access-through-pointer to main November 8, 2026 20:22
@SizzleUnrlsd SizzleUnrlsd reopened this Nov 8, 2026
Comment thread src/analysis/ParamAccessSummary.cpp Dismissed
@SizzleUnrlsd SizzleUnrlsd reopened this Nov 9, 2026
@SizzleUnrlsd
SizzleUnrlsd merged commit db7fb25 into main Nov 9, 2026
33 checks passed
@SizzleUnrlsd
SizzleUnrlsd deleted the fix/stack-buffer-callee-access branch November 9, 2026 03:51
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.

StackBufferOverflow: a callee's write past the end of its caller's stack array is not reported

2 participants