@@ -2080,7 +2081,8 @@ Valid values:
Parameters is the parameters for the analyzer
Parameters are different for each analyzer. For example, you can customize
-time_delay analyzer with sleep_duration, time_slope_error_range, etc. Refer
+time_delay analyzer with sleep_duration, time_slope_error_range, etc.
+The xss_context analyzer accepts an optional "canary" parameter. Refer
to the docs for each analyzer to get an idea about parameters.
diff --git a/cmd/nuclei/main.go b/cmd/nuclei/main.go
index 8421d1dc69..9529fc864c 100644
--- a/cmd/nuclei/main.go
+++ b/cmd/nuclei/main.go
@@ -330,6 +330,8 @@ on extensive configurability, massive extensibility and ease of use.`)
flagSet.StringVarP(&options.JSONExport, "json-export", "je", "", "file to export results in JSON format"),
flagSet.StringVarP(&options.JSONLExport, "jsonl-export", "jle", "", "file to export results in JSONL(ine) format"),
flagSet.StringSliceVarP(&options.Redact, "redact", "rd", nil, "redact given list of keys from query parameter, request header and body", goflags.CommaSeparatedStringSliceOptions),
+ flagSet.IntVarP(&options.HoneypotThreshold, "honeypot-threshold", "hpt", 0, "minimum number of unique template matches before a host is flagged as honeypot (0 = disabled)"),
+ flagSet.BoolVarP(&options.HoneypotSuppress, "honeypot-suppress", "hpsu", false, "suppress results from hosts flagged as honeypots"),
)
flagSet.CreateGroup("configs", "Configurations",
diff --git a/pkg/fuzz/analyzers/analyzers.go b/pkg/fuzz/analyzers/analyzers.go
index 6266e8bb01..b2b0b4977d 100644
--- a/pkg/fuzz/analyzers/analyzers.go
+++ b/pkg/fuzz/analyzers/analyzers.go
@@ -27,12 +27,14 @@ type AnalyzerTemplate struct {
// Name is the name of the analyzer to use
// values:
// - time_delay
+ // - xss_context
Name string `json:"name" yaml:"name"`
// description: |
// Parameters is the parameters for the analyzer
//
// Parameters are different for each analyzer. For example, you can customize
- // time_delay analyzer with sleep_duration, time_slope_error_range, etc. Refer
+ // time_delay analyzer with sleep_duration, time_slope_error_range, etc.
+ // The xss_context analyzer accepts an optional "canary" parameter. Refer
// to the docs for each analyzer to get an idea about parameters.
Parameters map[string]interface{} `json:"parameters" yaml:"parameters"`
}
@@ -61,6 +63,14 @@ type Options struct {
HttpClient *retryablehttp.Client
ResponseTimeDelay time.Duration
AnalyzerParameters map[string]interface{}
+
+ // ResponseBody is the raw response body from the fuzz request.
+ // Used by analyzers that need to inspect response content (e.g. xss_context).
+ ResponseBody string
+ // ResponseHeaders contains the response headers keyed by header name.
+ ResponseHeaders map[string][]string
+ // ResponseStatusCode is the HTTP status code from the fuzz response.
+ ResponseStatusCode int
}
var (
diff --git a/pkg/fuzz/analyzers/xss/analyzer.go b/pkg/fuzz/analyzers/xss/analyzer.go
new file mode 100644
index 0000000000..b5e2e848dd
--- /dev/null
+++ b/pkg/fuzz/analyzers/xss/analyzer.go
@@ -0,0 +1,252 @@
+// Package xss implements a context-aware XSS reflection analyzer for the
+// nuclei fuzzing engine. It detects where user-controlled input is
+// reflected in an HTTP response, classifies the surrounding HTML
+// parsing context, selects payloads that can structurally achieve
+// script execution in that context, and replays them to verify
+// exploitability.
+//
+// The analyzer is registered under the name "xss_context" and can be
+// used in fuzzing templates via the `analyzer` field:
+//
+// analyzer:
+// name: xss_context
+// parameters:
+// canary: "" # optional
+//
+// When no custom canary is provided, the analyzer generates one that
+// includes special characters needed for character-survival detection.
+package xss
+
+import (
+ "fmt"
+ "io"
+ "strings"
+
+ "github.com/pkg/errors"
+ "github.com/projectdiscovery/gologger"
+ "github.com/projectdiscovery/nuclei/v3/pkg/fuzz/analyzers"
+)
+
+// Analyzer implements the analyzers.Analyzer interface for XSS
+// context detection and verification.
+type Analyzer struct{}
+
+var _ analyzers.Analyzer = &Analyzer{}
+
+func init() {
+ analyzers.RegisterAnalyzer("xss_context", &Analyzer{})
+}
+
+// Name returns the registered name of this analyzer.
+func (a *Analyzer) Name() string {
+ return "xss_context"
+}
+
+// defaultCanarySuffix contains characters whose survival we want to
+// test. It is appended to the random marker so the reflection check
+// can determine which chars survive server-side filtering.
+const defaultCanarySuffix = `<>"'/`
+
+// ApplyInitialTransformation replaces the [XSS_CANARY] placeholder
+// in the payload template with a generated canary value. The canary
+// consists of a random alphanumeric prefix (to avoid collisions with
+// page content) plus special characters for character-survival testing.
+//
+// If the payload does not contain [XSS_CANARY], standard placeholder
+// transformations ([RANDNUM], [RANDSTR]) are applied instead.
+func (a *Analyzer) ApplyInitialTransformation(data string, params map[string]interface{}) string {
+ data = analyzers.ApplyPayloadTransformations(data)
+
+ if strings.Contains(data, "[XSS_CANARY]") {
+ // Allow a custom canary via template parameters.
+ canary := ""
+ if params != nil {
+ if v, ok := params["canary"]; ok {
+ canary, _ = v.(string)
+ }
+ }
+ if canary == "" {
+ canary = "nxss" + randAlphaNum(6) + defaultCanarySuffix
+ }
+ data = strings.ReplaceAll(data, "[XSS_CANARY]", canary)
+ if params != nil {
+ params["xss_canary"] = canary
+ }
+ }
+ return data
+}
+
+// Analyze inspects the HTTP response for reflected XSS vulnerabilities.
+//
+// High-level flow:
+// 1. Extract the canary from analyzer parameters (set by
+// ApplyInitialTransformation).
+// 2. Check if the canary is present in the response body.
+// 3. Run the HTML tokenizer-based context detector to classify each
+// reflection point.
+// 4. For each context, select payloads whose required characters
+// survived the server's filtering.
+// 5. Replay each candidate payload through the original fuzz
+// component and verify the response confirms exploitability.
+// 6. Return true with a descriptive reason string on the first
+// confirmed reflection, or false if nothing verifies.
+func (a *Analyzer) Analyze(options *analyzers.Options) (bool, string, error) {
+ // Retrieve the canary that ApplyInitialTransformation injected.
+ canary := ""
+ if options.AnalyzerParameters != nil {
+ if v, ok := options.AnalyzerParameters["xss_canary"]; ok {
+ canary, _ = v.(string)
+ }
+ }
+ if canary == "" {
+ return false, "", nil
+ }
+
+ body := options.ResponseBody
+ if body == "" {
+ return false, "", nil
+ }
+
+ // Quick check: is the canary reflected at all?
+ if !strings.Contains(body, canary) {
+ return false, "", nil
+ }
+
+ reflections := DetectReflections(body, canary)
+ if len(reflections) == 0 {
+ return false, "", nil
+ }
+
+ // For each reflection, try context-appropriate payloads.
+ for _, ref := range reflections {
+ payloads := SelectPayloads(ref.Context, ref.Chars)
+ if len(payloads) == 0 {
+ continue
+ }
+
+ for _, payload := range payloads {
+ ok, err := replayAndVerify(options, payload, ref.Context)
+ if err != nil {
+ gologger.Verbose().Msgf("[%s] replay error for payload %q: %v", a.Name(), payload, err)
+ continue
+ }
+ if ok {
+ reason := fmt.Sprintf(
+ "[xss_context] reflected XSS confirmed in %s context at position %d (payload: %s)",
+ ref.Context, ref.Position, payload,
+ )
+ return true, reason, nil
+ }
+ }
+ }
+
+ return false, "", nil
+}
+
+// replayAndVerify sends the candidate payload through the original
+// fuzz component (replacing the fuzzed value), reads the response, and
+// checks whether the payload appears unencoded in the appropriate
+// context. This reduces false positives that would occur if we only
+// checked whether characters survive without verifying actual
+// injection success.
+func replayAndVerify(options *analyzers.Options, payload string, ctx ContextType) (bool, error) {
+ gr := options.FuzzGenerated
+
+ // Save the original value so we can restore the component after
+ // replaying. This is important because other payloads or subsequent
+ // analysis steps need the component in its original state.
+ original := gr.Value
+ if original == "" {
+ original = gr.OriginalValue
+ }
+ needsRestore := original != "" || gr.Value != "" || gr.OriginalValue != ""
+ defer func() {
+ if needsRestore {
+ _ = gr.Component.SetValue(gr.Key, original)
+ _, _ = gr.Component.Rebuild()
+ }
+ }()
+
+ if err := gr.Component.SetValue(gr.Key, payload); err != nil {
+ return false, errors.Wrap(err, "could not set payload value")
+ }
+
+ rebuilt, err := gr.Component.Rebuild()
+ if err != nil {
+ return false, errors.Wrap(err, "could not rebuild request")
+ }
+
+ gologger.Verbose().Msgf("[%s] replaying payload %q to %s", "xss_context", payload, rebuilt.URL.String())
+
+ resp, err := options.HttpClient.Do(rebuilt)
+ if err != nil {
+ return false, errors.Wrap(err, "replay request failed")
+ }
+ defer resp.Body.Close()
+
+ respBody, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return false, errors.Wrap(err, "could not read replay response")
+ }
+
+ return verifyReplayBody(string(respBody), payload, ctx), nil
+}
+
+// verifyReplayBody checks whether the payload string (or its critical
+// components) appears in the response in a way that confirms
+// exploitability. Simple string containment is the baseline; for
+// specific contexts we look for structural indicators.
+func verifyReplayBody(body, payload string, ctx ContextType) bool {
+ if !strings.Contains(body, payload) {
+ return false
+ }
+
+ // Context-specific sanity checks to weed out false positives
+ // where the payload is present but not actually executable.
+ switch ctx {
+ case ContextHTMLText:
+ // The injected tag must appear as-is (not entity-encoded).
+ return strings.Contains(body, "") ||
+ strings.Contains(body, "onerror=alert(1)") ||
+ strings.Contains(body, "onload=alert(1)") ||
+ strings.Contains(body, "ontoggle=alert(1)")
+
+ case ContextAttribute, ContextAttributeUnquoted:
+ return strings.Contains(body, "onfocus=alert(1)") ||
+ strings.Contains(body, "onmouseover=alert(1)") ||
+ strings.Contains(body, "onload=alert(1)") ||
+ strings.Contains(body, "") ||
+ strings.Contains(body, ".
+ return strings.Contains(body, "-->") &&
+ (strings.Contains(body, "") ||
+ strings.Contains(body, "onerror=alert(1)"))
+
+ case ContextStyle:
+ return strings.Contains(body, "") &&
+ (strings.Contains(body, "") ||
+ strings.Contains(body, "onerror=alert(1)"))
+ }
+
+ // Fallback: the payload string is present verbatim.
+ return true
+}
+
+// randAlphaNum generates a random alphanumeric string of length n.
+// We reuse the shared random source from the analyzers package by
+// calling the exported helper.
+func randAlphaNum(n int) string {
+ const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
+ b := make([]byte, n)
+ for i := range b {
+ b[i] = charset[analyzers.GetRandomInteger()%len(charset)]
+ }
+ return string(b)
+}
diff --git a/pkg/fuzz/analyzers/xss/analyzer_test.go b/pkg/fuzz/analyzers/xss/analyzer_test.go
new file mode 100644
index 0000000000..e26c7892f9
--- /dev/null
+++ b/pkg/fuzz/analyzers/xss/analyzer_test.go
@@ -0,0 +1,103 @@
+package xss
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestAnalyzerName(t *testing.T) {
+ a := &Analyzer{}
+ require.Equal(t, "xss_context", a.Name())
+}
+
+func TestApplyInitialTransformation_CanaryReplacement(t *testing.T) {
+ a := &Analyzer{}
+ params := make(map[string]interface{})
+ result := a.ApplyInitialTransformation("test=[XSS_CANARY]&foo=bar", params)
+
+ // The canary placeholder should be replaced with the generated value.
+ require.NotContains(t, result, "[XSS_CANARY]")
+ // The canary should be stored in params.
+ canary, ok := params["xss_canary"]
+ require.True(t, ok, "xss_canary should be set in params")
+ require.NotEmpty(t, canary)
+ require.Contains(t, result, canary.(string))
+}
+
+func TestApplyInitialTransformation_CustomCanary(t *testing.T) {
+ a := &Analyzer{}
+ params := map[string]interface{}{
+ "canary": "my_custom_canary<>\"'",
+ }
+ result := a.ApplyInitialTransformation("q=[XSS_CANARY]", params)
+ require.Contains(t, result, "my_custom_canary<>\"'")
+ require.Equal(t, "my_custom_canary<>\"'", params["xss_canary"])
+}
+
+func TestApplyInitialTransformation_NoPlaceholder(t *testing.T) {
+ a := &Analyzer{}
+ params := make(map[string]interface{})
+ result := a.ApplyInitialTransformation("test=[RANDSTR]&id=[RANDNUM]", params)
+
+ // RANDSTR and RANDNUM should be replaced, but no canary.
+ require.NotContains(t, result, "[RANDSTR]")
+ require.NotContains(t, result, "[RANDNUM]")
+ _, ok := params["xss_canary"]
+ require.False(t, ok, "xss_canary should not be set without placeholder")
+}
+
+func TestApplyInitialTransformation_NilParams(t *testing.T) {
+ a := &Analyzer{}
+ // Should not panic with nil params.
+ result := a.ApplyInitialTransformation("test=[XSS_CANARY]", nil)
+ require.NotContains(t, result, "[XSS_CANARY]")
+}
+
+func TestVerifyReplayBody_HTMLText(t *testing.T) {
+ body := ``
+ require.True(t, verifyReplayBody(body, "", ContextHTMLText))
+}
+
+func TestVerifyReplayBody_HTMLText_Encoded(t *testing.T) {
+ // If the server entity-encodes the payload, it should NOT verify.
+ body := `<script>alert(1)</script>`
+ require.False(t, verifyReplayBody(body, "", ContextHTMLText))
+}
+
+func TestVerifyReplayBody_Attribute(t *testing.T) {
+ body := ``
+ payload := `" onfocus=alert(1) autofocus="`
+ require.True(t, verifyReplayBody(body, payload, ContextAttribute))
+}
+
+func TestVerifyReplayBody_Script(t *testing.T) {
+ body := ``
+ require.True(t, verifyReplayBody(body, ";alert(1)//", ContextScript))
+}
+
+func TestVerifyReplayBody_Comment(t *testing.T) {
+ body := `-->`
+ payload := "-->"
+ require.True(t, verifyReplayBody(body, payload, ContextHTMLComment))
+}
+
+func TestVerifyReplayBody_Style(t *testing.T) {
+ body := ``
+ payload := ""
+ require.True(t, verifyReplayBody(body, payload, ContextStyle))
+}
+
+func TestVerifyReplayBody_PayloadNotPresent(t *testing.T) {
+ body := `safe content`
+ require.False(t, verifyReplayBody(body, "", ContextHTMLText))
+}
+
+func TestRandAlphaNum(t *testing.T) {
+ s := randAlphaNum(8)
+ require.Len(t, s, 8)
+ for _, c := range s {
+ require.True(t, (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9'),
+ "character %c should be alphanumeric lowercase", c)
+ }
+}
diff --git a/pkg/fuzz/analyzers/xss/context_detector.go b/pkg/fuzz/analyzers/xss/context_detector.go
new file mode 100644
index 0000000000..1d4c7c6b9f
--- /dev/null
+++ b/pkg/fuzz/analyzers/xss/context_detector.go
@@ -0,0 +1,362 @@
+package xss
+
+import (
+ "bytes"
+ "strings"
+
+ "golang.org/x/net/html"
+)
+
+// Package-level byte slices to avoid per-call heap allocation in the
+// hot tokenizer loop.
+var (
+ scriptTag = []byte("script")
+ styleTag = []byte("style")
+ titleTag = []byte("title")
+ textareaT = []byte("textarea")
+ onPrefix = []byte("on")
+)
+
+// maxReflections caps how many reflection points we track per response
+// to bound memory and CPU in pathological pages.
+const maxReflections = 16
+
+// DetectReflections walks the HTML response body using the standard
+// tokenizer and returns metadata about every location where the marker
+// string is reflected. The caller typically sends a canary value and
+// passes it here as `marker`.
+//
+// The function handles:
+// - Text nodes (body content)
+// - Attribute values (quoted and unquoted)
+// - Script blocks (inline JS)
+// - Style blocks (inline CSS)
+// - RCDATA elements (title, textarea)
+// - Comments
+// - Event-handler attributes (classified as script context)
+//
+// When the HTML tokenizer fails to find a reflection that we know
+// exists (malformed/truncated HTML), we fall back to substring
+// scanning to avoid missing reflections.
+func DetectReflections(body, marker string) []ReflectionInfo {
+ if !strings.Contains(body, marker) {
+ return nil
+ }
+
+ m := []byte(marker)
+ z := html.NewTokenizer(strings.NewReader(body))
+
+ var (
+ reflections []ReflectionInfo
+ tagStack []string // tracks nesting for script/style/rcdata
+ )
+
+ currentContext := func() string {
+ if len(tagStack) == 0 {
+ return ""
+ }
+ return tagStack[len(tagStack)-1]
+ }
+
+ // Track how much of the body we have consumed so we can calculate
+ // byte offsets for each reflection.
+ bodyOffset := 0
+
+ for {
+ if len(reflections) >= maxReflections {
+ break
+ }
+
+ tt := z.Next()
+ raw := string(z.Raw())
+ tokenLen := len(raw)
+
+ switch tt {
+ case html.ErrorToken:
+ // End of document. Fall through to the drain logic.
+ goto drain
+
+ case html.CommentToken:
+ if bytes.Contains(z.Text(), m) {
+ chars := detectCharsNearMarker(body, marker, bodyOffset, tokenLen)
+ reflections = append(reflections, ReflectionInfo{
+ Context: ContextHTMLComment,
+ Position: bodyOffset + strings.Index(raw, marker),
+ Chars: chars,
+ })
+ }
+
+ case html.StartTagToken, html.SelfClosingTagToken:
+ tn, hasAttr := z.TagName()
+ tagLower := strings.ToLower(string(tn))
+
+ if tt == html.StartTagToken {
+ switch {
+ case bytes.EqualFold(tn, scriptTag):
+ tagStack = append(tagStack, "script")
+ case bytes.EqualFold(tn, styleTag):
+ tagStack = append(tagStack, "style")
+ case bytes.EqualFold(tn, titleTag), bytes.EqualFold(tn, textareaT):
+ tagStack = append(tagStack, tagLower)
+ }
+ }
+
+ // Check if marker appears in the raw tag (e.g. tag name injection)
+ if hasAttr {
+ reflections = findAttributeReflections(raw, z, marker, m, body, bodyOffset, tokenLen, reflections)
+ }
+
+ case html.EndTagToken:
+ tn, _ := z.TagName()
+ closingTag := strings.ToLower(string(tn))
+ // Pop matching tag from the stack, searching from the top.
+ for i := len(tagStack) - 1; i >= 0; i-- {
+ if tagStack[i] == closingTag {
+ tagStack = tagStack[:i]
+ break
+ }
+ }
+
+ case html.TextToken:
+ text := z.Text()
+ if bytes.Contains(text, m) {
+ ctx := currentContext()
+ chars := detectCharsNearMarker(body, marker, bodyOffset, tokenLen)
+ var ctxType ContextType
+
+ switch ctx {
+ case "script":
+ ctxType = classifyScriptContext(raw, marker)
+ case "style":
+ ctxType = ContextStyle
+ case "title", "textarea":
+ // RCDATA elements: content is not parsed as HTML
+ ctxType = ContextHTMLText
+ default:
+ ctxType = ContextHTMLText
+ }
+
+ idx := strings.Index(raw, marker)
+ if idx < 0 {
+ // Marker may be entity-encoded in raw but decoded in Text
+ idx = 0
+ }
+ reflections = append(reflections, ReflectionInfo{
+ Context: ctxType,
+ Position: bodyOffset + idx,
+ Chars: chars,
+ })
+ }
+ }
+
+ bodyOffset += tokenLen
+ }
+
+drain:
+ // If we know the marker is in the body but the tokenizer missed it
+ // (truncated HTML, unclosed tags, etc.), scan the remaining text
+ // with substring windows to pick up anything we missed.
+ reflections = drainRemainingReflections(body, marker, reflections)
+ return reflections
+}
+
+// findAttributeReflections examines the attributes of the current start
+// tag for reflections of the marker. Event handler attributes are
+// classified as ContextScript; other attributes are ContextAttribute
+// or ContextAttributeUnquoted depending on the quoting style in the
+// raw token string.
+func findAttributeReflections(
+ raw string,
+ z *html.Tokenizer,
+ marker string,
+ m []byte,
+ body string,
+ bodyOffset, tokenLen int,
+ reflections []ReflectionInfo,
+) []ReflectionInfo {
+ for {
+ if len(reflections) >= maxReflections {
+ break
+ }
+ k, v, more := z.TagAttr()
+
+ if bytes.Contains(v, m) {
+ attrName := strings.ToLower(string(k))
+ chars := detectCharsNearMarker(body, marker, bodyOffset, tokenLen)
+
+ if isEventHandler(k) {
+ reflections = append(reflections, ReflectionInfo{
+ Context: ContextScript,
+ Position: bodyOffset,
+ AttributeName: attrName,
+ Chars: chars,
+ })
+ } else {
+ ctxType := classifyAttributeContext(raw, marker)
+ reflections = append(reflections, ReflectionInfo{
+ Context: ctxType,
+ Position: bodyOffset,
+ AttributeName: attrName,
+ Chars: chars,
+ })
+ }
+ }
+
+ // Also check if the marker is in the attribute name itself
+ // (attribute injection).
+ if bytes.Contains(k, m) {
+ chars := detectCharsNearMarker(body, marker, bodyOffset, tokenLen)
+ reflections = append(reflections, ReflectionInfo{
+ Context: ContextAttribute,
+ Position: bodyOffset,
+ Chars: chars,
+ })
+ }
+
+ if !more {
+ break
+ }
+ }
+ return reflections
+}
+
+// classifyAttributeContext looks at the raw HTML around the marker to
+// determine whether the attribute is double-quoted, single-quoted, or
+// unquoted. The distinction matters because breakout characters differ.
+func classifyAttributeContext(raw, marker string) ContextType {
+ idx := strings.Index(raw, marker)
+ if idx < 0 {
+ return ContextAttribute
+ }
+
+ // Walk backward from the marker to find the quote character (or lack
+ // thereof) that opens this attribute value.
+ for i := idx - 1; i >= 0; i-- {
+ ch := raw[i]
+ switch ch {
+ case '"':
+ return ContextAttribute
+ case '\'':
+ return ContextAttribute
+ case '=':
+ // '=' immediately before the marker with no quote means unquoted
+ return ContextAttributeUnquoted
+ case ' ', '\t', '\n', '\r':
+ // Whitespace before an unquoted value
+ return ContextAttributeUnquoted
+ }
+ }
+ return ContextAttribute
+}
+
+// classifyScriptContext determines whether the marker inside a