Skip to content

fix: check for end iterator before dereferencing in SparseStructure lookups - #96

Merged
oberbichler merged 1 commit into
masterfrom
fix/sparse-get-index-bounds
Jul 26, 2026
Merged

oberbichler merged 1 commit into
masterfrom
fix/sparse-get-index-bounds

Conversation

@oberbichler

Copy link
Copy Markdown
Owner

Problem

SparseStructure::get_index_bounded and the non-index-map branch of get_index order the guard as:

const auto it = std::lower_bound(lower, upper, j);
if (*it != j || it == upper) { return -1; }   // *it evaluated before the end check

When j is absent and larger than every stored column of a row, lower_bound returns upper. *it is then dereferenced before it == upper is tested. On the last row upper == m_ja.end(), so this reads one element past the m_ja vector — undefined behaviour.

The || it == upper still makes the function return the correct -1, so the result is right; the defect is strictly the out-of-bounds read.

Fix

Swap the operands so the end check short-circuits before the dereference:

if (it == upper || *it != j) { return -1; }

Applied in both get_index_bounded and get_index.

Verification (AddressSanitizer)

The buggy path is not reachable from the Python API — the bound SparseStructure uses TIndexMap = true (hash-map lookup) and get_index_bounded is not exposed — so a standalone ASan harness against the real header is used. Building a SparseStructure<double, int, true, false> with 2 rows / ja = {0, 1} and querying a missing column on the last row:

Before the fix:

==ERROR: AddressSanitizer: heap-buffer-overflow ... READ of size 4
    #0 ... SparseStructure<double,int,true,false>::get_index_bounded(...) SparseStructure.h:121
0x... is located 0 bytes after 8-byte region  (the 2-int ja buffer)

After the fix: no error, and lookups return the expected values (missing → -1, present → correct index).

Test

A contract test (tests/test_sparse_structure.py::test_get_index_missing_column_past_last_row) guards the -1 result for a missing column past the end of the last row. Note: through the Python bindings this exercises the index-map path, so it documents/guards the contract; the out-of-bounds read itself is covered by the ASan harness above. Full suite: 43 passed.

A permanent C++/AddressSanitizer test job would be a sensible follow-up (this bug class is invisible to the pytest suite).

…ookups

get_index_bounded and the non-index-map branch of get_index evaluated
'*it != j' before 'it == upper', so a lookup for a column that is absent
and larger than every stored column of a row dereferenced the result of
std::lower_bound even when it pointed at the end of the range. On the
last row that end iterator is m_ja.end(), i.e. a read one past the
vector -- undefined behaviour (AddressSanitizer reports a
heap-buffer-overflow at SparseStructure.h). The '|| it == upper' still
yielded the correct -1, so the result was right but the read was not.

Swap the operands so the end check short-circuits before the deref.

The buggy path is not reachable from the Python API (the bound
SparseStructure uses the index-map lookup and get_index_bounded is not
exposed), so this is verified with an AddressSanitizer harness against
the header. A contract test for the missing-column-on-the-last-row case
is added to guard the -1 result.
@oberbichler
oberbichler merged commit b5d8aef into master Jul 26, 2026
10 checks passed
@oberbichler
oberbichler deleted the fix/sparse-get-index-bounds branch July 26, 2026 20:42
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