Skip to content

Heap out-of-bounds reads in .npz loader (header_len + uncompr_bytes underflow), CWE-125/-191 #106

Description

@ValheruEldarr

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions