on this page
CVE: CVE-2026-124959 (VU#681158.1) · CERT/CC Case: VU#681158: hnswlib and Voyager HNSW Index Deserialization Heap Out-of-Bounds Write (VINCE #4191)
Summary: The loadIndex function in nmslib/hnswlib and Spotify Voyager does not verify that the element count (cur_element_count) in a serialized HNSW index is within the stored capacity (max_elements_) used to allocate the heap buffer. An attacker can supply a crafted index file with an element count greater than the stored capacity, causing the loader to write beyond the allocated buffer and crash or corrupt the application when it loads the file.
An HNSW index is a saved map for vector search. It contains vectors, graph links between them, and enough header metadata for a program to rebuild the in-memory search structure without indexing the corpus again.
Two numbers in that header must agree:
max_elements_: how many elements the destination buffer has room for (the capacity).cur_element_count: how many elements are actually stored in the file (the count).
For any well-formed index, the count cannot exceed the capacity:
cur_element_count <= max_elements_
At the revisions reviewed for CVE-2026-124959 (CERT/CC Case VU#681158, VU#681158.1) (nmslib/hnswlib commit d9b3608, v0.9.0, and spotify/voyager commit 2a2f1f1), as well as current upstream HEAD as of 2026-11-05 (nmslib/hnswlib ca42672, v0.10.0-rc.2, and spotify/voyager 72de126), neither loader enforced that relationship inside loadIndex / loadIndexNoExceptions. Each loader can allocate its level-0 heap buffer using the smaller max_elements_ capacity and then populate that buffer using the larger cur_element_count. When an index file declares max_elements_ < cur_element_count, the deserialization path writes past the end of the heap allocation (CWE-787: Out-of-bounds Write).
1. What the File Header Declares
Hierarchical Navigable Small World (HNSW) indexes organize vectors into a multi-layer proximity graph. Level 0 holds the dense per-element records (links, vector payload, and label); upper layers hold sparse routing links for fast approximate nearest-neighbor traversal.
When saving an index to disk, saveIndex writes fixed-width binary header fields before streaming the level-0 buffer and per-element link lists. During deserialization, loadIndex reads:
max_elements_: the serialized capacityMcur_element_count: the serialized element countCsize_data_per_element_: the byte strideSof each level-0 element record
The relationship C <= M is not merely a bookkeeping convention. It governs whether the destination buffer allocated on the heap is large enough to hold the bytes read from the stream:
capacity M from file (max_elements_)
count C from file (cur_element_count)
stride S from file (size_data_per_element_)
allocate: M * S bytes
read: C * S bytes into that allocation
memory-safe only when: C <= M (and M * S does not overflow size_t)
Crucially, a crafted file can genuinely contain all C element records on disk and still be unsafe to load. File-length validation proves that the source stream has enough bytes to read; it does not prove that the destination heap buffer has enough space to receive them.
Read-side checks validate the stream. They do not validate the heap buffer.
Adjust the serialized capacity (M) and element count (C), or pick a preset, to see why the loaders' read-side checks pass while the level-0 bulk read overflows the heap.
The file genuinely contains all 512 element records (65,536 B) after the header, so every read-side check passes. None of them compares C with M:
- ✓ Link lists walk to exact EOF (hnswlib and Voyager)
- ✓
offsetLevel0_fits inside file size (Voyager only) - ✓
enterpoint_node_ < cur_element_count(Voyager only) - ✓
size_data_per_element_ == 128matches space (Voyager only; a stride check, not a length check) - ✓ Stream delivers full
C × 128bytes (Voyager only)
malloc(M × 128) allocates 256 B, then input.read(buf, C × 128) writes 65,536 B total, of which 65,280 B is out of bounds:
AddressSanitizer: heap-buffer-overflow WRITE of size 65536 (0 bytes after 256-byte region) 2. Where the Loader Trusts the Wrong Field
nmslib/hnswlib (hnswlib/hnswalg.h, d9b3608 and ca42672)
In hnswlib, the Python load_index binding (python_bindings/bindings.cpp:969 at d9b3608, line 971 at ca42672) passes a default caller capacity of max_elements_i = 0. Inside HierarchicalNSW::loadIndex (hnswalg.h:728-733,776,779 at d9b3608; loadIndexNoExceptions at hnswalg.h:862,865 at ca42672), the loader reads both max_elements_ and cur_element_count from the file, sees that the caller-supplied 0 is smaller than cur_element_count, and falls back to the file’s stored max_elements_. It never checks whether the file’s max_elements_ is itself smaller than cur_element_count:
readBinaryPOD(input, max_elements_);
readBinaryPOD(input, cur_element_count);
size_t max_elements = max_elements_i;
if (max_elements < cur_element_count)
max_elements = max_elements_;
max_elements_ = max_elements;
// Allocates M * S bytes from max_elements:
data_level0_memory_ = (char *) malloc(max_elements * size_data_per_element_);
// Reads C * S bytes from cur_element_count into data_level0_memory_:
input.read(data_level0_memory_, cur_element_count * size_data_per_element_);
Immediately after the level-0 bulk read into data_level0_memory_, loadIndex also allocates linkLists_ (sizeof(void*) * max_elements) and element_levels_ (sizeof(int) * max_elements) using max_elements, then iterates for (size_t i = 0; i < cur_element_count; i++), writing into both arrays and populating label_lookup_. The level-0 bulk read is simply the first out-of-bounds write reached.
spotify/voyager (cpp/src/hnswalg.h, 2a2f1f1 and 72de126)
Spotify’s Voyager descends from hnswlib and retains the same capacity fallback in loadIndex. In cpp/src/TypedIndex.h, the load constructors (lines 153, 169, and 185), which back Python voyager.Index.load (python/src/bindings.cpp:1020,1100), hard-wire 0 for max_elements_i when invoking loadIndex.
Voyager is particularly instructive because its authors added five defensive validation checks around the inherited hnswlib loader (cpp/src/hnswalg.h:732,753,779,826,844):
- Checking level-0 byte offsets against the total file size (
:732). - Bounding
enterpoint_node_againstcur_element_count(:753). - Pinning
size_data_per_element_to the configured distance space (:779). - Walking the upper-layer link lists for structural consistency and requiring exact EOF alignment (
:826). - Verifying the byte count returned by
inputStream->read(...)(:844).
Every single one of those five checks validates the read side of the operation, confirming that the input stream contains at least C * S bytes. None of them checks whether C <= M before data_level0_memory_ is allocated for M * S bytes (cpp/src/hnswalg.h:834, written at :843).
Even more strikingly, both libraries already enforce this exact invariant inside resizeIndex (hnswlib/hnswalg.h:633-635 at d9b3608, resizeIndexNoExceptions at :671-673 at ca42672; Voyager cpp/src/hnswalg.h:660-664), which rejects any attempt to resize an index below cur_element_count. Only loadIndex omitted the check.
3. Control vs. Crafted Reproduction on the Real Loader
To reproduce with the real library loader, build an index with hnswlib or voyager itself (512 points, 21 float dimensions, M_links = 4, which gives a 128-byte level-0 record stride S = 128), save it, and overwrite the serialized max_elements_ header field (little-endian u64 at byte offset 8 in hnswlib, or offset 27 after the 19-byte V1 metadata header in voyager) from 512 to 2. All 512 * 128 = 65,536 level-0 payload bytes and link lists remain valid on disk, so every read-side EOF check passes:
- Controls: two legitimate indexes, each built and saved by the real library. The first is the unmodified 512-point index above (
M = 512, C = 512, S = 128): it allocates and reads65,536bytes. The second is a separately built small index (M = 16, C = 8, S = 128): it allocates2,048bytes and reads1,024. Both load cleanly (exit 0) onhnswlibandvoyagerin every build we ran: the pinned revisions under ASan, andHEADandHEADplus the fix below under both ASan and release builds. - Crafted (
M = 2, C = 512, S = 128): Allocates2 * 128 = 256bytes and reads512 * 128 = 65,536bytes into that buffer. Under release builds (-O2 -DNDEBUGwithout sanitizers), both libraries abort in glibc withmalloc(): corrupted top size(exit 134). Under AddressSanitizer atv0.9.0(d9b3608),HierarchicalNSW<float>::loadIndexreports:
==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x... at pc 0x... bp 0x... sp 0x...
WRITE of size 65536 at 0x... thread T0
#0 ... in read
#3 ... in std::istream::read(char*, long)
#4 ... in hnswlib::HierarchicalNSW<float>::loadIndex(...) hnswlib/hnswalg.h:779:15
#5 ... in hnswlib::HierarchicalNSW<float>::HierarchicalNSW(...) hnswlib/hnswalg.h:85:9
0x... is located 0 bytes after 256-byte region [0x...,0x...)
allocated by thread T0 here:
#1 ... in hnswlib::HierarchicalNSW<float>::loadIndex(...) hnswlib/hnswalg.h:776:40
SUMMARY: AddressSanitizer: heap-buffer-overflow ... in read
At nmslib/hnswlib v0.10.0-rc.2 (ca42672), the identical WRITE of size 65536 occurs inside loadIndexNoExceptions at hnswlib/hnswalg.h:865:15 (allocated at hnswalg.h:862:40), and at spotify/voyager main (72de126) inside HierarchicalNSW<float>::loadIndex at cpp/src/hnswalg.h:843:40 (allocated at cpp/src/hnswalg.h:834:35).
4. Trust Boundary & Deployment Impact
A process that only loads HNSW indexes it generated itself from local, integrity-protected storage does not expose loadIndex to untrusted input.
The vulnerability matters whenever a serialized HNSW index crosses a trust boundary:
- Retrieval-augmented generation (RAG) pipelines or vector search services that import prebuilt
.hnsw/.voyindex artifacts from external buckets or model hubs. - Multi-tenant search services that load per-tenant or user-uploaded index snapshots.
- Embedded desktop or CLI tools that open shared project workspaces containing precomputed vector indexes.
In those deployments, treating a serialized HNSW index as passive data grants its header authority over native heap allocation sizes and subsequent bulk writes.
5. Remediation: Enforce cur_element_count <= max_elements Before Allocation
The fix belongs immediately after max_elements is resolved and before data_level0_memory_, linkLists_, or element_levels_ are allocated, paired with an overflow check on max_elements * size_data_per_element_. Without the second check, a header with a huge max_elements passes C <= M and the multiplication wraps to a tiny allocation instead.
nmslib/hnswlib (hnswlib/hnswalg.h, v0.10.0-rc.2 loadIndexNoExceptions)
Because hnswlib v0.10.0-rc.2 supports building with HNSWLIB_ENABLE_EXCEPTIONS=OFF, loadIndexNoExceptions returns a Status object rather than throwing directly. hnswalg.h already includes <limits> at this revision, so std::numeric_limits needs no new include:
@@ hnswlib/hnswalg.h loadIndexNoExceptions
size_t max_elements = max_elements_i;
if (max_elements < cur_element_count)
max_elements = max_elements_;
max_elements_ = max_elements;
+
+ if (cur_element_count > max_elements) {
+ return Status("Index seems to be corrupted or unsupported: "
+ "cur_element_count exceeds max_elements");
+ }
readBinaryPOD(input, size_data_per_element_);
+ if (size_data_per_element_ != 0 &&
+ max_elements > std::numeric_limits<size_t>::max() / size_data_per_element_) {
+ return Status("Index seems to be corrupted or unsupported: "
+ "level-0 allocation size overflow");
+ }
readBinaryPOD(input, label_offset_);
(On v0.9.0 d9b3608, where loadIndex is void and uses exceptions, throwing std::runtime_error at the same point provides the equivalent guard.)
spotify/voyager (cpp/src/hnswalg.h)
Voyager’s hnswalg.h does not include <limits> directly at 72de126 (it reaches it transitively), so the diff adds the include explicitly:
@@ cpp/src/hnswalg.h includes
#include <fstream>
+#include <limits>
#include <list>
@@ cpp/src/hnswalg.h loadIndex
size_t max_elements = max_elements_i;
if (max_elements < cur_element_count)
max_elements = max_elements_;
max_elements_ = max_elements;
+
+ if (cur_element_count > max_elements)
+ throw std::domain_error(
+ "Index seems to be corrupted or unsupported. cur_element_count (" +
+ std::to_string(cur_element_count) + ") exceeds capacity max_elements (" +
+ std::to_string(max_elements) + ").");
+
readBinaryPOD(inputStream, size_data_per_element_);
+ if (size_data_per_element_ != 0 &&
+ max_elements > std::numeric_limits<size_t>::max() / size_data_per_element_)
+ throw std::domain_error(
+ "Index seems to be corrupted or unsupported. Level-0 allocation size "
+ "(max_elements * size_data_per_element_) overflows size_t.");
readBinaryPOD(inputStream, label_offset_);
We applied both diffs to upstream HEAD (ca42672 and 72de126) and loaded each file through the real library entry point under AddressSanitizer (Apple clang 21, arm64 macOS). Both controls still load (exit 0). The crafted M = 2, C = 512 file is rejected by the first check (exit 3, no ASan report). A header with max_elements = 2^60 and C = 512, which passes C <= M and overflows the unpatched loaders, is rejected by the second check.
6. Disclosure Timeline (CVE-2026-124959 · CERT/CC VU#681158)
- 2026-07-17: Reported
hnswlibandvoyagerloadIndexcapacity-vs-count heap out-of-bounds write to CERT/CC (tracked under multi-vendor case VU#681158), as neither repository enabled GitHub Private Vulnerability Reporting or published aSECURITY.mdcontact. - 2026-08-11 to 2026-09-07: Two other GitHub users independently raised the same missing capacity check in
hnswlibin public:3nesdenizopened issuenmslib/hnswlib#673(2026-08-11), andchakshu-dhannawatopened PRnmslib/hnswlib#677(2026-09-07, open and unmerged as of2026-11-05). Thespotify/voyagerhalf had no public issue or PR. - 2026-09-29 to 2026-10-13: CERT/CC conducted vendor outreach via VINCE and coordinated disclosure under VU#681158.
- 2026-11-05: Public writeup published in coordination with CERT/CC for CVE assignment under VU#681158.
- 2026-11-09: CERT/CC published CVE-2026-124959 (
VU#681158.1under CERT/CC VU#681158 / VINCE Case #4191, affectingnmslib/hnswlib0.5.0through0.9.0andspotify/voyager2.0.7through2.1.1).