Loading an attacker-crafted .npz with cnpy::npz_load() triggers two heap out-of-bounds reads at parse time, confirmed with AddressSanitizer/UBSan on commit 4e8810b. These are reads only. No code execution / no write primitive is claimed; impact is denial of service (crash) and potential adjacent-heap information exposure.
1. header_len heap OOB read (CWE-125)
parse_npy_header(unsigned char* buffer, ...) (cnpy.cpp:62) reads a 16-bit header_len straight from the buffer and constructs:
uint16_t header_len = *reinterpret_cast<uint16_t*>(buffer+8);
std::string header(reinterpret_cast<char*>(buffer+9), header_len); // cnpy.cpp:67
The buffer-pointer overload has no length parameter, so there is no check that 9 + header_len is within the inflated chunk. A crafted .npz whose stored header_len exceeds the actual decompressed header forces the std::string constructor to read past the heap allocation.
PoC: a 77-byte .npz produces ASan heap-buffer-overflow READ of size 65535, 0 bytes after a 10-byte region, at cnpy.cpp:67 via load_the_npz_array then npz_load.
2. uncompr_bytes - num_bytes() underflow, wild memcpy source (CWE-191 to CWE-125)
In load_the_npz_array (cnpy.cpp:218-225):
cnpy::NpyArray array(shape, word_size, fortran_order);
size_t offset = uncompr_bytes - array.num_bytes(); // unsigned underflow
memcpy(array.data<unsigned char>(), &buffer_uncompr[0]+offset, array.num_bytes());
When the header's declared shape/word_size make array.num_bytes() larger than the decompressed uncompr_bytes, the unsigned subtraction wraps to a huge offset and memcpy reads from a wild source pointer.
PoC: a 137-byte .npz produces UBSan unsigned-overflow at cnpy.cpp:225 then ASan heap-buffer-overflow READ of size 8000.
3. (context) NpyArray ctor integer overflow (CWE-190), DoS-only on 64-bit
new std::vector<char>(num_vals * word_size) (cnpy.h:30) multiplies the file-declared dimensions with no overflow check. On a 64-bit build this degrades to an unbounded-allocation DoS (std::bad_alloc) rather than an OOB; on a 32-bit size_t it would be an undersized-allocation primitive. Included for completeness.
Suggested fixes
- Pass the chunk size into the buffer overload of
parse_npy_header and reject 9 + header_len > buffer_size.
- In
load_the_npz_array, reject array.num_bytes() > uncompr_bytes before the subtraction and memcpy.
- In the
NpyArray ctor, compute num_vals * word_size with a checked multiply (__builtin_mul_overflow).
Minimal ASan harness, the two crafted .npz PoCs, and the full traces are available on request. Affected: all released versions (the parser is unchanged since 2018).
Loading an attacker-crafted
.npzwithcnpy::npz_load()triggers two heap out-of-bounds reads at parse time, confirmed with AddressSanitizer/UBSan on commit4e8810b. These are reads only. No code execution / no write primitive is claimed; impact is denial of service (crash) and potential adjacent-heap information exposure.1.
header_lenheap OOB read (CWE-125)parse_npy_header(unsigned char* buffer, ...)(cnpy.cpp:62) reads a 16-bitheader_lenstraight from the buffer and constructs:The buffer-pointer overload has no length parameter, so there is no check that
9 + header_lenis within the inflated chunk. A crafted.npzwhose storedheader_lenexceeds the actual decompressed header forces thestd::stringconstructor to read past the heap allocation.PoC: a 77-byte
.npzproduces ASanheap-buffer-overflow READ of size 65535, 0 bytes after a 10-byte region, atcnpy.cpp:67viaload_the_npz_arraythennpz_load.2.
uncompr_bytes - num_bytes()underflow, wildmemcpysource (CWE-191 to CWE-125)In
load_the_npz_array(cnpy.cpp:218-225):When the header's declared
shape/word_sizemakearray.num_bytes()larger than the decompresseduncompr_bytes, the unsigned subtraction wraps to a hugeoffsetandmemcpyreads from a wild source pointer.PoC: a 137-byte
.npzproduces UBSan unsigned-overflow atcnpy.cpp:225then ASanheap-buffer-overflow READ of size 8000.3. (context)
NpyArrayctor integer overflow (CWE-190), DoS-only on 64-bitnew std::vector<char>(num_vals * word_size)(cnpy.h:30) multiplies the file-declared dimensions with no overflow check. On a 64-bit build this degrades to an unbounded-allocation DoS (std::bad_alloc) rather than an OOB; on a 32-bitsize_tit would be an undersized-allocation primitive. Included for completeness.Suggested fixes
parse_npy_headerand reject9 + header_len > buffer_size.load_the_npz_array, rejectarray.num_bytes() > uncompr_bytesbefore the subtraction andmemcpy.NpyArrayctor, computenum_vals * word_sizewith a checked multiply (__builtin_mul_overflow).Minimal ASan harness, the two crafted
.npzPoCs, and the full traces are available on request. Affected: all released versions (the parser is unchanged since 2018).