Skip to content

fix(analysis): resolve calls through constructor and destructor aliases - #190

Merged
SizzleUnrlsd merged 6 commits into
mainfrom
179-calls-through-aliases
Oct 8, 2026
Merged

SizzleUnrlsd merged 6 commits into
mainfrom
179-calls-through-aliases

Conversation

@SizzleUnrlsd

@SizzleUnrlsd SizzleUnrlsd commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

On ELF targets, clang emits a constructor or a destructor defined out of its class (C1, D1) as an alias of the base-object one (C2, D2), and calls the alias. Mach-O makes the same call without an alias. getCalledFunction() and stripPointerCasts() do not look through a GlobalAlias, so every analysis took that call for an indirect one. On x86-64 and AArch64 Linux:

  • the caller's max stack was unknown;
  • StackPointerEscape reported the object as escaping through an "indirect call";
  • the uninitialized-read analysis no longer saw the constructor initialize the object.

Alert 251, on const CallResolver resolver(modules); in StackComputation.cpp, is this case.

Changes

  • One lookup for every callee. directCallee() (IRValueUtils) gives the function a call reaches through pointer casts and aliases. It gives no callee for an interposable (weak) alias, which the link may replace by another definition: its target is not known. Every resolution of a callee now uses it:

    • the stack computation and its call graphs;
    • StackPointerEscape;
    • the uninitialized, const-parameter, resource lifetime and ownership analyses;
    • SizeMinusK, OOB read, integer overflow, TOCTOU, command injection, memory intrinsics, duplicate conditions, null dereference;
    • the never-return pass and the resource summary cache keys.

    The six file-local copies of that lookup, and lifetime_detail::resolveDirectCallee, are removed.

  • Duplicate conditions. They still require a call through a declaration without a prototype to be deterministic elsewhere; that case is now recognized by comparing function types.

  • Never-return pass. It finds callers by scanning the calls of the module, since a call through an alias is a user of the alias, not of the function.

  • Calls across files. CallResolver also indexes the aliases of defined functions, so a constructor defined in another file is reached through its alias.

  • Cache. Following aliases changes the resource summaries, so the resource summary cache moves to schema resource-summary-cache-v5 and key cross-tu-resource-summary-v5. Summaries cached by a previous version are then rebuilt, as for the previous change of the summaries (8420450).

Tests

  • New fixtures:

    • test/escape-stack/ctor-alias-no-escape.cpp: the constructor keeps nothing, so nothing is reported;
    • test/escape-stack/ctor-alias-stores-this.cpp and ctor-alias-passes-this.cpp: the constructor stores this in a global, or passes it to an unknown callback. Both are reported, with their expectations in the files;
    • test/false-positive-repro/ctor-dtor-alias.cpp: a constructor and a destructor through aliases; only the real uninitialized read of v is reported.
  • run_test.py, check_calls_through_aliases:

    • What it runs: these four fixtures, plus a constructor defined in another file than its caller (generated in a temporary directory).
    • Where: x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu and arm64-apple-macosx, with the default and the z3 configurations.
    • What it requires: the same findings on every target, the expected findings, and known max stacks.

    On main 112ffcf, all 10 cases fail on both Linux targets; with the fix, all pass.

    • Weak and plain aliases. On x86-64 and AArch64 Linux only, since clang has no aliases on Darwin:
      • the caller of a weak alias keeps an unknown max stack, with the definition that replaces it (272 bytes) and without it;
      • the caller of a plain alias gets a known one.
  • check_resource_cache_rebuilds_alias_summaries. A wrapper returns what an alias of an acquiring function returns:

    • with an empty cache, its summary acquires (acquire_ret);
    • with that cache rewritten as a previous version left it (previous schema, a wrapper that does not acquire), the run must leave the same summaries as with an empty one. Matching output alone is not enough: the output is the same in both cases.
    • only the files the run writes again are compared: entries of intermediate rounds of the fixed point are not read again.
    • both compared runs read their IR from a compile IR cache of the check's own, filled by a first run. The resource cache keys hash the printed IR, which differs between a fresh compile and the compile IR cache, so CI's --compile-ir-cache-dir had made the two runs use different keys.
  • check_ownership_wrapper_metadata. It now plants the previous schema, v4, and expects the rebuilt summaries under v5.

On 2513b35, the weak-alias cases and both cache checks fail, with and without CI's compile IR cache.

Validation

All local runs are on macOS arm64.
Tested head: a60d109.

  • run_test.py: 2380/2380, both without and with CI's CORETRACE_RUN_TEST_EXTRA_ANALYZER_ARGS=--compile-ir-cache-dir=…. Both unit-test binaries pass.
  • Every fixture compared with main, each alone, in the default and z3 configurations:
    • for the host, 423 units, 0 differences;
    • for x86_64-unknown-linux-gnu, only the four new fixtures change, as described above. 149 units do not compile for that target without a Linux sysroot (system headers), on both sides; the Linux CI runs them natively.

Code scanning

  • Alert 251 (StackPointerEscape on resolver, StackComputation.cpp:1134) is the False positive: StackPointerEscape #179 false positive, dismissed on main. It is no longer reported by this PR's self-analysis on Linux.
  • New alerts: none of this PR's own. Alerts 261 and 262 are main's 248 and 249: the same rule (ConstParameterNotModified.ReferenceRvaluePreferValue), the same message, the same line (parallelFor, AnalyzerApp.cpp:217) and function. They were fingerprinted again because this PR changes AnalyzerApp.cpp.
  • The other alerts listed on this PR already exist on main.

Out of scope

A plain weak function, not an alias, is still taken as the callee by the stack computation, as it is on main: #191.

The resource summary cache misses once when a module's IR changes origin, from a fresh compile to the compile IR cache: its keys hash the printed IR, which differs between the two. This is unchanged from main; the check above works around it.

Merge order

This PR does not conflict with #187 (git merge-tree).

Closes #179

…every target

On ELF targets, clang calls a constructor or a destructor defined out of
its class through an alias (C1 -> C2, D1 -> D2); Mach-O makes the same
call without one. The analyzer takes the alias call for an indirect one
(#179): on x86-64 and AArch64 Linux, an object that only reaches its
constructor escapes, the constructor no longer initializes it, and the
caller's max stack is unknown.

- ctor-alias-no-escape.cpp: the constructor keeps nothing: no finding.
- ctor-alias-stores-this.cpp, ctor-alias-passes-this.cpp: the
  constructor stores this in a global, or passes it to an unknown
  callback: reported, with the expectations in the files.
- ctor-dtor-alias.cpp: a constructor and a destructor through aliases:
  only the real read of an uninitialized local is reported.

run_test.py, check_calls_through_aliases: each of these, and a
constructor defined in another file than its caller, analyzed for
x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu and
arm64-apple-macosx, default and z3: the same findings on every target,
the expected ones, and known max stacks. On main 112ffcf, the 10 cases
fail on both Linux targets.

Refs #179
On ELF targets, clang emits a constructor or a destructor defined out of
its class as an alias of the base-object one and calls the alias.
getCalledFunction() and stripPointerCasts() do not look through a
GlobalAlias, so every analysis took that call for an indirect one
(#179): the caller's max stack was unknown, StackPointerEscape reported
the object as escaping, and the uninitialized-read analysis no longer saw
the constructor initialize it. The same objects were fine on macOS,
which makes these calls without an alias.

directCallee() (IRValueUtils) gives the function a call reaches through
pointer casts and aliases. Every resolution of a callee now uses it: the
stack computation and its call graphs, StackPointerEscape, the
uninitialized, const-parameter, resource lifetime and ownership
analyses, SizeMinusK, OOB read, integer overflow, TOCTOU, command
injection, memory intrinsics, duplicate conditions, null dereference,
the never-return pass and the resource summary cache keys. The six
file-local copies of that lookup and lifetime_detail's go.

CallResolver also indexes the aliases of defined functions: a
constructor defined in another file is reached through its alias, as a
function of that file is.

Refs #179
@SizzleUnrlsd SizzleUnrlsd added the bug Something isn't working label Oct 8, 2026
@SizzleUnrlsd SizzleUnrlsd self-assigned this Oct 8, 2026
…is rebuilt

Review of #190: following aliases with stripPointerCastsAndAliases()
also follows a weak alias, which the link may replace by another
definition. A caller of a weak alias got the max stack of the aliasee,
32 bytes, while the replacing definition alone takes 272. And the
resource summaries change with alias resolution under an unchanged cache
version, so a cache left by main kept the wrapper that did not acquire.

check_calls_through_aliases, on x86-64 and AArch64 Linux (clang has no
aliases on Darwin), default and z3: the caller of a weak alias keeps an
unknown max stack, with the definition that replaces it and without; the
caller of a plain alias gets a known one.

check_resource_cache_rebuilds_alias_summaries: a wrapper returns what an
alias of an acquiring function returns. With an empty cache its summary
acquires; with that cache rewritten as a previous version left it
(previous schema, a wrapper that does not acquire), the run must leave
the same summaries as with an empty one, not only the same output.

check_ownership_wrapper_metadata plants the previous schema, v4, and
expects the rebuilt summaries under the current one, v5.

On 2513b35, the weak alias cases and both cache checks fail; the plain
alias cases pass.

Refs #179
…maries

Review of #190. directCallee() followed every alias with
stripPointerCastsAndAliases(), weak ones included: the link may replace
a weak alias by another definition, so its aliasee is not the callee for
sure. It now follows aliases one at a time and gives no callee for an
interposable one, as for an indirect call. A constructor's C1 alias is
not interposable and is still followed; CallResolver already indexes
only aliases with an exact definition.

Following aliases changes the resource summaries, which a cache written
by a previous version kept: the resource summary cache moves to schema
resource-summary-cache-v5 and key cross-tu-resource-summary-v5, so those
summaries are rebuilt, as for the previous change of the summaries.

A weak function that is not an alias is still taken as the callee; that
predates #190 and is left to its own issue.

Refs #179
check_resource_cache_rebuilds_alias_summaries compared every file of
the cache with those of a run with an empty cache. A run also writes
entries for intermediate rounds of the fixed point, which it does not
read again: on the Linux CI hosts, two of them keep what the test
planted, and the check failed on c69e6dd although the summaries the run
uses were rebuilt.

The check now compares the files that the run with the previous cache
writes under the schema of the run with an empty one: each must equal
that run's file, and one must hold the wrapper's acquisition. On
2513b35, whose schema is the planted one, every planted file counts and
the check still fails.

Refs #179
… cache

The resource summary cache keys hash the IR as printed, which differs
between a fresh compile and a module read from the compile IR cache. CI
runs the suite with a compile IR cache: the run with an empty summary
cache compiled, the run with the previous cache read its IR from the
compile IR cache, and wrote its summaries under other keys than those
planted. check_resource_cache_rebuilds_alias_summaries then failed on
0b0a756 in CI, and does locally with CI's --compile-ir-cache-dir.

The check now gives its runs a compile IR cache of its own and fills it
with a first run, so that both compared runs read their IR from it,
whatever the runner passes. It passes with and without CI's option on
c69e6dd, and fails with and without it on 2513b35.

Refs #179
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

False positive: StackPointerEscape

1 participant