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.
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."
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.
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.
The Matchers page lists seven types. What each one can prove is narrower than its name suggests.
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.
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.
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 (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.
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.
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.
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.
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.
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.
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.
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.
The strongest templates match on a value the target computed, or on an interaction it made.
root:.*:0:0: is looking for the structure of /etc/passwd. The request contains the text "etc/passwd" but not that structure, so neither a reflection nor a block page that echoes the URL can satisfy the matcher.root:.*:0:0: example on interactsh_request, is the strongest. The out-of-band post covers correlation and the DNS caveat.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.
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.
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.
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.
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.
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.
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.
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.
Covered above. A compare_versions matcher proves that one string compared as less than another.
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.
[backup-dir] [failed] [http] [medium] 127.0.0.1. With -fr, it matched and reported the final URL, /backup/. HTTP to HTTPS, trailing slashes, locale prefixes and login redirects all produce this.Host: 127.0.0.1:8071). Scanning by IP address reaches the server's default virtual host, which may not be the application. Scanning a CDN hostname tests the edge, which may answer for paths the origin would serve differently.-itags option"; the listed files are skipped "unless asked for by the user", as the ignore file puts it. Code templates need -code, headless templates need -headless, and fuzzing templates need -dast (the older -fuzz flag is marked deprecated).Could not make http request for http://127.0.0.1:8072: unresolved variables found: interactsh-url, appeared only with -v or -debug-req. Either way, no OAST result can come back. Correlation also has a time limit: per the help text, requests are evicted from the interactions cache after 60 seconds by default (-interactions-eviction), with a 5-second cooldown before exit (-interactions-cooldown-period), so a callback from a queue worker or a scheduled job that arrives later may never be matched to its request. And a target with no outbound DNS or HTTP never calls back.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.
{{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.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:
matchers-condition: and. Have one matcher that identifies the product, one that proves the issue, and a status check as support. Reserve OR for detection templates whose job is to recognise a product by any of several markers.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:
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.
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.