diff --git a/cmd/nuclei/main.go b/cmd/nuclei/main.go index 13a7ec2c1a..fb03825c2a 100644 --- a/cmd/nuclei/main.go +++ b/cmd/nuclei/main.go @@ -323,6 +323,9 @@ on extensive configurability, massive extensibility and ease of use.`) flagSet.StringVarP(&options.FuzzingMode, "fuzzing-mode", "fm", "", "overrides fuzzing mode set in template (multiple, single)"), flagSet.BoolVar(&fuzzFlag, "fuzz", false, "enable loading fuzzing templates (Deprecated: use -dast instead)"), flagSet.BoolVar(&options.DAST, "dast", false, "enable / run dast (fuzz) nuclei templates"), + flagSet.BoolVarP(&options.DisplayFuzzPoints, "display-fuzz-points", "dfp", false, "display fuzz points in the output for debugging"), + flagSet.IntVar(&options.FuzzParamFrequency, "fuzz-param-frequency", 10, "frequency of uninteresting parameters for fuzzing before skipping"), + flagSet.StringVarP(&options.FuzzAggressionLevel, "fuzz-aggression", "fa", "low", "fuzzing aggression level controls payload count for fuzz (low, medium, high)"), ) flagSet.CreateGroup("uncover", "Uncover", diff --git a/integration_tests/fuzz/fuzz-path-sqli.yaml b/integration_tests/fuzz/fuzz-path-sqli.yaml index e23098d931..531427becb 100644 --- a/integration_tests/fuzz/fuzz-path-sqli.yaml +++ b/integration_tests/fuzz/fuzz-path-sqli.yaml @@ -15,21 +15,18 @@ http: - type: dsl dsl: - 'method == "GET"' - - regex("/(.*?/)([0-9]+)(/.*)?",path) condition: and payloads: pathsqli: - - "'OR1=1" - '%20OR%20True' fuzzing: - part: path - type: replace-regex + type: postfix mode: single - replace-regex: '/(.*?/)([0-9]+)(/.*)?' fuzz: - - '/${1}${2}{{pathsqli}}${3}' + - '{{pathsqli}}' matchers: - type: status diff --git a/internal/runner/runner.go b/internal/runner/runner.go index 5319f566d9..ed5d82bdef 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -15,6 +15,7 @@ import ( "github.com/projectdiscovery/nuclei/v3/internal/pdcp" "github.com/projectdiscovery/nuclei/v3/pkg/authprovider" + "github.com/projectdiscovery/nuclei/v3/pkg/fuzz/frequency" "github.com/projectdiscovery/nuclei/v3/pkg/input/provider" "github.com/projectdiscovery/nuclei/v3/pkg/installer" "github.com/projectdiscovery/nuclei/v3/pkg/loader/parser" @@ -74,21 +75,22 @@ var ( // Runner is a client for running the enumeration process. type Runner struct { - output output.Writer - interactsh *interactsh.Client - options *types.Options - projectFile *projectfile.ProjectFile - catalog catalog.Catalog - progress progress.Progress - colorizer aurora.Aurora - issuesClient reporting.Client - browser *engine.Browser - rateLimiter *ratelimit.Limiter - hostErrors hosterrorscache.CacheInterface - resumeCfg *types.ResumeCfg - pprofServer *http.Server - pdcpUploadErrMsg string - inputProvider provider.InputProvider + output output.Writer + interactsh *interactsh.Client + options *types.Options + projectFile *projectfile.ProjectFile + catalog catalog.Catalog + progress progress.Progress + colorizer aurora.Aurora + issuesClient reporting.Client + browser *engine.Browser + rateLimiter *ratelimit.Limiter + hostErrors hosterrorscache.CacheInterface + resumeCfg *types.ResumeCfg + pprofServer *http.Server + pdcpUploadErrMsg string + inputProvider provider.InputProvider + fuzzFrequencyCache *frequency.Tracker //general purpose temporary directory tmpDir string parser parser.Parser @@ -440,24 +442,28 @@ func (r *Runner) RunEnumeration() error { r.options.ExcludedTemplates = append(r.options.ExcludedTemplates, ignoreFile.Files...) } + fuzzFreqCache := frequency.New(frequency.DefaultMaxTrackCount, r.options.FuzzParamFrequency) + r.fuzzFrequencyCache = fuzzFreqCache + // Create the executor options which will be used throughout the execution // stage by the nuclei engine modules. executorOpts := protocols.ExecutorOptions{ - Output: r.output, - Options: r.options, - Progress: r.progress, - Catalog: r.catalog, - IssuesClient: r.issuesClient, - RateLimiter: r.rateLimiter, - Interactsh: r.interactsh, - ProjectFile: r.projectFile, - Browser: r.browser, - Colorizer: r.colorizer, - ResumeCfg: r.resumeCfg, - ExcludeMatchers: excludematchers.New(r.options.ExcludeMatchers), - InputHelper: input.NewHelper(), - TemporaryDirectory: r.tmpDir, - Parser: r.parser, + Output: r.output, + Options: r.options, + Progress: r.progress, + Catalog: r.catalog, + IssuesClient: r.issuesClient, + RateLimiter: r.rateLimiter, + Interactsh: r.interactsh, + ProjectFile: r.projectFile, + Browser: r.browser, + Colorizer: r.colorizer, + ResumeCfg: r.resumeCfg, + ExcludeMatchers: excludematchers.New(r.options.ExcludeMatchers), + InputHelper: input.NewHelper(), + TemporaryDirectory: r.tmpDir, + Parser: r.parser, + FuzzParamsFrequency: fuzzFreqCache, } if env.GetEnvOrDefault("NUCLEI_ARGS", "") == "req_url_pattern=true" { @@ -611,6 +617,7 @@ func (r *Runner) RunEnumeration() error { if executorOpts.InputHelper != nil { _ = executorOpts.InputHelper.Close() } + r.fuzzFrequencyCache.Close() // todo: error propagation without canonical straight error check is required by cloud? // use safe dereferencing to avoid potential panics in case of previous unchecked errors diff --git a/pkg/fuzz/component/path.go b/pkg/fuzz/component/path.go index 9d2e758025..b9aebd61af 100644 --- a/pkg/fuzz/component/path.go +++ b/pkg/fuzz/component/path.go @@ -2,10 +2,12 @@ package component import ( "context" + "strconv" + "strings" - "github.com/pkg/errors" "github.com/projectdiscovery/nuclei/v3/pkg/fuzz/dataformat" "github.com/projectdiscovery/retryablehttp-go" + urlutil "github.com/projectdiscovery/utils/url" ) // Path is a component for a request Path @@ -31,13 +33,18 @@ func (q *Path) Name() string { // parsed component func (q *Path) Parse(req *retryablehttp.Request) (bool, error) { q.req = req - q.value = NewValue(req.URL.Path) + q.value = NewValue("") - parsed, err := dataformat.Get(dataformat.RawDataFormat).Decode(q.value.String()) - if err != nil { - return false, err + splitted := strings.Split(req.URL.Path, "/") + values := make(map[string]interface{}) + for i := range splitted { + pathTillNow := strings.Join(splitted[:i+1], "/") + if pathTillNow == "" { + continue + } + values[strconv.Itoa(i)] = pathTillNow } - q.value.SetParsed(parsed, dataformat.RawDataFormat) + q.value.SetParsed(dataformat.KVMap(values), "") return true, nil } @@ -56,7 +63,8 @@ func (q *Path) Iterate(callback func(key string, value interface{}) error) (err // SetValue sets a value in the component // for a key func (q *Path) SetValue(key string, value string) error { - if !q.value.SetParsedValue(key, value) { + escaped := urlutil.ParamEncode(value) + if !q.value.SetParsedValue(key, escaped) { return ErrSetValue } return nil @@ -73,13 +81,31 @@ func (q *Path) Delete(key string) error { // Rebuild returns a new request with the // component rebuilt func (q *Path) Rebuild() (*retryablehttp.Request, error) { - encoded, err := q.value.Encode() - if err != nil { - return nil, errors.Wrap(err, "could not encode query") + originalValues := make(map[string]interface{}) + splitted := strings.Split(q.req.URL.Path, "/") + for i := range splitted { + pathTillNow := strings.Join(splitted[:i+1], "/") + if pathTillNow == "" { + continue + } + originalValues[strconv.Itoa(i)] = pathTillNow + } + + originalPath := q.req.URL.Path + lengthSplitted := len(q.value.parsed.Map) + for i := lengthSplitted; i > 0; i-- { + key := strconv.Itoa(i) + original := originalValues[key].(string) + new := q.value.parsed.Map[key].(string) + originalPath = strings.Replace(originalPath, original, new, 1) } + + rebuiltPath := originalPath + + // Clone the request and update the path cloned := q.req.Clone(context.Background()) - if err := cloned.UpdateRelPath(encoded, true); err != nil { - cloned.URL.RawPath = encoded + if err := cloned.UpdateRelPath(rebuiltPath, true); err != nil { + cloned.URL.RawPath = rebuiltPath } return cloned, nil } diff --git a/pkg/fuzz/component/path_test.go b/pkg/fuzz/component/path_test.go index 859ffcde12..c47f81f4ff 100644 --- a/pkg/fuzz/component/path_test.go +++ b/pkg/fuzz/component/path_test.go @@ -28,10 +28,10 @@ func TestURLComponent(t *testing.T) { return nil }) - require.Equal(t, []string{"value"}, keys, "unexpected keys") + require.Equal(t, []string{"1"}, keys, "unexpected keys") require.Equal(t, []string{"/testpath"}, values, "unexpected values") - err = urlComponent.SetValue("value", "/newpath") + err = urlComponent.SetValue("1", "/newpath") if err != nil { t.Fatal(err) } @@ -40,7 +40,41 @@ func TestURLComponent(t *testing.T) { if err != nil { t.Fatal(err) } - require.Equal(t, "/newpath", rebuilt.URL.Path, "unexpected URL path") require.Equal(t, "https://example.com/newpath", rebuilt.URL.String(), "unexpected full URL") } + +func TestURLComponent_NestedPaths(t *testing.T) { + path := NewPath() + req, err := retryablehttp.NewRequest(http.MethodGet, "https://example.com/user/753/profile", nil) + if err != nil { + t.Fatal(err) + } + found, err := path.Parse(req) + if err != nil { + t.Fatal(err) + } + if !found { + t.Fatal("expected path to be found") + } + + isSet := false + + _ = path.Iterate(func(key string, value interface{}) error { + if !isSet && value.(string) == "/user/753" { + isSet = true + if setErr := path.SetValue(key, "/user/753'"); setErr != nil { + t.Fatal(setErr) + } + } + return nil + }) + + newReq, err := path.Rebuild() + if err != nil { + t.Fatal(err) + } + if newReq.URL.Path != "/user/753'/profile" { + t.Fatal("expected path to be modified") + } +} diff --git a/pkg/fuzz/component/query.go b/pkg/fuzz/component/query.go index 3dc07e6cf1..571161ee13 100644 --- a/pkg/fuzz/component/query.go +++ b/pkg/fuzz/component/query.go @@ -61,6 +61,7 @@ func (q *Query) Iterate(callback func(key string, value interface{}) error) (err // SetValue sets a value in the component // for a key func (q *Query) SetValue(key string, value string) error { + // Is this safe? if !q.value.SetParsedValue(key, value) { return ErrSetValue } diff --git a/pkg/fuzz/execute.go b/pkg/fuzz/execute.go index bd1414638e..4f33ba6ddc 100644 --- a/pkg/fuzz/execute.go +++ b/pkg/fuzz/execute.go @@ -1,6 +1,7 @@ package fuzz import ( + "encoding/json" "fmt" "io" "regexp" @@ -15,6 +16,7 @@ import ( "github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/generators" "github.com/projectdiscovery/retryablehttp-go" errorutil "github.com/projectdiscovery/utils/errors" + sliceutil "github.com/projectdiscovery/utils/slice" urlutil "github.com/projectdiscovery/utils/url" ) @@ -45,6 +47,8 @@ type ExecuteRuleInput struct { Values map[string]interface{} // BaseRequest is the base http request for fuzzing rule BaseRequest *retryablehttp.Request + // DisplayFuzzPoints is a flag to display fuzz points + DisplayFuzzPoints bool } // GeneratedRequest is a single generated request for rule @@ -76,8 +80,9 @@ func (rule *Rule) Execute(input *ExecuteRuleInput) (err error) { var finalComponentList []component.Component // match rule part with component name + displayDebugFuzzPoints := make(map[string]map[string]string) for _, componentName := range component.Components { - if rule.partType != requestPartType && rule.Part != componentName { + if !(rule.Part == componentName || sliceutil.Contains(rule.Parts, componentName) || rule.partType == requestPartType) { continue } component := component.New(componentName) @@ -89,12 +94,25 @@ func (rule *Rule) Execute(input *ExecuteRuleInput) (err error) { if !discovered { continue } + // check rule applicable on this component if !rule.checkRuleApplicableOnComponent(component) { continue } + // Debugging display for fuzz points + if input.DisplayFuzzPoints { + displayDebugFuzzPoints[componentName] = make(map[string]string) + _ = component.Iterate(func(key string, value interface{}) error { + displayDebugFuzzPoints[componentName][key] = fmt.Sprintf("%v", value) + return nil + }) + } finalComponentList = append(finalComponentList, component) } + if len(displayDebugFuzzPoints) > 0 { + marshalled, _ := json.MarshalIndent(displayDebugFuzzPoints, "", " ") + gologger.Info().Msgf("[%s] Fuzz points for %s [%s]\n%s\n", rule.options.TemplateID, input.Input.MetaInput.Input, input.BaseRequest.Method, string(marshalled)) + } if len(finalComponentList) == 0 { return ErrRuleNotApplicable.Msgf("no component matched on this rule") @@ -225,7 +243,7 @@ func (rule *Rule) executeRuleValues(input *ExecuteRuleInput, ruleComponent compo if err != nil { return err } - if gotErr := rule.execWithInput(input, req, input.InteractURLs, ruleComponent, ""); gotErr != nil { + if gotErr := rule.execWithInput(input, req, input.InteractURLs, ruleComponent, "", ""); gotErr != nil { return gotErr } } @@ -261,8 +279,9 @@ func (rule *Rule) Compile(generator *generators.PayloadGenerator, options *proto } else { rule.partType = valueType } - } else { - rule.partType = queryPartType + } + if rule.Part == "" && len(rule.Parts) == 0 { + return errors.Errorf("no part specified for rule") } if rule.Type != "" { diff --git a/pkg/fuzz/frequency/tracker.go b/pkg/fuzz/frequency/tracker.go new file mode 100644 index 0000000000..063912b4c0 --- /dev/null +++ b/pkg/fuzz/frequency/tracker.go @@ -0,0 +1,152 @@ +package frequency + +import ( + "net" + "net/url" + "os" + "strings" + "sync" + "sync/atomic" + + "github.com/bluele/gcache" + "github.com/projectdiscovery/gologger" +) + +// Tracker implements a frequency tracker for a given input +// which is used to determine uninteresting input parameters +// which are not that interesting from fuzzing perspective for a template +// and target combination. +// +// This is used to reduce the number of requests made during fuzzing +// for parameters that are less likely to give results for a rule. +type Tracker struct { + frequencies gcache.Cache + paramOccurenceThreshold int + + isDebug bool +} + +const ( + DefaultMaxTrackCount = 10000 + DefaultParamOccurenceThreshold = 10 +) + +type cacheItem struct { + errors atomic.Int32 + sync.Once +} + +// New creates a new frequency tracker with a given maximum +// number of params to track in LRU fashion with a max error threshold +func New(maxTrackCount, paramOccurenceThreshold int) *Tracker { + gc := gcache.New(maxTrackCount).ARC().Build() + + var isDebug bool + if os.Getenv("FREQ_DEBUG") != "" { + isDebug = true + } + return &Tracker{ + isDebug: isDebug, + frequencies: gc, + paramOccurenceThreshold: paramOccurenceThreshold, + } +} + +func (t *Tracker) Close() { + t.frequencies.Purge() +} + +// MarkParameter marks a parameter as frequently occuring once. +// +// The logic requires a parameter to be marked as frequently occuring +// multiple times before it's considered as frequently occuring. +func (t *Tracker) MarkParameter(parameter, target, template string) { + normalizedTarget := normalizeTarget(target) + key := getFrequencyKey(parameter, normalizedTarget, template) + + if t.isDebug { + gologger.Verbose().Msgf("[%s] Marking %s as found uninteresting", template, key) + } + + existingCacheItem, err := t.frequencies.GetIFPresent(key) + if err != nil || existingCacheItem == nil { + newItem := &cacheItem{errors: atomic.Int32{}} + newItem.errors.Store(1) + _ = t.frequencies.Set(key, newItem) + return + } + existingCacheItemValue := existingCacheItem.(*cacheItem) + existingCacheItemValue.errors.Add(1) + + _ = t.frequencies.Set(key, existingCacheItemValue) +} + +// IsParameterFrequent checks if a parameter is frequently occuring +// in the input with no much results. +func (t *Tracker) IsParameterFrequent(parameter, target, template string) bool { + normalizedTarget := normalizeTarget(target) + key := getFrequencyKey(parameter, normalizedTarget, template) + + if t.isDebug { + gologger.Verbose().Msgf("[%s] Checking if %s is frequently found uninteresting", template, key) + } + + existingCacheItem, err := t.frequencies.GetIFPresent(key) + if err != nil { + return false + } + existingCacheItemValue := existingCacheItem.(*cacheItem) + + if existingCacheItemValue.errors.Load() >= int32(t.paramOccurenceThreshold) { + existingCacheItemValue.Do(func() { + gologger.Verbose().Msgf("[%s] Skipped %s from parameter for %s as found uninteresting %d times", template, parameter, target, existingCacheItemValue.errors.Load()) + }) + return true + } + return false +} + +// UnmarkParameter unmarks a parameter as frequently occuring. This carries +// more weight and resets the frequency counter for the parameter causing +// it to be checked again. This is done when results are found. +func (t *Tracker) UnmarkParameter(parameter, target, template string) { + normalizedTarget := normalizeTarget(target) + key := getFrequencyKey(parameter, normalizedTarget, template) + + if t.isDebug { + gologger.Verbose().Msgf("[%s] Unmarking %s as frequently found uninteresting", template, key) + } + + _ = t.frequencies.Remove(key) +} + +func getFrequencyKey(parameter, target, template string) string { + var sb strings.Builder + sb.WriteString(target) + sb.WriteString(":") + sb.WriteString(template) + sb.WriteString(":") + sb.WriteString(parameter) + str := sb.String() + return str +} + +func normalizeTarget(value string) string { + finalValue := value + if strings.HasPrefix(value, "http") { + if parsed, err := url.Parse(value); err == nil { + hostname := parsed.Host + finalPort := parsed.Port() + if finalPort == "" { + if parsed.Scheme == "https" { + finalPort = "443" + } else { + finalPort = "80" + } + hostname = net.JoinHostPort(parsed.Host, finalPort) + } + finalValue = hostname + } + } + return finalValue +} diff --git a/pkg/fuzz/fuzz.go b/pkg/fuzz/fuzz.go index c70bc3b2cc..b2109efc85 100644 --- a/pkg/fuzz/fuzz.go +++ b/pkg/fuzz/fuzz.go @@ -24,12 +24,27 @@ type Rule struct { ruleType ruleType // description: | // Part is the part of request to fuzz. - // - // query fuzzes the query part of url. More parts will be added later. // values: // - "query" + // - "header" + // - "path" + // - "body" + // - "cookie" + // - "request" Part string `yaml:"part,omitempty" json:"part,omitempty" jsonschema:"title=part of rule,description=Part of request rule to fuzz,enum=query,enum=header,enum=path,enum=body,enum=cookie,enum=request"` partType partType + // description: | + // Parts is the list of parts to fuzz. If multiple parts need to be + // defined while excluding some, this should be used instead of singular part. + // values: + // - "query" + // - "header" + // - "path" + // - "body" + // - "cookie" + // - "request" + Parts []string `yaml:"parts,omitempty" json:"parts,omitempty" jsonschema:"title=parts of rule,description=Part of request rule to fuzz,enum=query,enum=header,enum=path,enum=body,enum=cookie,enum=request"` + // description: | // Mode is the mode of fuzzing to perform. // diff --git a/pkg/fuzz/fuzz_test.go b/pkg/fuzz/fuzz_test.go index dc0771259b..6ef2e39b08 100644 --- a/pkg/fuzz/fuzz_test.go +++ b/pkg/fuzz/fuzz_test.go @@ -7,7 +7,9 @@ import ( ) func TestRuleMatchKeyOrValue(t *testing.T) { - rule := &Rule{} + rule := &Rule{ + Part: "query", + } err := rule.Compile(nil, nil) require.NoError(t, err, "could not compile rule") @@ -15,7 +17,7 @@ func TestRuleMatchKeyOrValue(t *testing.T) { require.True(t, result, "could not get correct result") t.Run("key", func(t *testing.T) { - rule := &Rule{Keys: []string{"url"}} + rule := &Rule{Keys: []string{"url"}, Part: "query"} err := rule.Compile(nil, nil) require.NoError(t, err, "could not compile rule") @@ -25,7 +27,7 @@ func TestRuleMatchKeyOrValue(t *testing.T) { require.False(t, result, "could not get correct result") }) t.Run("value", func(t *testing.T) { - rule := &Rule{ValuesRegex: []string{`https?:\/\/?([-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b)*(\/[\/\d\w\.-]*)*(?:[\?])*(.+)*`}} + rule := &Rule{ValuesRegex: []string{`https?:\/\/?([-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b)*(\/[\/\d\w\.-]*)*(?:[\?])*(.+)*`}, Part: "query"} err := rule.Compile(nil, nil) require.NoError(t, err, "could not compile rule") diff --git a/pkg/fuzz/parts.go b/pkg/fuzz/parts.go index 4c01135f66..6ab1643296 100644 --- a/pkg/fuzz/parts.go +++ b/pkg/fuzz/parts.go @@ -2,6 +2,7 @@ package fuzz import ( "io" + "strconv" "strings" "github.com/projectdiscovery/nuclei/v3/pkg/fuzz/component" @@ -9,6 +10,7 @@ import ( "github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/generators" "github.com/projectdiscovery/nuclei/v3/pkg/types" "github.com/projectdiscovery/retryablehttp-go" + sliceutil "github.com/projectdiscovery/utils/slice" ) // executePartRule executes part rules based on type @@ -18,7 +20,7 @@ func (rule *Rule) executePartRule(input *ExecuteRuleInput, payload ValueOrKeyVal // checkRuleApplicableOnComponent checks if a rule is applicable on given component func (rule *Rule) checkRuleApplicableOnComponent(component component.Component) bool { - if rule.Part != component.Name() { + if rule.Part != component.Name() && !sliceutil.Contains(rule.Parts, component.Name()) && rule.partType != requestPartType { return false } foundAny := false @@ -68,7 +70,7 @@ func (rule *Rule) executePartComponentOnValues(input *ExecuteRuleInput, payloadS return err } - if qerr := rule.execWithInput(input, req, input.InteractURLs, ruleComponent, key); qerr != nil { + if qerr := rule.execWithInput(input, req, input.InteractURLs, ruleComponent, key, valueStr); qerr != nil { return qerr } // fmt.Printf("executed with value: %s\n", evaluated) @@ -90,7 +92,7 @@ func (rule *Rule) executePartComponentOnValues(input *ExecuteRuleInput, payloadS if err != nil { return err } - if qerr := rule.execWithInput(input, req, input.InteractURLs, ruleComponent, ""); qerr != nil { + if qerr := rule.execWithInput(input, req, input.InteractURLs, ruleComponent, "", ""); qerr != nil { err = qerr return err } @@ -125,7 +127,7 @@ func (rule *Rule) executePartComponentOnKV(input *ExecuteRuleInput, payload Valu return err } - if qerr := rule.execWithInput(input, req, input.InteractURLs, ruleComponent, key); qerr != nil { + if qerr := rule.execWithInput(input, req, input.InteractURLs, ruleComponent, key, value); qerr != nil { return err } @@ -144,7 +146,23 @@ func (rule *Rule) executePartComponentOnKV(input *ExecuteRuleInput, payload Valu } // execWithInput executes a rule with input via callback -func (rule *Rule) execWithInput(input *ExecuteRuleInput, httpReq *retryablehttp.Request, interactURLs []string, component component.Component, parameter string) error { +func (rule *Rule) execWithInput(input *ExecuteRuleInput, httpReq *retryablehttp.Request, interactURLs []string, component component.Component, parameter, parameterValue string) error { + // If the parameter is a number, replace it with the parameter value + // or if the parameter is empty and the parameter value is not empty + // replace it with the parameter value + if _, err := strconv.Atoi(parameter); err == nil || (parameter == "" && parameterValue != "") { + parameter = parameterValue + } + // If the parameter is frequent, skip it if the option is enabled + if rule.options.FuzzParamsFrequency != nil { + if rule.options.FuzzParamsFrequency.IsParameterFrequent( + parameter, + httpReq.URL.String(), + rule.options.TemplateID, + ) { + return nil + } + } request := GeneratedRequest{ Request: httpReq, InteractURLs: interactURLs, diff --git a/pkg/protocols/common/generators/generators.go b/pkg/protocols/common/generators/generators.go index b99de24c0b..5711b445f5 100644 --- a/pkg/protocols/common/generators/generators.go +++ b/pkg/protocols/common/generators/generators.go @@ -25,8 +25,19 @@ func New(payloads map[string]interface{}, attackType AttackType, templatePath st // Resolve payload paths if they are files. payloadsFinal := make(map[string]interface{}) - for name, payload := range payloads { - payloadsFinal[name] = payload + for payloadName, v := range payloads { + switch value := v.(type) { + case map[interface{}]interface{}: + values, err := parsePayloadsWithAggression(payloadName, value, opts.FuzzAggressionLevel) + if err != nil { + return nil, errors.Wrap(err, "could not parse payloads with aggression") + } + for k, v := range values { + payloadsFinal[k] = v + } + default: + payloadsFinal[payloadName] = v + } } generator := &PayloadGenerator{catalog: catalog, options: opts} @@ -57,6 +68,60 @@ func New(payloads map[string]interface{}, attackType AttackType, templatePath st return generator, nil } +type aggressionLevelToPayloads struct { + Low []interface{} + Medium []interface{} + High []interface{} +} + +// parsePayloadsWithAggression parses the payloads with the aggression level +// +// Three agression are supported - +// - low +// - medium +// - high +// +// low is the default level. If medium is specified, all templates from +// low and medium are executed. Similarly with high, including all templates +// from low, medium, high. +func parsePayloadsWithAggression(name string, v map[interface{}]interface{}, agression string) (map[string]interface{}, error) { + payloadsLevels := &aggressionLevelToPayloads{} + + for k, v := range v { + if _, ok := v.([]interface{}); !ok { + return nil, errors.Errorf("only lists are supported for aggression levels payloads") + } + var ok bool + switch k { + case "low": + payloadsLevels.Low, ok = v.([]interface{}) + case "medium": + payloadsLevels.Medium, ok = v.([]interface{}) + case "high": + payloadsLevels.High, ok = v.([]interface{}) + default: + return nil, errors.Errorf("invalid aggression level %s specified for %s", k, name) + } + if !ok { + return nil, errors.Errorf("invalid aggression level %s specified for %s", k, name) + } + } + + payloads := make(map[string]interface{}) + switch agression { + case "low": + payloads[name] = payloadsLevels.Low + case "medium": + payloads[name] = append(payloadsLevels.Low, payloadsLevels.Medium...) + case "high": + payloads[name] = append(payloadsLevels.Low, payloadsLevels.Medium...) + payloads[name] = append(payloads[name].([]interface{}), payloadsLevels.High...) + default: + return nil, errors.Errorf("invalid aggression level %s specified for %s", agression, name) + } + return payloads, nil +} + // Iterator is a single instance of an iterator for a generator structure type Iterator struct { Type AttackType diff --git a/pkg/protocols/common/generators/generators_test.go b/pkg/protocols/common/generators/generators_test.go index a55e8e51ee..c478995525 100644 --- a/pkg/protocols/common/generators/generators_test.go +++ b/pkg/protocols/common/generators/generators_test.go @@ -1,9 +1,11 @@ package generators import ( + "strings" "testing" "github.com/stretchr/testify/require" + "gopkg.in/yaml.v2" "github.com/projectdiscovery/nuclei/v3/pkg/catalog/disk" "github.com/projectdiscovery/nuclei/v3/pkg/types" @@ -90,3 +92,49 @@ func getOptions(allowLocalFileAccess bool) *types.Options { opts.AllowLocalFileAccess = allowLocalFileAccess return opts } + +func TestParsePayloadsWithAggression(t *testing.T) { + testPayload := `linux_path: + low: + - /etc/passwd + medium: + - ../etc/passwd + - ../../etc/passwd + high: + - ../../../etc/passwd + - ../../../../etc/passwd + - ../../../../../etc/passwd` + + var payloads map[string]interface{} + err := yaml.NewDecoder(strings.NewReader(testPayload)).Decode(&payloads) + require.Nil(t, err, "could not unmarshal yaml") + + aggressionsToValues := map[string][]string{ + "low": { + "/etc/passwd", + }, + "medium": { + "/etc/passwd", + "../etc/passwd", + "../../etc/passwd", + }, + "high": { + "/etc/passwd", + "../etc/passwd", + "../../etc/passwd", + "../../../etc/passwd", + "../../../../etc/passwd", + "../../../../../etc/passwd", + }, + } + + for k, v := range payloads { + for aggression, values := range aggressionsToValues { + parsed, err := parsePayloadsWithAggression(k, v.(map[interface{}]interface{}), aggression) + require.Nil(t, err, "could not parse payloads with aggression") + + gotValues := parsed[k].([]interface{}) + require.Equal(t, len(values), len(gotValues), "could not get correct number of values") + } + } +} diff --git a/pkg/protocols/common/interactsh/interactsh.go b/pkg/protocols/common/interactsh/interactsh.go index c156164805..da59f10fb3 100644 --- a/pkg/protocols/common/interactsh/interactsh.go +++ b/pkg/protocols/common/interactsh/interactsh.go @@ -21,6 +21,7 @@ import ( "github.com/projectdiscovery/nuclei/v3/pkg/output" "github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/helpers/responsehighlighter" "github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/helpers/writer" + "github.com/projectdiscovery/retryablehttp-go" errorutil "github.com/projectdiscovery/utils/errors" stringsutil "github.com/projectdiscovery/utils/strings" ) @@ -180,6 +181,14 @@ func (c *Client) processInteractionForRequest(interaction *server.Interaction, d gologger.DefaultLogger.Print().Msgf("[Interactsh]: got result %v and status %v after processing interaction", result, matched) } + if c.options.FuzzParamsFrequency != nil { + if !matched { + c.options.FuzzParamsFrequency.MarkParameter(data.Parameter, data.Request.URL.String(), data.Operators.TemplateID) + } else { + c.options.FuzzParamsFrequency.UnmarkParameter(data.Parameter, data.Request.URL.String(), data.Operators.TemplateID) + } + } + // if we don't match, return if !matched || result == nil { return false @@ -320,6 +329,9 @@ type RequestData struct { Operators *operators.Operators MatchFunc operators.MatchFunc ExtractFunc operators.ExtractFunc + + Parameter string + Request *retryablehttp.Request } // RequestEvent is the event for a network request sent by nuclei. diff --git a/pkg/protocols/common/interactsh/options.go b/pkg/protocols/common/interactsh/options.go index c2ae250291..ca3dd459c9 100644 --- a/pkg/protocols/common/interactsh/options.go +++ b/pkg/protocols/common/interactsh/options.go @@ -4,6 +4,7 @@ import ( "time" "github.com/projectdiscovery/interactsh/pkg/client" + "github.com/projectdiscovery/nuclei/v3/pkg/fuzz/frequency" "github.com/projectdiscovery/nuclei/v3/pkg/output" "github.com/projectdiscovery/nuclei/v3/pkg/progress" "github.com/projectdiscovery/nuclei/v3/pkg/reporting" @@ -46,8 +47,9 @@ type Options struct { // NoColor disables printing colors for matches NoColor bool - StopAtFirstMatch bool - HTTPClient *retryablehttp.Client + FuzzParamsFrequency *frequency.Tracker + StopAtFirstMatch bool + HTTPClient *retryablehttp.Client } // DefaultOptions returns the default options for interactsh client diff --git a/pkg/protocols/http/request_fuzz.go b/pkg/protocols/http/request_fuzz.go index bc5f9caa9b..aa3953b2e6 100644 --- a/pkg/protocols/http/request_fuzz.go +++ b/pkg/protocols/http/request_fuzz.go @@ -121,7 +121,8 @@ func (request *Request) executeAllFuzzingRules(input *contextargs.Context, value } err := rule.Execute(&fuzz.ExecuteRuleInput{ - Input: input, + Input: input, + DisplayFuzzPoints: request.options.Options.DisplayFuzzPoints, Callback: func(gr fuzz.GeneratedRequest) bool { select { case <-input.Context().Done(): @@ -140,6 +141,7 @@ func (request *Request) executeAllFuzzingRules(input *contextargs.Context, value continue } if fuzz.IsErrRuleNotApplicable(err) { + gologger.Verbose().Msgf("[%s] fuzz: rule not applicable : %s\n", request.options.TemplateID, err) continue } if err == types.ErrNoMoreRequests { @@ -177,6 +179,7 @@ func (request *Request) executeGeneratedFuzzingRequest(gr fuzz.GeneratedRequest, result.FuzzingPosition = gr.Component.Name() } + setInteractshCallback := false if hasInteractMarkers && hasInteractMatchers && request.options.Interactsh != nil { requestData := &interactsh.RequestData{ MakeResultFunc: request.MakeResultEvent, @@ -184,7 +187,10 @@ func (request *Request) executeGeneratedFuzzingRequest(gr fuzz.GeneratedRequest, Operators: request.CompiledOperators, MatchFunc: request.Match, ExtractFunc: request.Extract, + Parameter: gr.Parameter, + Request: gr.Request, } + setInteractshCallback = true request.options.Interactsh.RequestEvent(gr.InteractURLs, requestData) gotMatches = request.options.Interactsh.AlreadyMatched(requestData) } else { @@ -194,6 +200,13 @@ func (request *Request) executeGeneratedFuzzingRequest(gr fuzz.GeneratedRequest, if event.OperatorsResult != nil { gotMatches = event.OperatorsResult.Matched } + if request.options.FuzzParamsFrequency != nil && !setInteractshCallback { + if !gotMatches { + request.options.FuzzParamsFrequency.MarkParameter(gr.Parameter, gr.Request.URL.String(), request.options.TemplateID) + } else { + request.options.FuzzParamsFrequency.UnmarkParameter(gr.Parameter, gr.Request.URL.String(), request.options.TemplateID) + } + } }, 0) // If a variable is unresolved, skip all further requests if errors.Is(requestErr, ErrMissingVars) { diff --git a/pkg/protocols/protocols.go b/pkg/protocols/protocols.go index 6b4904c8d8..68881fec5c 100644 --- a/pkg/protocols/protocols.go +++ b/pkg/protocols/protocols.go @@ -13,6 +13,7 @@ import ( "github.com/projectdiscovery/nuclei/v3/pkg/authprovider" "github.com/projectdiscovery/nuclei/v3/pkg/catalog" + "github.com/projectdiscovery/nuclei/v3/pkg/fuzz/frequency" "github.com/projectdiscovery/nuclei/v3/pkg/input" "github.com/projectdiscovery/nuclei/v3/pkg/js/compiler" "github.com/projectdiscovery/nuclei/v3/pkg/loader/parser" @@ -92,6 +93,8 @@ type ExecutorOptions struct { ExcludeMatchers *excludematchers.ExcludeMatchers // InputHelper is a helper for input normalization InputHelper *input.Helper + // FuzzParamsFrequency is a cache for parameter frequency + FuzzParamsFrequency *frequency.Tracker Operators []*operators.Operators // only used by offlinehttp module diff --git a/pkg/testutils/fuzzplayground/server.go b/pkg/testutils/fuzzplayground/server.go index 04f00a99df..af42552cbf 100644 --- a/pkg/testutils/fuzzplayground/server.go +++ b/pkg/testutils/fuzzplayground/server.go @@ -27,6 +27,7 @@ func GetPlaygroundServer() *echo.Echo { e.GET("/request", requestHandler) e.GET("/email", emailHandler) e.GET("/permissions", permissionsHandler) + e.GET("/blog/post", numIdorHandler) // for num based idors like ?id=44 e.POST("/reset-password", resetPasswordHandler) e.GET("/host-header-lab", hostHeaderLabHandler) @@ -47,13 +48,20 @@ var bodyTemplate = ` func indexHandler(ctx echo.Context) error { return ctx.HTML(200, fmt.Sprintf(bodyTemplate, `