Skip to content

fix(resource-lifetime): report use-after-release from the ownership engine - #270

Open
SizzleUnrlsd wants to merge 6 commits into
mainfrom
feat/ownership-use-event
Open

SizzleUnrlsd wants to merge 6 commits into
mainfrom
feat/ownership-use-event

Conversation

@SizzleUnrlsd

@SizzleUnrlsd SizzleUnrlsd commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

Part of #104 (the UseAfterRelease half; DoubleRelease stays on the counters).

Summary

Use-after-release is now reported by the ownership engine, which tracks resources apart from the variables that hold them. Before this change, it came from per-variable counters: after a release, any read or write of the variable that held the resource counted as a use. As a result:

int* q = p;
free(p);
p = NULL; /* reported as a use-after-release */
*q = 42;  /* missed */

Now p = NULL is not reported and *q = 42 is.

Changes

  • Engine.

    • A new Use event means "the resource src holds must be live here". It does not change the state.
    • releasedUses() lists a use when the resource may be released and is not uncertain. It also lists a use when the resource is released on every path where it exists: a certain release came last, so earlier uncertainty does not matter. Nothing is listed when the solve is incomplete.
    • Handing a resource over changes live resources only: a released resource stays released. Hand-overs are a copy into a global, an out parameter or a heap object, a return, and a call to unknown code. Summaries change for released inputs, so the resource summary cache moves to v6.
    • Summaries also list the arguments that a Use in the function may reach (usedArgs). The summary cache stores them, so a callee in another file gives the same result when its summary comes from the cache. A v6 file without them is a cache miss and is rebuilt.
  • Collector. These operations emit Use:

    • a memory access through the pointer (load, store, atomics, memset/memcpy/memmove);
    • a pointer argument the callee may use:
      • attributes and the library list decide first; a declaration counts;
      • otherwise a call through a pointer, or to a function without a summary, counts;
      • a defined function counts when its summary lists the argument, through a use in its body, on some path or through further calls, even if it also releases the argument;
    • a returned pointer.

    These operations do not emit Use:

    • a copy, including into a global or a heap object;
    • a write into the slot (p = NULL, p = malloc(..));
    • a comparison, including the null test that delete emits;
    • computing an address (p + 1, &p->field);
    • an argument the call only releases: a model rule, or a callee whose summary releases the argument and lists no use of it. An argument that the callee releases only through unknown code still counts.

    An address computed from a pointer carries that pointer's resource when it is copied (p = p + 1, q = &p->field).

  • Diagnostic. ResourceLifetime.UseAfterRelease is reported from the run that already produces MissingRelease. The message and severity are unchanged. The legacy counter path for this rule is removed. DoubleRelease, IncompleteInterproc, ReleasedHandleEscapes and MissingDestructorRelease are untouched.

Known limits

  • An external declaration, a call through a pointer, or a function without a summary counts as a use of its pointer arguments, even when it only stores or compares them.
  • A callee's use of the pointer stored behind a parameter (**pp) is not followed, as before.
  • The analysis is path-insensitive. if (c) { free(p); p = NULL; } if (p) *p = 1; is still reported, as before.
  • A comparison involving a released pointer is not reported.
  • An address computed from a resource pointer and stored in a variable names the whole resource. If a release function were given such an address, the whole resource would count as released. No rule in the shipped model takes one.
  • After p = p + 1 on a live handle that is never released, the leak warning reads "not released in this function" instead of "overwritten while still owned", because p still points into the allocation.
  • A function that releases a parameter and then uses it (free(p); *p = 1;) reports nothing about that use in its own body; its callers see the use at the call. The counters did not report it either: they needed an acquisition in the same function.

Review follow-up

A review compared db7fb25 with the first version of this PR. It found use-after-release reports that the counters made and the engine missed:

  • a use through a defined callee;
  • an access after the pointer was copied into a global;
  • an access after p = p + 1.

A wider probe found the same gap for:

  • a call through a pointer;
  • a callee that uses the argument only on some paths, or passes it to unknown code;
  • an out parameter and a heap object;
  • p++ and &t->b;
  • an access after the pointer was passed to unknown code.

Commits 4 and 5 fix these. Each case has a fixture with a live witness that reports nothing.

A second review found one more case: a callee that uses the argument and also releases it (*p = 42; free(p);, or a release on one path and a write on the other). The release hid the use, so free(p); write_and_free(p); reported only the double release. Commit 6 fixes it, in one file and across files. A callee that only releases the argument is still no use.

Tests

  • Engine unit tests:
    • testUses: a use leaves the state unchanged; an alias after a release is reported and a cleared owner is not; reacquisition; a loop; a release on one path only; uncertainty; an unknown call before and after a certain release; a use on an edge; an unreachable block; an incomplete solve.
    • testUses, hand-overs of a released resource: a global, an out parameter, a resource released on one path only, a return, a call to unknown code.
    • testUsedArgs: a use through a copy on one path, an unreachable use, a use of the function's own resource, and sameSummary.
  • Collector unit tests:
    • the operations that are uses and the ones that are not, plus a returned pointer;
    • calls to a defined function with and without a summary, an indirect call, a callee that hands the argument to unknown code, and p = p + 1;
    • a callee that uses then releases the argument is a use; one that only releases it is not;
    • usedArgs computed from real IR.
  • New fixtures:
    • test/resource-lifetime/use-after-release-alias.c. It covers the alias cases. It also covers the cases that are not uses: a cleared owner, reassignment from a modeled or an unmodeled callee (the DoubleRelease and UseAfterRelease: derive them from the ownership engine #104 acceptance case), and a comparison. Finally, it covers an external call after a release, and a certain release after an external call.
    • test/resource-lifetime/delete-twice-with-destructor.cpp. A second delete that runs a destructor stays a use-after-release as well as a double release.
    • test/resource-lifetime/use-after-release-through-callee.c. It covers calls that write, read, write on one path, pass the pointer to unknown code or to another local function, or go through a pointer. It also covers callees that write then release, or release on one path and write on the other. A callee that only keeps the pointer, or only releases it, is not a use. Live witnesses call the releasing callees too: no use-after-release, only the possible leak the one-path release leaves.
    • check_ownership_cross_tu in run_test.py, with cross-tu-use-then-release-def.c and -use.c. The same releasing callees are in another file, with the same live witnesses. The use-after-release must be reported at the two calls and nowhere else, without and with Z3, on three runs that share a summary cache; the third reads every summary from it.
    • test/resource-lifetime/use-after-release-escape-offset.c. It covers a global, an out parameter, a heap object, unknown code, p + 1, p++ and &t->b. The use is reported at the access, not at the copy or the address computation.
  • Expectation changes:
    • test/security/null-dereference/05_null_deref.c: the two use-after-release expectations, on p = NULL and on *p after it, are replaced by a negative assertion. The null dereference stays pinned.
    • test/resource-lifetime/new-double-delete.cpp, in its own commit: the use-after-release expectation is replaced by a negative assertion. The double release is now pinned at its exact location, 7:5. It was pinned at line 6, which only passed because of the runner's line tolerance.

Diagnostics before and after

This compares db7fb25 with this PR diagnostic by diagnostic (file, function, line, column, rule) on 86 sources. They are every fixture with a resource model, the review's reproducers and a probe file of 15 cases.

  • Removed:
    • the false positives this PR targets: p = NULL, uses of a slot reassigned after the release, and a comparison after a release;
    • 05_null_deref.c lines 37 and 38;
    • new-double-delete.cpp:7:12 (see below);
    • a copy or an address computation after a release (global = p, *out = p, h->q = p, p = p + 1, p++, &t->b); the access that follows is still reported;
    • a call to a callee that only keeps the pointer.
  • Added:
    • uses through a copy (*q after q = p; free(p));
    • uses through a computed address (*b after b = &t->b, *q after q = p + 1; free(p), *p after q = p + 0; free(q)).
  • Changed text: the leak warning after p = p + 1, in two reproducers (see Known limits).
  • Unchanged: the 167 other diagnostics, including every DoubleRelease, 03_use_after_free.c:21 and 18_use_after_free_advanced.c:37.

Commit 6 was compared with the previous head (bb5f1e7) the same way, on the same sources plus a file with the second review's cases. Nothing is removed or changed. The 13 added diagnostics are all in the new cases: 5 use-after-release at the call, the 7 double releases those cases contain, and the possible leak of the live witness whose callee releases on one path only.

new-double-delete.cpp still reports the double release at 7:5. The extra use-after-release at 7:12 came from the null test that delete emits, and it is gone. That expectation is changed in its own commit. delete-twice-with-destructor.cpp still reports both diagnostics, at 15:5 and 15:12.

Results

These results come from a Release build with Z3, in the CI's Linux image (x86_64, LLVM 20.1.8), with 4 CPUs.

  • With all six commits: ctest 2/2 and run_test.py --jobs=4 --no-cache 2760/2760, default and Z3 passes. That is 14 checks more than commit 5 (2746).
  • With five commits: ctest 2/2 and 2746/2746.
  • With the first four commits (engine fix without the collector fix): ctest 2/2 and run_test.py 2714/2714.
  • Commit 6, checked locally (macOS arm64, LLVM 20.1.2, Z3). The collector of commit 5 fails the new unit test, the two new use-after-release expectations of the fixture on both passes, and all six runs of the new cross-file check. Three mutations of commit 6 each make a test fail:
    • every released argument counted as a use;
    • any callee summary counted as a use;
    • usedArgs left out of the cache, which only the third cached run catches.
  • Mutations of commits 4 and 5: 15 mutations, plus the 11 of the first version.
    • 14 made a test fail.
    • The last one, an incomplete callee summary not counted as a use, survived. A collector unit test now fails under it. That test was added after the first Linux run and is part of the 2760.

Out of scope

A double release through an alias (q = p; free(p); free(q);) is still not reported. Only the incomplete-analysis warning appears on q. That belongs to the DoubleRelease half of #104, where the reproducer is posted.

A Use event states that the resource a location holds must be live there. It
leaves the state unchanged, so summaries and the cross-TU cache do not move.
releasedUses() lists the uses whose resource may be released and is not
uncertain, or is released on every path where it exists. An incomplete solve
lists nothing.
…ngine

The rule counted any read or write of a variable after a release as a use, so
`p = NULL` after free(p) was reported and `*q` through a copy was missed.

The collector now emits Use for memory accesses through a pointer, pointer
arguments the callee likely dereferences, and returned pointers. Copies,
writes into the slot, comparisons and the released argument are not uses. The
diagnostic comes from the engine run that already reports leaks. The counter
path for this rule is removed.

05_null_deref.c: the two use-after-release expectations, on `p = NULL` and on
the null dereference after it, become a negative assertion. The null
dereference stays pinned.

Part of #104.
…n delete

The second `delete p` of an int stays a double release, now pinned at its
exact location, 7:5. Its use-after-release came from the null test that
delete emits, which reads only the pointer value. A delete that runs a
destructor still reports both (see delete-twice-with-destructor.cpp).
Copying a released pointer into a global, an out parameter or a heap
object, returning it, or passing it to unknown code marked the resource
Escaped, so a later access through the pointer was no longer reported. A
new owner does not make freed memory live again: these hand-overs now
change live resources only. Summaries change for released inputs, so the
resource summary cache moves to v6.

Summaries also list the arguments that a Use in the function may reach
(usedArgs, kept in memory only), for the collector to read.
…esses

The counters reported these uses after a release; the engine missed them:
- A call to a defined function that reads or writes through the argument,
  on some path or through further calls. The call is now a use when the
  callee's summary lists the argument. A call through a pointer, or to a
  function without a summary, is a use, like a call to a declaration. An
  argument that a callee releases only through unknown code is still a use.
- p = p + 1, p++ or q = &p->field. The copy now carries the resource of the
  pointer the address is computed from, so *p and *q are judged. Computing
  the address is not a use.
…he argument

A callee that releases its argument was never a use, even when it also
accesses it, so `free(p); write_and_free(p);` reported only the double
release. The counters reported the use-after-release as well.

The call is now a use of a released argument when the callee's summary
lists a Use of it, on the same path as the release or on another one. A
model release or a wrapper that only releases stays no use.

The summary cache now stores usedArgs, so a callee in another file gives
the same result when its summary comes from the cache. A cache file
without them is a miss and is rebuilt.
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.

1 participant