Skip to content

fix: bound sequence output within the block buffer - #24

Open
spokodev wants to merge 1 commit into
101arrowz:masterfrom
spokodev:fix/bound-sequence-output
Open

spokodev wants to merge 1 commit into
101arrowz:masterfrom
spokodev:fix/bound-sequence-output

Conversation

@spokodev

@spokodev spokodev commented Sep 2, 2026

Copy link
Copy Markdown

The sequence loop in rzb copies each sequence's literals (ll) and match (ml) into the block buffer without checking that the running output position oubt stays within it. Out-of-bounds typed-array writes are silent no-ops in JS, so a frame whose sequences claim more output than the block can hold does not error — the loop just runs its full sequence count, burning CPU.

The sequence count and match lengths are decoded from the input (up to 519935 sequences via the 0xFF extended encoding, match length up to 65539+), so a ~20-byte frame can pin a core for a long time. decompress() and Decompress.push() both reach this path.

Impact (measured)

A ~20-byte frame, fixed except for the declared sequence count ns:

ns time on master
32512 3.4 s
48896 21.6 s
65280 did not finish within 120 s

The maximum declarable ns (519935) extrapolates to minutes of 100% CPU, and the returned output is only garbage (240 bytes). This freezes the browser main thread or blocks Node's event loop from an unauthenticated payload. Node's native zstd rejects the same bytes instantly (ZSTD_error_dstSize_tooSmall).

Fix

Reject a sequence whose ll + ml would carry the output past the block buffer, before copying. For a valid frame the cumulative literal+match length equals the block's regenerated size, which is at most st.m = the block buffer length, so oubt + ll + ml never exceeds it and the guard does not fire on conformant input. The window-back-reference branch only redistributes the same ml between window and buffer, so the pre-copy check on the original ml covers it.

Test

Adds tests/dos_malformed_sequences_test.ts: a 17-byte frame whose sequences claim far more output than the 240-byte block must be rejected. It fails on master (decompress returns instead of throwing) and passes with the fix. Existing suite unaffected: 4 passed / 0 failed (the "1,000,000 nuls" and "Lorem ipsum" cases exercise real sequences/matches and still pass, confirming the guard never fires on valid data). After the fix the DoS inputs above throw in ~0.4 ms instead of hanging.

The sequence loop in `rzb` copies each sequence's literals (`ll`) and
match (`ml`) into the block buffer without checking that the output
position stays within it. Out-of-bounds typed-array writes are silent
no-ops in JS, so a frame whose sequences claim more output than the block
can hold does not error: the loop just runs its full sequence count
burning CPU. The sequence count and match lengths come from the input
(up to 519935 sequences, match length up to 65539+), so a ~20-byte frame
can pin a core for tens of seconds to minutes and freeze the event loop /
main thread, while returning a garbage result. Node's native zstd
rejects the same bytes immediately (dstSize_tooSmall).

Reject a sequence whose `ll + ml` would carry the output past the block
buffer before copying. For valid frames the cumulative literal+match
length equals the block's regenerated size, which is at most the block
buffer length, so the guard never fires on conformant input; the
window-back-reference branch only redistributes the same `ml`, which the
pre-copy check already covers.
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