Skip to content

[BUG] zstd response decoder is never closed: goroutine/memory leak on large Content-Encoding: zstd responses #781

Description

@coderabbitai

Is there an existing issue for this?

  • I have searched the existing issues.

Current Behavior

When an HTTP response carries Content-Encoding: zstd, nuclei decompresses it via github.com/projectdiscovery/utils http/normalization.go wrapDecodeReader(), which builds a *zstd.Decoder (klauspost) and wraps it in io.NopCloser. The response body is capped with io.LimitReader(wrapped, maxBodySize), read into the body buffer, and then the decoder is never closed.

klauspost's zstd decoder runs background worker goroutines in its default concurrent mode (concurrency = min(GOMAXPROCS, 4)); those goroutines are released only when the decoder is closed/cancelled. gzip, deflate/zlib and brotli decoders do not spawn goroutines, so this affects zstd only.

If the decompressed body is larger than the read cap (nuclei default MaxBodyRead = 10 MB), LimitReader stops reading before EOF and the decoder's workers block permanently on their internal output channel. Each such response leaks goroutines plus their window/history buffers, which the GC cannot reclaim while the goroutines are alive. A tiny (~450-byte) response can decompress to megabytes, so a scan touching an third-party controlled endpoint (directly, or via a followed redirect) accumulates leaked goroutines and memory until the nuclei process is OOM-killed.

Relevant code:

  • pkg/protocols/http/request.goNewResponseChain(resp, maxBodylimit) then respChain.Fill()
  • utils v0.11.2 http/normalization.gowrapDecodeReader() case "zstd": wraps the decoder in io.NopCloser
  • utils v0.11.2 http/respChain.goResponseChain.Close() releases only pooled buffers, never the decoder

Expected Behavior

The zstd decoder should be closed (or run in a mode that spawns no persistent goroutine) after the response body is read, so that processing a Content-Encoding: zstd response leaks no goroutines or memory — the same as for gzip/deflate/brotli. Goroutine and heap usage should return to baseline after each response regardless of whether the decompressed body exceeds the read cap.

Steps To Reproduce

Trigger conditions: response with Content-Encoding: zstd, decompressed size larger than the read cap (10 MB default), on a multi-core host (GOMAXPROCS > 1; GOMAXPROCS=1 uses zstd's synchronous path and does not leak). Go's transport never auto-decodes zstd, so this is reachable with default templates. Hosts below are loopback only.

A) Against nuclei (memory profile):

  1. Stand up a local server on 127.0.0.1 that replies to any path with header Content-Encoding: zstd and a zstd stream decompressing to > 10 MB (e.g. compress ~12 MB of zeros; the compressed body is only a few KB).
  2. Run nuclei against it repeatedly with memory profiling, e.g.:
    nuclei -u http://127.0.0.1:PORT -t <any-http-template> -profile-mem prof
    
    (or loop many requests / many hosts to accelerate).
  3. Observe: goroutine count and heap in use grow monotonically per zstd response and do not recover after GC. An independent run over loopback showed +24 zstd goroutines and +76 MiB from 8 responses, with no recovery after a wait plus repeated GC.

B) Deterministic offline reproducer (exercises the exact NewResponseChain + Fill path, no network; run inside the nuclei module):

package main

import (
	"bufio"; "bytes"; "compress/gzip"; "fmt"; "net/http"; "runtime"; "time"
	httputil "github.com/projectdiscovery/utils/http"
	"github.com/klauspost/compress/zstd"
)
func rawResp(enc string, b []byte) []byte {
	h := fmt.Sprintf("HTTP/1.1 200 OK\r\nContent-Encoding: %s\r\nContent-Length: %d\r\n\r\n", enc, len(b))
	return append([]byte(h), b...)
}
func zstdBody(n int) []byte { var b bytes.Buffer; w,_ := zstd.NewWriter(&b); w.Write(make([]byte,n)); w.Close(); return b.Bytes() }
func gzipBody(n int) []byte { var b bytes.Buffer; w := gzip.NewWriter(&b); w.Write(make([]byte,n)); w.Close(); return b.Bytes() }
func once(raw []byte, maxBody int64) {
	resp,_ := http.ReadResponse(bufio.NewReader(bytes.NewReader(raw)), nil)
	rc := httputil.NewResponseChain(resp, maxBody)
	for rc.Has() { if rc.Fill()!=nil {break}; if !rc.Previous() {break} }
	rc.Close() // nuclei's cleanup; does NOT close the zstd decoder
}
func settle() int { runtime.GC(); time.Sleep(150*time.Millisecond); runtime.GC(); return runtime.NumGoroutine() }
func scn(name string, raw []byte, cap int64) {
	b := settle(); for i:=0;i<50;i++ { once(raw,cap) }; a := settle()
	fmt.Printf("%-32s goroutines %d -> %d (delta=%d)\n", name, b, a, a-b)
}
func main() {
	const cap = 1<<20
	scn("zstd 4MB (> cap, stops early)", rawResp("zstd", zstdBody(4<<20)), cap)
	scn("gzip 4MB (> cap) [control]",    rawResp("gzip", gzipBody(4<<20)), cap)
	scn("zstd 256KB (< cap, drained)",   rawResp("zstd", zstdBody(256<<10)), cap)
}

Relevant log output

zstd 4MB (> cap, stops early)    goroutines 1 -> 58 (delta=57)   # permanent; hundreds of MB heap from 50 x ~450B responses
gzip 4MB (> cap) [control]       goroutines 58 -> 58 (delta=0)   # gzip has no goroutines
zstd 256KB (< cap, drained)      goroutines 58 -> 58 (delta=0)   # fully drained zstd terminates normally


The gzip control isolates the defect to the zstd decoder; the fully-drained zstd case pins the trigger to "decompressed size exceeds the read cap." Leak does not occur under `GOMAXPROCS=1`.

Environment

- OS: Fedora Linux 44 (x86_64)
- Nuclei: v3.11.1 (dev, commit 80f1839aa321)
- Go: go1.26

Anything else?

Root cause is in the pinned dependency github.com/projectdiscovery/utils v0.11.2 (http/normalization.go), consumed by nuclei's HTTP protocol; klauspost/compress v1.18.7.

Suggested fix (patch) — close the decoder after reading, on every path. The decoder closer is captured before the optional charset-transform re-wrap (so Close() still reaches the decoder) and is kept distinct from origBody (so the uncompressed/network body is never closed here). (*zstd.Decoder).Close() does not close the underlying resp.Body, which ResponseChain still drains separately, so there is no double-close.

--- a/http/normalization.go
+++ b/http/normalization.go
@@ func readNNormalizeRespBody(rc *ResponseChain, body *bytes.Buffer) (err error) {
-	// wrap with decode if applicable
-	wrapped, err := wrapDecodeReader(response)
-	if err != nil {
-		wrapped = origBody
-	}
+	// wrap with decode if applicable
+	wrapped, decoderCloser, err := wrapDecodeReader(response)
+	if err != nil {
+		wrapped = origBody
+		decoderCloser = nil
+	}
+	// The streaming zstd decoder owns background goroutines/buffers released only
+	// when it is closed (gzip/zlib/brotli are GC-safe). Close the decoder -- never
+	// origBody -- on every return path.
+	if decoderCloser != nil {
+		defer func() { _ = decoderCloser.Close() }()
+	}
 	limitReader := io.LimitReader(wrapped, rc.maxBodySize)
@@ func wrapDecodeReader
-func wrapDecodeReader(resp *http.Response) (rc io.ReadCloser, err error) {
+func wrapDecodeReader(resp *http.Response) (rc io.ReadCloser, decoderCloser io.Closer, err error) {
 	switch resp.Header.Get("Content-Encoding") {
 	case "gzip":
 		rc, err = gzip.NewReader(resp.Body)
 	case "deflate":
 		rc, err = zlib.NewReader(resp.Body)
 	case "br":
 		rc, err = brotli.NewReader(resp.Body, nil)
 	case "zstd":
 		var zstdReader *zstd.Decoder
 		zstdReader, err = zstd.NewReader(resp.Body)
 		if err != nil {
-			return nil, err
+			return nil, nil, err
 		}
-		rc = io.NopCloser(zstdReader)
+		rc = zstdReader.IOReadCloser()
+		decoderCloser = rc // capture before any charset-transform re-wrap
 	default:
 		rc = resp.Body
 	}
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 	// handle GBK encoding
 	if isContentTypeGbk(resp.Header.Get("Content-Type")) {
 		rc = io.NopCloser(transform.NewReader(rc, simplifiedchinese.GBK.NewDecoder()))
 	}
 	// handle Windows-1251 encoding
 	if isContentTypeWindows1251(resp.Header.Get("Content-Type")) {
 		rc = io.NopCloser(transform.NewReader(rc, charmap.Windows1251.NewDecoder()))
 	}
-	return rc, nil
+	return rc, decoderCloser, nil
 }

Minimal one-line alternative — force synchronous decode so no persistent goroutine is spawned (matches the observed GOMAXPROCS=1 = no-leak behavior); simpler, but loses parallel decode and relies on GC to free buffers:

-		zstdReader, err = zstd.NewReader(resp.Body)
+		zstdReader, err = zstd.NewReader(resp.Body, zstd.WithDecoderConcurrency(1))

Optionally add zstd.WithDecoderMaxWindow(rc.maxBodySize) to bound per-frame window allocation as defense in depth. nuclei picks up the fix by bumping github.com/projectdiscovery/utils once released (interim: a replace directive).


Source issue: projectdiscovery/nuclei#7749

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions