Skip to content

tacasv2b: --slice for memtrack/memcleanup, and two correctness fixes - #70

Merged
hbgit merged 42 commits into
developfrom
feat/tacas-slicing-mem
Sep 28, 2026
Merged

hbgit merged 42 commits into
developfrom
feat/tacas-slicing-mem

Conversation

@GuilhermeBn198

Copy link
Copy Markdown
Collaborator

Summary

tacasv2b makes --slice work for --memtrack and --memcleanup-property. It also fixes two correctness defects that the evaluation of this slicing exposed, and adds an SV-COMP MemSafety evaluation harness.

This PR builds on #68 (tacasv2a) → #69 (tacasv1), and the base is develop so the CI runs. Until #69 and #68 are merged, this diff also shows their commits. Merge order: #69, #68, then this PR.

  • Spec: docs/superpowers/specs/2026-09-27-tacasv2b-slicing-memsafety-design.md
  • Plan: docs/superpowers/plans/2026-09-27-tacasv2b-slicing-memsafety.md
  • Every measurement round, with its comparison against the previous one: docs/reports/tacas-experiment-log.md (R6–R14)

What changed

  • Slicing after instrumentation. In the memory modes, the slicer runs on the instrumented module (<hash>-output.bc), between callPass and linkLLVM. It does not run before instrumentation there, because the property lives in the runtime calls that MemoryTrackPass inserts. The criteria are:

    • every map2check_* call, so no check can be removed;
    • every __VERIFIER_nondet_* call;
    • every external function (declared, no body);
    • the memory intrinsics llvm.memcpy, llvm.memset and llvm.memmove.

    The entry point is __map2check_main__. The slicer invocation is shared with reach/assert through a private Caller::runSlicer.

  • Refused when unsafe. If the instrumented module's runtime calls cannot be read, slicing is refused and the whole program is analysed. A slice without them as criteria would remove every check.

  • Harness.

    • build_corpus.py gains --property memsafety|memcleanup. Categories are directory lists that mirror the SV-COMP sets, and each task carries its expected verdict and subproperty.
    • A new tests/memsafety/run_memsafety_evaluation.sh classifies each result as correct/wrong TRUE, correct/wrong FALSE (a FALSE counts only with the task's subproperty), unknown or error. The classifier has its own table test.
    • The CASTLE and Juliet runners accept EXTRA_FLAGS.

Correctness defects the evaluation exposed (fixed, with tests that failed first)

  1. External calls were sliced away, giving a wrong TRUE.
    • A memory error can happen inside strcpy, memcpy or a lowered intrinsic. Nothing the runtime checks depends on that call, so the slicer removed it along with the bug.
    • Measured: CASTLE-787-2, and in R10 Juliet FN went from 117 to 169.
    • External functions and memory intrinsics are now criteria.
  2. KLEE halting on its own --max-time reported TRUE. This bug predates this work (tacasv1, v15).
    • A halted run exits 0, exactly like a complete exploration. If one short path had written NONE, the verdict was TRUE: a reachable null dereference came back TRUE with no slicing at all.
    • Slicing exposed it on memsafety-cve frr.i and pacparser.i.
    • A halted KLEE now counts as a timeout: a recorded violation is kept, anything else is UNKNOWN.

Results: consolidated round R14 (both fixes, same build, control × slice)

corpus control slice
CASTLE (119) TP 53, FN 14, FP 1 identical
SV-COMP MemSafety (50) 33 correct, wrong-true 2, wrong-false 3 33 correct, wrong-true 1, wrong-false 4
SV-COMP MemCleanup (10) 6 correct identical; median time 14 s → 8 s
Juliet scope C (842) TP 141, FN 117 TP 141, FN 117 (R10 without the fixes: 169)
  • Reading: with the fixes, slicing introduces no new wrong TRUE. On these small programs it is neutral on correct answers.
  • The individual changes:
    • Gains:
      • Juliet: 2 UNKNOWN → TP.
      • MemSafety: CWE122 …rand_18_bad went from TRUE (wrong) to FALSE-DEREF (correct).
    • Losses:
      • MemSafety csplit: correct → TIMEOUT.
      • Juliet CWE-121: 10 cases → ERROR/UNKNOWN. These were accidental detections: the out-of-bounds memcpy clobbered a neighbouring pointer, and the slice changes the stack layout.
    • busybox sleep-3 became FALSE-MEMTRACK. This is a pre-existing memtrack false positive, shown in R14 to occur without slicing too. It surfaces here only because the slice lets KLEE link.
  • R14b re-runs the MemSafety slice arm with the final build, which adds intrinsics as criteria; see the log.

Test plan

  • Integration tests/integration/test_testcomp_regressions.sh: 30 sections, all passing on an idle machine. Sections 10 and later cover this work:
    • detection kept for double free and leak;
    • no invented violation;
    • VLA fallback;
    • external call (strcpy);
    • halted KLEE;
    • memcpy intrinsic.
  • ctest 10/10: SlicerTest 16, KtestReaderTest 21.
  • tests/integration/test_memsafety_classifier.sh: 14/14.
  • Evaluation rounds R8–R14 and R14b, recorded in docs/reports/tacas-experiment-log.md.
  • Whole-branch review by a fresh reviewer. Its 3 Important findings are fixed (intrinsics, unsafe fallback, the Juliet classifier); the Minors are listed below.
  • CI

Known limitations (deferred)

  • MemoryTrackPass does not instrument LLVM 16 memory intrinsics. It matches the old typed names (llvm.memcpy.p0i8…), so memcpy/memset are never checked, with or without slicing. This predates this work and deserves its own fix; it changes the baseline.
  • Other ways KLEE stops early (memory cap, solver timeouts) can still read as complete.
  • The hybrid re-slices in every phase, which spends budget.
  • Some integration tests only assert "not TRUE".
  • Slicing is not sound with respect to non-termination. dg's standard control dependence is termination-insensitive.

🤖 Generated with Claude Code

GuilhermeBn198 and others added 30 commits September 25, 2026 22:34
Found by the final whole-branch review and confirmed by the Task 7 smoke
test in the rebuilt dev image.

- NonDetGeneratorAFL.c: define the persistent-mode macros exactly as
  afl-cc 4.40c injects them. The library is compiled to bitcode by plain
  clang, so without them every build (including KLEE-only and CI) failed,
  and afl-clang-fast never re-preprocesses the linked -result.bc, so the
  ##SIG_AFL_PERSISTENT## marker must already be in the bitcode.
- Caller: replay crashes from afl-out/default/crashes (the single
  instance's directory), feeding each over stdin (a standalone persistent
  binary ignores argv), bounded by timeout, and stop at the first
  confirmed one so a non-reproducing replay cannot overwrite it.
- Caller: set the headless AFL_* flags on the command line rather than
  relying on the image ENV; record crashing seeds as crashes and stop at
  the first crash, as the previous fuzzer did.
- Caller: use a private afl-in/ unless --seed-exchange, and copy the
  AFL++ queue back into seeds/ under it (afl-fuzz never writes into -i).
- tools.hpp: MAP2CHECK_AFL_CC / MAP2CHECK_AFL_FUZZ overrides; AFL_CC is
  AFL++'s own variable and reusing it recursed or disabled instrumentation.
- Regression test: --nondet-generator afl; stale comments updated.

Verified in map2check-dev:aflpp: persistent + shared-memory mode detected,
VERIFICATION FAILED on the plan's smoke program and on a non-trivial one,
test_testcomp_regressions.sh 19/19, ctest 9/9.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Cap each crash replay at max(5s, 10% of the budget): a crash that does
  not reproduce from a fresh process may loop, and must not eat KLEE's
  share.
- Select crash/queue files by type (regular, not README.txt) rather than
  by AFL++'s "id:" naming, which AFL_SHA1_FILENAMES changes.
- Warn when the AFL++ input dir or placeholder seed cannot be prepared.
- Document that seeds/ does not yet survive into the next hybrid phase:
  each Caller recreates the scratch directory. Inherited unchanged from
  v15; fixing it changes what the hybrid measures, so it belongs to the
  smart-seeds work.
- Regression test: count only the fuzzer's copied discoveries (afl-*),
  since the placeholder seed made the old file count pass trivially.

Verified in map2check-dev:aflpp: test_testcomp_regressions.sh 19/19
(4 afl-* discoveries copied), ctest 9/9.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The v15 LibFuzzer runs with -use_value_profile=1, which solves
comparison guards such as `x == 123456`. AFL++ with PCGUARD alone does
not, so the tacasv1 comparison measured an unarmed AFL++ against an
armed LibFuzzer. CmpLog (input-to-state) is AFL++'s counterpart.

- Caller compiles a third binary, <hash>-cmplog.out, from the same
  -result.bc with AFL_LLVM_CMPLOG=1 and passes it to afl-fuzz with -c.
  Optional: if it does not build, the fuzzer runs without it and says so.
- NonDetGeneratorAFL.c rewinds its read position for every __AFL_LOOP
  iteration. The position was function-static and carried over between
  inputs, so the same input read different values on every persistent
  run. That nondeterminism defeats CmpLog's byte-to-operand mapping.
  This reverses the earlier choice to preserve the index for v15 parity;
  without the reset CmpLog has no effect.

Fuzzer-only smoke, 3 runs x 8 seeded bugs, same image:
  v15 LibFuzzer 17/24 | AFL++ + CmpLog, index kept 4/24 |
  AFL++ + CmpLog, index rewound 17/24.

- Regression test: the KLEE -> fuzzer export check retries when the
  (now stronger) fuzzer phase solves seed.c first and KLEE never runs.
- Spec: addendum §2.1 with the decision and the numbers.

Verified in map2check-dev:aflpp: test_testcomp_regressions.sh 19/19
(twice), ctest 9/9.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
afl-fuzz -V compares gettimeofday() readings. A wall clock stepped
backwards -- measured at 1.1s within 20s under WSL2 -- underflows the
elapsed time, and afl-fuzz stops after a few hundred executions
("Time limit was reached", run_time ~2^64). `timeout` uses a relative
timer, and it is how the previous fuzzer was bounded. The budget is
also clamped to at least 1s, since `timeout 0` means no limit.

Spec §2.1 updated with the final fuzzer-only smoke (3 runs x 8 bugs):
v15 LibFuzzer 20/24, AFL++ + CmpLog with the rewound index 17/24,
without it 8/24. Hybrid verdicts identical to v15 on all 11 programs.

Verified in map2check-dev:aflpp: test_testcomp_regressions.sh 19/19,
ctest 9/9.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…input

621db93 downgrades a KLEE violation to UNKNOWN when no input vector
can be recovered, to stop FAILED verdicts that nothing reproduces. It
asked readViolatingKtest for a non-empty vector, so a program that
reads no nondeterministic input -- whose aborting path has a .ktest
with zero objects -- was downgraded too, and its suite came out with
no test case. That is tests/testcomp/programs/no_input.c, and it has
failed the TestCov CI gate on develop since that commit landed
(run 33523637266).

hasViolatingKtest counts an .abort.err whose .ktest exists, empty or
not; the downgrade now uses it. The halted-KLEE case 621db93 guards
against still has no .abort.err at all, so it is still downgraded.

Verified in both the current CI image (ghcr map2check-dev:latest) and
map2check-dev:aflpp: run_testcov_suite.sh 6/6 (no_input.c COVERED),
test_cover_branches.sh 5/5, test_benchexec_toolinfo.py 16/16,
test_testcomp_regressions.sh 19/19, ctest 9/9 (3 new KtestReader tests).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… suite valid

Diagnosis on 12 tasks the v15 slice arm lost, with the tacasv1 build:
the slicer's cutoff inserts exit(0) without !dbg and KLEE aborts on a
broken module; and nondet calls the slicer drops misalign the suite on
the original program. Cutoff off + nondets as criteria: 6/12 -> 10/12.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The cutoff's exit(0) carries no !dbg and KLEE rejected every sliced
module with a cut path; nondet reads the slicer dropped shifted the
suite on the original program. Both are integration-tested now, and the
slice is logged in functions/blocks/instructions, not only bytes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Control 12/12, slice 12/12 on the 12 tasks the v15 slice arm lost.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The fixed list cannot know every __VERIFIER_nondet_* a benchmark
declares (int128, uint128, ...), and a missing name silently brings the
shifted suite back. The slicer input is disassembled with opt -S and
every nondet symbol joins the criteria; the fixed list is the fallback.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Branch naming follows feat/** instead.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
GENERATOR=fuzzer still passed --nondet-generator fuzzer, which the CLI
no longer accepts. It now names AFL++, and the old value is refused
with a pointer to the new one rather than silently measuring nothing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…nstrumentation

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ation

The slicer invocation is shared by a private Caller::runSlicer; the new
sliceInstrumented() slices <hash>-output.bc with every map2check_* call
and every nondet read as criteria, entry __map2check_main__, between
callPass and linkLLVM. Integration sections 17-20 cover detection kept
(double free, leak), no invented violation, and the fallback on a VLA.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
GuilhermeBn198 and others added 11 commits September 27, 2026 12:50
build_corpus.py gains memsafety/memcleanup (directory categories that
mirror SV-COMP's sets, expected verdict and subproperty per task); the
new run_memsafety_evaluation.sh scores each verdict into correct/wrong
true/false, unknown or error, a FALSE counting only with the task's
subproperty. The classifier has its own table test.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A memory error can happen inside an external function: CASTLE-787-2
overflows a stack buffer in strcpy. No map2check_* call depends on that
call and the slicer treats external functions as only reading their
arguments, so it was dropped -- and the run answered TRUE (R8; same
pattern in R9, memsafety-cve frr/pacparser). Every declared-but-undefined
function is now a criterion in the post-instrumentation modes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
KLEE stopping on --max-time exits 0, exactly like a run that explored
every path. With one short path having written NONE to the property
file, a program with a reachable null dereference came back TRUE.
Pre-existing (tacasv1, v15); slicing exposed it by letting KLEE reach
its own timer on memsafety-cve frr.i and pacparser.i (R9). A halted run
now counts as a timeout: a recorded violation is kept, anything else is
UNKNOWN.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…licing

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… calls

From the final review of 2b+2c:
- clang lowers memcpy/memset/memmove to llvm.mem* intrinsics; an
  overflowing copy into a buffer nothing reads fed no criterion and was
  sliced away -- TRUE for an overflow (integration section 23).
- if the instrumented IR could not be read, the slice ran with only the
  nondet criteria and removed every runtime check; it now refuses.
- sv-benchmarks' Juliet_Test MemSafety tasks declare no subproperty; the
  corpus marks them 'any' and the classifier accepts any memory FALSE.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@GuilhermeBn198 GuilhermeBn198 mentioned this pull request Sep 28, 2026
4 tasks
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@GuilhermeBn198

Copy link
Copy Markdown
Collaborator Author

R14b (memory slice arm re-run on the final build, with intrinsics as criteria): SV-COMP MemSafety 34 correct vs 33 in the control, wrong-TRUE 1 vs 2, no new error attributable to slicing (the remaining new wrong-false, busybox sleep-3, is a pre-existing memtrack false positive — see the log). MemCleanup identical, median time 14 s → 7 s. Details: docs/reports/tacas-experiment-log.md, section R14b.

@hbgit
hbgit merged commit 2376330 into develop Sep 28, 2026
10 checks passed
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.

2 participants