From bde943cc5d37f6d370fdbc06b4376d1c74c3b5ee Mon Sep 17 00:00:00 2001 From: Joona Hoikkala Date: Sun, 27 Sep 2020 19:24:06 +0300 Subject: [PATCH] Add support for configuration files for ffuf (#308) * Refactor config and job creation * ConfigOptions defaults * Structure ConfigOptions for config file parser * Sort options * Finalize the configuration file reading and add examples and documentation * Fix issues with opts -> config translation --- README.md | 28 +- ffufrc.example | 76 +++++ go.mod | 4 +- go.sum | 3 + help.go | 2 +- main.go | 632 +++++++------------------------------- pkg/ffuf/config.go | 111 +++---- pkg/ffuf/optionsparser.go | 499 ++++++++++++++++++++++++++++++ pkg/filter/filter.go | 87 ++++++ pkg/input/input.go | 16 +- 10 files changed, 881 insertions(+), 577 deletions(-) create mode 100644 ffufrc.example create mode 100644 go.sum create mode 100644 pkg/ffuf/optionsparser.go diff --git a/README.md b/README.md index 3ce4c42..5e851a2 100644 --- a/README.md +++ b/README.md @@ -14,12 +14,14 @@ A fast web fuzzer written in Go. ## Installation - [Download](https://github.com/ffuf/ffuf/releases/latest) a prebuilt binary from [releases page](https://github.com/ffuf/ffuf/releases/latest), unpack and run! - or -- If you have recent go compiler installed: `go get github.com/ffuf/ffuf` - or -- git clone https://github.com/ffuf/ffuf ; cd ffuf ; go build + + _or_ +- If you have recent go compiler installed: `go get -u github.com/ffuf/ffuf` (the same command works for updating) + + _or_ +- git clone https://github.com/ffuf/ffuf ; cd ffuf ; go get ; go build -The only dependency of ffuf is Go 1.13. No dependencies outside of Go standard library are needed. +Ffuf depends on Go 1.13 or greater. ## Example usage @@ -110,6 +112,21 @@ radamsa -n 1000 -o %n.txt example1.txt example2.txt ffuf --input-cmd 'cat $FFUF_NUM.txt' -H "Content-Type: application/json" -X POST -u https://ffuf.io.fi/ -mc all -fc 400 ``` +### Configuration files + +When running ffuf, it first checks if a default configuration file exists. The file path for it is `~/.ffufrc` / `$HOME/.ffufrc` +for most *nixes (for example `/home/joohoi/.ffufrc`) and `%USERPROFILE%\.ffufrc` for Windows. You can configure one or +multiple options in this file, and they will be applied on every subsequent ffuf job. An example of .ffufrc file can be +found [here](https://github.com/ffuf/ffuf/blob/master/ffufrc.example). + +The configuration options provided on the command line override the ones loaded from `~/.ffufrc`. +Note: this does not apply for CLI flags that can be provided more than once. One of such examples is `-H` (header) flag. +In this case, the `-H` values provided on the command line will be _appended_ to the ones from the config file instead. + +Additionally, in case you wish to use bunch of configuration files for different use cases, you can do this by defining +the configuration file path using `-config` command line flag that takes the file path to the configuration file as its +parameter. + ## Usage To define the test case for ffuf, use the keyword `FUZZ` anywhere in the URL (`-u`), headers (`-H`), or POST data (`-d`). @@ -136,6 +153,7 @@ GENERAL OPTIONS: -ac Automatically calibrate filtering options (default: false) -acc Custom auto-calibration string. Can be used multiple times. Implies -ac -c Colorize output. (default: false) + -config Load configuration from a file -maxtime Maximum running time in seconds for entire process. (default: 0) -maxtime-job Maximum running time in seconds per job. (default: 0) -p Seconds of `delay` between requests, or a range of random delay. For example "0.1" or "0.1-2.0" diff --git a/ffufrc.example b/ffufrc.example new file mode 100644 index 0000000..fd9d442 --- /dev/null +++ b/ffufrc.example @@ -0,0 +1,76 @@ +# This is an example of a ffuf configuration file. +# https://github.com/ffuf/ffuf + +[http] + cookies = [ + "cookiename=cookievalue" + ] + data = "post=data&key=value" + followredirects = false + headers = [ + "X-Header-Name: value", + "X-Another-Header: value" + ] + ignorebody = false + method = "GET" + proxyurl = "http://127.0.0.1:8080" + recursion = false + recursiondepth = 0 + replayproxyurl = "http://127.0.0.1:8080" + timeout = 10 + url = "https://example.org/FUZZ" + +[general] + autocalibration = false + autocalibrationstrings = [ + "randomtest", + "admin" + ] + colors = false + delay = "" + maxtime = 0 + maxtimejob = 0 + quiet = false + rate = 0 + stopon403 = false + stoponall = false + stoponerrors = false + threads = 40 + verbose = false + +[input] + dirsearchcompat = false + extensions = "" + ignorewordlistcomments = false + inputmode = "clusterbomb" + inputnum = 100 + inputcommands = [ + "seq 1 100:CUSTOMKEYWORD" + ] + request = "requestfile.txt" + requestproto = "https" + wordlists = [ + "/path/to/wordlist:FUZZ", + "/path/to/hostlist:HOST" + ] + + +[output] + debuglog = "debug.log" + outputdirectory = "/tmp/rawoutputdir" + outputfile = "output.json" + outputformat = "json" + +[filter] + lines = "" + regexp = "" + size = "" + status = "" + words = "" + +[matcher] + lines = "" + regexp = "" + size = "" + status = "200,204,301,302,307,401,403" + words = "" diff --git a/go.mod b/go.mod index cd8be52..e33c141 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module github.com/ffuf/ffuf -go 1.11 +go 1.13 + +require github.com/pelletier/go-toml v1.8.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..6537354 --- /dev/null +++ b/go.sum @@ -0,0 +1,3 @@ +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pelletier/go-toml v1.8.1 h1:1Nf83orprkJyknT6h7zbuEGUEjcyVlCxSUGTENmNCRM= +github.com/pelletier/go-toml v1.8.1/go.mod h1:T2/BmBdy8dvIRq1a/8aqjN41wvWlN4lrapLU/GW4pbc= diff --git a/help.go b/help.go index b026dc3..3f1c6d0 100644 --- a/help.go +++ b/help.go @@ -61,7 +61,7 @@ func Usage() { Description: "", Flags: make([]UsageFlag, 0), Hidden: false, - ExpectedFlags: []string{"ac", "acc", "c", "maxtime", "maxtime-job", "p", "rate", "s", "sa", "se", "sf", "t", "v", "V"}, + ExpectedFlags: []string{"ac", "acc", "c", "config", "maxtime", "maxtime-job", "p", "rate", "s", "sa", "se", "sf", "t", "v", "V"}, } u_compat := UsageSection{ Name: "COMPATIBILITY OPTIONS", diff --git a/main.go b/main.go index 2191716..6d76e7f 100644 --- a/main.go +++ b/main.go @@ -1,17 +1,12 @@ package main import ( - "bufio" "context" "flag" "fmt" "io/ioutil" "log" - "net/textproto" - "net/url" "os" - "runtime" - "strconv" "strings" "github.com/ffuf/ffuf/pkg/ffuf" @@ -21,36 +16,6 @@ import ( "github.com/ffuf/ffuf/pkg/runner" ) -type cliOptions struct { - extensions string - delay string - filterStatus string - filterSize string - filterRegexp string - filterWords string - filterLines string - matcherStatus string - matcherSize string - matcherRegexp string - matcherWords string - matcherLines string - proxyURL string - rate int - replayProxyURL string - request string - requestProto string - URL string - outputFormat string - ignoreBody bool - wordlists wordlistFlag - inputcommands multiStringFlag - headers multiStringFlag - cookies multiStringFlag - AutoCalibrationStrings multiStringFlag - showVersion bool - debugLog string -} - type multiStringFlag []string type wordlistFlag []string @@ -79,76 +44,101 @@ func (m *wordlistFlag) Set(value string) error { return nil } -func main() { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - conf := ffuf.NewConfig(ctx, cancel) - opts := cliOptions{} +//ParseFlags parses the command line flags and (re)populates the ConfigOptions struct +func ParseFlags(opts *ffuf.ConfigOptions) *ffuf.ConfigOptions { var ignored bool - flag.BoolVar(&conf.IgnoreWordlistComments, "ic", false, "Ignore wordlist comments") - flag.StringVar(&opts.extensions, "e", "", "Comma separated list of extensions. Extends FUZZ keyword.") - flag.BoolVar(&conf.DirSearchCompat, "D", false, "DirSearch wordlist compatibility mode. Used in conjunction with -e flag.") - flag.Var(&opts.headers, "H", "Header `\"Name: Value\"`, separated by colon. Multiple -H flags are accepted.") - flag.StringVar(&opts.URL, "u", "", "Target URL") - flag.Var(&opts.wordlists, "w", "Wordlist file path and (optional) keyword separated by colon. eg. '/path/to/wordlist:KEYWORD'") - flag.BoolVar(&ignored, "k", false, "Dummy flag for backwards compatibility") - flag.StringVar(&opts.delay, "p", "", "Seconds of `delay` between requests, or a range of random delay. For example \"0.1\" or \"0.1-2.0\"") - flag.StringVar(&opts.filterStatus, "fc", "", "Filter HTTP status codes from response. Comma separated list of codes and ranges") - flag.StringVar(&opts.filterSize, "fs", "", "Filter HTTP response size. Comma separated list of sizes and ranges") - flag.StringVar(&opts.filterRegexp, "fr", "", "Filter regexp") - flag.StringVar(&opts.filterWords, "fw", "", "Filter by amount of words in response. Comma separated list of word counts and ranges") - flag.StringVar(&opts.filterLines, "fl", "", "Filter by amount of lines in response. Comma separated list of line counts and ranges") - flag.StringVar(&conf.Data, "d", "", "POST data") - flag.StringVar(&conf.Data, "data", "", "POST data (alias of -d)") - flag.StringVar(&conf.Data, "data-ascii", "", "POST data (alias of -d)") - flag.StringVar(&conf.Data, "data-binary", "", "POST data (alias of -d)") - flag.BoolVar(&conf.Colors, "c", false, "Colorize output.") + var cookies, autocalibrationstrings, headers, inputcommands multiStringFlag + var wordlists wordlistFlag + + cookies = opts.HTTP.Cookies + autocalibrationstrings = opts.General.AutoCalibrationStrings + headers = opts.HTTP.Headers + inputcommands = opts.Input.Inputcommands + flag.BoolVar(&ignored, "compressed", true, "Dummy flag for copy as curl functionality (ignored)") - flag.Var(&opts.inputcommands, "input-cmd", "Command producing the input. --input-num is required when using this input method. Overrides -w.") - flag.IntVar(&conf.InputNum, "input-num", 100, "Number of inputs to test. Used in conjunction with --input-cmd.") - flag.StringVar(&conf.InputMode, "mode", "clusterbomb", "Multi-wordlist operation mode. Available modes: clusterbomb, pitchfork") flag.BoolVar(&ignored, "i", true, "Dummy flag for copy as curl functionality (ignored)") - flag.Var(&opts.cookies, "b", "Cookie data `\"NAME1=VALUE1; NAME2=VALUE2\"` for copy as curl functionality.") - flag.Var(&opts.cookies, "cookie", "Cookie data (alias of -b)") - flag.StringVar(&opts.matcherStatus, "mc", "200,204,301,302,307,401,403", "Match HTTP status codes, or \"all\" for everything.") - flag.StringVar(&opts.matcherSize, "ms", "", "Match HTTP response size") - flag.StringVar(&opts.matcherRegexp, "mr", "", "Match regexp") - flag.StringVar(&opts.matcherWords, "mw", "", "Match amount of words in response") - flag.StringVar(&opts.matcherLines, "ml", "", "Match amount of lines in response") - flag.StringVar(&opts.proxyURL, "x", "", "HTTP Proxy URL") - flag.IntVar(&opts.rate, "rate", 0, "Rate of requests per second") - flag.StringVar(&opts.request, "request", "", "File containing the raw http request") - flag.StringVar(&opts.requestProto, "request-proto", "https", "Protocol to use along with raw request") - flag.StringVar(&conf.Method, "X", "GET", "HTTP method to use") - flag.StringVar(&conf.OutputFile, "o", "", "Write output to file") - flag.StringVar(&opts.outputFormat, "of", "json", "Output file format. Available formats: json, ejson, html, md, csv, ecsv (or, 'all' for all formats)") - flag.StringVar(&conf.OutputDirectory, "od", "", "Directory path to store matched results to.") - flag.BoolVar(&conf.IgnoreBody, "ignore-body", false, "Do not fetch the response content.") - flag.BoolVar(&conf.Quiet, "s", false, "Do not print additional information (silent mode)") - flag.BoolVar(&conf.StopOn403, "sf", false, "Stop when > 95% of responses return 403 Forbidden") - flag.BoolVar(&conf.StopOnErrors, "se", false, "Stop on spurious errors") - flag.BoolVar(&conf.StopOnAll, "sa", false, "Stop on all error cases. Implies -sf and -se.") - flag.BoolVar(&conf.FollowRedirects, "r", false, "Follow redirects") - flag.BoolVar(&conf.Recursion, "recursion", false, "Scan recursively. Only FUZZ keyword is supported, and URL (-u) has to end in it.") - flag.IntVar(&conf.RecursionDepth, "recursion-depth", 0, "Maximum recursion depth.") - flag.StringVar(&opts.replayProxyURL, "replay-proxy", "", "Replay matched requests using this proxy.") - flag.BoolVar(&conf.AutoCalibration, "ac", false, "Automatically calibrate filtering options") - flag.Var(&opts.AutoCalibrationStrings, "acc", "Custom auto-calibration string. Can be used multiple times. Implies -ac") - flag.IntVar(&conf.Threads, "t", 40, "Number of concurrent threads.") - flag.IntVar(&conf.Timeout, "timeout", 10, "HTTP request timeout in seconds.") - flag.IntVar(&conf.MaxTime, "maxtime", 0, "Maximum running time in seconds for entire process.") - flag.IntVar(&conf.MaxTimeJob, "maxtime-job", 0, "Maximum running time in seconds per job.") - flag.BoolVar(&conf.Verbose, "v", false, "Verbose output, printing full URL and redirect location (if any) with the results.") - flag.BoolVar(&opts.showVersion, "V", false, "Show version information.") - flag.StringVar(&opts.debugLog, "debug-log", "", "Write all of the internal logging to the specified file.") + flag.BoolVar(&ignored, "k", false, "Dummy flag for backwards compatibility") + flag.BoolVar(&opts.General.AutoCalibration, "ac", opts.General.AutoCalibration, "Automatically calibrate filtering options") + flag.BoolVar(&opts.General.Colors, "c", opts.General.Colors, "Colorize output.") + flag.BoolVar(&opts.General.Quiet, "s", opts.General.Quiet, "Do not print additional information (silent mode)") + flag.BoolVar(&opts.General.ShowVersion, "V", opts.General.ShowVersion, "Show version information.") + flag.BoolVar(&opts.General.StopOn403, "sf", opts.General.StopOn403, "Stop when > 95% of responses return 403 Forbidden") + flag.BoolVar(&opts.General.StopOnAll, "sa", opts.General.StopOnAll, "Stop on all error cases. Implies -sf and -se.") + flag.BoolVar(&opts.General.StopOnErrors, "se", opts.General.StopOnErrors, "Stop on spurious errors") + flag.BoolVar(&opts.General.Verbose, "v", opts.General.Verbose, "Verbose output, printing full URL and redirect location (if any) with the results.") + flag.BoolVar(&opts.HTTP.FollowRedirects, "r", opts.HTTP.FollowRedirects, "Follow redirects") + flag.BoolVar(&opts.HTTP.IgnoreBody, "ignore-body", opts.HTTP.IgnoreBody, "Do not fetch the response content.") + flag.BoolVar(&opts.HTTP.Recursion, "recursion", opts.HTTP.Recursion, "Scan recursively. Only FUZZ keyword is supported, and URL (-u) has to end in it.") + flag.BoolVar(&opts.Input.DirSearchCompat, "D", opts.Input.DirSearchCompat, "DirSearch wordlist compatibility mode. Used in conjunction with -e flag.") + flag.BoolVar(&opts.Input.IgnoreWordlistComments, "ic", opts.Input.IgnoreWordlistComments, "Ignore wordlist comments") + flag.IntVar(&opts.General.MaxTime, "maxtime", opts.General.MaxTime, "Maximum running time in seconds for entire process.") + flag.IntVar(&opts.General.MaxTimeJob, "maxtime-job", opts.General.MaxTimeJob, "Maximum running time in seconds per job.") + flag.IntVar(&opts.General.Rate, "rate", opts.General.Rate, "Rate of requests per second") + flag.IntVar(&opts.General.Threads, "t", opts.General.Threads, "Number of concurrent threads.") + flag.IntVar(&opts.HTTP.RecursionDepth, "recursion-depth", opts.HTTP.RecursionDepth, "Maximum recursion depth.") + flag.IntVar(&opts.HTTP.Timeout, "timeout", opts.HTTP.Timeout, "HTTP request timeout in seconds.") + flag.IntVar(&opts.Input.InputNum, "input-num", opts.Input.InputNum, "Number of inputs to test. Used in conjunction with --input-cmd.") + flag.StringVar(&opts.General.ConfigFile, "config", "", "Load configuration from a file") + flag.StringVar(&opts.Filter.Lines, "fl", opts.Filter.Lines, "Filter by amount of lines in response. Comma separated list of line counts and ranges") + flag.StringVar(&opts.Filter.Regexp, "fr", opts.Filter.Regexp, "Filter regexp") + flag.StringVar(&opts.Filter.Size, "fs", opts.Filter.Size, "Filter HTTP response size. Comma separated list of sizes and ranges") + flag.StringVar(&opts.Filter.Status, "fc", opts.Filter.Status, "Filter HTTP status codes from response. Comma separated list of codes and ranges") + flag.StringVar(&opts.Filter.Words, "fw", opts.Filter.Words, "Filter by amount of words in response. Comma separated list of word counts and ranges") + flag.StringVar(&opts.General.Delay, "p", opts.General.Delay, "Seconds of `delay` between requests, or a range of random delay. For example \"0.1\" or \"0.1-2.0\"") + flag.StringVar(&opts.HTTP.Data, "d", opts.HTTP.Data, "POST data") + flag.StringVar(&opts.HTTP.Data, "data", opts.HTTP.Data, "POST data (alias of -d)") + flag.StringVar(&opts.HTTP.Data, "data-ascii", opts.HTTP.Data, "POST data (alias of -d)") + flag.StringVar(&opts.HTTP.Data, "data-binary", opts.HTTP.Data, "POST data (alias of -d)") + flag.StringVar(&opts.HTTP.Method, "X", opts.HTTP.Method, "HTTP method to use") + flag.StringVar(&opts.HTTP.ProxyURL, "x", opts.HTTP.ProxyURL, "HTTP Proxy URL") + flag.StringVar(&opts.HTTP.ReplayProxyURL, "replay-proxy", opts.HTTP.ReplayProxyURL, "Replay matched requests using this proxy.") + flag.StringVar(&opts.HTTP.URL, "u", opts.HTTP.URL, "Target URL") + flag.StringVar(&opts.Input.Extensions, "e", opts.Input.Extensions, "Comma separated list of extensions. Extends FUZZ keyword.") + flag.StringVar(&opts.Input.InputMode, "mode", opts.Input.InputMode, "Multi-wordlist operation mode. Available modes: clusterbomb, pitchfork") + flag.StringVar(&opts.Input.Request, "request", opts.Input.Request, "File containing the raw http request") + flag.StringVar(&opts.Input.RequestProto, "request-proto", opts.Input.RequestProto, "Protocol to use along with raw request") + flag.StringVar(&opts.Matcher.Lines, "ml", opts.Matcher.Lines, "Match amount of lines in response") + flag.StringVar(&opts.Matcher.Regexp, "mr", opts.Matcher.Regexp, "Match regexp") + flag.StringVar(&opts.Matcher.Size, "ms", opts.Matcher.Size, "Match HTTP response size") + flag.StringVar(&opts.Matcher.Status, "mc", opts.Matcher.Status, "Match HTTP status codes, or \"all\" for everything.") + flag.StringVar(&opts.Matcher.Words, "mw", opts.Matcher.Words, "Match amount of words in response") + flag.StringVar(&opts.Output.DebugLog, "debug-log", opts.Output.DebugLog, "Write all of the internal logging to the specified file.") + flag.StringVar(&opts.Output.OutputDirectory, "od", opts.Output.OutputDirectory, "Directory path to store matched results to.") + flag.StringVar(&opts.Output.OutputFile, "o", opts.Output.OutputFile, "Write output to file") + flag.StringVar(&opts.Output.OutputFormat, "of", opts.Output.OutputFormat, "Output file format. Available formats: json, ejson, html, md, csv, ecsv (or, 'all' for all formats)") + flag.Var(&autocalibrationstrings, "acc", "Custom auto-calibration string. Can be used multiple times. Implies -ac") + flag.Var(&cookies, "b", "Cookie data `\"NAME1=VALUE1; NAME2=VALUE2\"` for copy as curl functionality.") + flag.Var(&cookies, "cookie", "Cookie data (alias of -b)") + flag.Var(&headers, "H", "Header `\"Name: Value\"`, separated by colon. Multiple -H flags are accepted.") + flag.Var(&inputcommands, "input-cmd", "Command producing the input. --input-num is required when using this input method. Overrides -w.") + flag.Var(&wordlists, "w", "Wordlist file path and (optional) keyword separated by colon. eg. '/path/to/wordlist:KEYWORD'") flag.Usage = Usage flag.Parse() - if opts.showVersion { + + opts.General.AutoCalibrationStrings = autocalibrationstrings + opts.HTTP.Cookies = cookies + opts.HTTP.Headers = headers + opts.Input.Inputcommands = inputcommands + opts.Input.Wordlists = wordlists + return opts +} + +func main() { + + var err, optserr error + + // prepare the default config options from default config file + var opts *ffuf.ConfigOptions + opts, optserr = ffuf.ReadDefaultConfig() + + opts = ParseFlags(opts) + + if opts.General.ShowVersion { fmt.Printf("ffuf version: %s\n", ffuf.VERSION) os.Exit(0) } - if len(opts.debugLog) != 0 { - f, err := os.OpenFile(opts.debugLog, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if len(opts.Output.DebugLog) != 0 { + f, err := os.OpenFile(opts.Output.DebugLog, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { fmt.Fprintf(os.Stderr, "Disabling logging, encountered error(s): %s\n", err) log.SetOutput(ioutil.Discard) @@ -159,20 +149,42 @@ func main() { } else { log.SetOutput(ioutil.Discard) } - if err := prepareConfig(&opts, &conf); err != nil { - fmt.Fprintf(os.Stderr, "Encountered error(s): %s\n", err) - Usage() - fmt.Fprintf(os.Stderr, "Encountered error(s): %s\n", err) - os.Exit(1) + if optserr != nil { + log.Printf("Error while opening default config file: %s", optserr) } - job, err := prepareJob(&conf) + + if opts.General.ConfigFile != "" { + opts, err = ffuf.ReadConfig(opts.General.ConfigFile) + if err != nil { + fmt.Fprintf(os.Stderr, "Encoutered error(s): %s\n", err) + Usage() + fmt.Fprintf(os.Stderr, "Encoutered error(s): %s\n", err) + os.Exit(1) + } + // Reset the flag package state + flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError) + // Re-parse the cli options + opts = ParseFlags(opts) + } + + // Prepare context and set up Config struct + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + conf, err := ffuf.ConfigFromOptions(opts, ctx, cancel) if err != nil { fmt.Fprintf(os.Stderr, "Encountered error(s): %s\n", err) Usage() fmt.Fprintf(os.Stderr, "Encountered error(s): %s\n", err) os.Exit(1) } - if err := prepareFilters(&opts, &conf); err != nil { + job, err := prepareJob(conf) + if err != nil { + fmt.Fprintf(os.Stderr, "Encountered error(s): %s\n", err) + Usage() + fmt.Fprintf(os.Stderr, "Encountered error(s): %s\n", err) + os.Exit(1) + } + if err := filter.SetupFilters(opts, conf); err != nil { fmt.Fprintf(os.Stderr, "Encountered error(s): %s\n", err) Usage() fmt.Fprintf(os.Stderr, "Encountered error(s): %s\n", err) @@ -190,423 +202,15 @@ func main() { func prepareJob(conf *ffuf.Config) (*ffuf.Job, error) { job := ffuf.NewJob(conf) - errs := ffuf.NewMultierror() - var err error - inputprovider, err := input.NewInputProvider(conf) - if err != nil { - errs.Add(err) - } + var errs ffuf.Multierror + job.Input, errs = input.NewInputProvider(conf) // TODO: implement error handling for runnerprovider and outputprovider // We only have http runner right now job.Runner = runner.NewRunnerByName("http", conf, false) if len(conf.ReplayProxyURL) > 0 { job.ReplayRunner = runner.NewRunnerByName("http", conf, true) } - // Initialize the correct inputprovider - for _, v := range conf.InputProviders { - err = inputprovider.AddProvider(v) - if err != nil { - errs.Add(err) - } - } - job.Input = inputprovider // We only have stdout outputprovider right now job.Output = output.NewOutputProviderByName("stdout", conf) return job, errs.ErrorOrNil() } - -func prepareFilters(parseOpts *cliOptions, conf *ffuf.Config) error { - errs := ffuf.NewMultierror() - // If any other matcher is set, ignore -mc default value - matcherSet := false - statusSet := false - warningIgnoreBody := false - flag.Visit(func(f *flag.Flag) { - if f.Name == "mc" { - statusSet = true - } - if f.Name == "ms" { - matcherSet = true - warningIgnoreBody = true - } - if f.Name == "ml" { - matcherSet = true - warningIgnoreBody = true - } - if f.Name == "mr" { - matcherSet = true - } - if f.Name == "mw" { - matcherSet = true - warningIgnoreBody = true - } - }) - if statusSet || !matcherSet { - if err := filter.AddMatcher(conf, "status", parseOpts.matcherStatus); err != nil { - errs.Add(err) - } - } - - if parseOpts.filterStatus != "" { - if err := filter.AddFilter(conf, "status", parseOpts.filterStatus); err != nil { - errs.Add(err) - } - } - if parseOpts.filterSize != "" { - warningIgnoreBody = true - if err := filter.AddFilter(conf, "size", parseOpts.filterSize); err != nil { - errs.Add(err) - } - } - if parseOpts.filterRegexp != "" { - if err := filter.AddFilter(conf, "regexp", parseOpts.filterRegexp); err != nil { - errs.Add(err) - } - } - if parseOpts.filterWords != "" { - warningIgnoreBody = true - if err := filter.AddFilter(conf, "word", parseOpts.filterWords); err != nil { - errs.Add(err) - } - } - if parseOpts.filterLines != "" { - warningIgnoreBody = true - if err := filter.AddFilter(conf, "line", parseOpts.filterLines); err != nil { - errs.Add(err) - } - } - if parseOpts.matcherSize != "" { - if err := filter.AddMatcher(conf, "size", parseOpts.matcherSize); err != nil { - errs.Add(err) - } - } - if parseOpts.matcherRegexp != "" { - if err := filter.AddMatcher(conf, "regexp", parseOpts.matcherRegexp); err != nil { - errs.Add(err) - } - } - if parseOpts.matcherWords != "" { - if err := filter.AddMatcher(conf, "word", parseOpts.matcherWords); err != nil { - errs.Add(err) - } - } - if parseOpts.matcherLines != "" { - if err := filter.AddMatcher(conf, "line", parseOpts.matcherLines); err != nil { - errs.Add(err) - } - } - if conf.IgnoreBody && warningIgnoreBody { - fmt.Printf("*** Warning: possible undesired combination of -ignore-body and the response options: fl,fs,fw,ml,ms and mw.\n") - } - return errs.ErrorOrNil() -} - -func prepareConfig(parseOpts *cliOptions, conf *ffuf.Config) error { - //TODO: refactor in a proper flag library that can handle things like required flags - errs := ffuf.NewMultierror() - - var err error - var err2 error - if len(parseOpts.URL) == 0 && parseOpts.request == "" { - errs.Add(fmt.Errorf("-u flag or -request flag is required")) - } - - // prepare extensions - if parseOpts.extensions != "" { - extensions := strings.Split(parseOpts.extensions, ",") - conf.Extensions = extensions - } - - // Convert cookies to a header - if len(parseOpts.cookies) > 0 { - parseOpts.headers.Set("Cookie: " + strings.Join(parseOpts.cookies, "; ")) - } - - //Prepare inputproviders - for _, v := range parseOpts.wordlists { - var wl []string - if runtime.GOOS == "windows" { - // Try to ensure that Windows file paths like C:\path\to\wordlist.txt:KEYWORD are treated properly - if ffuf.FileExists(v) { - // The wordlist was supplied without a keyword parameter - wl = []string{v} - } else { - filepart := v[:strings.LastIndex(v, ":")] - if ffuf.FileExists(filepart) { - wl = []string{filepart, v[strings.LastIndex(v, ":")+1:]} - } else { - // The file was not found. Use full wordlist parameter value for more concise error message down the line - wl = []string{v} - } - } - } else { - wl = strings.SplitN(v, ":", 2) - } - if len(wl) == 2 { - conf.InputProviders = append(conf.InputProviders, ffuf.InputProviderConfig{ - Name: "wordlist", - Value: wl[0], - Keyword: wl[1], - }) - } else { - conf.InputProviders = append(conf.InputProviders, ffuf.InputProviderConfig{ - Name: "wordlist", - Value: wl[0], - Keyword: "FUZZ", - }) - } - } - for _, v := range parseOpts.inputcommands { - ic := strings.SplitN(v, ":", 2) - if len(ic) == 2 { - conf.InputProviders = append(conf.InputProviders, ffuf.InputProviderConfig{ - Name: "command", - Value: ic[0], - Keyword: ic[1], - }) - conf.CommandKeywords = append(conf.CommandKeywords, ic[0]) - } else { - conf.InputProviders = append(conf.InputProviders, ffuf.InputProviderConfig{ - Name: "command", - Value: ic[0], - Keyword: "FUZZ", - }) - conf.CommandKeywords = append(conf.CommandKeywords, "FUZZ") - } - } - - if len(conf.InputProviders) == 0 { - errs.Add(fmt.Errorf("Either -w or --input-cmd flag is required")) - } - - // Prepare the request using body - if parseOpts.request != "" { - err := parseRawRequest(parseOpts, conf) - if err != nil { - errmsg := fmt.Sprintf("Could not parse raw request: %s", err) - errs.Add(fmt.Errorf(errmsg)) - } - } - - //Prepare URL - if parseOpts.URL != "" { - conf.Url = parseOpts.URL - } - - //Prepare headers and make canonical - for _, v := range parseOpts.headers { - hs := strings.SplitN(v, ":", 2) - if len(hs) == 2 { - // trim and make canonical - // except if used in custom defined header - var CanonicalNeeded bool = true - for _, a := range conf.CommandKeywords { - if a == hs[0] { - CanonicalNeeded = false - } - } - // check if part of InputProviders - if CanonicalNeeded { - for _, b := range conf.InputProviders { - if b.Keyword == hs[0] { - CanonicalNeeded = false - } - } - } - if CanonicalNeeded { - var CanonicalHeader string = textproto.CanonicalMIMEHeaderKey(strings.TrimSpace(hs[0])) - conf.Headers[CanonicalHeader] = strings.TrimSpace(hs[1]) - } else { - conf.Headers[strings.TrimSpace(hs[0])] = strings.TrimSpace(hs[1]) - } - } else { - errs.Add(fmt.Errorf("Header defined by -H needs to have a value. \":\" should be used as a separator")) - } - } - - //Prepare delay - d := strings.Split(parseOpts.delay, "-") - if len(d) > 2 { - errs.Add(fmt.Errorf("Delay needs to be either a single float: \"0.1\" or a range of floats, delimited by dash: \"0.1-0.8\"")) - } else if len(d) == 2 { - conf.Delay.IsRange = true - conf.Delay.HasDelay = true - conf.Delay.Min, err = strconv.ParseFloat(d[0], 64) - conf.Delay.Max, err2 = strconv.ParseFloat(d[1], 64) - if err != nil || err2 != nil { - errs.Add(fmt.Errorf("Delay range min and max values need to be valid floats. For example: 0.1-0.5")) - } - } else if len(parseOpts.delay) > 0 { - conf.Delay.IsRange = false - conf.Delay.HasDelay = true - conf.Delay.Min, err = strconv.ParseFloat(parseOpts.delay, 64) - if err != nil { - errs.Add(fmt.Errorf("Delay needs to be either a single float: \"0.1\" or a range of floats, delimited by dash: \"0.1-0.8\"")) - } - } - - // Verify proxy url format - if len(parseOpts.proxyURL) > 0 { - _, err := url.Parse(parseOpts.proxyURL) - if err != nil { - errs.Add(fmt.Errorf("Bad proxy url (-x) format: %s", err)) - } else { - conf.ProxyURL = parseOpts.proxyURL - } - } - - // Verify replayproxy url format - if len(parseOpts.replayProxyURL) > 0 { - _, err := url.Parse(parseOpts.replayProxyURL) - if err != nil { - errs.Add(fmt.Errorf("Bad replay-proxy url (-replay-proxy) format: %s", err)) - } else { - conf.ReplayProxyURL = parseOpts.replayProxyURL - } - } - - //Check the output file format option - if conf.OutputFile != "" { - //No need to check / error out if output file isn't defined - outputFormats := []string{"all", "json", "ejson", "html", "md", "csv", "ecsv"} - found := false - for _, f := range outputFormats { - if f == parseOpts.outputFormat { - conf.OutputFormat = f - found = true - } - } - if !found { - errs.Add(fmt.Errorf("Unknown output file format (-of): %s", parseOpts.outputFormat)) - } - } - - // Auto-calibration strings - if len(parseOpts.AutoCalibrationStrings) > 0 { - conf.AutoCalibrationStrings = parseOpts.AutoCalibrationStrings - } - // Using -acc implies -ac - if len(conf.AutoCalibrationStrings) > 0 { - conf.AutoCalibration = true - } - - // Handle copy as curl situation where POST method is implied by --data flag. If method is set to anything but GET, NOOP - if len(conf.Data) > 0 && - conf.Method == "GET" && - //don't modify the method automatically if a request file is being used as input - len(parseOpts.request) == 0 { - - conf.Method = "POST" - } - - conf.CommandLine = strings.Join(os.Args, " ") - - for _, provider := range conf.InputProviders { - if !keywordPresent(provider.Keyword, conf) { - errmsg := fmt.Sprintf("Keyword %s defined, but not found in headers, method, URL or POST data.", provider.Keyword) - errs.Add(fmt.Errorf(errmsg)) - } - } - - // Do checks for recursion mode - if conf.Recursion { - if !strings.HasSuffix(conf.Url, "FUZZ") { - errmsg := fmt.Sprintf("When using -recursion the URL (-u) must end with FUZZ keyword.") - errs.Add(fmt.Errorf(errmsg)) - } - } - - if parseOpts.rate < 0 { - conf.Rate = 0 - } else { - conf.Rate = int64(parseOpts.rate) - } - - return errs.ErrorOrNil() -} - -func parseRawRequest(parseOpts *cliOptions, conf *ffuf.Config) error { - file, err := os.Open(parseOpts.request) - if err != nil { - return fmt.Errorf("could not open request file: %s", err) - } - defer file.Close() - - r := bufio.NewReader(file) - - s, err := r.ReadString('\n') - if err != nil { - return fmt.Errorf("could not read request: %s", err) - } - parts := strings.Split(s, " ") - if len(parts) < 3 { - return fmt.Errorf("malformed request supplied") - } - // Set the request Method - conf.Method = parts[0] - - for { - line, err := r.ReadString('\n') - line = strings.TrimSpace(line) - - if err != nil || line == "" { - break - } - - p := strings.SplitN(line, ":", 2) - if len(p) != 2 { - continue - } - - if strings.EqualFold(p[0], "content-length") { - continue - } - - conf.Headers[strings.TrimSpace(p[0])] = strings.TrimSpace(p[1]) - } - - // Handle case with the full http url in path. In that case, - // ignore any host header that we encounter and use the path as request URL - if strings.HasPrefix(parts[1], "http") { - parsed, err := url.Parse(parts[1]) - if err != nil { - return fmt.Errorf("could not parse request URL: %s", err) - } - conf.Url = parts[1] - conf.Headers["Host"] = parsed.Host - } else { - // Build the request URL from the request - conf.Url = parseOpts.requestProto + "://" + conf.Headers["Host"] + parts[1] - } - - // Set the request body - b, err := ioutil.ReadAll(r) - if err != nil { - return fmt.Errorf("could not read request body: %s", err) - } - conf.Data = string(b) - - return nil -} - -func keywordPresent(keyword string, conf *ffuf.Config) bool { - //Search for keyword from HTTP method, URL and POST data too - if strings.Index(conf.Method, keyword) != -1 { - return true - } - if strings.Index(conf.Url, keyword) != -1 { - return true - } - if strings.Index(conf.Data, keyword) != -1 { - return true - } - for k, v := range conf.Headers { - if strings.Index(k, keyword) != -1 { - return true - } - if strings.Index(v, keyword) != -1 { - return true - } - } - return false -} diff --git a/pkg/ffuf/config.go b/pkg/ffuf/config.go index 8cbaf09..0268738 100644 --- a/pkg/ffuf/config.go +++ b/pkg/ffuf/config.go @@ -5,46 +5,47 @@ import ( ) type Config struct { - Headers map[string]string `json:"headers"` - Extensions []string `json:"extensions"` - DirSearchCompat bool `json:"dirsearch_compatibility"` - Method string `json:"method"` - Url string `json:"url"` - Data string `json:"postdata"` - Quiet bool `json:"quiet"` + AutoCalibration bool `json:"autocalibration"` + AutoCalibrationStrings []string `json:"autocalibration_strings"` + Cancel context.CancelFunc `json:"-"` Colors bool `json:"colors"` - InputProviders []InputProviderConfig `json:"inputproviders"` CommandKeywords []string `json:"-"` - InputNum int `json:"cmd_inputnum"` + CommandLine string `json:"cmdline"` + ConfigFile string `json:"configfile"` + Context context.Context `json:"-"` + Data string `json:"postdata"` + Delay optRange `json:"delay"` + DirSearchCompat bool `json:"dirsearch_compatibility"` + Extensions []string `json:"extensions"` + Filters map[string]FilterProvider `json:"filters"` + FollowRedirects bool `json:"follow_redirects"` + Headers map[string]string `json:"headers"` + IgnoreBody bool `json:"ignorebody"` + IgnoreWordlistComments bool `json:"ignore_wordlist_comments"` InputMode string `json:"inputmode"` + InputNum int `json:"cmd_inputnum"` + InputProviders []InputProviderConfig `json:"inputproviders"` + Matchers map[string]FilterProvider `json:"matchers"` + MaxTime int `json:"maxtime"` + MaxTimeJob int `json:"maxtime_job"` + Method string `json:"method"` OutputDirectory string `json:"outputdirectory"` OutputFile string `json:"outputfile"` OutputFormat string `json:"outputformat"` - IgnoreBody bool `json:"ignorebody"` - IgnoreWordlistComments bool `json:"ignore_wordlist_comments"` - StopOn403 bool `json:"stop_403"` - StopOnErrors bool `json:"stop_errors"` - StopOnAll bool `json:"stop_all"` - FollowRedirects bool `json:"follow_redirects"` - AutoCalibration bool `json:"autocalibration"` - AutoCalibrationStrings []string `json:"autocalibration_strings"` - Timeout int `json:"timeout"` ProgressFrequency int `json:"-"` - Delay optRange `json:"delay"` - Filters map[string]FilterProvider `json:"filters"` - Matchers map[string]FilterProvider `json:"matchers"` - Threads int `json:"threads"` - Context context.Context `json:"-"` - Cancel context.CancelFunc `json:"-"` ProxyURL string `json:"proxyurl"` - ReplayProxyURL string `json:"replayproxyurl"` - CommandLine string `json:"cmdline"` - Verbose bool `json:"verbose"` - MaxTime int `json:"maxtime"` - MaxTimeJob int `json:"maxtime_job"` + Quiet bool `json:"quiet"` + Rate int64 `json:"rate"` Recursion bool `json:"recursion"` RecursionDepth int `json:"recursion_depth"` - Rate int64 `json:"rate"` + ReplayProxyURL string `json:"replayproxyurl"` + StopOn403 bool `json:"stop_403"` + StopOnAll bool `json:"stop_all"` + StopOnErrors bool `json:"stop_errors"` + Threads int `json:"threads"` + Timeout int `json:"timeout"` + Url string `json:"url"` + Verbose bool `json:"verbose"` } type InputProviderConfig struct { @@ -55,37 +56,41 @@ type InputProviderConfig struct { func NewConfig(ctx context.Context, cancel context.CancelFunc) Config { var conf Config + conf.AutoCalibrationStrings = make([]string, 0) + conf.CommandKeywords = make([]string, 0) conf.Context = ctx conf.Cancel = cancel - conf.Headers = make(map[string]string) - conf.Method = "GET" - conf.Url = "" conf.Data = "" - conf.Quiet = false - conf.IgnoreWordlistComments = false - conf.StopOn403 = false - conf.StopOnErrors = false - conf.StopOnAll = false - conf.FollowRedirects = false - conf.InputProviders = make([]InputProviderConfig, 0) - conf.CommandKeywords = make([]string, 0) - conf.AutoCalibrationStrings = make([]string, 0) - conf.InputNum = 0 - conf.InputMode = "clusterbomb" - conf.ProxyURL = "" - conf.Filters = make(map[string]FilterProvider) - conf.Matchers = make(map[string]FilterProvider) conf.Delay = optRange{0, 0, false, false} - conf.Extensions = make([]string, 0) - conf.Timeout = 10 - // Progress update frequency, in milliseconds - conf.ProgressFrequency = 125 conf.DirSearchCompat = false - conf.Verbose = false + conf.Extensions = make([]string, 0) + conf.Filters = make(map[string]FilterProvider) + conf.FollowRedirects = false + conf.Headers = make(map[string]string) + conf.IgnoreWordlistComments = false + conf.InputMode = "clusterbomb" + conf.InputNum = 0 + conf.InputProviders = make([]InputProviderConfig, 0) + conf.Matchers = make(map[string]FilterProvider) conf.MaxTime = 0 conf.MaxTimeJob = 0 + conf.Method = "GET" + conf.ProgressFrequency = 125 + conf.ProxyURL = "" + conf.Quiet = false + conf.Rate = 0 conf.Recursion = false conf.RecursionDepth = 0 - conf.Rate = 0 + conf.StopOn403 = false + conf.StopOnAll = false + conf.StopOnErrors = false + conf.Timeout = 10 + conf.Url = "" + conf.Verbose = false return conf } + +func (c *Config) SetContext(ctx context.Context, cancel context.CancelFunc) { + c.Context = ctx + c.Cancel = cancel +} diff --git a/pkg/ffuf/optionsparser.go b/pkg/ffuf/optionsparser.go new file mode 100644 index 0000000..438f803 --- /dev/null +++ b/pkg/ffuf/optionsparser.go @@ -0,0 +1,499 @@ +package ffuf + +import ( + "bufio" + "context" + "fmt" + "io/ioutil" + "net/textproto" + "net/url" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + + "github.com/pelletier/go-toml" +) + +type ConfigOptions struct { + Filter FilterOptions + General GeneralOptions + HTTP HTTPOptions + Input InputOptions + Matcher MatcherOptions + Output OutputOptions +} + +type HTTPOptions struct { + Cookies []string + Data string + FollowRedirects bool + Headers []string + IgnoreBody bool + Method string + ProxyURL string + Recursion bool + RecursionDepth int + ReplayProxyURL string + Timeout int + URL string +} + +type GeneralOptions struct { + AutoCalibration bool + AutoCalibrationStrings []string + Colors bool + ConfigFile string `toml:"-"` + Delay string + MaxTime int + MaxTimeJob int + Quiet bool + Rate int + ShowVersion bool `toml:"-"` + StopOn403 bool + StopOnAll bool + StopOnErrors bool + Threads int + Verbose bool +} + +type InputOptions struct { + DirSearchCompat bool + Extensions string + IgnoreWordlistComments bool + InputMode string + InputNum int + Inputcommands []string + Request string + RequestProto string + Wordlists []string +} + +type OutputOptions struct { + DebugLog string + OutputDirectory string + OutputFile string + OutputFormat string +} + +type FilterOptions struct { + Lines string + Regexp string + Size string + Status string + Words string +} + +type MatcherOptions struct { + Lines string + Regexp string + Size string + Status string + Words string +} + +//NewConfigOptions returns a newly created ConfigOptions struct with default values +func NewConfigOptions() *ConfigOptions { + c := &ConfigOptions{} + c.Filter.Lines = "" + c.Filter.Regexp = "" + c.Filter.Size = "" + c.Filter.Status = "" + c.Filter.Words = "" + c.General.AutoCalibration = false + c.General.Colors = false + c.General.Delay = "" + c.General.MaxTime = 0 + c.General.MaxTimeJob = 0 + c.General.Quiet = false + c.General.Rate = 0 + c.General.ShowVersion = false + c.General.StopOn403 = false + c.General.StopOnAll = false + c.General.StopOnErrors = false + c.General.Threads = 40 + c.General.Verbose = false + c.HTTP.Data = "" + c.HTTP.FollowRedirects = false + c.HTTP.IgnoreBody = false + c.HTTP.Method = "GET" + c.HTTP.ProxyURL = "" + c.HTTP.Recursion = false + c.HTTP.RecursionDepth = 0 + c.HTTP.ReplayProxyURL = "" + c.HTTP.Timeout = 10 + c.HTTP.URL = "" + c.Input.DirSearchCompat = false + c.Input.Extensions = "" + c.Input.IgnoreWordlistComments = false + c.Input.InputMode = "clusterbomb" + c.Input.InputNum = 100 + c.Input.Request = "" + c.Input.RequestProto = "https" + c.Matcher.Lines = "" + c.Matcher.Regexp = "" + c.Matcher.Size = "" + c.Matcher.Status = "200,204,301,302,307,401,403" + c.Matcher.Words = "" + c.Output.DebugLog = "" + c.Output.OutputDirectory = "" + c.Output.OutputFile = "" + c.Output.OutputFormat = "json" + return c +} + +//ConfigFromOptions parses the values in ConfigOptions struct, ensures that the values are sane, +// and creates a Config struct out of them. +func ConfigFromOptions(parseOpts *ConfigOptions, ctx context.Context, cancel context.CancelFunc) (*Config, error) { + //TODO: refactor in a proper flag library that can handle things like required flags + errs := NewMultierror() + conf := NewConfig(ctx, cancel) + + var err error + var err2 error + if len(parseOpts.HTTP.URL) == 0 && parseOpts.Input.Request == "" { + errs.Add(fmt.Errorf("-u flag or -request flag is required")) + } + + // prepare extensions + if parseOpts.Input.Extensions != "" { + extensions := strings.Split(parseOpts.Input.Extensions, ",") + conf.Extensions = extensions + } + + // Convert cookies to a header + if len(parseOpts.HTTP.Cookies) > 0 { + parseOpts.HTTP.Headers = append(parseOpts.HTTP.Headers, "Cookie: "+strings.Join(parseOpts.HTTP.Cookies, "; ")) + } + + //Prepare inputproviders + for _, v := range parseOpts.Input.Wordlists { + var wl []string + if runtime.GOOS == "windows" { + // Try to ensure that Windows file paths like C:\path\to\wordlist.txt:KEYWORD are treated properly + if FileExists(v) { + // The wordlist was supplied without a keyword parameter + wl = []string{v} + } else { + filepart := v[:strings.LastIndex(v, ":")] + if FileExists(filepart) { + wl = []string{filepart, v[strings.LastIndex(v, ":")+1:]} + } else { + // The file was not found. Use full wordlist parameter value for more concise error message down the line + wl = []string{v} + } + } + } else { + wl = strings.SplitN(v, ":", 2) + } + if len(wl) == 2 { + conf.InputProviders = append(conf.InputProviders, InputProviderConfig{ + Name: "wordlist", + Value: wl[0], + Keyword: wl[1], + }) + } else { + conf.InputProviders = append(conf.InputProviders, InputProviderConfig{ + Name: "wordlist", + Value: wl[0], + Keyword: "FUZZ", + }) + } + } + for _, v := range parseOpts.Input.Inputcommands { + ic := strings.SplitN(v, ":", 2) + if len(ic) == 2 { + conf.InputProviders = append(conf.InputProviders, InputProviderConfig{ + Name: "command", + Value: ic[0], + Keyword: ic[1], + }) + conf.CommandKeywords = append(conf.CommandKeywords, ic[0]) + } else { + conf.InputProviders = append(conf.InputProviders, InputProviderConfig{ + Name: "command", + Value: ic[0], + Keyword: "FUZZ", + }) + conf.CommandKeywords = append(conf.CommandKeywords, "FUZZ") + } + } + + if len(conf.InputProviders) == 0 { + errs.Add(fmt.Errorf("Either -w or --input-cmd flag is required")) + } + + // Prepare the request using body + if parseOpts.Input.Request != "" { + err := parseRawRequest(parseOpts, &conf) + if err != nil { + errmsg := fmt.Sprintf("Could not parse raw request: %s", err) + errs.Add(fmt.Errorf(errmsg)) + } + } + + //Prepare URL + if parseOpts.HTTP.URL != "" { + conf.Url = parseOpts.HTTP.URL + } + + //Prepare headers and make canonical + for _, v := range parseOpts.HTTP.Headers { + hs := strings.SplitN(v, ":", 2) + if len(hs) == 2 { + // trim and make canonical + // except if used in custom defined header + var CanonicalNeeded = true + for _, a := range conf.CommandKeywords { + if a == hs[0] { + CanonicalNeeded = false + } + } + // check if part of InputProviders + if CanonicalNeeded { + for _, b := range conf.InputProviders { + if b.Keyword == hs[0] { + CanonicalNeeded = false + } + } + } + if CanonicalNeeded { + var CanonicalHeader = textproto.CanonicalMIMEHeaderKey(strings.TrimSpace(hs[0])) + conf.Headers[CanonicalHeader] = strings.TrimSpace(hs[1]) + } else { + conf.Headers[strings.TrimSpace(hs[0])] = strings.TrimSpace(hs[1]) + } + } else { + errs.Add(fmt.Errorf("Header defined by -H needs to have a value. \":\" should be used as a separator")) + } + } + + //Prepare delay + d := strings.Split(parseOpts.General.Delay, "-") + if len(d) > 2 { + errs.Add(fmt.Errorf("Delay needs to be either a single float: \"0.1\" or a range of floats, delimited by dash: \"0.1-0.8\"")) + } else if len(d) == 2 { + conf.Delay.IsRange = true + conf.Delay.HasDelay = true + conf.Delay.Min, err = strconv.ParseFloat(d[0], 64) + conf.Delay.Max, err2 = strconv.ParseFloat(d[1], 64) + if err != nil || err2 != nil { + errs.Add(fmt.Errorf("Delay range min and max values need to be valid floats. For example: 0.1-0.5")) + } + } else if len(parseOpts.General.Delay) > 0 { + conf.Delay.IsRange = false + conf.Delay.HasDelay = true + conf.Delay.Min, err = strconv.ParseFloat(parseOpts.General.Delay, 64) + if err != nil { + errs.Add(fmt.Errorf("Delay needs to be either a single float: \"0.1\" or a range of floats, delimited by dash: \"0.1-0.8\"")) + } + } + + // Verify proxy url format + if len(parseOpts.HTTP.ProxyURL) > 0 { + _, err := url.Parse(parseOpts.HTTP.ProxyURL) + if err != nil { + errs.Add(fmt.Errorf("Bad proxy url (-x) format: %s", err)) + } else { + conf.ProxyURL = parseOpts.HTTP.ProxyURL + } + } + + // Verify replayproxy url format + if len(parseOpts.HTTP.ReplayProxyURL) > 0 { + _, err := url.Parse(parseOpts.HTTP.ReplayProxyURL) + if err != nil { + errs.Add(fmt.Errorf("Bad replay-proxy url (-replay-proxy) format: %s", err)) + } else { + conf.ReplayProxyURL = parseOpts.HTTP.ReplayProxyURL + } + } + + //Check the output file format option + if parseOpts.Output.OutputFile != "" { + //No need to check / error out if output file isn't defined + outputFormats := []string{"all", "json", "ejson", "html", "md", "csv", "ecsv"} + found := false + for _, f := range outputFormats { + if f == parseOpts.Output.OutputFormat { + conf.OutputFormat = f + found = true + } + } + if !found { + errs.Add(fmt.Errorf("Unknown output file format (-of): %s", parseOpts.Output.OutputFormat)) + } + } + + // Auto-calibration strings + if len(parseOpts.General.AutoCalibrationStrings) > 0 { + conf.AutoCalibrationStrings = parseOpts.General.AutoCalibrationStrings + } + // Using -acc implies -ac + if len(parseOpts.General.AutoCalibrationStrings) > 0 { + conf.AutoCalibration = true + } + + if parseOpts.General.Rate < 0 { + conf.Rate = 0 + } else { + conf.Rate = int64(parseOpts.General.Rate) + } + + // Common stuff + conf.IgnoreWordlistComments = parseOpts.Input.IgnoreWordlistComments + conf.DirSearchCompat = parseOpts.Input.DirSearchCompat + conf.Data = parseOpts.HTTP.Data + conf.Colors = parseOpts.General.Colors + conf.InputNum = parseOpts.Input.InputNum + conf.InputMode = parseOpts.Input.InputMode + conf.Method = parseOpts.HTTP.Method + conf.OutputFile = parseOpts.Output.OutputFile + conf.OutputDirectory = parseOpts.Output.OutputDirectory + conf.IgnoreBody = parseOpts.HTTP.IgnoreBody + conf.Quiet = parseOpts.General.Quiet + conf.StopOn403 = parseOpts.General.StopOn403 + conf.StopOnAll = parseOpts.General.StopOnAll + conf.StopOnErrors = parseOpts.General.StopOnErrors + conf.FollowRedirects = parseOpts.HTTP.FollowRedirects + conf.Recursion = parseOpts.HTTP.Recursion + conf.RecursionDepth = parseOpts.HTTP.RecursionDepth + conf.AutoCalibration = parseOpts.General.AutoCalibration + conf.Threads = parseOpts.General.Threads + conf.Timeout = parseOpts.HTTP.Timeout + conf.MaxTime = parseOpts.General.MaxTime + conf.MaxTimeJob = parseOpts.General.MaxTimeJob + conf.Verbose = parseOpts.General.Verbose + + // Handle copy as curl situation where POST method is implied by --data flag. If method is set to anything but GET, NOOP + if len(conf.Data) > 0 && + conf.Method == "GET" && + //don't modify the method automatically if a request file is being used as input + len(parseOpts.Input.Request) == 0 { + + conf.Method = "POST" + } + + conf.CommandLine = strings.Join(os.Args, " ") + + for _, provider := range conf.InputProviders { + if !keywordPresent(provider.Keyword, &conf) { + errmsg := fmt.Sprintf("Keyword %s defined, but not found in headers, method, URL or POST data.", provider.Keyword) + errs.Add(fmt.Errorf(errmsg)) + } + } + + // Do checks for recursion mode + if parseOpts.HTTP.Recursion { + if !strings.HasSuffix(conf.Url, "FUZZ") { + errmsg := fmt.Sprintf("When using -recursion the URL (-u) must end with FUZZ keyword.") + errs.Add(fmt.Errorf(errmsg)) + } + } + return &conf, errs.ErrorOrNil() +} + +func parseRawRequest(parseOpts *ConfigOptions, conf *Config) error { + file, err := os.Open(parseOpts.Input.Request) + if err != nil { + return fmt.Errorf("could not open request file: %s", err) + } + defer file.Close() + + r := bufio.NewReader(file) + + s, err := r.ReadString('\n') + if err != nil { + return fmt.Errorf("could not read request: %s", err) + } + parts := strings.Split(s, " ") + if len(parts) < 3 { + return fmt.Errorf("malformed request supplied") + } + // Set the request Method + conf.Method = parts[0] + + for { + line, err := r.ReadString('\n') + line = strings.TrimSpace(line) + + if err != nil || line == "" { + break + } + + p := strings.SplitN(line, ":", 2) + if len(p) != 2 { + continue + } + + if strings.EqualFold(p[0], "content-length") { + continue + } + + conf.Headers[strings.TrimSpace(p[0])] = strings.TrimSpace(p[1]) + } + + // Handle case with the full http url in path. In that case, + // ignore any host header that we encounter and use the path as request URL + if strings.HasPrefix(parts[1], "http") { + parsed, err := url.Parse(parts[1]) + if err != nil { + return fmt.Errorf("could not parse request URL: %s", err) + } + conf.Url = parts[1] + conf.Headers["Host"] = parsed.Host + } else { + // Build the request URL from the request + conf.Url = parseOpts.Input.RequestProto + "://" + conf.Headers["Host"] + parts[1] + } + + // Set the request body + b, err := ioutil.ReadAll(r) + if err != nil { + return fmt.Errorf("could not read request body: %s", err) + } + conf.Data = string(b) + + return nil +} + +func keywordPresent(keyword string, conf *Config) bool { + //Search for keyword from HTTP method, URL and POST data too + if strings.Index(conf.Method, keyword) != -1 { + return true + } + if strings.Index(conf.Url, keyword) != -1 { + return true + } + if strings.Index(conf.Data, keyword) != -1 { + return true + } + for k, v := range conf.Headers { + if strings.Index(k, keyword) != -1 { + return true + } + if strings.Index(v, keyword) != -1 { + return true + } + } + return false +} + +func ReadConfig(configFile string) (*ConfigOptions, error) { + conf := NewConfigOptions() + configData, err := ioutil.ReadFile(configFile) + if err == nil { + err = toml.Unmarshal(configData, conf) + } + return conf, err +} + +func ReadDefaultConfig() (*ConfigOptions, error) { + userhome, err := os.UserHomeDir() + if err != nil { + return NewConfigOptions(), err + } + defaultconf := filepath.Join(userhome, ".ffufrc") + return ReadConfig(defaultconf) +} diff --git a/pkg/filter/filter.go b/pkg/filter/filter.go index 846612b..e4570aa 100644 --- a/pkg/filter/filter.go +++ b/pkg/filter/filter.go @@ -1,6 +1,7 @@ package filter import ( + "flag" "fmt" "strconv" "strings" @@ -95,3 +96,89 @@ func calibrateFilters(j *ffuf.Job, responses []ffuf.Response) { AddFilter(j.Config, "line", strings.Join(lineCalib, ",")) } } + +func SetupFilters(parseOpts *ffuf.ConfigOptions, conf *ffuf.Config) error { + errs := ffuf.NewMultierror() + // If any other matcher is set, ignore -mc default value + matcherSet := false + statusSet := false + warningIgnoreBody := false + flag.Visit(func(f *flag.Flag) { + if f.Name == "mc" { + statusSet = true + } + if f.Name == "ms" { + matcherSet = true + warningIgnoreBody = true + } + if f.Name == "ml" { + matcherSet = true + warningIgnoreBody = true + } + if f.Name == "mr" { + matcherSet = true + } + if f.Name == "mw" { + matcherSet = true + warningIgnoreBody = true + } + }) + if statusSet || !matcherSet { + if err := AddMatcher(conf, "status", parseOpts.Matcher.Status); err != nil { + errs.Add(err) + } + } + + if parseOpts.Filter.Status != "" { + if err := AddFilter(conf, "status", parseOpts.Filter.Status); err != nil { + errs.Add(err) + } + } + if parseOpts.Filter.Size != "" { + warningIgnoreBody = true + if err := AddFilter(conf, "size", parseOpts.Filter.Size); err != nil { + errs.Add(err) + } + } + if parseOpts.Filter.Regexp != "" { + if err := AddFilter(conf, "regexp", parseOpts.Filter.Regexp); err != nil { + errs.Add(err) + } + } + if parseOpts.Filter.Words != "" { + warningIgnoreBody = true + if err := AddFilter(conf, "word", parseOpts.Filter.Words); err != nil { + errs.Add(err) + } + } + if parseOpts.Filter.Lines != "" { + warningIgnoreBody = true + if err := AddFilter(conf, "line", parseOpts.Filter.Lines); err != nil { + errs.Add(err) + } + } + if parseOpts.Matcher.Size != "" { + if err := AddMatcher(conf, "size", parseOpts.Matcher.Size); err != nil { + errs.Add(err) + } + } + if parseOpts.Matcher.Regexp != "" { + if err := AddMatcher(conf, "regexp", parseOpts.Matcher.Regexp); err != nil { + errs.Add(err) + } + } + if parseOpts.Matcher.Words != "" { + if err := AddMatcher(conf, "word", parseOpts.Matcher.Words); err != nil { + errs.Add(err) + } + } + if parseOpts.Matcher.Lines != "" { + if err := AddMatcher(conf, "line", parseOpts.Matcher.Lines); err != nil { + errs.Add(err) + } + } + if conf.IgnoreBody && warningIgnoreBody { + fmt.Printf("*** Warning: possible undesired combination of -ignore-body and the response options: fl,fs,fw,ml,ms and mw.\n") + } + return errs.ErrorOrNil() +} diff --git a/pkg/input/input.go b/pkg/input/input.go index 57e1a76..14ba58e 100644 --- a/pkg/input/input.go +++ b/pkg/input/input.go @@ -13,17 +13,27 @@ type MainInputProvider struct { msbIterator int } -func NewInputProvider(conf *ffuf.Config) (ffuf.InputProvider, error) { +func NewInputProvider(conf *ffuf.Config) (ffuf.InputProvider, ffuf.Multierror) { validmode := false + errs := ffuf.NewMultierror() for _, mode := range []string{"clusterbomb", "pitchfork"} { if conf.InputMode == mode { validmode = true } } if !validmode { - return &MainInputProvider{}, fmt.Errorf("Input mode (-mode) %s not recognized", conf.InputMode) + errs.Add(fmt.Errorf("Input mode (-mode) %s not recognized", conf.InputMode)) + return &MainInputProvider{}, errs } - return &MainInputProvider{Config: conf, msbIterator: 0}, nil + mainip := MainInputProvider{Config: conf, msbIterator: 0} + // Initialize the correct inputprovider + for _, v := range conf.InputProviders { + err := mainip.AddProvider(v) + if err != nil { + errs.Add(err) + } + } + return &mainip, errs } func (i *MainInputProvider) AddProvider(provider ffuf.InputProviderConfig) error {