A Nuclei template matched. What did it prove? Matchers, conditions, evidence classes, soft 404s, block pages, and how a template finding gets confirmed

DedSec Security Research ·

Here is a hypothetical. A Nuclei run against a staging estate returns three findings worth a second look. The first comes from a custom template the team wrote last year, admin-panel-exposed, rated medium, and it matched on every host behind one load balancer. The second is a critical from a version-based CVE template against a web server, and its evidence is a Server header. The third is a high from a path traversal template on /download?file=, and the response it matched was a 403.

The developer who picks up the first one opens a ticket per host. The second is escalated, because the template says critical. The third is closed as a false positive once someone notices the 403 came from the web application firewall. Each call is made from the template's name and severity, not from its matchers and the response they were evaluated against. A Nuclei result says that a set of conditions, written by a template author, evaluated to true against one response or a sequence of responses. What that proves depends entirely on which conditions they were.

This post is for whoever is holding a Nuclei finding. It covers which parts of a template are evidence, the kinds of evidence a template can carry, why template severity is an estimate, how a match goes wrong and how a clean run misses, how to re-verify a match by hand, how to write safer custom templates, and what template signing establishes. It is the fourth post in a series about confirming DAST findings, after reflected XSS, SQL injection and OS command injection. It builds on out-of-band testing, version banners and backports, CVSS, EPSS and KEV, DAST coverage and why "the scan passed" is not "the scan ran".

Sources: ProjectDiscovery's documentation at docs.projectdiscovery.io, specifically the pages Nuclei Template Structure, Basic HTTP Protocol, Raw HTTP Protocol, Matchers, Extractors, Preprocessors, Helper Functions, Flow Protocol, Template Workflows Overview, OOB Testing, Template Signing, Code Protocol, Running Nuclei, the Nuclei FAQ and the Nuclei Templates FAQ; the nuclei repository's README and SYNTAX-REFERENCE.md on GitHub; and, from the nuclei-templates repository, CONTRIBUTING.md, TEMPLATE-CREATION-GUIDE.md, the .nuclei-ignore file, the git-config, tech-detect and wordpress-detect templates, and the wiki page "How to Write Unique Matchers in Nuclei Templates". All were retrieved on 30 October 2026, when the latest Nuclei release on GitHub was v3.11.1. The behaviour described as measured below was measured the same day with Nuclei v3.7.0, which is the version installed on our test machine, so a newer release may differ in detail. Nuclei and a small Python test server ran together inside a Linux network namespace that had only a loopback interface. No template library was installed, the update check was disabled with -duc, every template was one we wrote for the test, no interaction server was contacted, and nothing outside 127.0.0.1 was scanned. The opening scenario is hypothetical, and no number in this post measures the accuracy of any scanner, ours included.

What a match is

The structure page lists what a template contains: an ID, information and metadata, a protocol, the requests for that protocol, "A series of matchers to ascertain the presence of findings", and extractors "for data retrieval from the results". Nuclei sends the requests, evaluates each matcher against a named part of a response, combines the matcher results under the template's conditions, and prints a line when the combination is true.

Nothing else in the template takes part in that decision. The name, CVE ID, description and severity are metadata describing the vulnerability the author was aiming at, not what was observed on your host. The Nuclei Templates FAQ puts it in one sentence: "Templates are only as good as their matchers." The FAQ also says the ProjectDiscovery team reviews community templates before merging them, and "Still, there is a possibility that some templates with weak matchers will slip through the verification."

Anatomy of a template

id and info

The id is "used during output writing to specify the template name for an output line", and it must not contain spaces. The info block carries name, author, severity, description, reference and tags, plus a classification block (the syntax reference lists fields such as cve-id, cwe-id, cvss-metrics, cvss-score, epss-score and cpe) and a free-form metadata block.

Two of those fields matter for triage.

The request: path or raw

An http block lists requests. The simple form gives a method and a list of paths built from variables. The variables matter more than they look. For the input https://example.com:443/foo/bar.php, the documentation shows {{BaseURL}} expanding to the whole input URL, {{RootURL}} to https://example.com:443, and {{Hostname}} to example.com:443. A template whose path is {{BaseURL}}/.git/config requests a different URL depending on whether the target was given as a bare host or with a path. When two runs disagree, compare the matched-at URLs before comparing anything else.

The raw form takes a complete HTTP request, with the Host header written as Host: {{Hostname}}. Cookies are reused between the requests in a template by default.

Redirects change results quietly. The Basic HTTP page says: "By default, redirects are not followed." A template can set redirects: true with max-redirects, or host-redirects to follow only redirects to the same host, and the documentation warns that "Currently redirects are defined per template, not per request." On the command line, -fr (-follow-redirects), -fhr (-follow-host-redirects), -mr (-max-redirects, default 10) and -dr (-disable-redirects) override the template.

Templates also generate random values: the {{randstr}} preprocessor "Generates a random ID for a template on each nuclei run" and keeps it constant within the template, and the rand_base helper generates a random string of a given length.

Matchers

The Matchers page lists seven types. What each one can prove is narrower than its name suggests.

Two conditions, at two levels

There are two separate condition settings, and both default to OR.

A template that sets neither is an OR of ORs: any single word in any matcher is enough. That suits a detection template recognising a product by any of several markers. It is the wrong default for a template that claims a vulnerability.

part

The default part is the body: "default matched part is body if not defined." Other parts include header and all (the extractor documentation lists "header,body,all"), raw in the syntax reference's examples, and interactsh_protocol, interactsh_request and interactsh_response for out-of-band templates.

A matcher looking at the wrong part fails without saying so. Our test server sends a Server header containing BaseHTTP. A word matcher for BaseHTTP with no part set did not match. The same matcher with part: header did. In the other direction, a matcher on all or on header can be satisfied by a header a CDN or proxy added, which tells you about the CDN, not the origin.

negative

Any matcher can set negative: true, which the syntax reference defines as "It will only match if the condition is not true." With matchers-condition: and, a negative matcher is how a template says "and the response is not the soft 404 page, the login page or the block page".

Extractors

Extractors (regex, kval, json, xpath and dsl) pull values out of a response and print them on the result line. With internal: true an extractor stores its value for later requests instead, which is how a template carries a CSRF token or a version string into the next request.

An extractor is not a matcher, and its output is not a condition that was proven. We wrote a template with a regex extractor for the page title and no matchers at all, and ran it against a test site that returns 200 with an HTML page for any path. Nuclei reported [extract-only] [http] [info] http://127.0.0.1:8071/anything ["Acme"]: a result, at a path that does not exist, because the extractor found a title. Version numbers in detection results often come from extractors, which is why they deserve the same scepticism as a version banner.

Multi-request templates: req-condition, internal matchers and flow

A template can send several requests and match on all of them together. The Basic HTTP page describes request conditions: status_code_1, body_2 and so on refer to the responses of earlier requests by number, and the feature "will be automatically enabled if DSL matchers/extractors contain numbers as a suffix". The syntax reference's req-condition field "automatically assigns numbers to requests and preserves their history."

Nuclei v3 added flow, which orchestrates requests with JavaScript (ECMAScript 5.1, through the goja engine). flow: http(1) && http(2) sends the second request only if the first matched. Matchers on the first request can be marked internal: true, which "hides the matcher from output", so a precondition such as "this host runs the plugin" gates the exploit request without being reported as a finding of its own. The Matchers page uses a template for CVE-2023-6553 to show this.

Multi-request templates are how a template can carry its own baseline, as shown below.

stop-at-first-match

A request can list several paths, and stop-at-first-match (also -spm on the command line, whose help text adds "may break template/workflow logic") stops after the first path that matches. We measured the difference with three paths against a server that returns 200 for everything. Without the setting, Nuclei sent three requests and reported three results. With it, Nuclei sent one request, for the first path, and reported one result.

For triage, this means the order of paths decides what was tested. If the first path matched weakly, the later paths, which may have held stronger evidence, were never requested. The wordpress-detect template, as retrieved, lists four paths with stop-at-first-match set.

Workflows

A workflow runs templates in sequence. A conditional workflow runs subtemplates only when a base template, or a named matcher in it, matched. The documentation's example runs tech-detect and branches on matcher names such as vbulletin and jboss.

Workflows inherit errors in both directions. If detection misses the product, the vulnerability templates under it never run, and the result is silence. If detection fires wrongly, the vulnerability templates run against something they were not written for, and their results then stand or fall on their own matchers.

Three kinds of evidence a template can carry

Templates can be grouped by what their matchers are able to prove, whatever their directory or tags. Each group supports a different sentence in a finding report.

Detection and fingerprinting: "this looks like X"

The tech-detect template, as retrieved, is named "Wappalyzer Technology Detection". It has severity info, sends one request to {{BaseURL}}, follows up to two redirects on the same host, and has a long list of named matchers under matchers-condition: or. Each named matcher recognises one technology, such as atlassian-jira, highcharts or flat-ui, and the output reports the matcher name. The wordpress-detect template sends up to four requests with stop-at-first-match, follows up to two redirects, and has one regex matcher whose patterns are combined with OR. The patterns include a WordPress generator meta tag, wp-login.php and /wp-content/uploads/(.*), and three extractors pull version numbers from the generator tag and from ?ver= query strings on scripts and stylesheets.

Both templates are severity info and say "detect". What they establish is that the response contains markers associated with a product. A page that links to wp-login.php, or embeds an image from another site's /wp-content/uploads/, contains those markers too, since none of the patterns is tied to the scanned host. And the extracted version is whatever version an asset URL advertises, which a site can pin, strip or leave stale after an upgrade.

A detection result is an inventory fact and a lead, not a vulnerability.

Version comparison: "the advertised version is in a range"

Some templates go one step further and compare an extracted version against an affected range. The matcher wiki page shows one, for CVE-2024-25723 in ZenML: a dsl matcher requiring compare_versions(version, '< 0.46.7'), excluding three specific versions by name with !contains_any(...), and checking two product-specific strings in the body, all under condition: and. That is a careful version template. The explicit exclusions are how this template encodes fixed releases that sit inside the simple range.

The contribution guide says "We only accept templates with complete P.O.Cs instead of just detection based on version", but the matcher guide still documents the pattern, and custom template sets can use it freely. Wherever it comes from, a version match proves a string comparison. Distribution backports, banners a proxy rewrote and a front end answering for the origin all break the link between the string and the code that is running. The version banner post goes through those cases and what an outside check can honestly conclude from each.

Exposure: "this file is served"

The git-config template is the canonical exposure check. As retrieved, it requests {{BaseURL}}/.git/config and requires three things under matchers-condition: and: a word matcher for [credentials] or [core] in the body, a dsl matcher requiring that the lowercased body contains neither <html nor <body, and a status of 200. The second matcher is there to stop an HTML page that merely mentions those words from matching. Its extractors look for credentials in remote URLs and authorisation headers.

The evidence is strong for one sentence: a non-HTML response containing git configuration syntax is served at that path. It does not show that the rest of the .git directory can be downloaded, or what the history contains. That is the next question, and the git history post covers why a credential deleted from the latest commit is still in every full clone. It also does not show that the credentials extractor found anything. A result whose extracted values contain a credential is a different finding from one with no extracted values, and the result line shows which you have.

Exploit confirmation: "the target did something only the vulnerable code would do"

The strongest templates match on a value the target computed, or on an interaction it made.

Severity is the author's estimate

The contribution guide tells authors to set severity "Based on the CVSS score but can vary based on the exploit and real-world impact". It is one person's judgement of the vulnerability the template targets, made when the template was written. The git-config template, for instance, is medium, with a CVSS 3.1 score of 5.3 and CWE-200 in its classification.

That label does not move with the strength of the match. A critical on a version-only template and a critical on a computed-marker RCE template are the same word in the output, and only one has shown that the vulnerable code ran. Nor does it know your exposure. The CVSS, EPSS and KEV post covers turning a confirmed vulnerability into an urgency, which comes after confirmation.

Severity is also a selection filter, so an author's estimate decides what runs at all. -s (-severity) selects templates by severity. In our run, -s high,critical loaded 5 of the 14 templates in our test directory and silently left out the info, low and medium ones. A template whose author rated it low does not run in a high-and-critical scan, whatever it would have found.

How a match goes wrong

The repository's own default exclusion file, .nuclei-ignore, lists several templates under the comment "The following templates have been excluded because they have weak matchers and may generate FP results." Here is what weak matchers look like in mechanism, using the templates we ran.

The catch-all 200 that echoes the path

Many applications return 200 for any path. Single-page applications serve their shell, frameworks route unknown paths to a friendly error page, and error pages often print the path that was requested. Our "soft" test site does exactly that: every unknown path returns 200 with "The page /path could not be found."

A template that requests /server-status and matches the word server-status in the body matched that site, because the word was in the echoed path. It also failed to match our "real" test site, which serves an actual status page whose body says "Server Status for 127.0.0.1" and never contains the hyphenated string. That one weak matcher was wrong in both directions: a false positive on a page that does not exist, and a false negative on a page that does.

The general rule: any word or pattern that also appears in the request can be satisfied by an echo of the request.

Status-only matchers

A template that requests /admin/ and matches only a 200 matched the soft site and not the real one, which returns a proper 404. A 200 shows that something answered. On an application with a catch-all route, everything answers. The same is true of templates that match only a 401 or a 403 to infer "a protected admin panel exists": a WAF or an authentication proxy in front of the whole host answers every path with the same status.

OR conditions where one weak matcher carries the result

We wrote a template with two named matchers under matchers-condition: or: a word matcher for [core], named core-section, and a status matcher for 200, named status-200. Against the soft site it reported [git-config-or:status-200] [http] [medium] http://127.0.0.1:8071/.git/config. Against the real site, which serves a real .git/config, it reported two lines, one for each matcher name.

The name after the colon is the most useful thing on the result line. It tells you which matcher carried the result. In JSONL output (-jsonl) the same information is in the matcher-name field, and our soft-site result had "matcher-name":"status-200". A medium finding called "git config" whose matcher name is status-200 is not evidence of a git config. When matchers have no name, v3.7.0 printed a generated one in our debug run (word-1), so naming every matcher in your own templates makes results readable.

The same two matchers plus a dsl check that the body is not HTML, under matchers-condition: and, which is the shape of the public git-config template, matched the real site and did not match the soft site.

WAF and CDN block pages

Our third test site plays a web application firewall. Any request whose URL contains ../ gets a 403 and a page saying "Your request to /download?file=../../../../etc/passwd was blocked by policy." A path traversal template that matched the word etc/passwd reported a high-severity finding against it. A template sending the same request but matching the regex root:.*:0:0: did not match, because the block page contains the path and not the file. With -ms (-matcher-status, "display match failure status"), Nuclei printed [lfi-regex] [failed] [http] [high] 127.0.0.1 for that template. (The summary line of that run still said "1 matches found". Read the [failed] and [matched] tags, not the summary.)

A match against a block page is a false positive, and a non-match against one is not a clean result, because the payload never reached the application. The DAST coverage post covers block pages that return 200, which a status matcher cannot tell apart from the application.

Matching the reflected request

The contribution guide lists this in its Don'ts: "Avoid submitting templates with weak matchers. For example: Adding GET/POST data as the matchers in the template, as it can result in false positive results on few hosts". It is easy to do by accident. The Preprocessors page says randstr "is also supported within matchers and can be used to match the inputs", and its example sends echo '{{randstr}}' in a header and matches {{randstr}} in the body. That proves execution only on an application that cannot echo the header back.

We measured the difference on two test endpoints. /reflect prints the submitted command back in a "Queued job" message. /exec passes it to a shell after echo. A template that sent x;echo {{marker}}, with marker set to a random rand_base(8) value, and matched the marker as a word, matched both endpoints. A template that sent x;echo {{m1}}''{{m2}} and matched {{m1}}{{m2}}, the two random halves joined, matched only /exec. A shell removes the empty quotes and prints the halves together. A reflection prints them with the quotes still between them.

The literal-echo shape is the one that comes to hand first. Even the RCE snippet under the creation guide's heading "Common Vulnerability Types - Template Patterns" matches an echoed {{marker}}, alongside a separate regex for root|administrator|www-data with no matchers-condition, so under the default OR either matcher alone completes a match.

Caching

A CDN or reverse proxy can answer with a copy of what the origin returned earlier, so a file removed last week can still match from cache. This is general HTTP behaviour, which we did not measure here. Check the Age, Cache-Control and any cache-status headers in the debug response, and replay with a query string the cache has not seen, or against the origin.

Version strings and backports

Covered above. A compare_versions matcher proves that one string compared as less than another.

How a clean run misses

A clean result means no template's conditions were true. That covers "tested and not vulnerable", and it also covers every case where the conditions could not become true because the request never reached the code. The scan-passed post makes the general argument. These are the Nuclei-specific ways it happens.

Re-verifying a match by hand

The Nuclei FAQ's advice on results is direct: "we always recommend that you validate it a second time before reporting it", and it suggests rerunning the template "with -debug flag to inspect the output against the expected matcher defined in the template." Here is that advice expanded into a procedure.

  1. Read the template. -td (-template-display) prints it. Note how the paths expand for this input, each matcher's part, both conditions, matcher names, negative matchers, redirects, stop-at-first-match and any req-condition or flow. Then write one sentence saying what the matchers require. "A 200 at /admin/" is a different sentence from "a non-HTML body containing [core] at /.git/config".
  2. Rerun only that template against only that URL, with -debug, or -debug-req and -debug-resp separately, and with -ms. Read the response yourself, and find the bytes that satisfied each matcher.
  3. Check which matcher carried the result. Use the name after the colon on the result line, or matcher-name in JSONL.
  4. Take a baseline. Request a random path on the same host and compare the status, the length and whether the matched words appear there too. If they do, the matcher is satisfied by the catch-all. A template can do this itself. Ours requests {{BaseURL}}/{{randstr}} and then the real path, with req-condition: true and a dsl matcher requiring status_code_2 == 200, the expected content in body_2, and status_code_1 != 200 || !contains(body_1, 'Server Status for'). Against the soft site it did not match. Against the real site it did. In -debug-req the first request was GET /3LPAVssyJU4Xx1PnsXarc3J5oY3.
  5. Replay outside Nuclei. JSONL results include a curl-command field with the request Nuclei sent. Use it as sent. In our -debug-req output, the two requests of one template carried different User-Agent strings, so a WAF rule, bot filter or cache that varies on User-Agent can answer a replay differently from the scan.
  6. Vary one thing at a time: the marker, the Host header, the cache key. A real vulnerability survives a change of marker; an echo, a cache or a catch-all usually does not.
  7. Mind clustering. Nuclei merges identical requests from different templates. Our run that loaded five templates logged "Templates clustered: 2 (Reduced 1 Requests)". -dc (-disable-clustering) turns that off. Running one template alone sends only its own requests, which is what you want for verification.
  8. Keep the evidence. -sresp (-store-resp) stores every request and response. JSON and JSONL output include the request and response pairs for findings unless -or (-omit-raw) is set. Attach the pair that proves the result, so a retest after the fix can be compared with it.

Writing safer custom templates

The nuclei-templates matcher guide suggests starting from three questions before writing a matcher: what product, technology or service am I targeting; what vulnerability or issue am I detecting; and what supporting evidence strengthens the detection. The contribution guide adds: "Make sure to add more than one matcher to prevent false positive results. Avoid short word that could be encountered anywhere". From our measurements and those two documents, general practice for a template you own:

Template trust and signing

Templates are code. An http template decides what your scanner sends to targets, and a code template goes further: "Nuclei enables the execution of external code on the host operating system", with the target passed to the code on stdin.

The Template Signing page describes what Nuclei provides. Templates are signed with ECDSA. "All official Nuclei templates include a digital signature and are verified by Nuclei while loading templates using ProjectDiscovery's public key (shipped with the Nuclei binary)." Users can generate their own key pair with nuclei and sign their own templates with -sign. Signing "is optional for all protocols except code"; "Unsigned code templates are disabled and can not be executed using Nuclei"; and "Only signed code templates by the author (yourself) or ProjectDiscovery can be executed."

We checked what we could without creating keys. An unsigned code template was excluded by default ("Excluded 1 code template[s] (disabled as default), use -code option to run code templates"). With -code it was still not run: Nuclei reported "Found 1 unsigned or tampered code template" and "Skipping 1 unsigned template[s]". An unsigned http template ran by default, with the warning "Loading 1 unsigned templates for scan. Use with caution." With -dut (-disable-unsigned-templates, "disable running unsigned templates or templates with mismatched signature"), the same http template was skipped.

What a signature establishes is integrity and origin. The template has not changed since a holder of that key signed it. It says nothing about whether the template's matchers are any good, and the signing page notes that payload files a template references "are not included in the template digest". So:

A triage checklist for a Nuclei finding

  1. Find the template and read its matchers, not its name. Write one sentence saying what they require.
  2. Classify the evidence. Detection, version comparison, exposure, or exploit confirmation (a computed marker, content the request lacked, or an out-of-band interaction). The report should say which.
  3. Treat severity as the author's estimate until the class is known, then use CVSS, EPSS, KEV and your own exposure for urgency.
  4. Check which matcher fired. Use the name on the result line or matcher-name in JSONL. A strong template whose weakest matcher carried the result is a weak result.
  5. Check the conditions. Defaults are OR at both levels. An OR template can be satisfied by its weakest matcher.
  6. Rerun the one template against the one URL with -debug and -ms, and read the response that satisfied it.
  7. Take a random-path baseline. If the same words or status appear for a path that does not exist, the match is the catch-all.
  8. Check whether the match is the request coming back. Compare the matched text with what the template sent. A literal marker, a path or a parameter value in the response proves reflection, not execution.
  9. Check for a block page or cache. Look at the status, the Server and cache headers, and the body. A block page match is a false positive, and a block page non-match is untested.
  10. For version-based results, apply the version banner rules. Check backports, the component actually answering, and the distribution's fixed package.
  11. For out-of-band results, check the protocol and content. An HTTP interaction with a computed value is strong. DNS-only, on a parameter that holds a URL or host name, is what the application is meant to do.
  12. Replay outside Nuclei using the curl-command from JSONL, and vary one thing at a time.
  13. Record what was not tested. Note redirects not followed, authentication not given, hosts dropped by the error budget, templates excluded by filters or .nuclei-ignore, and OAST disabled. A clean run with these gaps is not a clean application.
  14. Fix the template when it was the problem, and close the finding with the evidence attached.

The hypothetical findings, resolved

Back to the three hypothetical findings.

In the scenario, the admin-panel finding came from a template with one status matcher for 200, and every host behind the load balancer served the same single-page application shell for any path. A random-path baseline returned the same 200 and body. The findings were closed with the baseline attached, and the template was rewritten with a product-specific word matcher, a negative matcher for the shell's title, matchers-condition: and and a random-path request under req-condition. Run again, it matched nothing.

In the scenario, the critical version finding came from a dsl matcher comparing a version extracted from the Server header. The host runs its distribution's packaged web server, whose changelog lists the CVE as fixed in the installed package revision, which the header does not show. Following the version banner post, the finding was recorded as "advertised version in the affected range, package fixed according to the distribution", and closed with the package evidence, not with the template's severity.

In the scenario, the path traversal finding is the instructive one. The template had matched the word etc/passwd, and the response was the WAF's block page, which echoed the URL. The match was a false positive. But a second reviewer challenged the closure, and a retest from an allowlisted address that bypassed the WAF, using the root:.*:0:0: regex, returned the file. The WAF blocked the literal ../ form, and the handler accepted an encoded variant the WAF did not decode. In this hypothetical, the vulnerability was real, and it had been found by a matcher that would have fired whether or not it was. The finding was reopened with the correct evidence, the handler was fixed to resolve paths against an allowlisted directory, and the template was replaced with one that sends the encoded variant and matches root:.*:0:0:, with the literal-form check run from the allowlisted address.

Where AppSecD fits

AppSecD's dynamic and API testing runs Nuclei, Dalfox, SQLMap, Commix, Katana, Arjun, FFuf and Interactsh for out-of-band detection, plus more than 150 native checks and API discovery across more than 50 web frameworks. Its findings are deduplicated across scans and tracked through new, reopened and fixed states, and its lifecycle supports maker-checker approval, so a finding marked as a false positive can require a second person to confirm it.

That is the whole claim. This post does not claim which Nuclei version or template library release AppSecD runs, which templates, tags or severities it selects, whether it uses automatic scan, whether it runs community or custom templates, whether it verifies template signatures or runs code templates, how it handles redirects, authentication, rate limits or the host error budget, which interaction server it uses or how long it waits for callbacks, how it maps a template's severity onto its own, whether it performs baseline comparisons, or how accurate its Nuclei-derived findings are. The anatomy, the measurements, the template practices and the checklist above are general practice, and they apply to any Nuclei output, ours included. The scenario in this post is hypothetical and does not describe any AppSecD deployment, user or finding.

More about AppSecD · All posts