Repository navigation
Bound decompress2() and decompress() by the sizes of dst and src - #780
Open
FabianIsensee wants to merge 3 commits into
Open
FabianIsensee wants to merge 3 commits into
FabianIsensee wants to merge 3 commits into
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
decompress2(src, dst)passed the chunk'snbytesfrom its header, not the length ofdst, as destsize toblosc2_decompress_ctx(). Adstsmaller than the decompressed data is overrun:decompress()usedblosc1_decompress(), which passesINT32_MAXas srcsize, so a truncatedsrcis 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 (inLZ4_decompress_generic, or inmemcpyfor a memcpyed chunk).decompress2()already passed the length ofsrc.First commit:
dstin bytes as destsize. Whendstis too small they raiseRuntimeError("The dst length (32 bytes) is smaller than the decompressed size of src (8388608 bytes)"), the exception both docstrings document for this case (decompress()raisedRuntimeError("Cannot decompress")here before). C-Blosc2 enforces the bound itself once it gets the real destsize; the check only gives the clearer message. An emptydststill raises the sameValueError.decompress()callsblosc2_decompress()with the length ofsrc.INT_MAX, so adstorsrclarger 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 adst(thesize_tdestsize was cast to a negativeint32_t) anddecompress2()raisedOverflowErrorwith such asrc.dstis released in afinally, so a failing call (an emptydstincluded) no longer leavesdstexported (a bytearray could not be resized afterwards).decompress2(): "cannot be compressed into dst" -> "decompressed".Second commit:
vldecompress_block()created its decompression context before checkingsrc, anddecompress2()got the buffer ofdstoutside the block that frees its context, so a too-short or non-buffersrc(vldecompress_block()) or a non-buffer or non-contiguousdst(decompress2()) leaked the context, 1360 bytes per call.Third commit:
decompress(),decompress2()andSChunk.decompress_chunk()gotdstwithPyBUF_SIMPLE, which read-only exporters grant too, soblosc2.decompress2(c, dst=bytes(n))wrote into an immutable bytes object (likewise a read-only memoryview or a NumPy array withwriteable=False). They now ask for a writable buffer (PyBUF_SIMPLE | PyBUF_WRITABLE), so such adstraisesBufferError(NumPy raisesValueError("buffer source array is read-only")); the docstrings say so. This is a behaviour change for callers that passed a read-onlydst, 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 ofdst(bytearray, NumPy 1-D, 2-D, int16, memoryview,array.array, a slice of a larger array) with an exact and a larger size; too smalldstas the start of a larger buffer (checks the message and that nothing pastdstis written); emptydstreleased;dstandsrcover 2 GiB (64-bit only; only the touched pages are allocated); truncatedsrc(also memcpyed chunks); on glibc, the leaked bytes per failing call (mallinfo2()); and read-onlydst(bytes, read-only memoryview, read-only NumPy array; alsoSChunk.decompress_chunk()), checking that it is left untouched. 27 of the 47 cases fail onmain. Full suite: same results asmain(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 onmain. 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
mainand a second build ofmain, us per call):decompress2()of a 4 KiB chunk is 5-8% faster (1.68 -> 1.55 us withdst, 1.70 -> 1.62 without), because the length ofsrcnow comes fromlen()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 secondmainbuild 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 callblosc2_decompress(src, srcsize, dest, destsize)instead ofblosc1_decompress(), so plain chunks also get the real srcsize. #769 also moves the context creation indecompress2()/vldecompress()below thesrcchecks, which stops the same leak there for a too-shortsrc. No conflicts with #770-#774.Not in this PR (happy to follow up):
SChunk.get_slice(out=...)andNDArray.get_slice_numpy(arr, key)also get the caller's buffer withPyBUF_SIMPLEand 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 adstover 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