Skip to content

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

Description

@G360-Niek

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.
// 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).

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):

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?

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