Fix ZSTD_DCtx leak when a frame fails to decode - #148
Open
Watson1978 wants to merge 1 commit into
Open
Conversation
Zstd.decompress creates a ZSTD_DCtx and frees it once the scan loop is done,
but decode_one_frame raises whenever libzstd reports an error, so the free is
skipped and the context is lost. libzstd sizes the context's inBuff and outBuff
from the frame header before decoding any block, so the leak carries those
buffers with it -- and the header is attacker-supplied, which is what decides
how big they are.
Measured with a valid header followed by a body that fails to decode: 200 such
calls grow RSS by ~435 MB, about 2.2 MB per call, and it does not come back.
A header declaring the maximum default window leaks far more.
Run the scan loop under rb_ensure so the context is freed on every path, and do
the same for the scratch buffer in decode_one_frame, which leaked as well if
rb_str_cat raised while appending output.
That means set_decompress_params can no longer free the context itself: doing so
while an ensure also owns it would double free. It now raises and leaves the
context to its owner, which is the ensure here and the TypedData free callback
for StreamingDecompress -- so that one assigns sd->dctx before the call.
Verified under AddressSanitizer over the failing decode, the rejected `dict:`
argument, and the same rejection through StreamingDecompress. Before this
change the failing decode reports
Direct leak of 4,798,800 byte(s) in 50 object(s) allocated from:
ZSTD_createDCtx decompress/zstd_decompress.c:313
rb_decompress ext/zstdruby/zstdruby.c:113
one per failed call. After it, no allocation from ZSTD_createDCtx is reported
and no error is raised on any of those paths.
The new specs only walk the two failure paths; they assert the raise, not the
leak, which Valgrind or ASan is what reports.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Watson1978
force-pushed
the
fix/decompress-dctx-leak
branch
from
August 9, 2026 19:01
d0c5830 to
18e3cc6
Compare
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.
Summary
Zstd.decompresscreates aZSTD_DCtxand frees it once the scan loop is done, butdecode_one_frameraises whenever libzstd reports an error, so the free is skipped and the context is lost.The size matters because libzstd allocates the context's
inBuffandoutBufffrom the frame header, before decoding any block. The header is attacker-supplied, so the caller decides how much is leaked per failed call — up to the default window cap.Measured with a valid header followed by a body that fails to decode: 200 such calls grow RSS by ~435 MB, about 2.2 MB per call, and it does not come back. A header declaring the maximum default window leaks far more. Anything that decompresses untrusted bytes can be walked into OOM by repeating a single malformed request.
Fix
Run the scan loop under
rb_ensure, so the context is freed on every path. The scratch buffer indecode_one_framegets the same treatment — it leaked as well ifrb_str_catraised while appending output.One consequence is worth calling out.
set_decompress_paramsused to free the context itself before raising; with anensurealso owning it, that would be a double free. It now raises and leaves the context to its owner:Zstd.decompress— theensureadded hereStreamingDecompress— the TypedData free callback, sosd->dctxis assigned before the call rather than afterVerification
Under AddressSanitizer, over three paths: the failing decode, a rejected
dict:argument, and the same rejection throughStreamingDecompress.Before this change the failing decode reports one leaked context per call:
After it, no allocation from
ZSTD_createDCtxis reported on any of those paths, and none of them raises an ASan error — in particular the ownership change does not introduce a double free.Tests
The two new specs walk the failure paths and assert the raise; they do not try to measure memory.
Zstd.decompress(x, dict: 123)still raisesArgumentErrorwith the same message as before.Compatibility
No API or behavior change. The same inputs raise the same errors; only the memory that was previously abandoned is now released.
Note on overlap
This touches the same loop in
decode_one_frameas #143, so whichever lands first will leave the other with a textual conflict. They are independent changes and I am happy to rebase this one on top of that.🤖 Generated with Claude Code