From 5456a37f7253145e8d3ada99e64a4df04a9c5ec7 Mon Sep 17 00:00:00 2001 From: Joona Hoikkala Date: Sun, 10 Nov 2019 23:30:54 +0200 Subject: [PATCH] Multiple wordlist support (#79) * Multiple wordlist support * Display error correctly if wordlist file could not be opened * Add back the redirect location * Support multiple keywords in HTML output and fix wordlist positioning * Support multiple wordlists for md output * Support multiple keywords in CSV output * Improve output for multi keyword runs * Add changelog entry * Switch the wordlist filename <-> keyword around to allow tab completion * Fix the usage example in README --- README.md | 42 +++++++------- main.go | 119 ++++++++++++++++++++++++++-------------- pkg/ffuf/config.go | 19 ++++--- pkg/ffuf/interfaces.go | 13 ++++- pkg/ffuf/job.go | 11 +++- pkg/ffuf/request.go | 2 +- pkg/input/command.go | 22 ++++++-- pkg/input/input.go | 60 ++++++++++++++++++-- pkg/input/wordlist.go | 20 +++++-- pkg/output/file_csv.go | 37 +++++++++---- pkg/output/file_html.go | 16 ++++-- pkg/output/file_md.go | 25 ++++----- pkg/output/stdout.go | 99 ++++++++++++++++++++++++--------- pkg/runner/simple.go | 28 ++++++---- 14 files changed, 364 insertions(+), 149 deletions(-) diff --git a/README.md b/README.md index 46080f7..ab56555 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,7 @@ ffuf --input-cmd 'cat $FFUF_NUM.txt' -H "Content-Type: application/json" -X POST To define the test case for ffuf, use the keyword `FUZZ` anywhere in the URL (`-u`), headers (`-H`), or POST data (`-d`). ``` +Usage of ./ffuf: -D DirSearch style wordlist compatibility mode. Used in conjunction with -e flag. Replaces %EXT% in wordlist entry with each of the extensions provided by -e. -H "Name: Value" Header "Name: Value", separated by colon. Multiple -H flags are accepted. @@ -100,57 +101,59 @@ To define the test case for ffuf, use the keyword `FUZZ` anywhere in the URL (`- HTTP method to use (default "GET") -ac Automatically calibrate filtering options - -acc - Custom auto-calibration string. Can be used multiple times. Implies -ac - -i - Dummy flag for copy as curl functionality (ignored) + -acc value + Custom auto-calibration string. Can be used multiple times. Implies -ac -b "NAME1=VALUE1; NAME2=VALUE2" Cookie data "NAME1=VALUE1; NAME2=VALUE2" for copy as curl functionality. Results unpredictable when combined with -H "Cookie: ..." - -cookie - Cookie data (alias of -b) -c Colorize output. -compressed Dummy flag for copy as curl functionality (ignored) (default true) + -cookie value + Cookie data (alias of -b) -d string POST data - -data-ascii - POST data (alias of -d) - -data-binary - POST data (alias of -d) -data string POST data (alias of -d) + -data-ascii string + POST data (alias of -d) + -data-binary string + POST data (alias of -d) + -debug-log string + Write all of the internal logging to the specified file. -e string Comma separated list of extensions to apply. Each extension provided will extend the wordlist entry once. -fc string Filter HTTP status codes from response. Comma separated list of codes and ranges + -fl string + Filter by amount of lines in response. Comma separated list of line counts and ranges -fr string Filter regexp -fs string Filter HTTP response size. Comma separated list of sizes and ranges -fw string Filter by amount of words in response. Comma separated list of word counts and ranges - -fl string - Filter by amount of lines in response. Comma separated list of line counts and ranges - -input-cmd string + -i Dummy flag for copy as curl functionality (ignored) (default true) + -input-cmd value Command producing the input. --input-num is required when using this input method. Overrides -w. -input-num int Number of inputs to test. Used in conjunction with --input-cmd. (default 100) -k TLS identity verification + -l Show target location of redirect responses -mc string Match HTTP status codes from respose, use "all" to match every response code. (default "200,204,301,302,307,401,403") + -ml string + Match amount of lines in response -mr string Match regexp -ms string Match HTTP response size -mw string Match amount of words in response - -ml string - Match amount of lines in response -o string Write output to file -of string - Output file format. Available formats: json, csv, ecsv (default "json") + Output file format. Available formats: json, html, md, csv, ecsv (default "json") -p delay Seconds of delay between requests, or a range of random delay. For example "0.1" or "0.1-2.0" -r Follow redirects @@ -167,12 +170,10 @@ To define the test case for ffuf, use the keyword `FUZZ` anywhere in the URL (`- HTTP request timeout in seconds. (default 10) -u string Target URL - -w string - Wordlist file path or - to read from standard input + -w value + Wordlist file path and (optional) custom fuzz keyword, using colon as delimiter. Use file path '-' to read from standard input. Can be supplied multiple times. Format: '/path/to/wordlist:KEYWORD' -x string HTTP Proxy URL - -debug-log string - Write the debug logging information to the specified file. ``` eg. `ffuf -u https://example.org/FUZZ -w /path/to/wordlist` @@ -195,6 +196,7 @@ The only dependency of ffuf is Go 1.11. No dependencies outside of Go standard l - New CLI flac: -acc, custom auto-calibration strings - New CLI flag: -debug-log, writes the debug logging to the specified file. - New CLI flags -ml and -fl, filters/matches line count in response + - Ability to use multiple wordlists / keywords by defining multiple -w command line flags. The if no keyword is defined, the default is FUZZ to keep backwards compatibility. Example: `-w "wordlists/custom.txt:CUSTOM" -H "RandomHeader: CUSTOM"`. - Changed diff --git a/main.go b/main.go index 901bd01..339d950 100644 --- a/main.go +++ b/main.go @@ -34,6 +34,8 @@ type cliOptions struct { matcherLines string proxyURL string outputFormat string + wordlists multiStringFlag + inputcommands multiStringFlag headers multiStringFlag cookies multiStringFlag AutoCalibrationStrings multiStringFlag @@ -62,7 +64,7 @@ func main() { flag.BoolVar(&conf.DirSearchCompat, "D", false, "DirSearch style wordlist compatibility mode. Used in conjunction with -e flag. Replaces %EXT% in wordlist entry with each of the extensions provided by -e.") flag.Var(&opts.headers, "H", "Header `\"Name: Value\"`, separated by colon. Multiple -H flags are accepted.") flag.StringVar(&conf.Url, "u", "", "Target URL") - flag.StringVar(&conf.Wordlist, "w", "", "Wordlist file path or - to read from standard input") + flag.Var(&opts.wordlists, "w", "Wordlist file path and (optional) custom fuzz keyword, using colon as delimiter. Use file path '-' to read from standard input. Can be supplied multiple times. Format: '/path/to/wordlist:KEYWORD'") flag.BoolVar(&conf.TLSVerify, "k", false, "TLS identity verification") 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") @@ -76,7 +78,7 @@ func main() { flag.StringVar(&conf.Data, "data-binary", "", "POST data (alias of -d)") flag.BoolVar(&conf.Colors, "c", false, "Colorize output.") flag.BoolVar(&ignored, "compressed", true, "Dummy flag for copy as curl functionality (ignored)") - flag.StringVar(&conf.InputCommand, "input-cmd", "", "Command producing the input. --input-num is required when using this input method. Overrides -w.") + 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.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.\nResults unpredictable when combined with -H \"Cookie: ...\"") @@ -148,18 +150,16 @@ func main() { func prepareJob(conf *ffuf.Config) (*ffuf.Job, error) { errs := ffuf.NewMultierror() var err error - var inputprovider ffuf.InputProvider + inputprovider := input.NewInputProvider(conf) // TODO: implement error handling for runnerprovider and outputprovider // We only have http runner right now runprovider := runner.NewRunnerByName("http", conf) // Initialize the correct inputprovider - if len(conf.InputCommand) > 0 { - inputprovider, err = input.NewInputProviderByName("command", conf) - } else { - inputprovider, err = input.NewInputProviderByName("wordlist", conf) - } - if err != nil { - errs.Add(fmt.Errorf("%s", err)) + for _, v := range conf.InputProviders { + err = inputprovider.AddProvider(v) + if err != nil { + errs.Add(fmt.Errorf("%s", err)) + } } // We only have stdout outputprovider right now outprovider := output.NewOutputProviderByName("stdout", conf) @@ -229,16 +229,12 @@ func prepareFilters(parseOpts *cliOptions, conf *ffuf.Config) error { 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() - foundkeyword := false var err error var err2 error if len(conf.Url) == 0 { errs.Add(fmt.Errorf("-u flag is required")) } - if len(conf.Wordlist) == 0 && len(conf.InputCommand) == 0 { - errs.Add(fmt.Errorf("Either -w or --input-cmd flag is required")) - } // prepare extensions if parseOpts.extensions != "" { extensions := strings.Split(parseOpts.extensions, ",") @@ -249,23 +245,52 @@ func prepareConfig(parseOpts *cliOptions, conf *ffuf.Config) error { if len(parseOpts.cookies) > 0 { parseOpts.headers.Set("Cookie: " + strings.Join(parseOpts.cookies, "; ")) } + + //Prepare inputproviders + for _, v := range parseOpts.wordlists { + 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 headers for _, v := range parseOpts.headers { hs := strings.SplitN(v, ":", 2) if len(hs) == 2 { - fuzzedheader := false - for _, fv := range hs { - if strings.Index(fv, "FUZZ") != -1 { - // Add to fuzzheaders - fuzzedheader = true - } - } - if fuzzedheader { - conf.FuzzHeaders[strings.TrimSpace(hs[0])] = strings.TrimSpace(hs[1]) - foundkeyword = true - } else { - conf.StaticHeaders[strings.TrimSpace(hs[0])] = strings.TrimSpace(hs[1]) - } + 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")) } @@ -333,20 +358,34 @@ func prepareConfig(parseOpts *cliOptions, conf *ffuf.Config) error { conf.CommandLine = strings.Join(os.Args, " ") - //Search for keyword from HTTP method, URL and POST data too - if conf.Method == "FUZZ" { - foundkeyword = true - } - if strings.Index(conf.Url, "FUZZ") != -1 { - foundkeyword = true - } - if strings.Index(conf.Data, "FUZZ") != -1 { - foundkeyword = true - } - - if !foundkeyword { - errs.Add(fmt.Errorf("No FUZZ keyword(s) found in headers, method, URL or POST data, nothing to do")) + 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)) + } } return errs.ErrorOrNil() } + +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 76691d2..06b4fd3 100644 --- a/pkg/ffuf/config.go +++ b/pkg/ffuf/config.go @@ -16,8 +16,7 @@ type optRange struct { } type Config struct { - StaticHeaders map[string]string - FuzzHeaders map[string]string + Headers map[string]string Extensions []string DirSearchCompat bool Method string @@ -26,8 +25,8 @@ type Config struct { Data string Quiet bool Colors bool - Wordlist string - InputCommand string + InputProviders []InputProviderConfig + CommandKeywords []string InputNum int OutputFile string OutputFormat string @@ -49,11 +48,16 @@ type Config struct { CommandLine string } +type InputProviderConfig struct { + Name string + Keyword string + Value string +} + func NewConfig(ctx context.Context) Config { var conf Config conf.Context = ctx - conf.StaticHeaders = make(map[string]string) - conf.FuzzHeaders = make(map[string]string) + conf.Headers = make(map[string]string) conf.Method = "GET" conf.Url = "" conf.TLSVerify = false @@ -64,7 +68,8 @@ func NewConfig(ctx context.Context) Config { conf.StopOnAll = false conf.ShowRedirectLocation = false conf.FollowRedirects = false - conf.InputCommand = "" + conf.InputProviders = make([]InputProviderConfig, 0) + conf.CommandKeywords = make([]string, 0) conf.InputNum = 0 conf.ProxyURL = http.ProxyFromEnvironment conf.Filters = make([]FilterProvider, 0) diff --git a/pkg/ffuf/interfaces.go b/pkg/ffuf/interfaces.go index f36ab77..7b36b70 100644 --- a/pkg/ffuf/interfaces.go +++ b/pkg/ffuf/interfaces.go @@ -8,14 +8,25 @@ type FilterProvider interface { //RunnerProvider is an interface for request executors type RunnerProvider interface { - Prepare(input []byte) (Request, error) + Prepare(input map[string][]byte) (Request, error) Execute(req *Request) (Response, error) } //InputProvider interface handles the input data for RunnerProvider type InputProvider interface { + AddProvider(InputProviderConfig) error Next() bool Position() int + Value() map[string][]byte + Total() int +} + +//InternalInputProvider interface handles providing input data to InputProvider +type InternalInputProvider interface { + Keyword() string + Next() bool + Position() int + ResetPosition() Value() []byte Total() int } diff --git a/pkg/ffuf/job.go b/pkg/ffuf/job.go index ccfcbb9..a044258 100644 --- a/pkg/ffuf/job.go +++ b/pkg/ffuf/job.go @@ -85,7 +85,7 @@ func (j *Job) Start() { go func() { defer func() { <-limiter }() defer wg.Done() - j.runTask([]byte(nextInput), nextPosition, false) + j.runTask(nextInput, nextPosition, false) if j.Config.Delay.HasDelay { var sleepDurationMS time.Duration if j.Config.Delay.IsRange { @@ -157,7 +157,7 @@ func (j *Job) isMatch(resp Response) bool { return true } -func (j *Job) runTask(input []byte, position int, retried bool) { +func (j *Job) runTask(input map[string][]byte, position int, retried bool) { req, err := j.Runner.Prepare(input) req.Position = position if err != nil { @@ -205,7 +205,12 @@ func (j *Job) CalibrateResponses() ([]Response, error) { results := make([]Response, 0) for _, input := range cInputs { - req, err := j.Runner.Prepare([]byte(input)) + inputs := make(map[string][]byte, 0) + for _, v := range j.Config.InputProviders { + inputs[v.Keyword] = []byte(input) + } + + req, err := j.Runner.Prepare(inputs) if err != nil { j.Output.Error(fmt.Sprintf("Encountered an error while preparing request: %s\n", err)) j.incError() diff --git a/pkg/ffuf/request.go b/pkg/ffuf/request.go index 8574cab..7aec09c 100644 --- a/pkg/ffuf/request.go +++ b/pkg/ffuf/request.go @@ -6,7 +6,7 @@ type Request struct { Url string Headers map[string]string Data []byte - Input []byte + Input map[string][]byte Position int } diff --git a/pkg/input/command.go b/pkg/input/command.go index 88ba480..fffa0ba 100644 --- a/pkg/input/command.go +++ b/pkg/input/command.go @@ -10,22 +10,36 @@ import ( ) type CommandInput struct { - config *ffuf.Config - count int + config *ffuf.Config + count int + keyword string + command string } -func NewCommandInput(conf *ffuf.Config) (*CommandInput, error) { +func NewCommandInput(keyword string, value string, conf *ffuf.Config) (*CommandInput, error) { var cmd CommandInput + cmd.keyword = keyword cmd.config = conf cmd.count = -1 + cmd.command = value return &cmd, nil } +//Keyword returns the keyword assigned to this InternalInputProvider +func (c *CommandInput) Keyword() string { + return c.keyword +} + //Position will return the current position in the input list func (c *CommandInput) Position() int { return c.count } +//ResetPosition will reset the current position of the InternalInputProvider +func (c *CommandInput) ResetPosition() { + c.count = 0 +} + //Next will increment the cursor position, and return a boolean telling if there's iterations left func (c *CommandInput) Next() bool { c.count++ @@ -39,7 +53,7 @@ func (c *CommandInput) Next() bool { func (c *CommandInput) Value() []byte { var stdout bytes.Buffer os.Setenv("FFUF_NUM", strconv.Itoa(c.count)) - cmd := exec.Command(SHELL_CMD, SHELL_ARG, c.config.InputCommand) + cmd := exec.Command(SHELL_CMD, SHELL_ARG, c.command) cmd.Stdout = &stdout err := cmd.Run() if err != nil { diff --git a/pkg/input/input.go b/pkg/input/input.go index a16e240..a3e31a4 100644 --- a/pkg/input/input.go +++ b/pkg/input/input.go @@ -4,11 +4,63 @@ import ( "github.com/ffuf/ffuf/pkg/ffuf" ) -func NewInputProviderByName(name string, conf *ffuf.Config) (ffuf.InputProvider, error) { - if name == "command" { - return NewCommandInput(conf) +type MainInputProvider struct { + Providers []ffuf.InternalInputProvider + Config *ffuf.Config + position int +} + +func NewInputProvider(conf *ffuf.Config) ffuf.InputProvider { + return &MainInputProvider{Config: conf} +} + +func (i *MainInputProvider) AddProvider(provider ffuf.InputProviderConfig) error { + if provider.Name == "command" { + newcomm, _ := NewCommandInput(provider.Keyword, provider.Value, i.Config) + i.Providers = append(i.Providers, newcomm) } else { // Default to wordlist - return NewWordlistInput(conf) + newwl, err := NewWordlistInput(provider.Keyword, provider.Value, i.Config) + if err != nil { + return err + } + i.Providers = append(i.Providers, newwl) } + return nil +} + +//Position will return the current position of progress +func (i *MainInputProvider) Position() int { + return i.position +} + +//Next will increment the cursor position, and return a boolean telling if there's inputs left +func (i *MainInputProvider) Next() bool { + if i.position >= i.Total() { + return false + } + i.position++ + return true +} + +//Value returns a map of keyword:value pairs including all inputs +func (i *MainInputProvider) Value() map[string][]byte { + values := make(map[string][]byte) + for _, p := range i.Providers { + if !p.Next() { + // Loop to beginning if the inputprovider has been exhausted + p.ResetPosition() + } + values[p.Keyword()] = p.Value() + } + return values +} + +//Total returns the amount of input combinations available +func (i *MainInputProvider) Total() int { + count := 1 + for _, p := range i.Providers { + count = count * p.Total() + } + return count } diff --git a/pkg/input/wordlist.go b/pkg/input/wordlist.go index 135c7f5..93bae62 100644 --- a/pkg/input/wordlist.go +++ b/pkg/input/wordlist.go @@ -12,27 +12,29 @@ type WordlistInput struct { config *ffuf.Config data [][]byte position int + keyword string } -func NewWordlistInput(conf *ffuf.Config) (*WordlistInput, error) { +func NewWordlistInput(keyword string, value string, conf *ffuf.Config) (*WordlistInput, error) { var wl WordlistInput + wl.keyword = keyword wl.config = conf wl.position = -1 var valid bool var err error // stdin? - if conf.Wordlist == "-" { + if value == "-" { // yes valid = true } else { // no - valid, err = wl.validFile(conf.Wordlist) + valid, err = wl.validFile(value) } if err != nil { return &wl, err } if valid { - err = wl.readFile(conf.Wordlist) + err = wl.readFile(value) } return &wl, err } @@ -42,6 +44,16 @@ func (w *WordlistInput) Position() int { return w.position } +//ResetPosition resets the position back to beginning of the wordlist. +func (w *WordlistInput) ResetPosition() { + w.position = 0 +} + +//Keyword returns the keyword assigned to this InternalInputProvider +func (w *WordlistInput) Keyword() string { + return w.keyword +} + //Next will increment the cursor position, and return a boolean telling if there's words left in the list func (w *WordlistInput) Next() bool { w.position++ diff --git a/pkg/output/file_csv.go b/pkg/output/file_csv.go index d217dbf..0e50cd1 100644 --- a/pkg/output/file_csv.go +++ b/pkg/output/file_csv.go @@ -9,9 +9,10 @@ import ( "github.com/ffuf/ffuf/pkg/ffuf" ) -var header = []string{"input", "position", "status_code", "content_length", "content_words", "content_lines"} +var staticheaders = []string{"position", "status_code", "content_length", "content_words", "content_lines"} func writeCSV(config *ffuf.Config, res []Result, encode bool) error { + header := make([]string, 0) f, err := os.Create(config.OutputFile) if err != nil { return err @@ -21,12 +22,24 @@ func writeCSV(config *ffuf.Config, res []Result, encode bool) error { w := csv.NewWriter(f) defer w.Flush() + for _, inputprovider := range config.InputProviders { + header = append(header, inputprovider.Keyword) + } + + for _, item := range staticheaders { + header = append(header, item) + } + if err := w.Write(header); err != nil { return err } for _, r := range res { if encode { - r.Input = base64encode(r.Input) + inputs := make(map[string][]byte, 0) + for k, v := range r.Input { + inputs[k] = []byte(base64encode(v)) + } + r.Input = inputs } err := w.Write(toCSV(r)) @@ -37,17 +50,19 @@ func writeCSV(config *ffuf.Config, res []Result, encode bool) error { return nil } -func base64encode(in string) string { - return base64.StdEncoding.EncodeToString([]byte(in)) +func base64encode(in []byte) string { + return base64.StdEncoding.EncodeToString(in) } func toCSV(r Result) []string { - return []string{ - r.Input, - strconv.Itoa(r.Position), - strconv.FormatInt(r.StatusCode, 10), - strconv.FormatInt(r.ContentLength, 10), - strconv.FormatInt(r.ContentWords, 10), - strconv.FormatInt(r.ContentLines, 10), + res := make([]string, 0) + for _, v := range r.Input { + res = append(res, string(v)) } + res = append(res, strconv.Itoa(r.Position)) + res = append(res, strconv.FormatInt(r.StatusCode, 10)) + res = append(res, strconv.FormatInt(r.ContentLength, 10)) + res = append(res, strconv.FormatInt(r.ContentWords, 10)) + res = append(res, strconv.FormatInt(r.ContentLines, 10)) + return res } diff --git a/pkg/output/file_html.go b/pkg/output/file_html.go index 0730849..a937631 100644 --- a/pkg/output/file_html.go +++ b/pkg/output/file_html.go @@ -11,6 +11,7 @@ import ( type htmlFileOutput struct { CommandLine string Time string + Keys []string Results []Result } @@ -62,7 +63,8 @@ const ( Status - Input +{{ range .Keys }} {{ . }} +{{ end }} Position Length Words @@ -71,11 +73,11 @@ const ( - {{range .Results}} + {{range $result := .Results}}
-|result_raw|{{ .StatusCode }}|{{ .Input }}|{{ .Position }}|{{ .ContentLength }}|{{ .ContentWords }}|{{ .ContentLines }}| +|result_raw|{{ $result.StatusCode }}{{ range $keyword, $value := $result.Input }}|{{ $value | printf "%s" }}{{ end }}|{{ $result.Position }}|{{ $result.ContentLength }}|{{ $result.ContentWords }}|{{ $result.ContentLines }}|
- {{ .StatusCode }}{{ .Input }}{{ .Position }}{{ .ContentLength }}{{ .ContentWords }}{{ .ContentLines }} + {{ $result.StatusCode }}{{ range $keyword, $value := $result.Input }}{{ $value | printf "%s" }}{{ end }}{{ $result.Position }}{{ $result.ContentLength }}{{ $result.ContentWords }}{{ $result.ContentLines }} {{end}} @@ -142,10 +144,16 @@ func writeHTML(config *ffuf.Config, results []Result) error { ti := time.Now() + keywords := make([]string, 0) + for _, inputprovider := range config.InputProviders { + keywords = append(keywords, inputprovider.Keyword) + } + outHTML := htmlFileOutput{ CommandLine: config.CommandLine, Time: ti.Format(time.RFC3339), Results: results, + Keys: keywords, } f, err := os.Create(config.OutputFile) diff --git a/pkg/output/file_md.go b/pkg/output/file_md.go index a66d4ea..b408cf0 100644 --- a/pkg/output/file_md.go +++ b/pkg/output/file_md.go @@ -8,33 +8,32 @@ import ( "github.com/ffuf/ffuf/pkg/ffuf" ) -type markdownFileOutput struct { - CommandLine string - Time string - Results []Result -} - const ( markdownTemplate = `# FFUF Report Command line : ` + "`{{.CommandLine}}`" + ` Time: ` + "{{ .Time }}" + ` - | Input | Position | Status Code | Content Length | Content Words | Content Lines | - | :---- | :------- | :---------- | :------------- | :------------ | :------------ | - {{range .Results}}| {{ .Input }} | {{ .Position }} | {{ .StatusCode }} | {{ .ContentLength }} | {{ .ContentWords }} | {{ .ContentLines }} | - {{end}} - ` // The template format is not pretty but follows the markdown guide + {{ range .Keys }}| {{ . }} {{ end }}| Position | Status Code | Content Length | Content Words | Content Lines | + {{ range .Keys }}| :- {{ end }}| :---- | :------- | :---------- | :------------- | :------------ | :------------ | + {{range .Results}}{{ range $keyword, $value := .Input }}| {{ $value | printf "%s" }} {{ end }}| {{ .Position }} | {{ .StatusCode }} | {{ .ContentLength }} | {{ .ContentWords }} | {{ .ContentLines }} | + {{end}}` // The template format is not pretty but follows the markdown guide ) func writeMarkdown(config *ffuf.Config, res []Result) error { ti := time.Now() - outHTML := htmlFileOutput{ + keywords := make([]string, 0) + for _, inputprovider := range config.InputProviders { + keywords = append(keywords, inputprovider.Keyword) + } + + outMD := htmlFileOutput{ CommandLine: config.CommandLine, Time: ti.Format(time.RFC3339), Results: res, + Keys: keywords, } f, err := os.Create(config.OutputFile) @@ -46,6 +45,6 @@ func writeMarkdown(config *ffuf.Config, res []Result) error { templateName := "output.md" t := template.New(templateName).Delims("{{", "}}") t.Parse(markdownTemplate) - t.Execute(f, outHTML) + t.Execute(f, outMD) return nil } diff --git a/pkg/output/stdout.go b/pkg/output/stdout.go index 5904f2b..e659c9e 100644 --- a/pkg/output/stdout.go +++ b/pkg/output/stdout.go @@ -27,13 +27,13 @@ type Stdoutput struct { } type Result struct { - Input string `json:"input"` - Position int `json:"position"` - StatusCode int64 `json:"status"` - ContentLength int64 `json:"length"` - ContentWords int64 `json:"words"` - ContentLines int64 `json:"lines"` - HTMLColor string `json:"html_color"` + Input map[string][]byte `json:"input"` + Position int `json:"position"` + StatusCode int64 `json:"status"` + ContentLength int64 `json:"length"` + ContentWords int64 `json:"words"` + ContentLines int64 `json:"lines"` + HTMLColor string `json:"-"` } func NewStdoutput(conf *ffuf.Config) *Stdoutput { @@ -133,8 +133,12 @@ func (s *Stdoutput) Result(resp ffuf.Response) { // Check if we need the data later if s.config.OutputFile != "" { // No need to store results if we're not going to use them later + inputs := make(map[string][]byte, 0) + for k, v := range resp.Request.Input { + inputs[k] = v + } sResult := Result{ - Input: string(resp.Request.Input), + Input: inputs, Position: resp.Request.Position, StatusCode: resp.StatusCode, ContentLength: resp.ContentLength, @@ -149,28 +153,64 @@ func (s *Stdoutput) printResult(resp ffuf.Response) { if s.config.Quiet { s.resultQuiet(resp) } else { - s.resultNormal(resp) + if len(resp.Request.Input) > 1 { + // Print a multi-line result (when using multiple input keywords and wordlists) + s.resultMultiline(resp) + } else { + s.resultNormal(resp) + } } } +func (s *Stdoutput) prepareInputsOneLine(resp ffuf.Response) string { + inputs := "" + if len(resp.Request.Input) > 1 { + for k, v := range resp.Request.Input { + if inSlice(k, s.config.CommandKeywords) { + // If we're using external command for input, display the position instead of input + inputs = fmt.Sprintf("%s%s : %s ", inputs, k, strconv.Itoa(resp.Request.Position)) + } else { + inputs = fmt.Sprintf("%s%s : %s ", inputs, k, v) + } + } + } else { + for k, v := range resp.Request.Input { + if inSlice(k, s.config.CommandKeywords) { + // If we're using external command for input, display the position instead of input + inputs = strconv.Itoa(resp.Request.Position) + } else { + inputs = string(v) + } + } + } + return inputs +} + func (s *Stdoutput) resultQuiet(resp ffuf.Response) { - if len(s.config.InputCommand) > 0 { - // If we're using external command for input, display the position instead of input - fmt.Println(strconv.Itoa(resp.Request.Position)) - } else { - fmt.Println(string(resp.Request.Input)) + fmt.Println(s.prepareInputsOneLine(resp)) +} + +func (s *Stdoutput) resultMultiline(resp ffuf.Response) { + var res_hdr, res_str string + res_str = "%s * %s: %s\n" + res_hdr = fmt.Sprintf("%s[Status: %d, Size: %d, Words: %d, Lines: %d%s]", TERMINAL_CLEAR_LINE, resp.StatusCode, resp.ContentLength, resp.ContentWords, resp.ContentLines, s.addRedirectLocation(resp)) + fmt.Println(s.colorize(res_hdr, resp.StatusCode)) + for k, v := range resp.Request.Input { + if inSlice(k, s.config.CommandKeywords) { + // If we're using external command for input, display the position instead of input + fmt.Printf(res_str, TERMINAL_CLEAR_LINE, k, strconv.Itoa(resp.Request.Position)) + } else { + // Wordlist input + fmt.Printf(res_str, TERMINAL_CLEAR_LINE, k, v) + } } + } func (s *Stdoutput) resultNormal(resp ffuf.Response) { - var responseString string - if len(s.config.InputCommand) > 0 { - // If we're using external command for input, display the position instead of input - responseString = fmt.Sprintf("%s%-23s [Status: %s, Size: %d, Words: %d, Lines: %d]", TERMINAL_CLEAR_LINE, strconv.Itoa(resp.Request.Position), s.colorizeStatus(resp.StatusCode), resp.ContentLength, resp.ContentWords, resp.ContentLines) - } else { - responseString = fmt.Sprintf("%s%-23s [Status: %s, Size: %d, Words: %d, Lines: %d]", TERMINAL_CLEAR_LINE, resp.Request.Input, s.colorizeStatus(resp.StatusCode), resp.ContentLength, resp.ContentWords, resp.ContentLines) - } - fmt.Println(responseString) + var res_str string + res_str = fmt.Sprintf("%s%-23s [Status: %s, Size: %d, Words: %d, Lines: %d%s]", TERMINAL_CLEAR_LINE, s.prepareInputsOneLine(resp), s.colorize(fmt.Sprintf("%d", resp.StatusCode), resp.StatusCode), resp.ContentLength, resp.ContentWords, resp.ContentLines, s.addRedirectLocation(resp)) + fmt.Println(res_str) } // addRedirectLocation returns a formatted string containing the Redirect location or returns an empty string @@ -184,9 +224,9 @@ func (s *Stdoutput) addRedirectLocation(resp ffuf.Response) string { return "" } -func (s *Stdoutput) colorizeStatus(status int64) string { +func (s *Stdoutput) colorize(input string, status int64) string { if !s.config.Colors { - return fmt.Sprintf("%d", status) + return fmt.Sprintf("%s", input) } colorCode := ANSI_CLEAR if status >= 200 && status < 300 { @@ -201,9 +241,18 @@ func (s *Stdoutput) colorizeStatus(status int64) string { if status >= 500 && status < 600 { colorCode = ANSI_RED } - return fmt.Sprintf("%s%d%s", colorCode, status, ANSI_CLEAR) + return fmt.Sprintf("%s%s%s", colorCode, input, ANSI_CLEAR) } func printOption(name []byte, value []byte) { fmt.Printf(" :: %-12s : %s\n", name, value) } + +func inSlice(key string, slice []string) bool { + for _, v := range slice { + if v == key { + return true + } + } + return false +} diff --git a/pkg/runner/simple.go b/pkg/runner/simple.go index 6d2ac52..5aceb9b 100644 --- a/pkg/runner/simple.go +++ b/pkg/runner/simple.go @@ -44,22 +44,26 @@ func NewSimpleRunner(conf *ffuf.Config) ffuf.RunnerProvider { return &simplerunner } -func (r *SimpleRunner) Prepare(input []byte) (ffuf.Request, error) { +func (r *SimpleRunner) Prepare(input map[string][]byte) (ffuf.Request, error) { req := ffuf.NewRequest(r.config) - // should we fuzz the http method - if r.config.Method == "FUZZ" { - req.Method = string(input) + + req.Headers = r.config.Headers + req.Url = r.config.Url + req.Method = r.config.Method + req.Data = []byte(r.config.Data) + + for keyword, inputitem := range input { + req.Method = strings.Replace(req.Method, keyword, string(inputitem), -1) + headers := make(map[string]string, 0) + for h, v := range req.Headers { + headers[strings.Replace(h, keyword, string(inputitem), -1)] = strings.Replace(v, keyword, string(inputitem), -1) + } + req.Headers = headers + req.Url = strings.Replace(req.Url, keyword, string(inputitem), -1) + req.Data = []byte(strings.Replace(string(req.Data), keyword, string(inputitem), -1)) } - for h, v := range r.config.StaticHeaders { - req.Headers[h] = v - } - for h, v := range r.config.FuzzHeaders { - req.Headers[strings.Replace(h, "FUZZ", string(input), -1)] = strings.Replace(v, "FUZZ", string(input), -1) - } req.Input = input - req.Url = strings.Replace(r.config.Url, "FUZZ", string(input), -1) - req.Data = []byte(strings.Replace(r.config.Data, "FUZZ", string(input), -1)) return req, nil }