From 8883aea432e13f054c11c20d01f672bac243c413 Mon Sep 17 00:00:00 2001 From: Joona Hoikkala Date: Mon, 17 Jun 2019 00:42:42 +0300 Subject: [PATCH] New input provider --input-cmd (#40) * New input provider: command * Set env var and move to Windows and POSIX constants for shell instead of CLI flag. * Display position instead of input payload when --input-cmd is used * Update README * Fix README and flags help * Add an example to README --- README.md | 35 +++++++++++++++++++++++- main.go | 18 +++++++++---- pkg/ffuf/config.go | 4 +++ pkg/ffuf/interfaces.go | 1 + pkg/ffuf/job.go | 8 +++--- pkg/ffuf/request.go | 11 ++++---- pkg/input/command.go | 54 ++++++++++++++++++++++++++++++++++++++ pkg/input/const.go | 8 ++++++ pkg/input/const_windows.go | 8 ++++++ pkg/input/input.go | 8 ++++-- pkg/input/wordlist.go | 5 ++++ pkg/output/file_csv.go | 3 ++- pkg/output/stdout.go | 18 +++++++++++-- 13 files changed, 162 insertions(+), 19 deletions(-) create mode 100644 pkg/input/command.go create mode 100644 pkg/input/const.go create mode 100644 pkg/input/const_windows.go diff --git a/README.md b/README.md index 051549e..8832677 100644 --- a/README.md +++ b/README.md @@ -66,9 +66,32 @@ This is a very straightforward operation, again by using the `FUZZ` keyword. Thi ffuf -w /path/to/postdata.txt -X POST -d "username=admin\&password=FUZZ" https://target/login.php -fc 401 ``` +### Using external mutator to produce test cases + +For this example, we'll fuzz JSON data that's sent over POST. [Radamsa](https://gitlab.com/akihe/radamsa) is used as the mutator. + +When `--input-cmd` is used, ffuf will display matches as their position. This same position value will be available for the callee as an environment variable `$FFUF_NUM`. We'll use this position value as the seed for the mutator. Files example1.txt and example2.txt contain valid JSON payloads. We are matching all the responses, but filtering out response code `400 - Bad request`: + +``` +ffuf --input-cmd 'radamsa --seed $FFUF_NUM example1.txt example2.txt' -H "Content-Type: application/json" -X POST -u https://ffuf.io.fi/ -mc all -fc 400 +``` + +It of course isn't very efficient to call the mutator for each payload, so we can also pre-generate the payloads, still using [Radamsa](https://gitlab.com/akihe/radamsa) as an example: + +``` +# Generate 1000 example payloads +radamsa -n 1000 -o %n.txt example1.txt example2.txt + +# This results into files 1.txt ... 1000.txt +# Now we can just read the payload data in a loop from file for ffuf + +ffuf --input-cmd 'cat $FFUF_NUM.txt' -H "Content-Type: application/json" -X POST -u https://ffuf.io.fi/ -mc all -fc 400 +``` + ## Usage To define the test case for ffuf, use the keyword `FUZZ` anywhere in the URL (`-u`), headers (`-H`), or POST data (`-d`). + ``` -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" @@ -79,8 +102,12 @@ To define the test case for ffuf, use the keyword `FUZZ` anywhere in the URL (`- -ac Automatically calibrate filtering options -c Colorize output. + -compressed + Dummy flag for copy as curl functionality (ignored) (default true) -d string - POST data. + POST data + -data string + POST data (alias of -d) -e string Comma separated list of extensions to apply. Each extension provided will extend the wordlist entry once. -fc string @@ -91,6 +118,10 @@ To define the test case for ffuf, use the keyword `FUZZ` anywhere in the URL (`- Filter HTTP response size -fw string Filter by amount of words in response + -input-cmd string + 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 -mc string Match HTTP status codes from respose, use "all" to match every response code. (default "200,204,301,302,307,401,403") @@ -144,6 +175,8 @@ The only dependency of ffuf is Go 1.11. No dependencies outside of Go standard l - New CLI flag: -timeout to specify custom timeouts for all HTTP requests. - New CLI flag: --data for compatibility with copy as curl functionality of browsers. - New CLI flag: --compress, dummy flag that does nothing. for compatibility with copy as curl. + - New CLI flags: --input-cmd, and --input-num to handle input generation using external commands. Mutators for example. Environment variable FFUF_NUM will be updated on every call of the command. + - When --input-cmd is used, display position instead of the payload in results. The output file (of all formats) will include the payload in addition to the position however. - Changed - Wordlist can also be read from standard input diff --git a/main.go b/main.go index edff31c..3ff425b 100644 --- a/main.go +++ b/main.go @@ -55,7 +55,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 path") + flag.StringVar(&conf.Wordlist, "w", "", "Wordlist file path or - to read from standard input") 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") @@ -66,6 +66,8 @@ func main() { flag.StringVar(&conf.Data, "data", "", "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.IntVar(&conf.InputNum, "input-num", 100, "Number of inputs to test. Used in conjunction with --input-cmd.") flag.StringVar(&opts.matcherStatus, "mc", "200,204,301,302,307,401,403", "Match HTTP status codes from respose, use \"all\" to match every response code.") flag.StringVar(&opts.matcherSize, "ms", "", "Match HTTP response size") flag.StringVar(&opts.matcherRegexp, "mr", "", "Match regexp") @@ -116,11 +118,17 @@ func main() { func prepareJob(conf *ffuf.Config) (*ffuf.Job, error) { errs := ffuf.NewMultierror() + var err error + var inputprovider ffuf.InputProvider // TODO: implement error handling for runnerprovider and outputprovider // We only have http runner right now runprovider := runner.NewRunnerByName("http", conf) - // We only have wordlist inputprovider right now - inputprovider, err := input.NewInputProviderByName("wordlist", 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)) } @@ -189,8 +197,8 @@ func prepareConfig(parseOpts *cliOptions, conf *ffuf.Config) error { if len(conf.Url) == 0 { errs.Add(fmt.Errorf("-u flag is required")) } - if len(conf.Wordlist) == 0 { - errs.Add(fmt.Errorf("-w 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 != "" { diff --git a/pkg/ffuf/config.go b/pkg/ffuf/config.go index 737c805..7a9409b 100644 --- a/pkg/ffuf/config.go +++ b/pkg/ffuf/config.go @@ -27,6 +27,8 @@ type Config struct { Quiet bool Colors bool Wordlist string + InputCommand string + InputNum int OutputFile string OutputFormat string StopOn403 bool @@ -59,6 +61,8 @@ func NewConfig(ctx context.Context) Config { conf.StopOnErrors = false conf.StopOnAll = false conf.FollowRedirects = false + conf.InputCommand = "" + conf.InputNum = 0 conf.ProxyURL = http.ProxyFromEnvironment conf.Filters = make([]FilterProvider, 0) conf.Delay = optRange{0, 0, false, false} diff --git a/pkg/ffuf/interfaces.go b/pkg/ffuf/interfaces.go index 3ea076c..f36ab77 100644 --- a/pkg/ffuf/interfaces.go +++ b/pkg/ffuf/interfaces.go @@ -15,6 +15,7 @@ type RunnerProvider interface { //InputProvider interface handles the input data for RunnerProvider type InputProvider interface { Next() bool + Position() int Value() []byte Total() int } diff --git a/pkg/ffuf/job.go b/pkg/ffuf/job.go index f0ec2a3..98f9d6d 100644 --- a/pkg/ffuf/job.go +++ b/pkg/ffuf/job.go @@ -79,12 +79,13 @@ func (j *Job) Start() { } limiter <- true nextInput := j.Input.Value() + nextPosition := j.Input.Position() wg.Add(1) j.Counter++ go func() { defer func() { <-limiter }() defer wg.Done() - j.runTask([]byte(nextInput), false) + j.runTask([]byte(nextInput), nextPosition, false) if j.Config.Delay.HasDelay { var sleepDurationMS time.Duration if j.Config.Delay.IsRange { @@ -156,8 +157,9 @@ func (j *Job) isMatch(resp Response) bool { return true } -func (j *Job) runTask(input []byte, retried bool) { +func (j *Job) runTask(input []byte, position int, retried bool) { req, err := j.Runner.Prepare(input) + req.Position = position if err != nil { j.Output.Error(fmt.Sprintf("Encountered an error while preparing request: %s\n", err)) j.incError() @@ -168,7 +170,7 @@ func (j *Job) runTask(input []byte, retried bool) { if retried { j.incError() } else { - j.runTask(input, true) + j.runTask(input, position, true) } return } diff --git a/pkg/ffuf/request.go b/pkg/ffuf/request.go index c414ff6..8574cab 100644 --- a/pkg/ffuf/request.go +++ b/pkg/ffuf/request.go @@ -2,11 +2,12 @@ package ffuf // Request holds the meaningful data that is passed for runner for making the query type Request struct { - Method string - Url string - Headers map[string]string - Data []byte - Input []byte + Method string + Url string + Headers map[string]string + Data []byte + Input []byte + Position int } func NewRequest(conf *Config) Request { diff --git a/pkg/input/command.go b/pkg/input/command.go new file mode 100644 index 0000000..88ba480 --- /dev/null +++ b/pkg/input/command.go @@ -0,0 +1,54 @@ +package input + +import ( + "bytes" + "os" + "os/exec" + "strconv" + + "github.com/ffuf/ffuf/pkg/ffuf" +) + +type CommandInput struct { + config *ffuf.Config + count int +} + +func NewCommandInput(conf *ffuf.Config) (*CommandInput, error) { + var cmd CommandInput + cmd.config = conf + cmd.count = -1 + return &cmd, nil +} + +//Position will return the current position in the input list +func (c *CommandInput) Position() int { + return c.count +} + +//Next will increment the cursor position, and return a boolean telling if there's iterations left +func (c *CommandInput) Next() bool { + c.count++ + if c.count >= c.config.InputNum { + return false + } + return true +} + +//Value returns the input from command stdoutput +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.Stdout = &stdout + err := cmd.Run() + if err != nil { + return []byte("") + } + return stdout.Bytes() +} + +//Total returns the size of wordlist +func (c *CommandInput) Total() int { + return c.config.InputNum +} diff --git a/pkg/input/const.go b/pkg/input/const.go new file mode 100644 index 0000000..cbf41a5 --- /dev/null +++ b/pkg/input/const.go @@ -0,0 +1,8 @@ +// +build !windows + +package input + +const ( + SHELL_CMD = "/bin/sh" + SHELL_ARG = "-c" +) diff --git a/pkg/input/const_windows.go b/pkg/input/const_windows.go new file mode 100644 index 0000000..d558b7e --- /dev/null +++ b/pkg/input/const_windows.go @@ -0,0 +1,8 @@ +// +build windows + +package input + +const ( + SHELL_CMD = "cmd.exe" + SHELL_ARG = "/C" +) diff --git a/pkg/input/input.go b/pkg/input/input.go index a30bc0f..a16e240 100644 --- a/pkg/input/input.go +++ b/pkg/input/input.go @@ -5,6 +5,10 @@ import ( ) func NewInputProviderByName(name string, conf *ffuf.Config) (ffuf.InputProvider, error) { - // We have only one inputprovider at the moment - return NewWordlistInput(conf) + if name == "command" { + return NewCommandInput(conf) + } else { + // Default to wordlist + return NewWordlistInput(conf) + } } diff --git a/pkg/input/wordlist.go b/pkg/input/wordlist.go index 7a42253..be487d6 100644 --- a/pkg/input/wordlist.go +++ b/pkg/input/wordlist.go @@ -37,6 +37,11 @@ func NewWordlistInput(conf *ffuf.Config) (*WordlistInput, error) { return &wl, err } +//Position will return the current position in the input list +func (w *WordlistInput) Position() int { + return w.position +} + //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 57e3ca4..17f6c8d 100644 --- a/pkg/output/file_csv.go +++ b/pkg/output/file_csv.go @@ -9,7 +9,7 @@ import ( "github.com/ffuf/ffuf/pkg/ffuf" ) -var header = []string{"input", "status_code", "content_length", "content_words"} +var header = []string{"input", "position", "status_code", "content_length", "content_words"} func writeCSV(config *ffuf.Config, res []Result, encode bool) error { f, err := os.Create(config.OutputFile) @@ -44,6 +44,7 @@ func base64encode(in string) string { 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), diff --git a/pkg/output/stdout.go b/pkg/output/stdout.go index 375d27a..d91a2f0 100644 --- a/pkg/output/stdout.go +++ b/pkg/output/stdout.go @@ -3,6 +3,7 @@ package output import ( "fmt" "os" + "strconv" "time" "github.com/ffuf/ffuf/pkg/ffuf" @@ -27,6 +28,7 @@ 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"` @@ -127,6 +129,7 @@ func (s *Stdoutput) Result(resp ffuf.Response) { // No need to store results if we're not going to use them later sResult := Result{ Input: string(resp.Request.Input), + Position: resp.Request.Position, StatusCode: resp.StatusCode, ContentLength: resp.ContentLength, ContentWords: resp.ContentWords, @@ -144,11 +147,22 @@ func (s *Stdoutput) printResult(resp ffuf.Response) { } func (s *Stdoutput) resultQuiet(resp ffuf.Response) { - fmt.Println(string(resp.Request.Input)) + 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)) + } } func (s *Stdoutput) resultNormal(resp ffuf.Response) { - res_str := fmt.Sprintf("%s%-23s [Status: %s, Size: %d, Words: %d]", TERMINAL_CLEAR_LINE, resp.Request.Input, s.colorizeStatus(resp.StatusCode), resp.ContentLength, resp.ContentWords) + var res_str string + if len(s.config.InputCommand) > 0 { + // If we're using external command for input, display the position instead of input + res_str = fmt.Sprintf("%s%-23s [Status: %s, Size: %d, Words: %d]", TERMINAL_CLEAR_LINE, strconv.Itoa(resp.Request.Position), s.colorizeStatus(resp.StatusCode), resp.ContentLength, resp.ContentWords) + } else { + res_str = fmt.Sprintf("%s%-23s [Status: %s, Size: %d, Words: %d]", TERMINAL_CLEAR_LINE, resp.Request.Input, s.colorizeStatus(resp.StatusCode), resp.ContentLength, resp.ContentWords) + } fmt.Println(res_str) }