#7686·nuclei

[BUG] ThreadSafeNucleiEngine: live heap scales with concurrent ExecuteNucleiWithOptsCtx calls (compiled template store rebuilt per call, DoNotCache)

Author: G360-NiekCreated Sep 1, 2026Updated Sep 2, 2026

Is there an existing issue for this?

  • I have searched the existing issues.

This is the still-unresolved core of #7569 ([BUG] ThreadSafeNucleiEngine accumulates memory across ExecuteNucleiWithOptsCtx calls), which was closed. The parser-sharing / metadata-cache work in #7659 (fix(lib): avoid caching engine state in shared parsers) and #7608 (feat(lib): reuse metadata cache across thread-safe scans) reduced re-parsing overhead, but the compiled template store is still rebuilt per call, so peak heap still grows with concurrency. Filing this to capture the remaining memory scaling with a profile and to ask whether our usage pattern is the intended one.

Current Behavior

We embed Nuclei as a library (the lib SDK) in a long-lived scanning service. We want to run only the templates relevant to each host, and because a single Execute run applies one template set to all of its targets, per-host template selection means fanning out into per-host calls.

Targets are partitioned by protocol class (http / dns / network / …); each class is processed sequentially with its own ThreadSafeNucleiEngine. Within a class there are two phases against that one engine:

  1. A single fingerprint pre-scan — one ExecuteNucleiWithOptsCtx over all of the class's hosts (host-sprayed, narrow tech/network tag filter) to learn what each host runs.
  2. A concurrent per-host fan-out — for each host, one ExecuteNucleiWithOptsCtx with that host's fingerprinted tag subset, run through a bounded worker pool (up to BulkSize concurrent), all sharing the class engine.
go
// One engine PER protocol class (classes run sequentially).
eng, _ := nuclei.NewThreadSafeNucleiEngineCtx(ctx /* Options below */)

// Phase 1 — one pre-scan call over ALL of the class's hosts:
eng.ExecuteNucleiWithOptsCtx(ctx, allHostsInClass,
    nuclei.WithTemplateFilters(nuclei.TemplateFilters{Tags: fingerprintTags}),
    nuclei.WithResultCallback(collect),
)

// Phase 2 — a bounded pool of concurrent calls, one per host, each with
// that host's fingerprinted tag subset (this is the memory driver):
eng.ExecuteNucleiWithOptsCtx(ctx, oneHostsTargets,
    nuclei.WithTemplateFilters(nuclei.TemplateFilters{Tags: hostTags}),
    nuclei.WithResultCallback(collect),
)

Engine Options (host-spray, ≤2 requests per host — a politeness contract we must keep):

ScanStrategy:       host-spray
RateLimit:          128 / s
BulkSize:           up to 128         # host pool; see "Anything else" — we had to cut this to 16
TemplateThreads:    2                 # ≤2 concurrent templates per host
PayloadConcurrency: 1
HeadlessBulkSize:   0
Retries:            0
Timeout:            2s

Container limit 8 GiB, GOMEMLIMIT=5GiB, GOGC=50.

The problem: each concurrent ExecuteNucleiWithOptsCtx call does loader.New(...) + store.Load() and builds/holds its own compiled []*templates.Template (compiled matchers, extractors, and regexes). createEphemeralObjects sets DoNotCache: true (lib/multi.go), so templates.Parse takes the re-parse-and-recompile path (parseFromSource) on every call rather than the cache-hit path. So live heap scales ~linearly with the number of concurrent calls, and over a broad template corpus the process OOMs well before it should.

GOMEMLIMIT cannot contain it — the memory is live/in-use (many concurrent compiled stores), not collectable garbage, so GC thrashes and the container still hits its hard limit and is OOM-killed.

Heap profile (inuse_space) at ~29 concurrent calls, broad corpus — ~76% of live heap is template parse/compile:

parseTemplateNoVerify ........... 460 MB   52% (cum)
  reflect.unsafe_New ............ 182 MB   21%   (reflection during template YAML unmarshal)
  yaml (*parser).scalar ......... 127 MB   14%
  regexp.compile ................ 140 MB   16% (cum)   (compiled matcher regexes)
  http.(*Request).Compile ....... 71 MB     8% (cum)
ExecutorOptions.Copy ............ 62 MB     7%

Every concurrent call holds a private copy of that compiled corpus. Measured scaling on the same workload: ~25 concurrent calls ≈ 1.3 GB heap, ~34 ≈ 2.7 GB, and larger host counts (60–112 concurrent) exceed 8 GiB and OOM-kill.

Expected Behavior

For concurrent, per-target-filtered scans over one engine, live heap should stay ~O(one compiled corpus) regardless of how many calls run concurrently — i.e. the compiled template store should be buildable once, read-only, and shared across concurrent ExecuteNucleiWithOptsCtx calls, with each call's tag filter selecting a subset at dispatch rather than compiling its own store.

Alternatively: documented guidance on the intended way to run many concurrent, per-host-filtered scans over a single engine without per-call compiled-store duplication.

Steps To Reproduce

Self-contained reproducer — one shared ThreadSafeNucleiEngine, N concurrent ExecuteNucleiWithOptsCtx calls against a local httptest server, each loading the http/ corpus with a per-call tag filter. Peak HeapInuse is sampled and scales with N.

Run: go run . -templates /path/to/nuclei-templates/http -n 32 (sweep -n).

go
package main

import (
	"context"
	"flag"
	"fmt"
	"net/http"
	"net/http/httptest"
	"runtime"
	"sync"
	"time"

	nuclei "github.com/projectdiscovery/nuclei/v3/lib"
	"github.com/projectdiscovery/nuclei/v3/pkg/output"
)

func main() {
	tmpl := flag.String("templates", "", "path to a nuclei-templates dir (e.g. .../nuclei-templates/http)")
	n := flag.Int("n", 32, "number of concurrent ExecuteNucleiWithOptsCtx calls")
	tag := flag.String("tag", "tech", "per-call template tag filter")
	flag.Parse()

	// Local target so no external hosts are needed; WP-flavored so http templates evaluate.
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.Header().Set("Server", "Apache/2.4")
		w.Header().Set("X-Powered-By", "PHP/8.2")
		fmt.Fprint(w, `<!doctype html><html><head><meta name="generator" content="WordPress 6.4"></head><body>wp-content wp-includes readme.txt</body></html>`)
	}))
	defer srv.Close()

	eng, err := nuclei.NewThreadSafeNucleiEngineCtx(context.Background())
	if err != nil {
		panic(err)
	}
	defer eng.Close()

	// Peak HeapInuse sampler.
	var mu sync.Mutex
	var peak uint64
	stop := make(chan struct{})
	go func() {
		t := time.NewTicker(50 * time.Millisecond)
		defer t.Stop()
		for {
			select {
			case <-stop:
				return
			case <-t.C:
				var m runtime.MemStats
				runtime.ReadMemStats(&m)
				mu.Lock()
				if m.HeapInuse > peak {
					peak = m.HeapInuse
				}
				mu.Unlock()
			}
		}
	}()

	// Fan out N concurrent calls, each loading the corpus + a per-call tag filter.
	var wg sync.WaitGroup
	for i := 0; i < *n; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			_ = eng.ExecuteNucleiWithOptsCtx(context.Background(), []string{srv.URL},
				nuclei.WithTemplatesOrWorkflows(nuclei.TemplateSources{Templates: []string{*tmpl}}),
				nuclei.WithTemplateFilters(nuclei.TemplateFilters{Tags: []string{*tag}}),
				nuclei.WithResultCallback(func(*output.ResultEvent) {}),
			)
		}()
	}
	wg.Wait()
	close(stop)

	mu.Lock()
	p := peak
	mu.Unlock()
	fmt.Printf("N=%-3d  peak HeapInuse=%d MiB\n", *n, p/1024/1024)
}

Running the reproducer while sweeping N (the number of concurrent ExecuteNucleiWithOptsCtx calls) prints the peak HeapInuse at each concurrency level — see the Relevant log output section below. Observed on v3.11.x (http/ corpus ≈ 10.7k templates; -tag tech, so only ~6 templates actually fire — yet each concurrent call independently loads/compiles its own store from the corpus directory): a single call holds ~300 MiB; heap then grows with concurrency (≈250–300 MiB per additional in-flight call) until GC pressure caps it near the top. By N=16–32 it is already in the 5–6.5 GiB range — over an 8 GiB container once RSS overhead is added — and larger host counts OOM-kill. A pprof inuse_space at that point is ~76% template parse/compile (parseTemplateNoVerify dominant). (This is a slightly amplified case: it passes the whole http/ directory as each call's template source; our production path passes a fingerprint-filtered subset per host, so the per-call store is smaller — but it scales with concurrency the same way.)

Relevant log output

Peak HeapInuse from the reproducer above, sweeping the number of concurrent ExecuteNucleiWithOptsCtx calls (v3.11.x, http/ corpus, -tag tech):

N=1    peak HeapInuse= 309 MiB
N=2    peak HeapInuse= 557 MiB
N=4    peak HeapInuse=1049 MiB
N=8    peak HeapInuse=1687 MiB
N=16   peak HeapInuse=4891 MiB
N=24   peak HeapInuse=6238 MiB
N=32   peak HeapInuse=6571 MiB

In our production runtime, the Go memory sampler over one such run shows heap_inuse climbing to ~6.6–7.3 GB with NumGC in the thousands (GC thrashing on live data) before the container is OOM-killed at the 8 GiB cgroup limit.

Environment

  • Nuclei: v3.11.x used as a library (lib SDK), ThreadSafeNucleiEngine.
  • Go: 1.2x, linux/amd64 and linux/arm64.
  • Runtime: containerized, 8 GiB limit, GOMEMLIMIT=5GiB, GOGC=50.
  • Templates: broad corpus (network + http), fingerprint-filtered per host.

Anything else?

Root cause as we understand it (and why sharing the store is non-trivial): the cache-hit path in templates.Parse shallow-copies the cached template and then re-stamps per-call ExecutorOptions (Output writer, rate limiter, interactsh) onto the request objects via Request.UpdateOptionsExecutorOptions.ApplyNewEngineOptions. Under concurrent calls with distinct per-call Output writers, that mutates shared request state and misroutes findings — which is exactly why DoNotCache: true is set for the thread-safe path. So the per-call recompile is a correctness workaround, and the memory cost is its consequence. A safe shared store would need the per-call output/rate-limiter resolved at dispatch (or the request slices/options copied cheaply) rather than stamped onto shared compiled objects, plus tag filtering moved from load-time to dispatch-time over one preloaded store.

Prior history on this exact problem. We reported the same class of issue in nuclei #7569 ([BUG] ThreadSafeNucleiEngine accumulates memory across ExecuteNucleiWithOptsCtx calls, now closed). ProjectDiscovery addressed parts of it with #7659 (fix(lib): avoid caching engine state in shared parsers) and #7608 (feat(lib): reuse metadata cache across thread-safe scans) — both merged, and both helped by sharing the parser and metadata index across calls. But the compiled store is still rebuilt per call (DoNotCache: true), so peak heap still scales with concurrency, which is why we're re-raising it here with a profile.

Related recompilation/leak sources we've reported in the ecosystem (they show up in the profile above and under high concurrency):

  • projectdiscovery/dsl#322 / projectdiscovery/dsl#323 — regex helpers recompile their pattern on every call; cache compiled regex patterns instead (issue + PR, open) — corresponds to regexp.compile in the profile.
  • projectdiscovery/utils#759 / projectdiscovery/utils#761ConnReadN leaks a goroutine per context-cancelled read (unbuffered chan in ExecFuncWithTwoReturns); fix proposed (issue + PR, open) — a leak we hit under high concurrency.

Questions for the maintainers:

  1. Is running many concurrent ExecuteNucleiWithOptsCtx calls with per-target WithTemplateFilters over one shared engine a supported/intended pattern for per-host-filtered scanning at scale — or is there a better API for "different template subset per target"?
  2. Is DoNotCache: true per call the intended long-term tradeoff for thread-safe concurrent executes, or is a safely-shareable compiled store on the roadmap (dispatch-time filtering / copy-on-write request options)?
  3. If the fan-out is not the recommended shape, what is — a single Execute over all targets with the union of templates (accepting over-scan), or something else we're missing?