Skip to content

Zstd.decompress has no bound on output size (decompression bomb) #153

Description

@Watson1978

Summary

Zstd.decompress places no bound on how much it will produce, so a small input can force an arbitrarily large allocation. On v2.0.8:

input 25,618 bytes
output 838,860,800 bytes (800 MiB)
amplification ~32,745x
peak RSS 839,416 KB
bomb = Zstd.compress("\0" * 800 * 1024 * 1024, level: 19)   # => 25,618 bytes
Zstd.decompress(bomb)                                        # => 800 MiB

Anything that decompresses attacker-influenced bytes — an HTTP body, an upload, a queue payload — can be pushed into swap or OOM by a payload small enough to look unremarkable, and repeating it a few times in parallel takes the host down. Under ulimit -v 500000 the failure is at least controlled (NoMemoryError, rescuable); without a limit the process simply grows.

What is already safe

Worth saying, because it rules out the more common variant of this bug: the extension does not trust the size declared in the frame header. There is no ZSTD_getFrameContentSize or ZSTD_decompressBound call anywhere in the glue code — decode_one_frame appends to a Ruby String as blocks come out. So a header that lies about its content size cannot cause a huge up-front allocation.

The gap is only that the running total is unbounded.

Zstd::StreamingDecompress is not affected in the same way, because the caller drives it chunk by chunk and can stop.

Why I am opening an issue rather than a PR

The fix is small, but it needs a decision I cannot make for you: what the API looks like and what the default is. Some options, roughly in increasing order of protection and of breakage:

  1. Opt-in per callZstd.decompress(data, max_output_size: 64 * 1024 * 1024), unlimited when omitted. Fully backward compatible; nothing changes for anyone who does not ask. The downside is that everyone stays exposed by default, which for a DoS-shaped bug is most of the population.
  2. Opt-in globally — a module-level default (e.g. Zstd.max_decompressed_size = ...) that per-call max_output_size: overrides. Still backward compatible, and one line for an application to protect every call site.
  3. A default limit with an opt-out — safest, but it breaks anyone legitimately decompressing something larger than whatever number is chosen, and that number is hard to pick.

My inclination is 1, or 1 + 2 together, since they cost nothing to existing users. But if you would rather have a default, that is a reasonable call too and I am happy to write it either way.

Implementation is straightforward whichever shape you pick: decode_one_frame already loops over output blocks, so it is a running total plus a check.

total_out += o.pos;
if (max_out && total_out > max_out) {
  rb_raise(rb_eRuntimeError, "decompressed size exceeds limit");
}

Reproduction

require 'zstd-ruby'

bomb = Zstd.compress("\0" * 800 * 1024 * 1024, level: 19)
puts bomb.bytesize                     # 25618

out = Zstd.decompress(bomb)
puts out.bytesize                      # 838860800
puts File.read('/proc/self/status')[/^VmHWM:\s+(\d+)/, 1]   # ~839416

Measured on v2.0.8 (2f033eb), bundled libzstd 1.5.7, Ruby 4.0.6, Linux x86_64.

Operational note

Independently of what this gem does, an application in front of it should cap the accepted payload size and the number of concurrent decompressions. That does not remove the need for a limit here — the amplification means a payload small enough to pass any reasonable request-size check still expands to hundreds of megabytes.

I am glad to send a PR once you have a preference.

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