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:
- Opt-in per call —
Zstd.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.
- 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.
- 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.
Summary
Zstd.decompressplaces no bound on how much it will produce, so a small input can force an arbitrarily large allocation. On v2.0.8: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 500000the 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_getFrameContentSizeorZSTD_decompressBoundcall anywhere in the glue code —decode_one_frameappends 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::StreamingDecompressis 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:
Zstd.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.Zstd.max_decompressed_size = ...) that per-callmax_output_size:overrides. Still backward compatible, and one line for an application to protect every call site.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_framealready loops over output blocks, so it is a running total plus a check.Reproduction
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.