Skip to content

Fix heap-use-after-free in offline speaker diarization (likely #3253) - #3826

Open
kairoi-llc wants to merge 3 commits into
k2-fsa:masterfrom
kairoi-llc:fix/diarization-embedding-uaf
Open

kairoi-llc wants to merge 3 commits into
k2-fsa:masterfrom
kairoi-llc:fix/diarization-embedding-uaf

Conversation

@kairoi-llc

@kairoi-llc kairoi-llc commented Jul 29, 2026

Copy link
Copy Markdown

Fixes a heap-use-after-free in offline speaker diarization that has been crashing callers since at least 1.12.x, plus two pieces of hardening on the same fault path. Very likely the root cause of #3253 (System.AccessViolationException from .NET, SIGBUS from Python/macOS) — see the reasoning below.

The bug

OfflineSpeakerDiarizationPyannoteImpl::ComputeEmbeddings() over-allocates the embedding matrix (one row per (chunk, speaker) pair), then compacts it to the rows that produced a usable embedding:

if (k != cur_row_index) {
  auto seq = Eigen::seqN(0, cur_row_index);
  ans = ans(seq, Eigen::placeholders::all);   // aliased self-assignment
}

This is an aliased assignment. Shrinking the row count changes the total size, so the assignment resizes the destination before evaluating the source, and DenseStorage::resize() frees the old buffer and allocates a new one whenever the size changes (Eigen/src/Core/DenseStorage.h). The right-hand side Block still refers to the freed buffer, so the copy reads freed memory.

The branch runs only when at least one embedding was discarded — the case the code immediately above it already anticipates ("The embedding model may output NaN"). So the crash is data-dependent, reproduces on the same audio every time, and is invisible on well-conditioned audio.

ASan, against sherpa-onnx-pyannote-segmentation-3-0 + wespeaker_en_voxceleb_resnet34_LM:

ERROR: AddressSanitizer: heap-use-after-free ... READ of size 16
  #0 Eigen ...::assignPacket<16,0,__simd128_float32_t>   AssignEvaluator.h:712
  #2 OfflineSpeakerDiarizationPyannoteImpl::ComputeEmbeddings   :548
  #3 OfflineSpeakerDiarizationPyannoteImpl::Process             :144
0x62c000000200 is located 0 bytes inside of 30720-byte region
freed by thread T0 here:
  #1 Eigen ...call_dense_assignment_loop   AssignEvaluator.h:821    <-- the resize

Why this is probably #3253

Both reporters there describe symptoms this mechanism predicts, and one of them independently narrowed it to the exact precondition:

"The crash is content-dependent and reproducible on the same audio. Audio ≤ 10s always works; certain segments at 12–15s reliably crash. Same segment works fine if truncated to 10s — the crash is triggered when audio exceeds window_size (160000 samples / 10s) and enters the multi-chunk code path."

That falls straight out of this bug: Process() short-circuits single-chunk audio through HandleOneChunkSpecialCase() and never reaches ComputeEmbeddings(), so only multi-chunk audio can reach the aliased shrink.

It also explains why the crash looks platform-specific. glibc/macOS malloc usually hands the same block straight back, so the read silently succeeds and the bug hides; allocators that quarantine freed chunks (Android's Scudo) fault hard. The AccessViolationException shape in #3253 is what this looks like from a .NET P/Invoke frame.

What's in this PR

Three commits, smallest-blast-radius first:

  1. Fix heap-use-after-free when compacting filtered speaker embeddings — evaluate the selection into a temporary before assigning. This is the crash fix; the other two are hardening.
  2. Do not let a non-finite distance abort clustering — the embedding filter tested std::isnan only, so an ±Inf embedding survived it. FastClustering normalizes every row, which maps inf/inf to NaN; the NaN spreads through the condensed cosine-distance matrix, and hclust_fast() then throws fastclustercpp::nan_error. This switches the filter to !std::isfinite, clamps any residual non-finite distance to maximum dissimilarity, and adds an early return when every embedding was rejected (which would otherwise index &embeddings(0, 0) on an empty matrix).
  3. Do not let exceptions escape the diarization C API entries — the three SherpaOnnxOfflineSpeakerDiarizationProcess* functions had no try/catch, so nan_error could unwind out of a extern "C" boundary; that is UB for non-C++ callers and crashes .NET/JVM frames rather than unwinding. Note these need a bare catch (...): fastclustercpp::nan_error and fenv_error do not derive from std::exception, so the catch (const std::exception &) idiom used elsewhere in c-api.cc is not sufficient on its own.

Validation

Built with -fsanitize=address,undefined on macOS arm64 and run against two batteries with the real models.

7 pathological fixtures (32-bit float WAVs carrying non-finite / ~1e38 sample values, ~39 s each so they take the multi-chunk path):

before after
heap-use-after-free 4 of 7 fault 0 of 7
output all 7 return real speaker turns

Minimal audio repro: a single +inf sample anywhere in a 39-second clip. (f06 faults on some runs and not others on macOS — the allocator sometimes returns the same block, which is exactly why this is easy to miss locally and reliable on Android.)

12 realistic clips (two-speaker, digital silence, clipped, very quiet, DC offset, pure tone, overlapping speech, sparse bursts) — the check that matters for commit 2, since !isfinite rejects strictly more embeddings than isnan:

speaker turns are byte-identical before and after on all 12 clips.

No healthy embedding was non-finite, so the stricter filter costs nothing on real audio. There is also a standalone reproduction of just the aliasing bug that needs only Eigen — no models, no audio, no sherpa sources — if that's useful for a regression test.

How this was found

Reported by a .NET caller on Android arm64: one particular ~39-second clip killed the process on every run, always on the first Process() call of a freshly-constructed engine — which ruled out reuse, disposal and concurrency and pointed at something input-shaped. Config was num_clusters = -1, cluster_threshold = 0.5, pyannote segmentation-3.0 + wespeaker resnet34.

Two notes in the interest of full disclosure:

  • This diagnosis and fix were produced entirely by an AI agent (Claude), including the ASan reproduction, the root-cause analysis, the patch, and the before/after validation described above. A human reviewed and approved submitting it, but did not independently re-derive the analysis. Please review it as you would any unfamiliar contributor's patch — the evidence above is meant to be checkable rather than taken on trust, and I'm happy to supply the fixtures, the standalone Eigen repro, or any of the raw ASan logs.
  • The original crashing audio could not be recovered from that device, so the reproduction is synthesized. The link to that specific crash rests on the symptom match described above, not on a replay of the original input. The fix itself is validated directly.

Summary by CodeRabbit

  • Bug Fixes
    • Improved speaker diarization stability when invalid or non-finite audio embeddings are encountered.
    • Prevented crashes and unexpected failures during diarization processing.
    • Added safer handling for empty or invalid diarization results.
    • Improved clustering reliability by guarding against invalid distance calculations.
    • Strengthened callback-based diarization error handling and reporting.

@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@dosubot dosubot Bot added the size:M This PR changes 30-99 lines, ignoring generated files. label Jul 29, 2026
ComputeEmbeddings() over-allocates the embedding matrix (one row per
(chunk, speaker) pair) and then compacts it down to the rows that produced a
usable embedding:

    ans = ans(seq, Eigen::placeholders::all);

That assignment aliases. Because the row count shrinks, Eigen resizes the
destination before evaluating the source, and DenseStorage::resize() frees the
old buffer and allocates a new one whenever the total size changes. The
right-hand side still refers to the freed buffer, so the copy reads freed
memory.

The compaction only runs when at least one embedding was discarded, which is
exactly the documented "the embedding model may output NaN" case, so the bug
stays dormant on well-behaved audio and then fires deterministically on a clip
that produces a bad embedding. Under the macOS/glibc allocators the freed
block is usually handed straight back and the read silently succeeds; on
Android's Scudo allocator the freed chunk is quarantined, so the read faults
and surfaces as a SIGSEGV inside the diarization call.

Evaluate the row selection into a temporary and move it into place.

AddressSanitizer, sherpa-onnx-offline-speaker-diarization, 39s 16 kHz mono
clip that yields at least one NaN embedding:

  ERROR: AddressSanitizer: heap-use-after-free
  READ of size 16 at 0x62c000000200 thread T0
    #0 Eigen::internal::generic_dense_assignment_kernel<...>::assignPacket
       AssignEvaluator.h:712
    k2-fsa#1 Eigen::internal::call_dense_assignment_loop<...> AssignEvaluator.h:828
    k2-fsa#2 OfflineSpeakerDiarizationPyannoteImpl::ComputeEmbeddings
       offline-speaker-diarization-pyannote-impl.h:527
    k2-fsa#3 OfflineSpeakerDiarizationPyannoteImpl::Process
       offline-speaker-diarization-pyannote-impl.h:144
  freed by thread T0 here:
    k2-fsa#1 Eigen::internal::call_dense_assignment_loop<...> AssignEvaluator.h:821
    k2-fsa#2 OfflineSpeakerDiarizationPyannoteImpl::ComputeEmbeddings
       offline-speaker-diarization-pyannote-impl.h:527

Two related hardening changes in the same function, both on the path that the
crash exposed:

- Reject non-finite embeddings, not just NaN ones. An Inf passes an isnan()
  test but is still fatal downstream: FastClustering normalizes every row,
  which turns inf/inf into NaN, the NaN spreads through the cosine distance
  matrix, and hclust_fast() then throws fastclustercpp::nan_error. Verified
  with a unit probe driving FastClustering::Cluster directly: a single Inf in
  one embedding row produces 7/28 NaN distances for an 8-row matrix and throws.
  (Zero-norm and underflowing rows are safe -- Eigen's normalize() guards
  squaredNorm() == 0 -- so isfinite is the exact boundary.)

- Return early when every embedding was rejected, instead of taking
  &embeddings(0, 0) on an empty matrix.
hclust_fast() throws fastclustercpp::nan_error if the condensed distance matrix
contains NaN. FastClustering::Cluster() builds that matrix from cosine
similarities of L2-normalized embedding rows, so a single non-finite value
anywhere in an embedding poisons every distance involving that row and takes
down the whole diarization call.

The caller now rejects non-finite embeddings, so this is defense in depth, but
it is cheap and it keeps a numerical edge case from turning into a thrown
exception in a library that is mostly consumed through a C API. Clamp any
non-finite distance to 2, the maximum cosine dissimilarity, which makes the
offending row maximally dissimilar to everything else and therefore its own
cluster.
The three SherpaOnnxOfflineSpeakerDiarizationProcess* entry points had no
try/catch, unlike most of the rest of c-api.cc. Letting a C++ exception escape
a C-linkage function is undefined behaviour for non-C++ callers: the unwinder
walks into a frame with no handler and no C++ unwind info, so a .NET or JVM
P/Invoke caller crashes (typically SIGSEGV or a std::terminate abort) instead
of seeing an error return.

This is reachable in practice. hclust_fast() throws fastclustercpp::nan_error
from inside Process() whenever the condensed distance matrix contains NaN.
Confirmed off-device: an extern "C" wrapper around FastClustering::Cluster
built the same way as this entry point dies with

  libc++abi: terminating due to uncaught exception of type
  fastclustercpp::nan_error

Note that nan_error and fenv_error do not derive from std::exception, so
catch (const std::exception &) alone is not enough; a catch-all is required.

Also null-check the handle, matching the other entry points in this file.
@kairoi-llc
kairoi-llc force-pushed the fix/diarization-embedding-uaf branch from 7541d70 to 307cd73 Compare July 30, 2026 00:37
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Speaker diarization now rejects non-finite embeddings, handles empty clustering inputs, avoids unsafe Eigen aliasing, sanitizes invalid distances, and protects C API processing functions with null checks and exception handling.

Changes

Speaker diarization robustness

Layer / File(s) Summary
Numeric validation and clustering guards
sherpa-onnx/csrc/offline-speaker-diarization-pyannote-impl.h, sherpa-onnx/csrc/fast-clustering.cc
Embedding validation rejects NaN and infinite values, empty embedding results return early, Eigen row trimming uses a temporary matrix, and non-finite clustering distances are replaced with 2.
C API failure handling
sherpa-onnx/c-api/c-api.cc
Speaker diarization processing functions validate sd and sd->impl, catch standard and unknown exceptions, log failures, and return nullptr.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

  • k2-fsa/sherpa-onnx#3515: Adds related null-handle guards and defensive handling in the speaker diarization C API.

Suggested reviewers: csukuangfj

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main fix in offline speaker diarization and references the related issue.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@sherpa-onnx/c-api/c-api.cc`:
- Line 3263: Update the three affected guards in sherpa-onnx/c-api/c-api.cc at
lines 3263-3263, 3294-3294, and 3315-3315 to reject null samples when n is
positive, while preserving the existing handle validation and allowing null
samples when n is zero or negative.
- Around line 3317-3320: Update the callback validation in the surrounding
processing entry point to reject a null NoArg callback before constructing or
invoking the wrapper; alternatively, preserve nullness by passing a null
callback through to Process. Ensure callback is never dereferenced when absent,
while retaining the existing wrapper behavior for valid callbacks.
- Around line 3266-3267: Prevent leaks in the three speaker diarization result
construction sites at sherpa-onnx/c-api/c-api.cc lines 3266-3267, 3297-3298, and
3323-3324: use RAII ownership for each newly allocated result while calling the
corresponding Process method, then release ownership only after processing
succeeds and the raw pointer is returned.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 956bac0a-5a03-4204-b825-3dc6b9edf73d

📥 Commits

Reviewing files that changed from the base of the PR and between 88bbc82 and 307cd73.

📒 Files selected for processing (3)
  • sherpa-onnx/c-api/c-api.cc
  • sherpa-onnx/csrc/fast-clustering.cc
  • sherpa-onnx/csrc/offline-speaker-diarization-pyannote-impl.h

int32_t n) {
auto ans = new SherpaOnnxOfflineSpeakerDiarizationResult;
ans->impl = sd->impl->Process(samples, n);
if (!sd || !sd->impl) return nullptr;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Validate samples when n is positive.

The new guards only validate the handle. A null samples pointer with n > 0 reaches internal audio reads and can crash before C++ exception handling applies.

  • sherpa-onnx/c-api/c-api.cc#L3263-L3263: reject !samples && n > 0.
  • sherpa-onnx/c-api/c-api.cc#L3294-L3294: reject !samples && n > 0.
  • sherpa-onnx/c-api/c-api.cc#L3315-L3315: reject !samples && n > 0.
📍 Affects 1 file
  • sherpa-onnx/c-api/c-api.cc#L3263-L3263 (this comment)
  • sherpa-onnx/c-api/c-api.cc#L3294-L3294
  • sherpa-onnx/c-api/c-api.cc#L3315-L3315
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sherpa-onnx/c-api/c-api.cc` at line 3263, Update the three affected guards in
sherpa-onnx/c-api/c-api.cc at lines 3263-3263, 3294-3294, and 3315-3315 to
reject null samples when n is positive, while preserving the existing handle
validation and allowing null samples when n is zero or negative.

Comment on lines +3266 to +3267
auto ans = new SherpaOnnxOfflineSpeakerDiarizationResult;
ans->impl = sd->impl->Process(samples, n);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Avoid leaking the result object on exceptions.

Process() can throw after new succeeds, bypassing deletion of ans. Use std::unique_ptr until successful return, or allocate after processing completes.

  • sherpa-onnx/c-api/c-api.cc#L3266-L3267: hold the allocated result in RAII storage until Process succeeds.
  • sherpa-onnx/c-api/c-api.cc#L3297-L3298: hold the allocated result in RAII storage until Process succeeds.
  • sherpa-onnx/c-api/c-api.cc#L3323-L3324: hold the allocated result in RAII storage until Process succeeds.
📍 Affects 1 file
  • sherpa-onnx/c-api/c-api.cc#L3266-L3267 (this comment)
  • sherpa-onnx/c-api/c-api.cc#L3297-L3298
  • sherpa-onnx/c-api/c-api.cc#L3323-L3324
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sherpa-onnx/c-api/c-api.cc` around lines 3266 - 3267, Prevent leaks in the
three speaker diarization result construction sites at
sherpa-onnx/c-api/c-api.cc lines 3266-3267, 3297-3298, and 3323-3324: use RAII
ownership for each newly allocated result while calling the corresponding
Process method, then release ownership only after processing succeeds and the
raw pointer is returned.

Comment on lines 3317 to 3320
auto wrapper = [callback](int32_t num_processed_chunks,
int32_t num_total_chunks, void *) {
return callback(num_processed_chunks, num_total_chunks);
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Reject a null NoArg callback.

This wrapper is non-null even when callback is null, so processing invokes it and dereferences a null function pointer. Include !callback in the entry validation or pass a null callback through to Process.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sherpa-onnx/c-api/c-api.cc` around lines 3317 - 3320, Update the callback
validation in the surrounding processing entry point to reject a null NoArg
callback before constructing or invoking the wrapper; alternatively, preserve
nullness by passing a null callback through to Process. Ensure callback is never
dereferenced when absent, while retaining the existing wrapper behavior for
valid callbacks.

Comment on lines +47 to +56
// hclust_fast() throws fastclustercpp::nan_error (which is not a
// std::exception) if the condensed distance matrix contains NaN, and
// that exception would unwind out through the C API. A non-finite
// distance can only come from a degenerate embedding, so treat it as
// "maximally dissimilar" instead of letting it abort the whole
// diarization.
if (!std::isfinite(consine_dissimilarity)) {
consine_dissimilarity = 2;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please don't change it unless you can give an example of how this if statement is reachable from our current code.

Comment on lines +581 to +590
// NOTE: `ans = ans(seq, all)` is an ALIASED assignment and is not safe
// here. Assigning an indexed view of `ans` back into `ans` changes its
// number of rows, so the assignment resizes `ans` first, and Eigen's
// DenseStorage::resize() frees the old buffer and allocates a new one
// whenever the total size changes. The right-hand side still refers to
// the old, now-freed buffer, so the copy reads freed memory
// (heap-use-after-free). Evaluate the selection into a temporary first.
Matrix2D valid =
ans(Eigen::seqN(0, cur_row_index), Eigen::placeholders::all);
ans = std::move(valid);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please use

ans.conservativeResize(cur_row_index, ans.cols());

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

Labels

size:M This PR changes 30-99 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants