Nuclei: writing a custom template for an uncovered vulnerability
By Elias Lankinen11 min read
I have thorough, well-sourced material and four verified images. Writing the post now.
The five-hour window
When researchers disclosed a critical flaw in the Kubernetes NGINX ingress controller in March 2025, a bug chain nicknamed IngressNightmare that let an unauthenticated attacker run code inside a cluster, ProjectDiscovery shipped a detection rule for it in about five hours. Legacy commercial scanners, according to ProjectDiscovery's own figures, took two to five days to catch up. That gap, hours versus days, is the whole game in vulnerability management: the window between a flaw becoming public and your ability to find every place it lives in your estate. But here is the uncomfortable part. That five-hour rule existed because someone wrote it. Nuclei, the open-source scanner it ran on, does not "know" about vulnerabilities in any built-in sense. It knows how to make a request and check the answer against a pattern, and every one of those patterns is a text file that a human authored. The community has written more than 12,000 of them. The one you actually need, for the bespoke internal API your company built, or the CVE that dropped this morning and has no rule yet, or the misconfiguration specific to your stack, does not exist until you write it. This is a guide to writing that file.
What Nuclei actually is, and what it is not
Nuclei is a fast, open-source vulnerability scanner from ProjectDiscovery, the company built around a set of security tools started in 2020 by a handful of bug-bounty hunters and security engineers, including Nizamul Rana, who goes by Ice3man543. Its public debut came in July 2020. The core idea was blunt: existing open-source scanners were slow, hard to extend, and locked their detection logic inside code. Nuclei moved the logic out into flat YAML files that anyone can read, audit, fork, and contribute back. The result now carries more than 30,000 GitHub stars, has over 900 contributors, and runs an estimated 50 million scans a month. YAML, short for "YAML Ain't Markup Language," is a plain-text format for structured data that leans on indentation instead of brackets. If you have seen a Docker Compose file or a GitHub Actions workflow, you have read YAML. Nuclei layers a small domain-specific language (DSL) on top of it: a handful of keywords for describing a request and the conditions that mean "vulnerable." Two misconceptions are worth clearing up before going further. The first is that Nuclei is an exploitation framework, a Metasploit for the YAML crowd. It is not. A well-written template detects a vulnerability and stops there. The Openfire template we will build below reaches a page it should not be able to reach and confirms the page loaded. It does not create a rogue admin account, even though the underlying flaw allows exactly that. Detection and exploitation are different jobs, and conflating them is how you turn a scan into an incident. The second misconception is that the enormous community library means you never need to write your own. The library is superb for known, public, widely-shared CVEs. In November 2025 alone the project added 197 templates covering 83 CVEs, including 19 vulnerabilities from CISA's Known Exploited Vulnerabilities catalog. But nobody in that community has seen your internal admin panel, your custom authentication header, or the specific 24-hour-old advisory you are racing against. The library is a floor, not a ceiling.
The anatomy of a template
Every template is two parts stacked in one file: metadata about the finding, then the mechanics of the check.
The metadata lives under an id and an info block. The id is a unique, space-free string used to reference the template in output. The info block, per the official structure docs, carries the human context: a name, an author, a severity rating, a description, references, and an optional classification block for machine-readable scoring. Severity follows five tiers that map to CVSS: info, low, medium, high, and critical.
Here is the skeleton, with nothing detecting anything yet:
id: example-detection
info:
name: Example Vulnerability Detection
author: your-handle
severity: high
description: |
A one-paragraph explanation of the flaw and why it matters.
reference:
- https://nvd.nist.gov/vuln/detail/CVE-XXXX-XXXXX
classification:
cvss-metrics: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N
cvss-score: 7.5
cve-id: CVE-XXXX-XXXXX
cwe-id: CWE-22
tags: cve,exposure,example
Below the metadata comes the protocol block. Nuclei speaks several protocols, HTTP, DNS, TCP, SSL, file, headless browser, and a code protocol, but the overwhelming majority of web templates use http. A note on syntax drift: older templates use a top-level requests: key, which is deprecated in favour of http:. If you copy an old example and it warns you, that is why.
An HTTP block describes the request or requests to send and the matchers that decide the verdict:
http:
- method: GET
path:
- '{{BaseURL}}/.git/config'
matchers-condition: and
matchers:
- type: word
words:
- '[core]'
- type: status
status:
- 200
{{BaseURL}} is a variable Nuclei fills in from the target you point it at. This tiny template, adapted from the canonical git-config example, asks for /.git/config and reports a finding only if the response is a 200 and the body contains the string [core]. That "and" is doing real work, and it is where most of the craft lives.
Matchers: where templates are won and lost
A matcher is a comparison against some part of the response. Nuclei v3 supports several matcher types: word for literal strings, status for HTTP codes, regex for regular expressions, binary for hex byte patterns, size for content length, dsl for dynamic expressions, and xpath for querying HTML and XML. Each matcher can target a specific part of the response: body (the default), header, all_headers, status_code, or all.
Multiple matchers combine through matchers-condition, which is either and (every matcher must fire) or or (any one is enough, and this is the default). It is the single most consequential line in most templates.
The reason is false positives, and this is the part the template creation guide hammers hardest. A matcher for the word admin, or error, or login will fire on a staggering number of perfectly healthy, non-vulnerable systems. A template that cries wolf is worse than no template, because it trains your team to ignore its output. The guide's prescribed defence is layered verification: confirm you are talking to the right application, confirm the affected version or behaviour, and confirm the specific technical artefact that only the vulnerability produces. Tie those together with and, and test the template against a patched copy of the same software and against competing products to make sure it stays silent when it should.
The dsl matcher is the escape hatch for logic a simple word match cannot express. It evaluates expressions using built-in helper functions such as contains(), tolower(), and rand_base():
matchers:
- type: dsl
condition: and
dsl:
- "status_code == 200"
- "contains(tolower(body), 'jivesoftware')"
Extractors are the complementary tool. Where a matcher returns a yes or no, an extractor pulls a value out of the response, a version string, a token, a hostname, for reporting or for feeding into a follow-up request. They come in the same flavours: regex, kval for header and cookie key-value pairs, json for JSON responses using a jq-like path, and xpath.
A worked example: the Openfire admin console bypass
Abstractions only get you so far, so let us build a real template for a real flaw. CVE-2023-32315 is a path-traversal authentication bypass in Openfire, the open-source XMPP chat server from Ignite Realtime. The advisory explains that Openfire's administrative console had path-traversal protection, but that protection did not account for a non-standard UTF-16 URL encoding the embedded web server happened to accept. The flaw affects every version released since April 2015, starting with 3.10.0, and was patched in 4.7.5 and 4.6.8. It sits in CISA's Known Exploited Vulnerabilities list with an EPSS score that rounds to a near-certain 1.0, meaning exploitation in the wild is effectively guaranteed.
The proof of concept is a single request. Normally the admin console at /log.jsp requires a session. But the unauthenticated setup environment can be abused to traverse to it:
GET /setup/setup-s/%u002e%u002e/%u002e%u002e/log.jsp
Those %u002e sequences are the UTF-16 encoding of a dot. Two dots plus a slash is ../, the classic directory-traversal step, dressed in an encoding the server's traversal filter failed to recognise. Chain two of them and you climb out of /setup/ and land on a restricted page as an anonymous user.
Now think like a template author. What does a successful exploit look like on the wire, and how do I distinguish it from a login redirect, a 404, or a totally different product that happens to have a /log.jsp? The answer: the traversal succeeds only if I receive an HTTP 200 and the body contains fingerprints unique to an Openfire admin page. The real community template, authored by a contributor called vsh00t, does exactly that:
http:
- raw:
- |+
GET /setup/setup-s/%u002e%u002e/%u002e%u002e/log.jsp HTTP/1.1
Host: {{Hostname}}
Origin: {{BaseURL}}
unsafe: true
matchers-condition: and
matchers:
- type: word
part: body
words:
- "apache"
- "java"
- "openfire"
- "jivesoftware"
condition: and
- type: status
status:
- 200
Three details are worth pulling apart. First, it uses raw rather than path. A raw request lets you write the exact HTTP request byte for byte, which matters here because you do not want Nuclei's normal URL handling to "helpfully" decode or normalise those %u002e sequences before they reach the target. That is also why unsafe: true is set: it tells Nuclei to send the request through a client that will not sanitise the malformed path.
Second, the word matcher lists four strings and joins them with condition: and, so all four (apache, java, openfire, jivesoftware) must appear in the body. Any one of those alone would be a weak, false-positive-prone signal. Together they are a tight fingerprint for an Openfire admin page specifically.
Third, the whole thing is gated behind matchers-condition: and with a status check for 200. Reach the page, prove it is genuinely Openfire's restricted console, confirm the server served it rather than redirecting you to a login. Only then does it fire. That is the layered discipline from the previous section, made concrete.
When the response tells you nothing: out-of-band detection
The Openfire check is easy in one respect: the vulnerable behaviour shows up right there in the HTTP response you can read. A large and dangerous class of vulnerabilities does not cooperate that way. Server-side request forgery, blind command injection, blind XXE, and the Log4Shell-style JNDI lookups all share a nasty property: the application may execute your payload perfectly while returning a completely normal-looking response. There is nothing in the body to match on.
The trick, and it is genuinely clever, is to stop watching what the server says and start watching what it does. If your payload makes the target reach out to a server you control, a DNS lookup, an HTTP call, an SMTP connection, then the mere fact that the callback arrived is your proof. Nuclei ships this capability through a companion service called Interactsh, integrated since v2.3.6.
In a template you write the placeholder {{interactsh-url}} wherever the payload would trigger a callback. At scan time Nuclei swaps in a unique, randomly generated Interactsh URL, then polls the Interactsh server, every five seconds, holding an LRU cache of up to 5,000 outstanding requests so it can correlate which callback belongs to which target. You match on the callback using a special part: interactsh_protocol (dns, http, or smtp), interactsh_request, or interactsh_response.
A skeleton for a blind command-injection check reads like this:
http:
- raw:
- |
POST /vulnerable/endpoint HTTP/1.1
Host: {{Hostname}}
Content-Type: application/x-www-form-urlencoded
cmd=nslookup+{{interactsh-url}}
matchers:
- type: word
part: interactsh_protocol
words:
- "dns"
If the target runs the injected nslookup, its DNS resolver contacts the Interactsh server, the callback lands, and the matcher fires, regardless of what the application returned to the attacker. ProjectDiscovery's own example of this pattern injects nslookup {{interactsh-url}} into a HashiCorp Consul service registration to confirm remote code execution with nothing but a DNS ping to go on.
Validate, test, then trust
A template you have not run is a hypothesis, not a detection. The workflow has three cheap steps and skipping them is how bad templates reach production.
Start with a syntax check. nuclei -t my-template.yaml -validate loads the file and reports structural errors without sending a single packet. Fix whatever it complains about. Be aware this catches syntax, not logic: an open issue on the project notes that -validate has historically passed templates that were semantically wrong, so a clean validate is necessary but not sufficient.
Next, run it against a target you control and turn on -debug:
nuclei -t my-template.yaml -u http://your-test-instance -debug
The -debug flag prints the full request Nuclei sent and the full response it got back, which is the fastest way to see why a matcher did or did not fire. If your %u002e sequences got mangled, you will see it here.
Finally, and this is the step people skip, run the template against something that should not match: a patched version, a similar product, a default error page. The creation guide is explicit that you should test against patched systems and competing applications to prove the template stays quiet. A detection that never produces a false positive on a healthy host is worth ten that are noisy. When a finding does fire on a real asset, confirm it by hand once and keep the request, response, and evidence.
What this changes
The reflex, when a scary CVE lands, is to wait for your scanner vendor to add a check. Templates invert that dependency. The skill is not exotic: a request, a couple of matchers joined by and, a fingerprint tight enough to avoid crying wolf. In an afternoon you can go from reading an advisory to sweeping your entire estate for it, on your own timeline rather than a vendor's.
There is a second-order effect worth watching. ProjectDiscovery now ships an AI-assisted template editor that turns a vulnerability description into a draft template, and the release cadence keeps climbing: 226 new templates in April 2026 covering 123 CVEs. As generating a detection gets closer to free, the bottleneck moves from writing the check to judging it. A machine can draft a matcher; it cannot yet tell you, with confidence, that your matcher will stay silent against every patched host and lookalike product in the wild. That judgment, knowing the difference between a string that appears in a response and a string that proves a vulnerability, is the part that stays human. For now.
Sources
- ProjectDiscovery, Nuclei: Community-powered vulnerability scanning, 2026.
- ProjectDiscovery, Nuclei GitHub repository, 2026.
- ProjectDiscovery, Nuclei Template Structure documentation, 2026.
- ProjectDiscovery, Nuclei SYNTAX-REFERENCE.md, 2026.
- ProjectDiscovery, nuclei-templates TEMPLATE-CREATION-GUIDE.md, 2026.
- ProjectDiscovery, If You're Not Writing Custom Nuclei Templates, You're Missing Out, 2024.
- ProjectDiscovery, Nuclei + Interactsh Integration for Automating OOB Testing, 2021.
- ProjectDiscovery, Nuclei Templates, November 2025, 2025.
- ProjectDiscovery, Nuclei Templates, April 2026, 2026.
- GitHub Advisory Database, Administration Console authentication bypass in Openfire (CVE-2023-32315, GHSA-gw42-f939-fhvm), 2023.
- ProjectDiscovery, CVE-2023-32315 nuclei template (raw), 2023.
- GitHub, nuclei issue #4866: validate accepts invalid template, 2024.
- Ice3man543 (Nizamul Rana), announcement of the Nuclei tool, 2020.