Notebook/Essay

When an HNSW Index Lies About Its Capacity

An HNSW vector index header stores both its allocated capacity (max_elements_) and its live element count (cur_element_count). Neither hnswlib nor Spotify's Voyager checked that cur_element_count fits inside max_elements_ during loadIndex, allowing a crafted index to allocate for the smaller capacity and copy the larger count onto the heap (CVE-2026-124959, CERT/CC VU#681158).

on this page
Official Advisory & CVE Record (Published November 9, 2026)

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:

  1. max_elements_: how many elements the destination buffer has room for (the capacity).
  2. 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 capacity M
  • cur_element_count: the serialized element count C
  • size_data_per_element_: the byte stride S of 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.

Interactive deserialization model · CVE-2026-124959 (VU#681158)

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.

01 · Input stream on disk Read-side checks PASS

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_ == 128 matches space (Voyager only; a stride check, not a length check)
  • ✓ Stream delivers full C × 128 bytes (Voyager only)
02 · Destination heap buffer HEAP OVERFLOW (+65,280 B)

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:

Allocated heap buffer (M × 128) 256 B
Bulk stream write (C × 128) 65,536 B
Captured run (M = 2, C = 512): AddressSanitizer: heap-buffer-overflow WRITE of size 65536 (0 bytes after 256-byte region)
Each level-0 record uses a 128-byte stride (21 float dimensions, M_links = 4). In both hnswlib (d9b3608 / ca42672) and Spotify Voyager (2a2f1f1 / 72de126), loadIndex allocates M × S bytes on the heap and reads C × S bytes into that allocation. The benign control preset is the unmodified 512-point build that the crafted file was derived from; the headroom preset is illustrative and was not run.

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):

  1. Checking level-0 byte offsets against the total file size (:732).
  2. Bounding enterpoint_node_ against cur_element_count (:753).
  3. Pinning size_data_per_element_ to the configured distance space (:779).
  4. Walking the upper-layer link lists for structural consistency and requiring exact EOF alignment (:826).
  5. 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 reads 65,536 bytes. The second is a separately built small index (M = 16, C = 8, S = 128): it allocates 2,048 bytes and reads 1,024. Both load cleanly (exit 0) on hnswlib and voyager in every build we ran: the pinned revisions under ASan, and HEAD and HEAD plus the fix below under both ASan and release builds.
  • Crafted (M = 2, C = 512, S = 128): Allocates 2 * 128 = 256 bytes and reads 512 * 128 = 65,536 bytes into that buffer. Under release builds (-O2 -DNDEBUG without sanitizers), both libraries abort in glibc with malloc(): corrupted top size (exit 134). Under AddressSanitizer at v0.9.0 (d9b3608), HierarchicalNSW<float>::loadIndex reports:
==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 / .voy index 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 hnswlib and voyager loadIndex capacity-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 a SECURITY.md contact.
  • 2026-08-11 to 2026-09-07: Two other GitHub users independently raised the same missing capacity check in hnswlib in public: 3nesdeniz opened issue nmslib/hnswlib#673 (2026-08-11), and chakshu-dhannawat opened PR nmslib/hnswlib#677 (2026-09-07, open and unmerged as of 2026-11-05). The spotify/voyager half 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.1 under CERT/CC VU#681158 / VINCE Case #4191, affecting nmslib/hnswlib 0.5.0 through 0.9.0 and spotify/voyager 2.0.7 through 2.1.1).