Skip to content

Fix out-of-bounds read in expandSparsityPattern for empty block columns - #1

Open
zfergus wants to merge 2 commits into
MeshFEM:mainfrom
zfergus:fix-empty-block-columns
Open

Fix out-of-bounds read in expandSparsityPattern for empty block columns#1
zfergus wants to merge 2 commits into
MeshFEM:mainfrom
zfergus:fix-empty-block-columns

Conversation

@zfergus

@zfergus zfergus commented Jul 31, 2026

Copy link
Copy Markdown

CSCMatrix::expandSparsityPattern<UniformBlockSize>() detects the presence of a diagonal block with

bool hasDiagonal = AssumeDiagonalExists || (blockHsp.Ai[blockHsp.Ap[block_j + 1] - 1] == block_j);

For a block column with zero entries (Ap[block_j] == Ap[block_j + 1]), this reads the last row index of the previous non-empty column — or Ai[-1] when the empty column precedes any nonzeros. When the stray value happens to equal block_j, hasDiagonal is spuriously true and

size_t colSize = (numBlocks - 1) * N + 1; // numBlocks == 0 → wraps around

underflows to a huge size_t, corrupting the InOrderBuilder's column-size array and causing out-of-bounds writes (we observed intermittent SIGSEGVs inside BlockCSCHessian::toScalar/toEigen).

Why FEM never hits this: every node belongs to an element, so every block column has at least its diagonal block. We hit it using SystemAssembler::blockSparsityPattern for IPC contact Hessians, where the overwhelming majority of block columns are empty (only vertices currently in contact appear).

AddressSanitizer repro (fails before this change, clean after):

SystemAssembler<3> assembler(1000);
auto stencil = [](size_t) {
    ElementBlockVarsWithSizeRange<1, 4> s(2);
    s[0] = 500; s[1] = 501;
    return s;
};
auto H = assembler.blockSparsityPattern(1, stencil); // 998 empty block columns
H->setZero();
auto S = H->toScalar(); // heap-buffer-overflow READ in the counting lambda
SUMMARY: AddressSanitizer: heap-buffer-overflow SparseMatrices.hh:968 in
MeshFEM::CSCMatrix<...>::InOrderBuilder::InOrderBuilder<... expandSparsityPattern<3ul, false>() ...>

Fix: guard the detection on numBlocks > 0 (the filler loop below already iterates the empty range safely).

A possibly-related observation while reading BlockCSCHessian::toScalar: the uniformBlockSize() fast path computes its result and then falls through without returning it —

if (uniformBlockSize()) {
    CSCMat result = this->template expandSparsityPattern<VarStructure::MaxBlockDim>();
    copyValuesInto(result);
} // result discarded; the generic path below runs anyway

so the fast path's work is discarded (it was also how the OOB above got executed even though its result was unused). Possibly a missing return result; — left out of this PR since "fixing" it changes which code path serves all uniform-block-size callers, and you're best placed to judge intent.

🤖 Generated with Claude Code

The diagonal-block detection in CSCMatrix::expandSparsityPattern reads
Ai[Ap[block_j + 1] - 1], which for an empty block column reads the last
entry of the previous column -- or Ai[-1] when the empty column precedes
any nonzeros. When the stray value happens to equal block_j, the column
size computation (numBlocks - 1) * N + 1 underflows with numBlocks == 0,
corrupting the InOrderBuilder's column sizes and causing out-of-bounds
writes (intermittent SIGSEGVs in BlockCSCHessian::toScalar/toEigen).

FE Hessians always have diagonal blocks (every node belongs to an
element), but patterns built from contact stencils are mostly empty
columns -- only vertices currently in contact appear -- which is how
this was found (AddressSanitizer repro: a SystemAssembler<3>
blockSparsityPattern over a single 2-vertex stencil among 1000 block
variables, followed by toScalar()).

Guard the detection on numBlocks > 0; the filler loop below is already
safe for empty columns.
Copilot AI review requested due to automatic review settings July 31, 2026 17:13

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

visitDiagonalScalarEntries walked every block column and took
diagBlockScalarLoc() for each. For an empty block column that offset is
N^2 * Ap[bj] - N^2, which points into the preceding column's storage, or
below the start of Ax entirely when the first block column is empty.

trace() therefore summed unrelated values (and read out of bounds) on any
matrix with empty block columns, and addDiag()/setDiag() wrote into the
wrong entries. This is the same assumption behind the out-of-bounds read
in expandSparsityPattern fixed in the previous commit: FE Hessians always
have a diagonal block per column because every node belongs to an
element, but Hessians assembled from contact stencils leave most columns
empty, since only vertices currently in contact appear.

Skip empty columns while still advancing the scalar column index, so
trace() ignores their (structurally zero) diagonals. The mutating
operations cannot be fixed by skipping, because there is no stored entry
to write, so they now check for the missing blocks and throw instead of
corrupting neighboring columns.

Note that missingRequiredDiagonalBlocks(), and hence
assertSupportsAssembly(), does not catch this: it excludes the
StoreFullDiagonalBlocks case, where diagBlockScalarLoc() is equally
invalid for an empty column. I left that alone rather than widen it,
since assembly itself is unaffected -- the assembler only touches columns
a stencil references, and those always contain their diagonal block.
@zfergus

zfergus commented Jul 31, 2026

Copy link
Copy Markdown
Author

Pushed a second commit to this PR: the same assumption shows up again in visitDiagonalScalarEntries, and I hit it while exercising the fixed toScalar path.

for (_Index bj = 0; bj < n; ++bj) {
    auto cs = columnScanner(bj);
    _Index loc = cs.diagBlockScalarLoc();   // <-- for an empty column?

diagBlockScalarLoc() is scalarOffsetForColumn(bj + 1) - diagBlockSize(), which for an empty block column is N²·Ap[bj] - N². That points into the preceding column's storage, or below the start of Ax when the first block column is empty.

The visible symptom was trace(). On a contact Hessian I get 1951.93 where the equivalent dense trace is 447.82, because empty columns re-count the previous column's diagonal. apply() on the same matrix matches Eigen exactly, which is what narrowed it down: the values are fine, only the diagonal offsets are wrong. addDiag() and setDiag() write to those same bad locations.

The commit skips empty columns while still advancing the scalar column index, so trace() ignores their structurally-zero diagonals. Skipping is not a fix for the mutating operations, since there is no stored entry to write at all, so those now check and throw rather than corrupting a neighboring column.

One thing I deliberately did not change: missingRequiredDiagonalBlocks() excludes the StoreFullDiagonalBlocks case, so assertSupportsAssembly() never fires for these matrices even though diagBlockScalarLoc() is equally invalid there. Widening it would make setZero() throw for legitimately sparse patterns, and assembly itself is fine, since ElementHessianContribAssembler only touches columns that a stencil references and those always contain their diagonal block. Happy to widen it if you would rather the precondition be strict, but it seemed like it would break working uses.

Both fixes are validated through the IPC Toolkit's block-CSC assembly backend (ipc-sim/ipc-toolkit#246): trace() now agrees with the dense trace to 1e-12, addDiag() throws as expected, and the apply() round-trip against Eigen is unchanged.

🤖 Addressed by Claude Code

zfergus added a commit to ipc-sim/ipc-toolkit that referenced this pull request Jul 31, 2026
Adds MeshFEMHessianAssembler::block_matrix(), which returns the assembled
matrix in MeshFEMSparse's native block-CSC form so a downstream user can
feed it to MeshFEM's block SpMV or Cholesky factorizers instead of paying
for the Eigen conversion (0.11 vs 0.30 ms on bunny, 42.8 vs 51.9 ms on
puffer-ball). MeshFEM::BlockCSCHessianBase is forward declared, so our
header still does not pull in MeshFEMSparse's; callers that want the
block matrix include <MeshFEMSparse/BlockCSCHessian.hh> themselves and
everyone else pays nothing.

Binds assemble_hessian, HessianAssembler, TripletHessianAssembler, and
MeshFEMHessianAssembler to Python, so Python callers can now hold an
assembler across iterations and get pattern reuse (previously they were
limited to the cold path inside hessian()). All three classes are
py::is_final(): a Python-defined assembler would take the GIL once per
collision, which is hundreds of thousands of times per assembly on the
larger scenes.

Exercising block_matrix() turned up a third instance of the empty-block-
column assumption upstream, in visitDiagonalScalarEntries, which made
trace() read the preceding column's storage (1951.93 against a dense
trace of 447.82) and addDiag()/setDiag() write to the wrong entries.
Fixed in the pinned fork commit alongside the other two
(MeshFEM/MeshFEMSparse#1); the tests now cover trace() agreement and that
addDiag() rejects a pattern with missing diagonal blocks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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