Skip to content

Bound decompress2() and decompress() by the sizes of dst and src - #780

Open
FabianIsensee wants to merge 3 commits into
Blosc:mainfrom
FabianIsensee:fix-decompress-dst-size
Open

FabianIsensee wants to merge 3 commits into
Blosc:mainfrom
FabianIsensee:fix-decompress-dst-size

Conversation

@FabianIsensee

Copy link
Copy Markdown
Contributor

decompress2(src, dst) passed the chunk's nbytes from its header, not the length of dst, as destsize to blosc2_decompress_ctx(). A dst smaller than the decompressed data is overrun:

c = blosc2.compress2(np.arange(1 << 20, dtype=np.int64))  # 8 MiB
blosc2.decompress2(c, np.zeros(4, dtype=np.int64))  # heap corruption / SIGSEGV; ASan: heap-buffer-overflow WRITE

decompress() used blosc1_decompress(), which passes INT32_MAX as srcsize, so a truncated src is read past its end: blosc2.decompress(memoryview(bytearray(chunk))[:-1]) "succeeds" from the bytes after the view, and with a separately allocated truncated copy ASan reports a heap-buffer-overflow READ (in LZ4_decompress_generic, or in memcpy for a memcpyed chunk). decompress2() already passed the length of src.

First commit:

  • Both functions pass the length of dst in bytes as destsize. When dst is too small they raise RuntimeError("The dst length (32 bytes) is smaller than the decompressed size of src (8388608 bytes)"), the exception both docstrings document for this case (decompress() raised RuntimeError("Cannot decompress") here before). C-Blosc2 enforces the bound itself once it gets the real destsize; the check only gives the clearer message. An empty dst still raises the same ValueError.
  • decompress() calls blosc2_decompress() with the length of src.
  • Both lengths are clamped to INT_MAX, so a dst or src larger than 2 GiB (e.g. a chunk at the start of a big memory-mapped buffer) works in both functions. Before, decompress() failed with such a dst (the size_t destsize was cast to a negative int32_t) and decompress2() raised OverflowError with such a src.
  • The buffer of dst is released in a finally, so a failing call (an empty dst included) no longer leaves dst exported (a bytearray could not be resized afterwards).
  • Docstring typo in decompress2(): "cannot be compressed into dst" -> "decompressed".

Second commit: vldecompress_block() created its decompression context before checking src, and decompress2() got the buffer of dst outside the block that frees its context, so a too-short or non-buffer src (vldecompress_block()) or a non-buffer or non-contiguous dst (decompress2()) leaked the context, 1360 bytes per call.

Third commit: decompress(), decompress2() and SChunk.decompress_chunk() got dst with PyBUF_SIMPLE, which read-only exporters grant too, so blosc2.decompress2(c, dst=bytes(n)) wrote into an immutable bytes object (likewise a read-only memoryview or a NumPy array with writeable=False). They now ask for a writable buffer (PyBUF_SIMPLE | PyBUF_WRITABLE), so such a dst raises BufferError (NumPy raises ValueError("buffer source array is read-only")); the docstrings say so. This is a behaviour change for callers that passed a read-only dst, but those calls silently mutated objects that Python and other code treat as immutable (equal bytes objects can be shared), so refusing them is the safe outcome.

Tests, in the new tests/test_decompress_buffer_sizes.py, for both functions: every kind of dst (bytearray, NumPy 1-D, 2-D, int16, memoryview, array.array, a slice of a larger array) with an exact and a larger size; too small dst as the start of a larger buffer (checks the message and that nothing past dst is written); empty dst released; dst and src over 2 GiB (64-bit only; only the touched pages are allocated); truncated src (also memcpyed chunks); on glibc, the leaked bytes per failing call (mallinfo2()); and read-only dst (bytes, read-only memoryview, read-only NumPy array; also SChunk.decompress_chunk()), checking that it is left untouched. 27 of the 47 cases fail on main. Full suite: same results as main (only the 2 b2view tests that need aiohttp fail locally). ASan build: no reports in the touched test files and in the repro cases that report on main. Fork CI: green on Linux x86 (Python 3.12 default and legacy, 3.14), Linux ARM, macOS, Windows, NumPy 1.26 and WASM.

Perf (measured before the rebase onto the #741 merge, on the first two commits; the third only adds the writable flag to the buffer request; EPYC 9554 node, one core, 7 interleaved rounds against main and a second build of main, us per call): decompress2() of a 4 KiB chunk is 5-8% faster (1.68 -> 1.55 us with dst, 1.70 -> 1.62 without), because the length of src now comes from len() of the typed memoryview instead of .nbytes, which built a Python object per call; decompress() at 4 KiB and both functions at 256 KiB show no measurable change (within ±2.5%; the second main build differs from the first by up to 4.3% at 256 KiB).

This conflicts with #769 (decide threads and the GIL per chunk for Python codecs and filters) in 2 hunks of decompress(); I'll rebase this PR after whichever of the two merges first. The resolution is small: keep this block and call #769's _blosc1_decompress() wrapper with the same arguments, and make the wrapper call blosc2_decompress(src, srcsize, dest, destsize) instead of blosc1_decompress(), so plain chunks also get the real srcsize. #769 also moves the context creation in decompress2()/vldecompress() below the src checks, which stops the same leak there for a too-short src. No conflicts with #770-#774.

Not in this PR (happy to follow up):

  • SChunk.get_slice(out=...) and NDArray.get_slice_numpy(arr, key) also get the caller's buffer with PyBUF_SIMPLE and write into a read-only one (get_slice(0, 10, out=bytes(80)) changes the bytes object).
  • SChunk.decompress_chunk(nchunk, dst) still fails with a dst over 2 GiB (<int32_t>buf.len).

I used Claude Code (Anthropic) to find the causes, write the fix and the tests, and run the sanitizer checks and benchmarks; I reviewed the result.

🤖 Generated with Claude Code

FabianIsensee and others added 3 commits November 6, 2026 13:09
decompress2(src, dst) passed the chunk's nbytes, not the length of dst,
as destsize to blosc2_decompress_ctx(), so a dst smaller than the
decompressed data was overrun: heap corruption or a segfault.
decompress() used blosc1_decompress(), which passes INT32_MAX as
srcsize, so a truncated src was read past its end.

Both functions now pass the length of dst in bytes as destsize and
raise a RuntimeError naming both sizes when dst is too small, as their
docstrings say, and decompress() calls blosc2_decompress() with the
length of src.  Both lengths are clamped to INT_MAX, the most
C-Blosc2 takes, so a dst or src larger than 2 GiB (a chunk at the
start of a big buffer) works in both functions; before, decompress()
failed with such a dst and decompress2() raised OverflowError with
such a src.  dst's buffer is released in a finally block, so a failing
call (an empty dst included) no longer keeps it exported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiPFzsfE5i74cBSxZN13M8
vldecompress_block() created its decompression context before
checking src, and decompress2() got the buffer of dst outside the
try block that frees its context, so a src that is too short or not
a buffer (vldecompress_block()), or a dst that is not a buffer or not
contiguous (decompress2()), leaked the context: 1360 bytes per call.
Create the context once src is known to be usable, and get the
buffer of dst inside the try block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiPFzsfE5i74cBSxZN13M8
decompress(), decompress2() and SChunk.decompress_chunk() got dst's
buffer with PyBUF_SIMPLE, which read-only exporters grant too, so they
decompressed into bytes objects, read-only memoryviews and NumPy arrays
with writeable=False.  Writing into an immutable object corrupts every
other user of it (equal bytes objects can be shared).  Ask for a
writable buffer instead: the exporter then raises (BufferError, or
ValueError for a read-only NumPy array), as the docstrings now say.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiPFzsfE5i74cBSxZN13M8

This branch has not been deployed

No deployments
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