Skip to content

fix(npz): null memcpy on a zero-row Fortran-order .npy#87

Merged
balwierz merged 2 commits into
mainfrom
fix/npy-zero-row-fortran
Jul 25, 2026
Merged

fix(npz): null memcpy on a zero-row Fortran-order .npy#87
balwierz merged 2 commits into
mainfrom
fix/npy-zero-row-fortran

Conversation

@balwierz

Copy link
Copy Markdown
Owner

Found by the .npy fuzz harness from #85. It surfaced on the fuzz smoke step of an unrelated PR (#86) — that step is time-bounded (-max_total_time=40), so it explores a different corpus each run and this had simply not been reached before.

The bug

The per-column gather in build_2d_table sizes its scratch buffer to rows * item_size. At zero rows the buffer is empty, so tmp.data() is null — and the Fortran-order branch then calls memcpy(nullptr, src, 0).

A zero length does not make a null argument legal: both memcpy parameters are declared non-null, so this is undefined behaviour, and UBSan traps it.

main.cpp:9721: runtime error: null pointer passed as argument 1,
which is declared to never be null

This is not only reachable from a crafted file. It is a perfectly legitimate archive:

np.savez(f, a=np.asfortranarray(np.zeros((0, 3))))

The fix

Guard the gather at rows > 0. The C-order branch was already a no-op at zero rows (for r < rows doesn't execute); both are guarded so the intent is explicit rather than accidental.

Verification

  • 379 tests pass, and the same suite reruns green under ASan + UBSan.
  • New fixture tiny.zerorow.npz carries both memory orders (empty_f, empty_c); the suite asserts each tab opens with exit 0 and reports 0 rows. That makes the ASan/UBSan CI job the regression test, rather than depending on the fuzzer reaching the same input again.
  • fuzz_npy is clean over 1.48 M iterations seeded with the minimal repro (a v1.0 .npy, fortran_order: True, shape: (0, 3), no data bytes).

This is the third and fourth-plus finding from the memory-safety pair added in #80/#84/#85, and the same null-memcpy class as the LociSSD v4 empty-block fix in #84.

🤖 Generated with Claude Code

balwierz and others added 2 commits July 25, 2026 13:02
The per-column gather in build_2d_table sizes its scratch buffer to
rows * item_size. At zero rows the buffer is empty, so tmp.data() is null,
and the Fortran-order branch then calls memcpy(nullptr, src, 0). A zero
length does not make a null argument legal — both memcpy parameters are
declared non-null — so this is undefined behaviour, and UBSan traps it:

    main.cpp:9721: runtime error: null pointer passed as argument 1,
    which is declared to never be null

Found by the .npy fuzz harness from #85; it turned up on an unrelated PR's
fuzz smoke step, which is time-bounded and so explores a different corpus
each run. The C-order branch was already a no-op at zero rows; both are now
guarded at rows > 0 so the intent is explicit.

Not only reachable from a crafted file — this is a legitimate archive:

    np.savez(f, a=np.asfortranarray(np.zeros((0, 3))))

Minimal repro (.npy v1.0, fortran_order True, shape (0, 3), no data bytes)
is what the new fixture encodes.

Fixture tiny.zerorow.npz carries both memory orders; the suite asserts each
tab opens with exit 0 and reports 0 rows, so the ASan/UBSan CI job is the
regression test rather than depending on the fuzzer reaching the same input
again. 379 tests pass, and the same suite reruns green under ASan+UBSan.
fuzz_npy is clean over 1.48 M iterations seeded with the repro.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@balwierz
balwierz merged commit 8859d0f into main Jul 25, 2026
3 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.

1 participant