├── .gitignore ├── go.mod ├── pkg ├── ffuf │ ├── const.go │ ├── progress.go │ ├── request.go │ ├── multierror.go │ ├── util.go │ ├── response.go │ ├── valuerange.go │ ├── interfaces.go │ ├── optrange.go │ ├── config.go │ └── job.go ├── input │ ├── const.go │ ├── const_windows.go │ ├── command.go │ ├── wordlist.go │ └── input.go ├── runner │ ├── runner.go │ └── simple.go ├── output │ ├── const_windows.go │ ├── output.go │ ├── const.go │ ├── file_md.go │ ├── file_csv.go │ ├── file_json.go │ ├── file_html.go │ └── stdout.go └── filter │ ├── size_test.go │ ├── regexp_test.go │ ├── filter_test.go │ ├── status_test.go │ ├── lines_test.go │ ├── words_test.go │ ├── regex.go │ ├── size.go │ ├── words.go │ ├── lines.go │ ├── status.go │ └── filter.go ├── .goreleaser.yml ├── CONTRIBUTORS.md ├── LICENSE ├── CHANGELOG.md ├── README.md └── main.go /.gitignore: -------------------------------------------------------------------------------- 1 | /ffuf 2 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ffuf/ffuf 2 | 3 | go 1.11 4 | -------------------------------------------------------------------------------- /pkg/ffuf/const.go: -------------------------------------------------------------------------------- 1 | package ffuf 2 | 3 | const ( 4 | //VERSION holds the current version number 5 | VERSION = "1.0-rc1" 6 | ) 7 | -------------------------------------------------------------------------------- /pkg/input/const.go: -------------------------------------------------------------------------------- 1 | // +build !windows 2 | 3 | package input 4 | 5 | const ( 6 | SHELL_CMD = "/bin/sh" 7 | SHELL_ARG = "-c" 8 | ) 9 | -------------------------------------------------------------------------------- /pkg/input/const_windows.go: -------------------------------------------------------------------------------- 1 | // +build windows 2 | 3 | package input 4 | 5 | const ( 6 | SHELL_CMD = "cmd.exe" 7 | SHELL_ARG = "/C" 8 | ) 9 | -------------------------------------------------------------------------------- /pkg/ffuf/progress.go: -------------------------------------------------------------------------------- 1 | package ffuf 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type Progress struct { 8 | StartedAt time.Time 9 | ReqCount int 10 | ReqTotal int 11 | QueuePos int 12 | QueueTotal int 13 | ErrorCount int 14 | } 15 | -------------------------------------------------------------------------------- /pkg/runner/runner.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "github.com/ffuf/ffuf/pkg/ffuf" 5 | ) 6 | 7 | func NewRunnerByName(name string, conf *ffuf.Config) ffuf.RunnerProvider { 8 | // We have only one Runner at the moment 9 | return NewSimpleRunner(conf) 10 | } 11 | -------------------------------------------------------------------------------- /pkg/output/const_windows.go: -------------------------------------------------------------------------------- 1 | // +build windows 2 | 3 | package output 4 | 5 | const ( 6 | TERMINAL_CLEAR_LINE = "\r\r" 7 | ANSI_CLEAR = "" 8 | ANSI_RED = "" 9 | ANSI_GREEN = "" 10 | ANSI_BLUE = "" 11 | ANSI_YELLOW = "" 12 | ) 13 | -------------------------------------------------------------------------------- /pkg/output/output.go: -------------------------------------------------------------------------------- 1 | package output 2 | 3 | import ( 4 | "github.com/ffuf/ffuf/pkg/ffuf" 5 | ) 6 | 7 | func NewOutputProviderByName(name string, conf *ffuf.Config) ffuf.OutputProvider { 8 | //We have only one outputprovider at the moment 9 | return NewStdoutput(conf) 10 | } 11 | -------------------------------------------------------------------------------- /pkg/output/const.go: -------------------------------------------------------------------------------- 1 | // +build !windows 2 | 3 | package output 4 | 5 | const ( 6 | TERMINAL_CLEAR_LINE = "\r\x1b[2K" 7 | ANSI_CLEAR = "\x1b[0m" 8 | ANSI_RED = "\x1b[31m" 9 | ANSI_GREEN = "\x1b[32m" 10 | ANSI_BLUE = "\x1b[34m" 11 | ANSI_YELLOW = "\x1b[33m" 12 | ) 13 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | builds: 2 | - binary: ffuf 3 | goos: 4 | - linux 5 | - windows 6 | - freebsd 7 | - openbsd 8 | - darwin 9 | goarch: 10 | - amd64 11 | - 386 12 | - arm 13 | - arm64 14 | 15 | 16 | archive: 17 | format: tar.gz 18 | replacements: 19 | darwin: macOS 20 | format_overrides: 21 | - goos: windows 22 | format: zip 23 | 24 | sign: 25 | artifacts: checksum 26 | -------------------------------------------------------------------------------- /pkg/ffuf/request.go: -------------------------------------------------------------------------------- 1 | package ffuf 2 | 3 | // Request holds the meaningful data that is passed for runner for making the query 4 | type Request struct { 5 | Method string 6 | Url string 7 | Headers map[string]string 8 | Data []byte 9 | Input map[string][]byte 10 | Position int 11 | Raw string 12 | } 13 | 14 | func NewRequest(conf *Config) Request { 15 | var req Request 16 | req.Method = conf.Method 17 | req.Url = conf.Url 18 | req.Headers = make(map[string]string) 19 | return req 20 | } 21 | -------------------------------------------------------------------------------- /pkg/ffuf/multierror.go: -------------------------------------------------------------------------------- 1 | package ffuf 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | type Multierror struct { 8 | errors []error 9 | } 10 | 11 | //NewMultierror returns a new Multierror 12 | func NewMultierror() Multierror { 13 | return Multierror{} 14 | } 15 | 16 | func (m *Multierror) Add(err error) { 17 | m.errors = append(m.errors, err) 18 | } 19 | 20 | func (m *Multierror) ErrorOrNil() error { 21 | var errString string 22 | if len(m.errors) > 0 { 23 | errString += fmt.Sprintf("%d errors occured.\n", len(m.errors)) 24 | for _, e := range m.errors { 25 | errString += fmt.Sprintf("\t* %s\n", e) 26 | } 27 | return fmt.Errorf("%s", errString) 28 | } 29 | return nil 30 | } 31 | -------------------------------------------------------------------------------- /CONTRIBUTORS.md: -------------------------------------------------------------------------------- 1 | # Contributors 2 | 3 | * [ccsplit](https://github.com/ccsplit) 4 | * [delic](https://github.com/delic) 5 | * [eur0pa](https://github.com/eur0pa) 6 | * [fang0654](https://github.com/fang0654) 7 | * [JamTookTheBait](https://github.com/JamTookTheBait) 8 | * [joohoi](https://github.com/joohoi) 9 | * [jvesiluoma](https://github.com/jvesiluoma) 10 | * [lc](https://github.com/lc) 11 | * [nnwakelam](https://twitter.com/nnwakelam) 12 | * [oh6hay](https://github.com/oh6hay) 13 | * [putsi](https://github.com/putsi) 14 | * [SakiiR](https://github.com/SakiiR) 15 | * [seblw](https://github.com/seblw) 16 | * [SolomonSklash](https://github.com/SolomonSklash) 17 | * [Shaked](https://github.com/Shaked) 18 | -------------------------------------------------------------------------------- /pkg/ffuf/util.go: -------------------------------------------------------------------------------- 1 | package ffuf 2 | 3 | import ( 4 | "math/rand" 5 | ) 6 | 7 | //used for random string generation in calibration function 8 | var chars = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") 9 | 10 | //RandomString returns a random string of length of parameter n 11 | func RandomString(n int) string { 12 | s := make([]rune, n) 13 | for i := range s { 14 | s[i] = chars[rand.Intn(len(chars))] 15 | } 16 | return string(s) 17 | } 18 | 19 | //UniqStringSlice returns an unordered slice of unique strings. The duplicates are dropped 20 | func UniqStringSlice(inslice []string) []string { 21 | found := map[string]bool{} 22 | 23 | for _, v := range inslice { 24 | found[v] = true 25 | } 26 | ret := []string{} 27 | for k, _ := range found { 28 | ret = append(ret, k) 29 | } 30 | return ret 31 | } 32 | -------------------------------------------------------------------------------- /pkg/ffuf/response.go: -------------------------------------------------------------------------------- 1 | package ffuf 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | // Response struct holds the meaningful data returned from request and is meant for passing to filters 8 | type Response struct { 9 | StatusCode int64 10 | Headers map[string][]string 11 | Data []byte 12 | ContentLength int64 13 | ContentWords int64 14 | ContentLines int64 15 | Cancelled bool 16 | Request *Request 17 | Raw string 18 | ResultFile string 19 | } 20 | 21 | // GetRedirectLocation returns the redirect location for a 3xx redirect HTTP response 22 | func (resp *Response) GetRedirectLocation() string { 23 | 24 | redirectLocation := "" 25 | if resp.StatusCode >= 300 && resp.StatusCode <= 399 { 26 | redirectLocation = resp.Headers["Location"][0] 27 | } 28 | 29 | return redirectLocation 30 | } 31 | 32 | func NewResponse(httpresp *http.Response, req *Request) Response { 33 | var resp Response 34 | resp.Request = req 35 | resp.StatusCode = int64(httpresp.StatusCode) 36 | resp.Headers = httpresp.Header 37 | resp.Cancelled = false 38 | resp.Raw = "" 39 | resp.ResultFile = "" 40 | return resp 41 | } 42 | -------------------------------------------------------------------------------- /pkg/ffuf/valuerange.go: -------------------------------------------------------------------------------- 1 | package ffuf 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strconv" 7 | ) 8 | 9 | type ValueRange struct { 10 | Min, Max int64 11 | } 12 | 13 | func ValueRangeFromString(instr string) (ValueRange, error) { 14 | // is the value a range 15 | minmax := regexp.MustCompile("^(\\d+)\\-(\\d+)$").FindAllStringSubmatch(instr, -1) 16 | if minmax != nil { 17 | // yes 18 | minval, err := strconv.ParseInt(minmax[0][1], 10, 0) 19 | if err != nil { 20 | return ValueRange{}, fmt.Errorf("Invalid value: %s", minmax[0][1]) 21 | } 22 | maxval, err := strconv.ParseInt(minmax[0][2], 10, 0) 23 | if err != nil { 24 | return ValueRange{}, fmt.Errorf("Invalid value: %s", minmax[0][2]) 25 | } 26 | if minval >= maxval { 27 | return ValueRange{}, fmt.Errorf("Minimum has to be smaller than maximum") 28 | } 29 | return ValueRange{minval, maxval}, nil 30 | } else { 31 | // no, a single value or something else 32 | intval, err := strconv.ParseInt(instr, 10, 0) 33 | if err != nil { 34 | return ValueRange{}, fmt.Errorf("Invalid value: %s", instr) 35 | } 36 | return ValueRange{intval, intval}, nil 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Joona Hoikkala 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /pkg/filter/size_test.go: -------------------------------------------------------------------------------- 1 | package filter 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/ffuf/ffuf/pkg/ffuf" 8 | ) 9 | 10 | func TestNewSizeFilter(t *testing.T) { 11 | f, _ := NewSizeFilter("1,2,3,444,5-90") 12 | sizeRepr := f.Repr() 13 | if strings.Index(sizeRepr, "1,2,3,444,5-90") == -1 { 14 | t.Errorf("Size filter was expected to have 5 values") 15 | } 16 | } 17 | 18 | func TestNewSizeFilterError(t *testing.T) { 19 | _, err := NewSizeFilter("invalid") 20 | if err == nil { 21 | t.Errorf("Was expecting an error from errenous input data") 22 | } 23 | } 24 | 25 | func TestFiltering(t *testing.T) { 26 | f, _ := NewSizeFilter("1,2,3,5-90,444") 27 | for i, test := range []struct { 28 | input int64 29 | output bool 30 | }{ 31 | {1, true}, 32 | {2, true}, 33 | {3, true}, 34 | {4, false}, 35 | {5, true}, 36 | {70, true}, 37 | {90, true}, 38 | {91, false}, 39 | {444, true}, 40 | } { 41 | resp := ffuf.Response{ContentLength: test.input} 42 | filterReturn, _ := f.Filter(&resp) 43 | if filterReturn != test.output { 44 | t.Errorf("Filter test %d: Was expecing filter return value of %t but got %t", i, test.output, filterReturn) 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /pkg/filter/regexp_test.go: -------------------------------------------------------------------------------- 1 | package filter 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/ffuf/ffuf/pkg/ffuf" 8 | ) 9 | 10 | func TestNewRegexpFilter(t *testing.T) { 11 | f, _ := NewRegexpFilter("s([a-z]+)arch") 12 | statusRepr := f.Repr() 13 | if strings.Index(statusRepr, "s([a-z]+)arch") == -1 { 14 | t.Errorf("Status filter was expected to have a regexp value") 15 | } 16 | } 17 | 18 | func TestNewRegexpFilterError(t *testing.T) { 19 | _, err := NewRegexpFilter("r((") 20 | if err == nil { 21 | t.Errorf("Was expecting an error from errenous input data") 22 | } 23 | } 24 | 25 | func TestRegexpFiltering(t *testing.T) { 26 | f, _ := NewRegexpFilter("s([a-z]+)arch") 27 | for i, test := range []struct { 28 | input string 29 | output bool 30 | }{ 31 | {"search", true}, 32 | {"text and search", true}, 33 | {"sbarch in beginning", true}, 34 | {"midd scarch le", true}, 35 | {"s1arch", false}, 36 | {"invalid", false}, 37 | } { 38 | resp := ffuf.Response{Data: []byte(test.input)} 39 | filterReturn, _ := f.Filter(&resp) 40 | if filterReturn != test.output { 41 | t.Errorf("Filter test %d: Was expecing filter return value of %t but got %t", i, test.output, filterReturn) 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /pkg/filter/filter_test.go: -------------------------------------------------------------------------------- 1 | package filter 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestNewFilterByName(t *testing.T) { 8 | scf, _ := NewFilterByName("status", "200") 9 | if _, ok := scf.(*StatusFilter); !ok { 10 | t.Errorf("Was expecting statusfilter") 11 | } 12 | 13 | szf, _ := NewFilterByName("size", "200") 14 | if _, ok := szf.(*SizeFilter); !ok { 15 | t.Errorf("Was expecting sizefilter") 16 | } 17 | 18 | wf, _ := NewFilterByName("word", "200") 19 | if _, ok := wf.(*WordFilter); !ok { 20 | t.Errorf("Was expecting wordfilter") 21 | } 22 | 23 | lf, _ := NewFilterByName("line", "200") 24 | if _, ok := lf.(*LineFilter); !ok { 25 | t.Errorf("Was expecting linefilter") 26 | } 27 | 28 | ref, _ := NewFilterByName("regexp", "200") 29 | if _, ok := ref.(*RegexpFilter); !ok { 30 | t.Errorf("Was expecting regexpfilter") 31 | } 32 | } 33 | 34 | func TestNewFilterByNameError(t *testing.T) { 35 | _, err := NewFilterByName("status", "invalid") 36 | if err == nil { 37 | t.Errorf("Was expecing an error") 38 | } 39 | } 40 | 41 | func TestNewFilterByNameNotFound(t *testing.T) { 42 | _, err := NewFilterByName("nonexistent", "invalid") 43 | if err == nil { 44 | t.Errorf("Was expecing an error with invalid filter name") 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /pkg/filter/status_test.go: -------------------------------------------------------------------------------- 1 | package filter 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/ffuf/ffuf/pkg/ffuf" 8 | ) 9 | 10 | func TestNewStatusFilter(t *testing.T) { 11 | f, _ := NewStatusFilter("200,301,400-410,500") 12 | statusRepr := f.Repr() 13 | if strings.Index(statusRepr, "200,301,400-410,500") == -1 { 14 | t.Errorf("Status filter was expected to have 4 values") 15 | } 16 | } 17 | 18 | func TestNewStatusFilterError(t *testing.T) { 19 | _, err := NewStatusFilter("invalid") 20 | if err == nil { 21 | t.Errorf("Was expecting an error from errenous input data") 22 | } 23 | } 24 | 25 | func TestStatusFiltering(t *testing.T) { 26 | f, _ := NewStatusFilter("200,301,400-498,500") 27 | for i, test := range []struct { 28 | input int64 29 | output bool 30 | }{ 31 | {200, true}, 32 | {301, true}, 33 | {500, true}, 34 | {4, false}, 35 | {399, false}, 36 | {400, true}, 37 | {444, true}, 38 | {498, true}, 39 | {499, false}, 40 | {302, false}, 41 | } { 42 | resp := ffuf.Response{StatusCode: test.input} 43 | filterReturn, _ := f.Filter(&resp) 44 | if filterReturn != test.output { 45 | t.Errorf("Filter test %d: Was expecing filter return value of %t but got %t", i, test.output, filterReturn) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /pkg/ffuf/interfaces.go: -------------------------------------------------------------------------------- 1 | package ffuf 2 | 3 | //FilterProvider is a generic interface for both Matchers and Filters 4 | type FilterProvider interface { 5 | Filter(response *Response) (bool, error) 6 | Repr() string 7 | } 8 | 9 | //RunnerProvider is an interface for request executors 10 | type RunnerProvider interface { 11 | Prepare(input map[string][]byte) (Request, error) 12 | Execute(req *Request) (Response, error) 13 | } 14 | 15 | //InputProvider interface handles the input data for RunnerProvider 16 | type InputProvider interface { 17 | AddProvider(InputProviderConfig) error 18 | Next() bool 19 | Position() int 20 | Reset() 21 | Value() map[string][]byte 22 | Total() int 23 | } 24 | 25 | //InternalInputProvider interface handles providing input data to InputProvider 26 | type InternalInputProvider interface { 27 | Keyword() string 28 | Next() bool 29 | Position() int 30 | ResetPosition() 31 | IncrementPosition() 32 | Value() []byte 33 | Total() int 34 | } 35 | 36 | //OutputProvider is responsible of providing output from the RunnerProvider 37 | type OutputProvider interface { 38 | Banner() error 39 | Finalize() error 40 | Progress(status Progress) 41 | Info(infostring string) 42 | Error(errstring string) 43 | Warning(warnstring string) 44 | Result(resp Response) 45 | } 46 | -------------------------------------------------------------------------------- /pkg/filter/lines_test.go: -------------------------------------------------------------------------------- 1 | package filter 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/ffuf/ffuf/pkg/ffuf" 8 | ) 9 | 10 | func TestNewLineFilter(t *testing.T) { 11 | f, _ := NewLineFilter("200,301,400-410,500") 12 | linesRepr := f.Repr() 13 | if strings.Index(linesRepr, "200,301,400-410,500") == -1 { 14 | t.Errorf("Word filter was expected to have 4 values") 15 | } 16 | } 17 | 18 | func TestNewLineFilterError(t *testing.T) { 19 | _, err := NewLineFilter("invalid") 20 | if err == nil { 21 | t.Errorf("Was expecting an error from errenous input data") 22 | } 23 | } 24 | 25 | func TestLineFiltering(t *testing.T) { 26 | f, _ := NewLineFilter("200,301,402-450,500") 27 | for i, test := range []struct { 28 | input int64 29 | output bool 30 | }{ 31 | {200, true}, 32 | {301, true}, 33 | {500, true}, 34 | {4, false}, 35 | {444, true}, 36 | {302, false}, 37 | {401, false}, 38 | {402, true}, 39 | {450, true}, 40 | {451, false}, 41 | } { 42 | var data []string 43 | for i := int64(0); i < test.input; i++ { 44 | data = append(data, "A") 45 | } 46 | resp := ffuf.Response{Data: []byte(strings.Join(data, " "))} 47 | filterReturn, _ := f.Filter(&resp) 48 | if filterReturn != test.output { 49 | t.Errorf("Filter test %d: Was expecing filter return value of %t but got %t", i, test.output, filterReturn) 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /pkg/filter/words_test.go: -------------------------------------------------------------------------------- 1 | package filter 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/ffuf/ffuf/pkg/ffuf" 8 | ) 9 | 10 | func TestNewWordFilter(t *testing.T) { 11 | f, _ := NewWordFilter("200,301,400-410,500") 12 | wordsRepr := f.Repr() 13 | if strings.Index(wordsRepr, "200,301,400-410,500") == -1 { 14 | t.Errorf("Word filter was expected to have 4 values") 15 | } 16 | } 17 | 18 | func TestNewWordFilterError(t *testing.T) { 19 | _, err := NewWordFilter("invalid") 20 | if err == nil { 21 | t.Errorf("Was expecting an error from errenous input data") 22 | } 23 | } 24 | 25 | func TestWordFiltering(t *testing.T) { 26 | f, _ := NewWordFilter("200,301,402-450,500") 27 | for i, test := range []struct { 28 | input int64 29 | output bool 30 | }{ 31 | {200, true}, 32 | {301, true}, 33 | {500, true}, 34 | {4, false}, 35 | {444, true}, 36 | {302, false}, 37 | {401, false}, 38 | {402, true}, 39 | {450, true}, 40 | {451, false}, 41 | } { 42 | var data []string 43 | for i := int64(0); i < test.input; i++ { 44 | data = append(data, "A") 45 | } 46 | resp := ffuf.Response{Data: []byte(strings.Join(data, " "))} 47 | filterReturn, _ := f.Filter(&resp) 48 | if filterReturn != test.output { 49 | t.Errorf("Filter test %d: Was expecing filter return value of %t but got %t", i, test.output, filterReturn) 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /pkg/filter/regex.go: -------------------------------------------------------------------------------- 1 | package filter 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "regexp" 7 | "strings" 8 | 9 | "github.com/ffuf/ffuf/pkg/ffuf" 10 | ) 11 | 12 | type RegexpFilter struct { 13 | Value *regexp.Regexp 14 | valueRaw string 15 | } 16 | 17 | func NewRegexpFilter(value string) (ffuf.FilterProvider, error) { 18 | re, err := regexp.Compile(value) 19 | if err != nil { 20 | return &RegexpFilter{}, fmt.Errorf("Regexp filter or matcher (-fr / -mr): invalid value: %s", value) 21 | } 22 | return &RegexpFilter{Value: re, valueRaw: value}, nil 23 | } 24 | 25 | func (f *RegexpFilter) MarshalJSON() ([]byte, error) { 26 | return json.Marshal(&struct { 27 | Value string `json:"value"` 28 | }{ 29 | Value: f.valueRaw, 30 | }) 31 | } 32 | 33 | func (f *RegexpFilter) Filter(response *ffuf.Response) (bool, error) { 34 | matchheaders := "" 35 | for k, v := range response.Headers { 36 | for _, iv := range v { 37 | matchheaders += k + ": " + iv + "\r\n" 38 | } 39 | } 40 | matchdata := []byte(matchheaders) 41 | matchdata = append(matchdata, response.Data...) 42 | pattern := f.valueRaw 43 | for keyword, inputitem := range response.Request.Input { 44 | pattern = strings.Replace(pattern, keyword, regexp.QuoteMeta(string(inputitem)), -1) 45 | } 46 | matched, err := regexp.Match(pattern, matchdata) 47 | if err != nil { 48 | return false, nil 49 | } 50 | return matched, nil 51 | } 52 | 53 | func (f *RegexpFilter) Repr() string { 54 | return fmt.Sprintf("Regexp: %s", f.valueRaw) 55 | } 56 | -------------------------------------------------------------------------------- /pkg/output/file_md.go: -------------------------------------------------------------------------------- 1 | package output 2 | 3 | import ( 4 | "html/template" 5 | "os" 6 | "time" 7 | 8 | "github.com/ffuf/ffuf/pkg/ffuf" 9 | ) 10 | 11 | const ( 12 | markdownTemplate = `# FFUF Report 13 | 14 | Command line : ` + "`{{.CommandLine}}`" + ` 15 | Time: ` + "{{ .Time }}" + ` 16 | 17 | {{ range .Keys }}| {{ . }} {{ end }}| URL | Redirectlocation | Position | Status Code | Content Length | Content Words | Content Lines | ResultFile | 18 | {{ range .Keys }}| :- {{ end }}| :-- | :--------------- | :---- | :------- | :---------- | :------------- | :------------ | :--------- | 19 | {{range .Results}}{{ range $keyword, $value := .Input }}| {{ $value | printf "%s" }} {{ end }}| {{ .Url }} | {{ .RedirectLocation }} | {{ .Position }} | {{ .StatusCode }} | {{ .ContentLength }} | {{ .ContentWords }} | {{ .ContentLines }} | {{ .ResultFile }} | 20 | {{end}}` // The template format is not pretty but follows the markdown guide 21 | ) 22 | 23 | func writeMarkdown(config *ffuf.Config, res []Result) error { 24 | 25 | ti := time.Now() 26 | 27 | keywords := make([]string, 0) 28 | for _, inputprovider := range config.InputProviders { 29 | keywords = append(keywords, inputprovider.Keyword) 30 | } 31 | 32 | outMD := htmlFileOutput{ 33 | CommandLine: config.CommandLine, 34 | Time: ti.Format(time.RFC3339), 35 | Results: res, 36 | Keys: keywords, 37 | } 38 | 39 | f, err := os.Create(config.OutputFile) 40 | if err != nil { 41 | return err 42 | } 43 | defer f.Close() 44 | 45 | templateName := "output.md" 46 | t := template.New(templateName).Delims("{{", "}}") 47 | t.Parse(markdownTemplate) 48 | t.Execute(f, outMD) 49 | return nil 50 | } 51 | -------------------------------------------------------------------------------- /pkg/filter/size.go: -------------------------------------------------------------------------------- 1 | package filter 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/ffuf/ffuf/pkg/ffuf" 10 | ) 11 | 12 | type SizeFilter struct { 13 | Value []ffuf.ValueRange 14 | } 15 | 16 | func NewSizeFilter(value string) (ffuf.FilterProvider, error) { 17 | var intranges []ffuf.ValueRange 18 | for _, sv := range strings.Split(value, ",") { 19 | vr, err := ffuf.ValueRangeFromString(sv) 20 | if err != nil { 21 | return &SizeFilter{}, fmt.Errorf("Size filter or matcher (-fs / -ms): invalid value: %s", sv) 22 | } 23 | 24 | intranges = append(intranges, vr) 25 | } 26 | return &SizeFilter{Value: intranges}, nil 27 | } 28 | 29 | func (f *SizeFilter) MarshalJSON() ([]byte, error) { 30 | value := make([]string, 0) 31 | for _, v := range f.Value { 32 | if v.Min == v.Max { 33 | value = append(value, strconv.FormatInt(v.Min, 10)) 34 | } else { 35 | value = append(value, fmt.Sprintf("%d-%d", v.Min, v.Max)) 36 | } 37 | } 38 | return json.Marshal(&struct { 39 | Value string `json:"value"` 40 | }{ 41 | Value: strings.Join(value, ","), 42 | }) 43 | } 44 | 45 | func (f *SizeFilter) Filter(response *ffuf.Response) (bool, error) { 46 | for _, iv := range f.Value { 47 | if iv.Min <= response.ContentLength && response.ContentLength <= iv.Max { 48 | return true, nil 49 | } 50 | } 51 | return false, nil 52 | } 53 | 54 | func (f *SizeFilter) Repr() string { 55 | var strval []string 56 | for _, iv := range f.Value { 57 | if iv.Min == iv.Max { 58 | strval = append(strval, strconv.Itoa(int(iv.Min))) 59 | } else { 60 | strval = append(strval, strconv.Itoa(int(iv.Min))+"-"+strconv.Itoa(int(iv.Max))) 61 | } 62 | } 63 | return fmt.Sprintf("Response size: %s", strings.Join(strval, ",")) 64 | } 65 | -------------------------------------------------------------------------------- /pkg/filter/words.go: -------------------------------------------------------------------------------- 1 | package filter 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/ffuf/ffuf/pkg/ffuf" 10 | ) 11 | 12 | type WordFilter struct { 13 | Value []ffuf.ValueRange 14 | } 15 | 16 | func NewWordFilter(value string) (ffuf.FilterProvider, error) { 17 | var intranges []ffuf.ValueRange 18 | for _, sv := range strings.Split(value, ",") { 19 | vr, err := ffuf.ValueRangeFromString(sv) 20 | if err != nil { 21 | return &WordFilter{}, fmt.Errorf("Word filter or matcher (-fw / -mw): invalid value: %s", sv) 22 | } 23 | intranges = append(intranges, vr) 24 | } 25 | return &WordFilter{Value: intranges}, nil 26 | } 27 | 28 | func (f *WordFilter) MarshalJSON() ([]byte, error) { 29 | value := make([]string, 0) 30 | for _, v := range f.Value { 31 | if v.Min == v.Max { 32 | value = append(value, strconv.FormatInt(v.Min, 10)) 33 | } else { 34 | value = append(value, fmt.Sprintf("%d-%d", v.Min, v.Max)) 35 | } 36 | } 37 | return json.Marshal(&struct { 38 | Value string `json:"value"` 39 | }{ 40 | Value: strings.Join(value, ","), 41 | }) 42 | } 43 | 44 | func (f *WordFilter) Filter(response *ffuf.Response) (bool, error) { 45 | wordsSize := len(strings.Split(string(response.Data), " ")) 46 | for _, iv := range f.Value { 47 | if iv.Min <= int64(wordsSize) && int64(wordsSize) <= iv.Max { 48 | return true, nil 49 | } 50 | } 51 | return false, nil 52 | } 53 | 54 | func (f *WordFilter) Repr() string { 55 | var strval []string 56 | for _, iv := range f.Value { 57 | if iv.Min == iv.Max { 58 | strval = append(strval, strconv.Itoa(int(iv.Min))) 59 | } else { 60 | strval = append(strval, strconv.Itoa(int(iv.Min))+"-"+strconv.Itoa(int(iv.Max))) 61 | } 62 | } 63 | return fmt.Sprintf("Response words: %s", strings.Join(strval, ",")) 64 | } 65 | -------------------------------------------------------------------------------- /pkg/filter/lines.go: -------------------------------------------------------------------------------- 1 | package filter 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/ffuf/ffuf/pkg/ffuf" 10 | ) 11 | 12 | type LineFilter struct { 13 | Value []ffuf.ValueRange 14 | } 15 | 16 | func NewLineFilter(value string) (ffuf.FilterProvider, error) { 17 | var intranges []ffuf.ValueRange 18 | for _, sv := range strings.Split(value, ",") { 19 | vr, err := ffuf.ValueRangeFromString(sv) 20 | if err != nil { 21 | return &LineFilter{}, fmt.Errorf("Line filter or matcher (-fl / -ml): invalid value: %s", sv) 22 | } 23 | intranges = append(intranges, vr) 24 | } 25 | return &LineFilter{Value: intranges}, nil 26 | } 27 | 28 | func (f *LineFilter) MarshalJSON() ([]byte, error) { 29 | value := make([]string, 0) 30 | for _, v := range f.Value { 31 | if v.Min == v.Max { 32 | value = append(value, strconv.FormatInt(v.Min, 10)) 33 | } else { 34 | value = append(value, fmt.Sprintf("%d-%d", v.Min, v.Max)) 35 | } 36 | } 37 | return json.Marshal(&struct { 38 | Value string `json:"value"` 39 | }{ 40 | Value: strings.Join(value, ","), 41 | }) 42 | } 43 | 44 | func (f *LineFilter) Filter(response *ffuf.Response) (bool, error) { 45 | linesSize := len(strings.Split(string(response.Data), "\n")) 46 | for _, iv := range f.Value { 47 | if iv.Min <= int64(linesSize) && int64(linesSize) <= iv.Max { 48 | return true, nil 49 | } 50 | } 51 | return false, nil 52 | } 53 | 54 | func (f *LineFilter) Repr() string { 55 | var strval []string 56 | for _, iv := range f.Value { 57 | if iv.Min == iv.Max { 58 | strval = append(strval, strconv.Itoa(int(iv.Min))) 59 | } else { 60 | strval = append(strval, strconv.Itoa(int(iv.Min))+"-"+strconv.Itoa(int(iv.Max))) 61 | } 62 | } 63 | return fmt.Sprintf("Response lines: %s", strings.Join(strval, ",")) 64 | } 65 | -------------------------------------------------------------------------------- /pkg/input/command.go: -------------------------------------------------------------------------------- 1 | package input 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | "os/exec" 7 | "strconv" 8 | 9 | "github.com/ffuf/ffuf/pkg/ffuf" 10 | ) 11 | 12 | type CommandInput struct { 13 | config *ffuf.Config 14 | count int 15 | keyword string 16 | command string 17 | } 18 | 19 | func NewCommandInput(keyword string, value string, conf *ffuf.Config) (*CommandInput, error) { 20 | var cmd CommandInput 21 | cmd.keyword = keyword 22 | cmd.config = conf 23 | cmd.count = 0 24 | cmd.command = value 25 | return &cmd, nil 26 | } 27 | 28 | //Keyword returns the keyword assigned to this InternalInputProvider 29 | func (c *CommandInput) Keyword() string { 30 | return c.keyword 31 | } 32 | 33 | //Position will return the current position in the input list 34 | func (c *CommandInput) Position() int { 35 | return c.count 36 | } 37 | 38 | //ResetPosition will reset the current position of the InternalInputProvider 39 | func (c *CommandInput) ResetPosition() { 40 | c.count = 0 41 | } 42 | 43 | //IncrementPosition increments the current position in the inputprovider 44 | func (c *CommandInput) IncrementPosition() { 45 | c.count += 1 46 | } 47 | 48 | //Next will increment the cursor position, and return a boolean telling if there's iterations left 49 | func (c *CommandInput) Next() bool { 50 | if c.count >= c.config.InputNum { 51 | return false 52 | } 53 | return true 54 | } 55 | 56 | //Value returns the input from command stdoutput 57 | func (c *CommandInput) Value() []byte { 58 | var stdout bytes.Buffer 59 | os.Setenv("FFUF_NUM", strconv.Itoa(c.count)) 60 | cmd := exec.Command(SHELL_CMD, SHELL_ARG, c.command) 61 | cmd.Stdout = &stdout 62 | err := cmd.Run() 63 | if err != nil { 64 | return []byte("") 65 | } 66 | return stdout.Bytes() 67 | } 68 | 69 | //Total returns the size of wordlist 70 | func (c *CommandInput) Total() int { 71 | return c.config.InputNum 72 | } 73 | -------------------------------------------------------------------------------- /pkg/output/file_csv.go: -------------------------------------------------------------------------------- 1 | package output 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/csv" 6 | "os" 7 | "strconv" 8 | 9 | "github.com/ffuf/ffuf/pkg/ffuf" 10 | ) 11 | 12 | var staticheaders = []string{"url", "redirectlocation", "position", "status_code", "content_length", "content_words", "content_lines", "resultfile"} 13 | 14 | func writeCSV(config *ffuf.Config, res []Result, encode bool) error { 15 | header := make([]string, 0) 16 | f, err := os.Create(config.OutputFile) 17 | if err != nil { 18 | return err 19 | } 20 | defer f.Close() 21 | 22 | w := csv.NewWriter(f) 23 | defer w.Flush() 24 | 25 | for _, inputprovider := range config.InputProviders { 26 | header = append(header, inputprovider.Keyword) 27 | } 28 | 29 | for _, item := range staticheaders { 30 | header = append(header, item) 31 | } 32 | 33 | if err := w.Write(header); err != nil { 34 | return err 35 | } 36 | for _, r := range res { 37 | if encode { 38 | inputs := make(map[string][]byte, 0) 39 | for k, v := range r.Input { 40 | inputs[k] = []byte(base64encode(v)) 41 | } 42 | r.Input = inputs 43 | } 44 | 45 | err := w.Write(toCSV(r)) 46 | if err != nil { 47 | return err 48 | } 49 | } 50 | return nil 51 | } 52 | 53 | func base64encode(in []byte) string { 54 | return base64.StdEncoding.EncodeToString(in) 55 | } 56 | 57 | func toCSV(r Result) []string { 58 | res := make([]string, 0) 59 | for _, v := range r.Input { 60 | res = append(res, string(v)) 61 | } 62 | res = append(res, r.Url) 63 | res = append(res, r.RedirectLocation) 64 | res = append(res, strconv.Itoa(r.Position)) 65 | res = append(res, strconv.FormatInt(r.StatusCode, 10)) 66 | res = append(res, strconv.FormatInt(r.ContentLength, 10)) 67 | res = append(res, strconv.FormatInt(r.ContentWords, 10)) 68 | res = append(res, strconv.FormatInt(r.ContentLines, 10)) 69 | res = append(res, r.ResultFile) 70 | return res 71 | } 72 | -------------------------------------------------------------------------------- /pkg/ffuf/optrange.go: -------------------------------------------------------------------------------- 1 | package ffuf 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strconv" 7 | "strings" 8 | ) 9 | 10 | //optRange stores either a single float, in which case the value is stored in min and IsRange is false, 11 | //or a range of floats, in which case IsRange is true 12 | type optRange struct { 13 | Min float64 14 | Max float64 15 | IsRange bool 16 | HasDelay bool 17 | } 18 | 19 | type optRangeJSON struct { 20 | Value string `json:"value"` 21 | } 22 | 23 | func (o *optRange) MarshalJSON() ([]byte, error) { 24 | value := "" 25 | if o.Min == o.Max { 26 | value = fmt.Sprintf("%.2f", o.Min) 27 | } else { 28 | value = fmt.Sprintf("%.2f-%.2f", o.Min, o.Max) 29 | } 30 | return json.Marshal(&optRangeJSON{ 31 | Value: value, 32 | }) 33 | } 34 | 35 | func (o *optRange) UnmarshalJSON(b []byte) error { 36 | var inc optRangeJSON 37 | err := json.Unmarshal(b, &inc) 38 | if err != nil { 39 | return err 40 | } 41 | return o.Initialize(inc.Value) 42 | } 43 | 44 | //Initialize sets up the optRange from string value 45 | func (o *optRange) Initialize(value string) error { 46 | var err, err2 error 47 | d := strings.Split(value, "-") 48 | if len(d) > 2 { 49 | return fmt.Errorf("Delay needs to be either a single float: \"0.1\" or a range of floats, delimited by dash: \"0.1-0.8\"") 50 | } else if len(d) == 2 { 51 | o.IsRange = true 52 | o.HasDelay = true 53 | o.Min, err = strconv.ParseFloat(d[0], 64) 54 | o.Max, err2 = strconv.ParseFloat(d[1], 64) 55 | if err != nil || err2 != nil { 56 | return fmt.Errorf("Delay range min and max values need to be valid floats. For example: 0.1-0.5") 57 | } 58 | } else if len(value) > 0 { 59 | o.IsRange = false 60 | o.HasDelay = true 61 | o.Min, err = strconv.ParseFloat(value, 64) 62 | if err != nil { 63 | return fmt.Errorf("Delay needs to be either a single float: \"0.1\" or a range of floats, delimited by dash: \"0.1-0.8\"") 64 | } 65 | } 66 | return nil 67 | } 68 | -------------------------------------------------------------------------------- /pkg/filter/status.go: -------------------------------------------------------------------------------- 1 | package filter 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/ffuf/ffuf/pkg/ffuf" 10 | ) 11 | 12 | const AllStatuses = 0 13 | 14 | type StatusFilter struct { 15 | Value []ffuf.ValueRange 16 | } 17 | 18 | func NewStatusFilter(value string) (ffuf.FilterProvider, error) { 19 | var intranges []ffuf.ValueRange 20 | for _, sv := range strings.Split(value, ",") { 21 | if sv == "all" { 22 | intranges = append(intranges, ffuf.ValueRange{AllStatuses, AllStatuses}) 23 | } else { 24 | vr, err := ffuf.ValueRangeFromString(sv) 25 | if err != nil { 26 | return &StatusFilter{}, fmt.Errorf("Status filter or matcher (-fc / -mc): invalid value %s", sv) 27 | } 28 | intranges = append(intranges, vr) 29 | } 30 | } 31 | return &StatusFilter{Value: intranges}, nil 32 | } 33 | 34 | func (f *StatusFilter) MarshalJSON() ([]byte, error) { 35 | value := make([]string, 0) 36 | for _, v := range f.Value { 37 | if v.Min == 0 && v.Max == 0 { 38 | value = append(value, "all") 39 | } else { 40 | if v.Min == v.Max { 41 | value = append(value, strconv.FormatInt(v.Min, 10)) 42 | } else { 43 | value = append(value, fmt.Sprintf("%d-%d", v.Min, v.Max)) 44 | } 45 | } 46 | } 47 | return json.Marshal(&struct { 48 | Value string `json:"value"` 49 | }{ 50 | Value: strings.Join(value, ","), 51 | }) 52 | } 53 | 54 | func (f *StatusFilter) Filter(response *ffuf.Response) (bool, error) { 55 | for _, iv := range f.Value { 56 | if iv.Min == AllStatuses && iv.Max == AllStatuses { 57 | // Handle the "all" case 58 | return true, nil 59 | } 60 | if iv.Min <= response.StatusCode && response.StatusCode <= iv.Max { 61 | return true, nil 62 | } 63 | } 64 | return false, nil 65 | } 66 | 67 | func (f *StatusFilter) Repr() string { 68 | var strval []string 69 | for _, iv := range f.Value { 70 | if iv.Min == AllStatuses && iv.Max == AllStatuses { 71 | strval = append(strval, "all") 72 | } else if iv.Min == iv.Max { 73 | strval = append(strval, strconv.Itoa(int(iv.Min))) 74 | } else { 75 | strval = append(strval, strconv.Itoa(int(iv.Min))+"-"+strconv.Itoa(int(iv.Max))) 76 | } 77 | } 78 | return fmt.Sprintf("Response status: %s", strings.Join(strval, ",")) 79 | } 80 | -------------------------------------------------------------------------------- /pkg/output/file_json.go: -------------------------------------------------------------------------------- 1 | package output 2 | 3 | import ( 4 | "encoding/json" 5 | "io/ioutil" 6 | "time" 7 | 8 | "github.com/ffuf/ffuf/pkg/ffuf" 9 | ) 10 | 11 | type ejsonFileOutput struct { 12 | CommandLine string `json:"commandline"` 13 | Time string `json:"time"` 14 | Results []Result `json:"results"` 15 | } 16 | 17 | type JsonResult struct { 18 | Input map[string]string `json:"input"` 19 | Position int `json:"position"` 20 | StatusCode int64 `json:"status"` 21 | ContentLength int64 `json:"length"` 22 | ContentWords int64 `json:"words"` 23 | ContentLines int64 `json:"lines"` 24 | RedirectLocation string `json:"redirectlocation"` 25 | ResultFile string `json:"resultfile"` 26 | Url string `json:"url"` 27 | } 28 | 29 | type jsonFileOutput struct { 30 | CommandLine string `json:"commandline"` 31 | Time string `json:"time"` 32 | Results []JsonResult `json:"results"` 33 | Config *ffuf.Config `json:"config"` 34 | } 35 | 36 | func writeEJSON(config *ffuf.Config, res []Result) error { 37 | t := time.Now() 38 | outJSON := ejsonFileOutput{ 39 | CommandLine: config.CommandLine, 40 | Time: t.Format(time.RFC3339), 41 | Results: res, 42 | } 43 | 44 | outBytes, err := json.Marshal(outJSON) 45 | if err != nil { 46 | return err 47 | } 48 | err = ioutil.WriteFile(config.OutputFile, outBytes, 0644) 49 | if err != nil { 50 | return err 51 | } 52 | return nil 53 | } 54 | 55 | func writeJSON(config *ffuf.Config, res []Result) error { 56 | t := time.Now() 57 | jsonRes := make([]JsonResult, 0) 58 | for _, r := range res { 59 | strinput := make(map[string]string) 60 | for k, v := range r.Input { 61 | strinput[k] = string(v) 62 | } 63 | jsonRes = append(jsonRes, JsonResult{ 64 | Input: strinput, 65 | Position: r.Position, 66 | StatusCode: r.StatusCode, 67 | ContentLength: r.ContentLength, 68 | ContentWords: r.ContentWords, 69 | ContentLines: r.ContentLines, 70 | RedirectLocation: r.RedirectLocation, 71 | ResultFile: r.ResultFile, 72 | Url: r.Url, 73 | }) 74 | } 75 | outJSON := jsonFileOutput{ 76 | CommandLine: config.CommandLine, 77 | Time: t.Format(time.RFC3339), 78 | Results: jsonRes, 79 | Config: config, 80 | } 81 | outBytes, err := json.Marshal(outJSON) 82 | if err != nil { 83 | return err 84 | } 85 | err = ioutil.WriteFile(config.OutputFile, outBytes, 0644) 86 | if err != nil { 87 | return err 88 | } 89 | return nil 90 | } 91 | -------------------------------------------------------------------------------- /pkg/filter/filter.go: -------------------------------------------------------------------------------- 1 | package filter 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/ffuf/ffuf/pkg/ffuf" 9 | ) 10 | 11 | func NewFilterByName(name string, value string) (ffuf.FilterProvider, error) { 12 | if name == "status" { 13 | return NewStatusFilter(value) 14 | } 15 | if name == "size" { 16 | return NewSizeFilter(value) 17 | } 18 | if name == "word" { 19 | return NewWordFilter(value) 20 | } 21 | if name == "line" { 22 | return NewLineFilter(value) 23 | } 24 | if name == "regexp" { 25 | return NewRegexpFilter(value) 26 | } 27 | return nil, fmt.Errorf("Could not create filter with name %s", name) 28 | } 29 | 30 | //AddFilter adds a new filter to Config 31 | func AddFilter(conf *ffuf.Config, name string, option string) error { 32 | newf, err := NewFilterByName(name, option) 33 | if err == nil { 34 | conf.Filters[name] = newf 35 | } 36 | return err 37 | } 38 | 39 | //AddMatcher adds a new matcher to Config 40 | func AddMatcher(conf *ffuf.Config, name string, option string) error { 41 | newf, err := NewFilterByName(name, option) 42 | if err == nil { 43 | conf.Matchers[name] = newf 44 | } 45 | return err 46 | } 47 | 48 | //CalibrateIfNeeded runs a self-calibration task for filtering options (if needed) by requesting random resources and acting accordingly 49 | func CalibrateIfNeeded(j *ffuf.Job) error { 50 | if !j.Config.AutoCalibration { 51 | return nil 52 | } 53 | // Handle the calibration 54 | responses, err := j.CalibrateResponses() 55 | if err != nil { 56 | return err 57 | } 58 | if len(responses) > 0 { 59 | calibrateFilters(j, responses) 60 | } 61 | return nil 62 | } 63 | 64 | func calibrateFilters(j *ffuf.Job, responses []ffuf.Response) { 65 | sizeCalib := make([]string, 0) 66 | wordCalib := make([]string, 0) 67 | lineCalib := make([]string, 0) 68 | for _, r := range responses { 69 | if r.ContentLength > 0 { 70 | // Only add if we have an actual size of responses 71 | sizeCalib = append(sizeCalib, strconv.FormatInt(r.ContentLength, 10)) 72 | } 73 | if r.ContentWords > 0 { 74 | // Only add if we have an actual word length of response 75 | wordCalib = append(wordCalib, strconv.FormatInt(r.ContentWords, 10)) 76 | } 77 | if r.ContentLines > 1 { 78 | // Only add if we have an actual word length of response 79 | lineCalib = append(lineCalib, strconv.FormatInt(r.ContentLines, 10)) 80 | } 81 | } 82 | 83 | //Remove duplicates 84 | sizeCalib = ffuf.UniqStringSlice(sizeCalib) 85 | wordCalib = ffuf.UniqStringSlice(wordCalib) 86 | lineCalib = ffuf.UniqStringSlice(lineCalib) 87 | 88 | if len(sizeCalib) > 0 { 89 | AddFilter(j.Config, "size", strings.Join(sizeCalib, ",")) 90 | } 91 | if len(wordCalib) > 0 { 92 | AddFilter(j.Config, "word", strings.Join(wordCalib, ",")) 93 | } 94 | if len(lineCalib) > 0 { 95 | AddFilter(j.Config, "line", strings.Join(lineCalib, ",")) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /pkg/input/wordlist.go: -------------------------------------------------------------------------------- 1 | package input 2 | 3 | import ( 4 | "bufio" 5 | "os" 6 | "regexp" 7 | 8 | "github.com/ffuf/ffuf/pkg/ffuf" 9 | ) 10 | 11 | type WordlistInput struct { 12 | config *ffuf.Config 13 | data [][]byte 14 | position int 15 | keyword string 16 | } 17 | 18 | func NewWordlistInput(keyword string, value string, conf *ffuf.Config) (*WordlistInput, error) { 19 | var wl WordlistInput 20 | wl.keyword = keyword 21 | wl.config = conf 22 | wl.position = 0 23 | var valid bool 24 | var err error 25 | // stdin? 26 | if value == "-" { 27 | // yes 28 | valid = true 29 | } else { 30 | // no 31 | valid, err = wl.validFile(value) 32 | } 33 | if err != nil { 34 | return &wl, err 35 | } 36 | if valid { 37 | err = wl.readFile(value) 38 | } 39 | return &wl, err 40 | } 41 | 42 | //Position will return the current position in the input list 43 | func (w *WordlistInput) Position() int { 44 | return w.position 45 | } 46 | 47 | //ResetPosition resets the position back to beginning of the wordlist. 48 | func (w *WordlistInput) ResetPosition() { 49 | w.position = 0 50 | } 51 | 52 | //Keyword returns the keyword assigned to this InternalInputProvider 53 | func (w *WordlistInput) Keyword() string { 54 | return w.keyword 55 | } 56 | 57 | //Next will increment the cursor position, and return a boolean telling if there's words left in the list 58 | func (w *WordlistInput) Next() bool { 59 | if w.position >= len(w.data) { 60 | return false 61 | } 62 | return true 63 | } 64 | 65 | //IncrementPosition will increment the current position in the inputprovider data slice 66 | func (w *WordlistInput) IncrementPosition() { 67 | w.position += 1 68 | } 69 | 70 | //Value returns the value from wordlist at current cursor position 71 | func (w *WordlistInput) Value() []byte { 72 | return w.data[w.position] 73 | } 74 | 75 | //Total returns the size of wordlist 76 | func (w *WordlistInput) Total() int { 77 | return len(w.data) 78 | } 79 | 80 | //validFile checks that the wordlist file exists and can be read 81 | func (w *WordlistInput) validFile(path string) (bool, error) { 82 | _, err := os.Stat(path) 83 | if err != nil { 84 | return false, err 85 | } 86 | f, err := os.Open(path) 87 | if err != nil { 88 | return false, err 89 | } 90 | f.Close() 91 | return true, nil 92 | } 93 | 94 | //readFile reads the file line by line to a byte slice 95 | func (w *WordlistInput) readFile(path string) error { 96 | var file *os.File 97 | var err error 98 | if path == "-" { 99 | file = os.Stdin 100 | } else { 101 | file, err = os.Open(path) 102 | if err != nil { 103 | return err 104 | } 105 | } 106 | defer file.Close() 107 | 108 | var data [][]byte 109 | reader := bufio.NewScanner(file) 110 | re := regexp.MustCompile(`(?i)%ext%`) 111 | for reader.Scan() { 112 | if w.config.DirSearchCompat && len(w.config.Extensions) > 0 { 113 | text := []byte(reader.Text()) 114 | if re.Match(text) { 115 | for _, ext := range w.config.Extensions { 116 | contnt := re.ReplaceAll(text, []byte(ext)) 117 | data = append(data, []byte(contnt)) 118 | } 119 | } else { 120 | data = append(data, []byte(reader.Text())) 121 | } 122 | } else { 123 | data = append(data, []byte(reader.Text())) 124 | if w.keyword == "FUZZ" && len(w.config.Extensions) > 0 { 125 | for _, ext := range w.config.Extensions { 126 | data = append(data, []byte(reader.Text()+ext)) 127 | } 128 | } 129 | } 130 | } 131 | w.data = data 132 | return reader.Err() 133 | } 134 | -------------------------------------------------------------------------------- /pkg/ffuf/config.go: -------------------------------------------------------------------------------- 1 | package ffuf 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type Config struct { 8 | Headers map[string]string `json:"headers"` 9 | Extensions []string `json:"extensions"` 10 | DirSearchCompat bool `json:"dirsearch_compatibility"` 11 | Method string `json:"method"` 12 | Url string `json:"url"` 13 | Data string `json:"postdata"` 14 | Quiet bool `json:"quiet"` 15 | Colors bool `json:"colors"` 16 | InputProviders []InputProviderConfig `json:"inputproviders"` 17 | CommandKeywords []string `json:"-"` 18 | InputNum int `json:"cmd_inputnum"` 19 | InputMode string `json:"inputmode"` 20 | OutputDirectory string `json:"outputdirectory"` 21 | OutputFile string `json:"outputfile"` 22 | OutputFormat string `json:"outputformat"` 23 | StopOn403 bool `json:"stop_403"` 24 | StopOnErrors bool `json:"stop_errors"` 25 | StopOnAll bool `json:"stop_all"` 26 | FollowRedirects bool `json:"follow_redirects"` 27 | AutoCalibration bool `json:"autocalibration"` 28 | AutoCalibrationStrings []string `json:"autocalibration_strings"` 29 | Timeout int `json:"timeout"` 30 | ProgressFrequency int `json:"-"` 31 | Delay optRange `json:"delay"` 32 | Filters map[string]FilterProvider `json:"filters"` 33 | Matchers map[string]FilterProvider `json:"matchers"` 34 | Threads int `json:"threads"` 35 | Context context.Context `json:"-"` 36 | ProxyURL string `json:"proxyurl"` 37 | CommandLine string `json:"cmdline"` 38 | Verbose bool `json:"verbose"` 39 | MaxTime int `json:"maxtime"` 40 | Recursion bool `json:"recursion"` 41 | RecursionDepth int `json:"recursion_depth"` 42 | } 43 | 44 | type InputProviderConfig struct { 45 | Name string `json:"name"` 46 | Keyword string `json:"keyword"` 47 | Value string `json:"value"` 48 | } 49 | 50 | func NewConfig(ctx context.Context) Config { 51 | var conf Config 52 | conf.Context = ctx 53 | conf.Headers = make(map[string]string) 54 | conf.Method = "GET" 55 | conf.Url = "" 56 | conf.Data = "" 57 | conf.Quiet = false 58 | conf.StopOn403 = false 59 | conf.StopOnErrors = false 60 | conf.StopOnAll = false 61 | conf.FollowRedirects = false 62 | conf.InputProviders = make([]InputProviderConfig, 0) 63 | conf.CommandKeywords = make([]string, 0) 64 | conf.AutoCalibrationStrings = make([]string, 0) 65 | conf.InputNum = 0 66 | conf.InputMode = "clusterbomb" 67 | conf.ProxyURL = "" 68 | conf.Filters = make(map[string]FilterProvider) 69 | conf.Matchers = make(map[string]FilterProvider) 70 | conf.Delay = optRange{0, 0, false, false} 71 | conf.Extensions = make([]string, 0) 72 | conf.Timeout = 10 73 | // Progress update frequency, in milliseconds 74 | conf.ProgressFrequency = 100 75 | conf.DirSearchCompat = false 76 | conf.Verbose = false 77 | conf.MaxTime = 0 78 | conf.Recursion = false 79 | conf.RecursionDepth = 0 80 | return conf 81 | } 82 | -------------------------------------------------------------------------------- /pkg/runner/simple.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "bytes" 5 | "crypto/tls" 6 | "fmt" 7 | "io/ioutil" 8 | "net/http" 9 | "net/url" 10 | "strconv" 11 | "strings" 12 | "time" 13 | "unicode/utf8" 14 | 15 | "github.com/ffuf/ffuf/pkg/ffuf" 16 | ) 17 | 18 | //Download results < 5MB 19 | const MAX_DOWNLOAD_SIZE = 5242880 20 | 21 | type SimpleRunner struct { 22 | config *ffuf.Config 23 | client *http.Client 24 | } 25 | 26 | func NewSimpleRunner(conf *ffuf.Config) ffuf.RunnerProvider { 27 | var simplerunner SimpleRunner 28 | proxyURL := http.ProxyFromEnvironment 29 | 30 | if len(conf.ProxyURL) > 0 { 31 | pu, err := url.Parse(conf.ProxyURL) 32 | if err == nil { 33 | proxyURL = http.ProxyURL(pu) 34 | } 35 | } 36 | 37 | simplerunner.config = conf 38 | simplerunner.client = &http.Client{ 39 | CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }, 40 | Timeout: time.Duration(time.Duration(conf.Timeout) * time.Second), 41 | Transport: &http.Transport{ 42 | Proxy: proxyURL, 43 | MaxIdleConns: 1000, 44 | MaxIdleConnsPerHost: 500, 45 | MaxConnsPerHost: 500, 46 | TLSClientConfig: &tls.Config{ 47 | InsecureSkipVerify: true, 48 | }, 49 | }} 50 | 51 | if conf.FollowRedirects { 52 | simplerunner.client.CheckRedirect = nil 53 | } 54 | return &simplerunner 55 | } 56 | 57 | func (r *SimpleRunner) Prepare(input map[string][]byte) (ffuf.Request, error) { 58 | req := ffuf.NewRequest(r.config) 59 | 60 | req.Headers = r.config.Headers 61 | req.Url = r.config.Url 62 | req.Method = r.config.Method 63 | req.Data = []byte(r.config.Data) 64 | 65 | for keyword, inputitem := range input { 66 | req.Method = strings.Replace(req.Method, keyword, string(inputitem), -1) 67 | headers := make(map[string]string, 0) 68 | for h, v := range req.Headers { 69 | headers[strings.Replace(h, keyword, string(inputitem), -1)] = strings.Replace(v, keyword, string(inputitem), -1) 70 | } 71 | req.Headers = headers 72 | req.Url = strings.Replace(req.Url, keyword, string(inputitem), -1) 73 | req.Data = []byte(strings.Replace(string(req.Data), keyword, string(inputitem), -1)) 74 | } 75 | 76 | req.Input = input 77 | return req, nil 78 | } 79 | 80 | func (r *SimpleRunner) Execute(req *ffuf.Request) (ffuf.Response, error) { 81 | var httpreq *http.Request 82 | var err error 83 | var rawreq, rawresp strings.Builder 84 | data := bytes.NewReader(req.Data) 85 | httpreq, err = http.NewRequest(req.Method, req.Url, data) 86 | if err != nil { 87 | return ffuf.Response{}, err 88 | } 89 | // Add user agent string if not defined 90 | if _, ok := req.Headers["User-Agent"]; !ok { 91 | req.Headers["User-Agent"] = fmt.Sprintf("%s v%s", "Fuzz Faster U Fool", ffuf.VERSION) 92 | } 93 | // Handle Go http.Request special cases 94 | if _, ok := req.Headers["Host"]; ok { 95 | httpreq.Host = req.Headers["Host"] 96 | } 97 | httpreq = httpreq.WithContext(r.config.Context) 98 | for k, v := range req.Headers { 99 | httpreq.Header.Set(k, v) 100 | } 101 | httpresp, err := r.client.Do(httpreq) 102 | if err != nil { 103 | return ffuf.Response{}, err 104 | } 105 | 106 | resp := ffuf.NewResponse(httpresp, req) 107 | defer httpresp.Body.Close() 108 | 109 | if len(r.config.OutputDirectory) > 0 { 110 | // store raw request 111 | httpreq.Write(&rawreq) 112 | resp.Request.Raw = rawreq.String() 113 | // store raw response 114 | httpresp.Write(&rawresp) 115 | resp.Raw = rawresp.String() 116 | } 117 | 118 | // Check if we should download the resource or not 119 | size, err := strconv.Atoi(httpresp.Header.Get("Content-Length")) 120 | if err == nil { 121 | resp.ContentLength = int64(size) 122 | if size > MAX_DOWNLOAD_SIZE { 123 | resp.Cancelled = true 124 | return resp, nil 125 | } 126 | } 127 | 128 | if respbody, err := ioutil.ReadAll(httpresp.Body); err == nil { 129 | resp.ContentLength = int64(utf8.RuneCountInString(string(respbody))) 130 | resp.Data = respbody 131 | } 132 | 133 | wordsSize := len(strings.Split(string(resp.Data), " ")) 134 | linesSize := len(strings.Split(string(resp.Data), "\n")) 135 | resp.ContentWords = int64(wordsSize) 136 | resp.ContentLines = int64(linesSize) 137 | 138 | return resp, nil 139 | } 140 | -------------------------------------------------------------------------------- /pkg/input/input.go: -------------------------------------------------------------------------------- 1 | package input 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ffuf/ffuf/pkg/ffuf" 7 | ) 8 | 9 | type MainInputProvider struct { 10 | Providers []ffuf.InternalInputProvider 11 | Config *ffuf.Config 12 | position int 13 | msbIterator int 14 | } 15 | 16 | func NewInputProvider(conf *ffuf.Config) (ffuf.InputProvider, error) { 17 | validmode := false 18 | for _, mode := range []string{"clusterbomb", "pitchfork"} { 19 | if conf.InputMode == mode { 20 | validmode = true 21 | } 22 | } 23 | if !validmode { 24 | return &MainInputProvider{}, fmt.Errorf("Input mode (-mode) %s not recognized", conf.InputMode) 25 | } 26 | return &MainInputProvider{Config: conf, msbIterator: 0}, nil 27 | } 28 | 29 | func (i *MainInputProvider) AddProvider(provider ffuf.InputProviderConfig) error { 30 | if provider.Name == "command" { 31 | newcomm, _ := NewCommandInput(provider.Keyword, provider.Value, i.Config) 32 | i.Providers = append(i.Providers, newcomm) 33 | } else { 34 | // Default to wordlist 35 | newwl, err := NewWordlistInput(provider.Keyword, provider.Value, i.Config) 36 | if err != nil { 37 | return err 38 | } 39 | i.Providers = append(i.Providers, newwl) 40 | } 41 | return nil 42 | } 43 | 44 | //Position will return the current position of progress 45 | func (i *MainInputProvider) Position() int { 46 | return i.position 47 | } 48 | 49 | //Next will increment the cursor position, and return a boolean telling if there's inputs left 50 | func (i *MainInputProvider) Next() bool { 51 | if i.position >= i.Total() { 52 | return false 53 | } 54 | i.position++ 55 | return true 56 | } 57 | 58 | //Value returns a map of inputs for keywords 59 | func (i *MainInputProvider) Value() map[string][]byte { 60 | retval := make(map[string][]byte) 61 | if i.Config.InputMode == "clusterbomb" { 62 | retval = i.clusterbombValue() 63 | } 64 | if i.Config.InputMode == "pitchfork" { 65 | retval = i.pitchforkValue() 66 | } 67 | return retval 68 | } 69 | 70 | //Reset resets all the inputproviders and counters 71 | func (i *MainInputProvider) Reset() { 72 | for _, p := range i.Providers { 73 | p.ResetPosition() 74 | } 75 | i.position = 0 76 | i.msbIterator = 0 77 | } 78 | 79 | //pitchforkValue returns a map of keyword:value pairs including all inputs. 80 | //This mode will iterate through wordlists in lockstep. 81 | func (i *MainInputProvider) pitchforkValue() map[string][]byte { 82 | values := make(map[string][]byte) 83 | for _, p := range i.Providers { 84 | if !p.Next() { 85 | // Loop to beginning if the inputprovider has been exhausted 86 | p.ResetPosition() 87 | } 88 | values[p.Keyword()] = p.Value() 89 | p.IncrementPosition() 90 | } 91 | return values 92 | } 93 | 94 | //clusterbombValue returns map of keyword:value pairs including all inputs. 95 | //this mode will iterate through all possible combinations. 96 | func (i *MainInputProvider) clusterbombValue() map[string][]byte { 97 | values := make(map[string][]byte) 98 | // Should we signal the next InputProvider in the slice to increment 99 | signalNext := false 100 | first := true 101 | for index, p := range i.Providers { 102 | if signalNext { 103 | p.IncrementPosition() 104 | signalNext = false 105 | } 106 | if !p.Next() { 107 | // No more inputs in this inputprovider 108 | if index == i.msbIterator { 109 | // Reset all previous wordlists and increment the msb counter 110 | i.msbIterator += 1 111 | i.clusterbombIteratorReset() 112 | // Start again 113 | return i.clusterbombValue() 114 | } 115 | p.ResetPosition() 116 | signalNext = true 117 | } 118 | values[p.Keyword()] = p.Value() 119 | if first { 120 | p.IncrementPosition() 121 | first = false 122 | } 123 | } 124 | return values 125 | } 126 | 127 | func (i *MainInputProvider) clusterbombIteratorReset() { 128 | for index, p := range i.Providers { 129 | if index < i.msbIterator { 130 | p.ResetPosition() 131 | } 132 | if index == i.msbIterator { 133 | p.IncrementPosition() 134 | } 135 | } 136 | } 137 | 138 | //Total returns the amount of input combinations available 139 | func (i *MainInputProvider) Total() int { 140 | count := 0 141 | if i.Config.InputMode == "pitchfork" { 142 | for _, p := range i.Providers { 143 | if p.Total() > count { 144 | count = p.Total() 145 | } 146 | } 147 | } 148 | if i.Config.InputMode == "clusterbomb" { 149 | count = 1 150 | for _, p := range i.Providers { 151 | count = count * p.Total() 152 | } 153 | } 154 | return count 155 | } 156 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Changelog 2 | 3 | - master 4 | - New 5 | - New CLI flag `-od` (output directory) to enable writing requests and responses for matched results to a file for postprocessing or debugging purposes. 6 | - New CLI flag `-maxtime` to limit the running time of ffuf 7 | - New CLI flags `-recursion` and `-recursion-depth` to control recursive ffuf jobs if directories are found. This requires the `-u` to end with FUZZ keyword. 8 | - Changed 9 | - Limit the use of `-e` (extensions) to a single keyword: FUZZ 10 | - Regexp matching and filtering (-mr/-fr) allow using keywords in patterns 11 | - Take 429 responses into account when -sa (stop on all error cases) is used 12 | - Remove -k flag support, convert to dummy flag #134 13 | - Write configuration to output JSON 14 | 15 | - v0.12 16 | - New 17 | - Added a new flag to select a multi wordlist operation mode: `--mode`, possible values: `clusterbomb` and `pitchfork`. 18 | - Added a new output file format eJSON, for always base64 encoding the input data. 19 | - Redirect location is always shown in the output files (when using `-o`) 20 | - Full URL is always shown in the output files (when using `-o`) 21 | - HTML output format got [DataTables](https://datatables.net/) support allowing realtime searches, sorting by column etc. 22 | - New CLI flag `-v` for verbose output. Including full URL, and redirect location. 23 | - SIGTERM monitoring, in order to catch keyboard interrupts an such, to be able to write `-o` files before exiting. 24 | - Changed 25 | - Fixed a bug in the default multi wordlist mode 26 | - Fixed JSON output regression, where all the input data was always encoded in base64 27 | - `--debug-log` no correctly logs connection errors 28 | - Removed `-l` flag in favor of `-v` 29 | - More verbose information in banner shown in startup. 30 | 31 | - v0.11 32 | - New 33 | 34 | - New CLI flag: -l, shows target location of redirect responses 35 | - New CLI flac: -acc, custom auto-calibration strings 36 | - New CLI flag: -debug-log, writes the debug logging to the specified file. 37 | - New CLI flags -ml and -fl, filters/matches line count in response 38 | - 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"`. 39 | 40 | - Changed 41 | - New CLI flag: -i, dummy flag that does nothing. for compatibility with copy as curl. 42 | - New CLI flag: -b/--cookie, cookie data for compatibility with copy as curl. 43 | - New Output format are available: HTML and Markdown table. 44 | - New CLI flag: -l, shows target location of redirect responses 45 | - Filtering and matching by status code, response size or word count now allow using ranges in addition to single values 46 | - The internal logging information to be discarded, and can be written to a file with the new `-debug-log` flag. 47 | 48 | - v0.10 49 | - New 50 | - New CLI flag: -ac to autocalibrate response size and word filters based on few preset URLs. 51 | - New CLI flag: -timeout to specify custom timeouts for all HTTP requests. 52 | - New CLI flag: --data for compatibility with copy as curl functionality of browsers. 53 | - New CLI flag: --compressed, dummy flag that does nothing. for compatibility with copy as curl. 54 | - 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. 55 | - 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. 56 | 57 | - Changed 58 | - Wordlist can also be read from standard input 59 | - Defining -d or --data implies POST method if -X doesn't set it to something else than GET 60 | 61 | - v0.9 62 | - New 63 | - New output file formats: CSV and eCSV (CSV with base64 encoded input field to avoid CSV breakage with payloads containing a comma) 64 | - New CLI flag to follow redirects 65 | - Erroring connections will be retried once 66 | - Error counter in status bar 67 | - New CLI flags: -se (stop on spurious errors) and -sa (stop on all errors, implies -se and -sf) 68 | - New CLI flags: -e to provide a list of extensions to add to wordlist entries, and -D to provide DirSearch wordlist format compatibility. 69 | - Wildcard option for response status code matcher. 70 | - v0.8 71 | - New 72 | - New CLI flag to write output to a file in JSON format 73 | - New CLI flag to stop on spurious 403 responses 74 | - Changed 75 | - Regex matching / filtering now matches the headers alongside of the response body 76 | -------------------------------------------------------------------------------- /pkg/output/file_html.go: -------------------------------------------------------------------------------- 1 | package output 2 | 3 | import ( 4 | "html/template" 5 | "os" 6 | "time" 7 | 8 | "github.com/ffuf/ffuf/pkg/ffuf" 9 | ) 10 | 11 | type htmlFileOutput struct { 12 | CommandLine string 13 | Time string 14 | Keys []string 15 | Results []Result 16 | } 17 | 18 | const ( 19 | htmlTemplate = ` 20 | 21 | 22 | 23 | 24 | 28 | FFUF Report - 29 | 30 | 31 | 35 | 39 | 44 | 45 | 46 | 47 | 48 | 55 | 56 |
57 |
58 |

59 |

FFUF Report

60 |
61 | 62 |
{{ .CommandLine }}
63 |
{{ .Time }}
64 | 65 | 66 | 67 |
68 | |result_raw|StatusCode|Input|Position|ContentLength|ContentWords|ContentLines| 69 |
70 | 71 | 72 | {{ range .Keys }} 73 | {{ end }} 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | {{range $result := .Results}} 86 |
87 | |result_raw|{{ $result.StatusCode }}{{ range $keyword, $value := $result.Input }}|{{ $value | printf "%s" }}{{ end }}|{{ $result.Url }}|{{ $result.RedirectLocation }}|{{ $result.Position }}|{{ $result.ContentLength }}|{{ $result.ContentWords }}|{{ $result.ContentLines }}| 88 |
89 | {{ range $keyword, $value := $result.Input }}{{ end }} 90 | {{end}} 91 | 92 |
Status{{ . }}URLRedirect locationPositionLengthWordsLinesResultfile
{{ $result.StatusCode }}{{ $value | printf "%s" }}{{ $result.Url }}{{ $result.RedirectLocation }}{{ $result.Position }}{{ $result.ContentLength }}{{ $result.ContentWords }}{{ $result.ContentLines }}{{ $result.ResultFile }}
93 | 94 |
95 |

96 |
97 |
98 | 99 | 100 | 101 | 102 | 103 | 108 | 119 | 120 | 121 | 122 | ` 123 | ) 124 | 125 | // colorizeResults returns a new slice with HTMLColor attribute 126 | func colorizeResults(results []Result) []Result { 127 | newResults := make([]Result, 0) 128 | 129 | for _, r := range results { 130 | result := r 131 | result.HTMLColor = "black" 132 | 133 | s := result.StatusCode 134 | 135 | if s >= 200 && s <= 299 { 136 | result.HTMLColor = "#adea9e" 137 | } 138 | 139 | if s >= 300 && s <= 399 { 140 | result.HTMLColor = "#bbbbe6" 141 | } 142 | 143 | if s >= 400 && s <= 499 { 144 | result.HTMLColor = "#d2cb7e" 145 | } 146 | 147 | if s >= 500 && s <= 599 { 148 | result.HTMLColor = "#de8dc1" 149 | } 150 | 151 | newResults = append(newResults, result) 152 | } 153 | 154 | return newResults 155 | } 156 | 157 | func writeHTML(config *ffuf.Config, results []Result) error { 158 | 159 | results = colorizeResults(results) 160 | 161 | ti := time.Now() 162 | 163 | keywords := make([]string, 0) 164 | for _, inputprovider := range config.InputProviders { 165 | keywords = append(keywords, inputprovider.Keyword) 166 | } 167 | 168 | outHTML := htmlFileOutput{ 169 | CommandLine: config.CommandLine, 170 | Time: ti.Format(time.RFC3339), 171 | Results: results, 172 | Keys: keywords, 173 | } 174 | 175 | f, err := os.Create(config.OutputFile) 176 | if err != nil { 177 | return err 178 | } 179 | defer f.Close() 180 | 181 | templateName := "output.html" 182 | t := template.New(templateName).Delims("{{", "}}") 183 | t.Parse(htmlTemplate) 184 | t.Execute(f, outHTML) 185 | return nil 186 | } 187 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ``` 2 | /'___\ /'___\ /'___\ 3 | /\ \__/ /\ \__/ __ __ /\ \__/ 4 | \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\ 5 | \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/ 6 | \ \_\ \ \_\ \ \____/ \ \_\ 7 | \/_/ \/_/ \/___/ \/_/ 8 | ``` 9 | 10 | # ffuf - Fuzz Faster U Fool 11 | 12 | A fast web fuzzer written in Go. 13 | 14 | ## Installation 15 | 16 | - [Download](https://github.com/ffuf/ffuf/releases/latest) a prebuilt binary from [releases page](https://github.com/ffuf/ffuf/releases/latest), unpack and run! 17 | or 18 | - If you have go compiler installed: `go get github.com/ffuf/ffuf` 19 | 20 | The only dependency of ffuf is Go 1.11. No dependencies outside of Go standard library are needed. 21 | 22 | ## Example usage 23 | 24 | ### Typical directory discovery 25 | 26 | [![asciicast](https://asciinema.org/a/211350.png)](https://asciinema.org/a/211350) 27 | 28 | By using the FUZZ keyword at the end of URL (`-u`): 29 | 30 | ``` 31 | ffuf -w /path/to/wordlist -u https://target/FUZZ 32 | ``` 33 | 34 | ### Virtual host discovery (without DNS records) 35 | 36 | [![asciicast](https://asciinema.org/a/211360.png)](https://asciinema.org/a/211360) 37 | 38 | Assuming that the default virtualhost response size is 4242 bytes, we can filter out all the responses of that size (`-fs 4242`)while fuzzing the Host - header: 39 | 40 | ``` 41 | ffuf -w /path/to/vhost/wordlist -u https://target -H "Host: FUZZ" -fs 4242 42 | ``` 43 | 44 | ### GET parameter fuzzing 45 | 46 | GET parameter name fuzzing is very similar to directory discovery, and works by defining the `FUZZ` keyword as a part of the URL. This also assumes an response size of 4242 bytes for invalid GET parameter name. 47 | 48 | ``` 49 | ffuf -w /path/to/paramnames.txt -u https://target/script.php?FUZZ=test_value -fs 4242 50 | ``` 51 | 52 | If the parameter name is known, the values can be fuzzed the same way. This example assumes a wrong parameter value returning HTTP response code 401. 53 | 54 | ``` 55 | ffuf -w /path/to/values.txt -u https://target/script.php?valid_name=FUZZ -fc 401 56 | ``` 57 | 58 | ### POST data fuzzing 59 | 60 | This is a very straightforward operation, again by using the `FUZZ` keyword. This example is fuzzing only part of the POST request. We're again filtering out the 401 responses. 61 | 62 | ``` 63 | ffuf -w /path/to/postdata.txt -X POST -d "username=admin\&password=FUZZ" -u https://target/login.php -fc 401 64 | ``` 65 | 66 | ### Using external mutator to produce test cases 67 | 68 | For this example, we'll fuzz JSON data that's sent over POST. [Radamsa](https://gitlab.com/akihe/radamsa) is used as the mutator. 69 | 70 | 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`: 71 | 72 | ``` 73 | 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 74 | ``` 75 | 76 | 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: 77 | 78 | ``` 79 | # Generate 1000 example payloads 80 | radamsa -n 1000 -o %n.txt example1.txt example2.txt 81 | 82 | # This results into files 1.txt ... 1000.txt 83 | # Now we can just read the payload data in a loop from file for ffuf 84 | 85 | ffuf --input-cmd 'cat $FFUF_NUM.txt' -H "Content-Type: application/json" -X POST -u https://ffuf.io.fi/ -mc all -fc 400 86 | ``` 87 | 88 | ## Usage 89 | 90 | To define the test case for ffuf, use the keyword `FUZZ` anywhere in the URL (`-u`), headers (`-H`), or POST data (`-d`). 91 | 92 | ``` 93 | Usage of ffuf: 94 | -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. 95 | -H "Name: Value" 96 | Header "Name: Value", separated by colon. Multiple -H flags are accepted. 97 | -V Show version information. 98 | -X string 99 | HTTP method to use (default "GET") 100 | -ac 101 | Automatically calibrate filtering options 102 | -acc value 103 | Custom auto-calibration string. Can be used multiple times. Implies -ac 104 | -b "NAME1=VALUE1; NAME2=VALUE2" 105 | Cookie data "NAME1=VALUE1; NAME2=VALUE2" for copy as curl functionality. 106 | Results unpredictable when combined with -H "Cookie: ..." 107 | -c Colorize output. 108 | -compressed 109 | Dummy flag for copy as curl functionality (ignored) (default true) 110 | -cookie value 111 | Cookie data (alias of -b) 112 | -d string 113 | POST data 114 | -data string 115 | POST data (alias of -d) 116 | -data-ascii string 117 | POST data (alias of -d) 118 | -data-binary string 119 | POST data (alias of -d) 120 | -debug-log string 121 | Write all of the internal logging to the specified file. 122 | -e string 123 | Comma separated list of extensions to apply. Each extension provided will extend the wordlist entry once. Only extends a wordlist with (default) FUZZ keyword. 124 | -fc string 125 | Filter HTTP status codes from response. Comma separated list of codes and ranges 126 | -fl string 127 | Filter by amount of lines in response. Comma separated list of line counts and ranges 128 | -fr string 129 | Filter regexp 130 | -fs string 131 | Filter HTTP response size. Comma separated list of sizes and ranges 132 | -fw string 133 | Filter by amount of words in response. Comma separated list of word counts and ranges 134 | -i Dummy flag for copy as curl functionality (ignored) (default true) 135 | -input-cmd value 136 | Command producing the input. --input-num is required when using this input method. Overrides -w. 137 | -input-num int 138 | Number of inputs to test. Used in conjunction with --input-cmd. (default 100) 139 | -k TLS identity verification 140 | -maxtime int 141 | Maximum running time in seconds. (default 0 = inf.) 142 | -mc string 143 | Match HTTP status codes from respose, use "all" to match every response code. (default "200,204,301,302,307,401,403") 144 | -ml string 145 | Match amount of lines in response 146 | -mode string 147 | Multi-wordlist operation mode. Available modes: clusterbomb, pitchfork (default "clusterbomb") 148 | -mr string 149 | Match regexp 150 | -ms string 151 | Match HTTP response size 152 | -mw string 153 | Match amount of words in response 154 | -o string 155 | Write output to file 156 | -od string 157 | Directory path to store matched results to. 158 | -of string 159 | Output file format. Available formats: json, ejson, html, md, csv, ecsv (default "json") 160 | -p delay 161 | Seconds of delay between requests, or a range of random delay. For example "0.1" or "0.1-2.0" 162 | -r Follow redirects 163 | -s Do not print additional information (silent mode) 164 | -sa 165 | Stop on all error cases. Implies -sf and -se. Also stops on spurious 429 response codes. 166 | -se 167 | Stop on spurious errors 168 | -sf 169 | Stop when > 95% of responses return 403 Forbidden 170 | -t int 171 | Number of concurrent threads. (default 40) 172 | -timeout int 173 | HTTP request timeout in seconds. (default 10) 174 | -u string 175 | Target URL 176 | -v Verbose output, printing full URL and redirect location (if any) with the results. 177 | -w value 178 | 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' 179 | -x string 180 | HTTP Proxy URL 181 | ``` 182 | 183 | eg. `ffuf -u https://example.org/FUZZ -w /path/to/wordlist` 184 | 185 | 186 | ## License 187 | 188 | ffuf is released under MIT license. See [LICENSE](https://github.com/ffuf/ffuf/blob/master/LICENSE). 189 | -------------------------------------------------------------------------------- /pkg/ffuf/job.go: -------------------------------------------------------------------------------- 1 | package ffuf 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "math/rand" 7 | "os" 8 | "os/signal" 9 | "sync" 10 | "syscall" 11 | "time" 12 | ) 13 | 14 | //Job ties together Config, Runner, Input and Output 15 | type Job struct { 16 | Config *Config 17 | ErrorMutex sync.Mutex 18 | Input InputProvider 19 | Runner RunnerProvider 20 | Output OutputProvider 21 | Counter int 22 | ErrorCounter int 23 | SpuriousErrorCounter int 24 | Total int 25 | Running bool 26 | Count403 int 27 | Count429 int 28 | Error string 29 | startTime time.Time 30 | queuejobs []QueueJob 31 | queuepos int 32 | currentDepth int 33 | } 34 | 35 | type QueueJob struct { 36 | Url string 37 | depth int 38 | } 39 | 40 | func NewJob(conf *Config) Job { 41 | var j Job 42 | j.Counter = 0 43 | j.ErrorCounter = 0 44 | j.SpuriousErrorCounter = 0 45 | j.Running = false 46 | j.queuepos = 0 47 | j.queuejobs = make([]QueueJob, 0) 48 | j.currentDepth = 0 49 | return j 50 | } 51 | 52 | //incError increments the error counter 53 | func (j *Job) incError() { 54 | j.ErrorMutex.Lock() 55 | defer j.ErrorMutex.Unlock() 56 | j.ErrorCounter++ 57 | j.SpuriousErrorCounter++ 58 | } 59 | 60 | //inc403 increments the 403 response counter 61 | func (j *Job) inc403() { 62 | j.ErrorMutex.Lock() 63 | defer j.ErrorMutex.Unlock() 64 | j.Count403++ 65 | } 66 | 67 | // inc429 increments the 429 response counter 68 | func (j *Job) inc429() { 69 | j.ErrorMutex.Lock() 70 | defer j.ErrorMutex.Unlock() 71 | j.Count429++ 72 | } 73 | 74 | //resetSpuriousErrors resets the spurious error counter 75 | func (j *Job) resetSpuriousErrors() { 76 | j.ErrorMutex.Lock() 77 | defer j.ErrorMutex.Unlock() 78 | j.SpuriousErrorCounter = 0 79 | } 80 | 81 | //Start the execution of the Job 82 | func (j *Job) Start() { 83 | // Add the default job to job queue 84 | j.queuejobs = append(j.queuejobs, QueueJob{Url: j.Config.Url, depth: 0}) 85 | rand.Seed(time.Now().UnixNano()) 86 | j.Total = j.Input.Total() 87 | defer j.Stop() 88 | j.Running = true 89 | j.startTime = time.Now() 90 | //Show banner if not running in silent mode 91 | if !j.Config.Quiet { 92 | j.Output.Banner() 93 | } 94 | // Monitor for SIGTERM and do cleanup properly (writing the output files etc) 95 | j.interruptMonitor() 96 | for j.jobsInQueue() { 97 | j.prepareQueueJob() 98 | if j.queuepos > 1 { 99 | // Print info for queued recursive jobs 100 | j.Output.Info(fmt.Sprintf("Scanning: %s", j.Config.Url)) 101 | } 102 | j.Input.Reset() 103 | j.Counter = 0 104 | j.startExecution() 105 | } 106 | 107 | j.Output.Finalize() 108 | } 109 | 110 | func (j *Job) jobsInQueue() bool { 111 | if j.queuepos < len(j.queuejobs) { 112 | return true 113 | } 114 | return false 115 | } 116 | 117 | func (j *Job) prepareQueueJob() { 118 | j.Config.Url = j.queuejobs[j.queuepos].Url 119 | j.currentDepth = j.queuejobs[j.queuepos].depth 120 | j.queuepos += 1 121 | } 122 | 123 | func (j *Job) startExecution() { 124 | var wg sync.WaitGroup 125 | wg.Add(1) 126 | go j.runProgress(&wg) 127 | //Limiter blocks after reaching the buffer, ensuring limited concurrency 128 | limiter := make(chan bool, j.Config.Threads) 129 | for j.Input.Next() { 130 | // Check if we should stop the process 131 | j.CheckStop() 132 | if !j.Running { 133 | defer j.Output.Warning(j.Error) 134 | break 135 | } 136 | limiter <- true 137 | nextInput := j.Input.Value() 138 | nextPosition := j.Input.Position() 139 | wg.Add(1) 140 | j.Counter++ 141 | go func() { 142 | defer func() { <-limiter }() 143 | defer wg.Done() 144 | j.runTask(nextInput, nextPosition, false) 145 | if j.Config.Delay.HasDelay { 146 | var sleepDurationMS time.Duration 147 | if j.Config.Delay.IsRange { 148 | sTime := j.Config.Delay.Min + rand.Float64()*(j.Config.Delay.Max-j.Config.Delay.Min) 149 | sleepDurationMS = time.Duration(sTime * 1000) 150 | } else { 151 | sleepDurationMS = time.Duration(j.Config.Delay.Min * 1000) 152 | } 153 | time.Sleep(sleepDurationMS * time.Millisecond) 154 | } 155 | }() 156 | } 157 | wg.Wait() 158 | j.updateProgress() 159 | return 160 | } 161 | 162 | func (j *Job) interruptMonitor() { 163 | sigChan := make(chan os.Signal, 2) 164 | signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) 165 | go func() { 166 | for _ = range sigChan { 167 | j.Error = "Caught keyboard interrupt (Ctrl-C)\n" 168 | j.Stop() 169 | } 170 | }() 171 | } 172 | 173 | func (j *Job) runProgress(wg *sync.WaitGroup) { 174 | defer wg.Done() 175 | totalProgress := j.Input.Total() 176 | for j.Counter <= totalProgress { 177 | if !j.Running { 178 | break 179 | } 180 | j.updateProgress() 181 | if j.Counter == totalProgress { 182 | return 183 | } 184 | time.Sleep(time.Millisecond * time.Duration(j.Config.ProgressFrequency)) 185 | } 186 | } 187 | 188 | func (j *Job) updateProgress() { 189 | prog := Progress{ 190 | StartedAt: j.startTime, 191 | ReqCount: j.Counter, 192 | ReqTotal: j.Input.Total(), 193 | QueuePos: j.queuepos, 194 | QueueTotal: len(j.queuejobs), 195 | ErrorCount: j.ErrorCounter, 196 | } 197 | j.Output.Progress(prog) 198 | } 199 | 200 | func (j *Job) isMatch(resp Response) bool { 201 | matched := false 202 | for _, m := range j.Config.Matchers { 203 | match, err := m.Filter(&resp) 204 | if err != nil { 205 | continue 206 | } 207 | if match { 208 | matched = true 209 | } 210 | } 211 | // The response was not matched, return before running filters 212 | if !matched { 213 | return false 214 | } 215 | for _, f := range j.Config.Filters { 216 | fv, err := f.Filter(&resp) 217 | if err != nil { 218 | continue 219 | } 220 | if fv { 221 | return false 222 | } 223 | } 224 | return true 225 | } 226 | 227 | func (j *Job) runTask(input map[string][]byte, position int, retried bool) { 228 | req, err := j.Runner.Prepare(input) 229 | req.Position = position 230 | if err != nil { 231 | j.Output.Error(fmt.Sprintf("Encountered an error while preparing request: %s\n", err)) 232 | j.incError() 233 | log.Printf("%s", err) 234 | return 235 | } 236 | resp, err := j.Runner.Execute(&req) 237 | if err != nil { 238 | if retried { 239 | j.incError() 240 | log.Printf("%s", err) 241 | } else { 242 | j.runTask(input, position, true) 243 | } 244 | return 245 | } 246 | if j.SpuriousErrorCounter > 0 { 247 | j.resetSpuriousErrors() 248 | } 249 | if j.Config.StopOn403 || j.Config.StopOnAll { 250 | // Increment Forbidden counter if we encountered one 251 | if resp.StatusCode == 403 { 252 | j.inc403() 253 | } 254 | } 255 | if j.Config.StopOnAll { 256 | // increment 429 counter if the response code is 429 257 | if j.Config.StopOnAll { 258 | if resp.StatusCode == 429 { 259 | j.inc429() 260 | } 261 | } 262 | } 263 | if j.isMatch(resp) { 264 | j.Output.Result(resp) 265 | // Refresh the progress indicator as we printed something out 266 | j.updateProgress() 267 | } 268 | 269 | if j.Config.Recursion && len(resp.GetRedirectLocation()) > 0 { 270 | j.handleRecursionJob(resp) 271 | } 272 | return 273 | } 274 | 275 | //handleRecursionJob adds a new recursion job to the job queue if a new directory is found 276 | func (j *Job) handleRecursionJob(resp Response) { 277 | if (resp.Request.Url + "/") != resp.GetRedirectLocation() { 278 | // Not a directory, return early 279 | return 280 | } 281 | if j.Config.RecursionDepth == 0 || j.currentDepth < j.Config.RecursionDepth { 282 | // We have yet to reach the maximum recursion depth 283 | recUrl := resp.Request.Url + "/" + "FUZZ" 284 | newJob := QueueJob{Url: recUrl, depth: j.currentDepth + 1} 285 | j.queuejobs = append(j.queuejobs, newJob) 286 | j.Output.Info(fmt.Sprintf("Adding a new job to the queue: %s", recUrl)) 287 | } else { 288 | j.Output.Warning(fmt.Sprintf("Directory found, but recursion depth exceeded. Ignoring: %s", resp.GetRedirectLocation())) 289 | } 290 | } 291 | 292 | //CalibrateResponses returns slice of Responses for randomly generated filter autocalibration requests 293 | func (j *Job) CalibrateResponses() ([]Response, error) { 294 | cInputs := make([]string, 0) 295 | if len(j.Config.AutoCalibrationStrings) < 1 { 296 | cInputs = append(cInputs, "admin"+RandomString(16)+"/") 297 | cInputs = append(cInputs, ".htaccess"+RandomString(16)) 298 | cInputs = append(cInputs, RandomString(16)+"/") 299 | cInputs = append(cInputs, RandomString(16)) 300 | } else { 301 | cInputs = append(cInputs, j.Config.AutoCalibrationStrings...) 302 | } 303 | 304 | results := make([]Response, 0) 305 | for _, input := range cInputs { 306 | inputs := make(map[string][]byte, 0) 307 | for _, v := range j.Config.InputProviders { 308 | inputs[v.Keyword] = []byte(input) 309 | } 310 | 311 | req, err := j.Runner.Prepare(inputs) 312 | if err != nil { 313 | j.Output.Error(fmt.Sprintf("Encountered an error while preparing request: %s\n", err)) 314 | j.incError() 315 | log.Printf("%s", err) 316 | return results, err 317 | } 318 | resp, err := j.Runner.Execute(&req) 319 | if err != nil { 320 | return results, err 321 | } 322 | 323 | // Only calibrate on responses that would be matched otherwise 324 | if j.isMatch(resp) { 325 | results = append(results, resp) 326 | } 327 | } 328 | return results, nil 329 | } 330 | 331 | // CheckStop stops the job if stopping conditions are met 332 | func (j *Job) CheckStop() { 333 | if j.Counter > 50 { 334 | // We have enough samples 335 | if j.Config.StopOn403 || j.Config.StopOnAll { 336 | if float64(j.Count403)/float64(j.Counter) > 0.95 { 337 | // Over 95% of requests are 403 338 | j.Error = "Getting an unusual amount of 403 responses, exiting." 339 | j.Stop() 340 | } 341 | } 342 | if j.Config.StopOnErrors || j.Config.StopOnAll { 343 | if j.SpuriousErrorCounter > j.Config.Threads*2 { 344 | // Most of the requests are erroring 345 | j.Error = "Receiving spurious errors, exiting." 346 | j.Stop() 347 | } 348 | 349 | } 350 | if j.Config.StopOnAll && (float64(j.Count429)/float64(j.Counter) > 0.2) { 351 | // Over 20% of responses are 429 352 | j.Error = "Getting an unusual amount of 429 responses, exiting." 353 | j.Stop() 354 | } 355 | } 356 | 357 | // check for maximum running time 358 | if j.Config.MaxTime > 0 { 359 | dur := time.Now().Sub(j.startTime) 360 | runningSecs := int(dur / time.Second) 361 | if runningSecs >= j.Config.MaxTime { 362 | j.Error = "Maximum running time reached, exiting." 363 | j.Stop() 364 | } 365 | } 366 | } 367 | 368 | //Stop the execution of the Job 369 | func (j *Job) Stop() { 370 | j.Running = false 371 | return 372 | } 373 | -------------------------------------------------------------------------------- /pkg/output/stdout.go: -------------------------------------------------------------------------------- 1 | package output 2 | 3 | import ( 4 | "crypto/md5" 5 | "fmt" 6 | "io/ioutil" 7 | "os" 8 | "path" 9 | "strconv" 10 | "time" 11 | 12 | "github.com/ffuf/ffuf/pkg/ffuf" 13 | ) 14 | 15 | const ( 16 | BANNER_HEADER = ` 17 | /'___\ /'___\ /'___\ 18 | /\ \__/ /\ \__/ __ __ /\ \__/ 19 | \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\ 20 | \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/ 21 | \ \_\ \ \_\ \ \____/ \ \_\ 22 | \/_/ \/_/ \/___/ \/_/ 23 | ` 24 | BANNER_SEP = "________________________________________________" 25 | ) 26 | 27 | type Stdoutput struct { 28 | config *ffuf.Config 29 | Results []Result 30 | } 31 | 32 | type Result struct { 33 | Input map[string][]byte `json:"input"` 34 | Position int `json:"position"` 35 | StatusCode int64 `json:"status"` 36 | ContentLength int64 `json:"length"` 37 | ContentWords int64 `json:"words"` 38 | ContentLines int64 `json:"lines"` 39 | RedirectLocation string `json:"redirectlocation"` 40 | Url string `json:"url"` 41 | ResultFile string `json:"resultfile"` 42 | HTMLColor string `json:"-"` 43 | } 44 | 45 | func NewStdoutput(conf *ffuf.Config) *Stdoutput { 46 | var outp Stdoutput 47 | outp.config = conf 48 | outp.Results = []Result{} 49 | return &outp 50 | } 51 | 52 | func (s *Stdoutput) Banner() error { 53 | fmt.Printf("%s\n v%s\n%s\n\n", BANNER_HEADER, ffuf.VERSION, BANNER_SEP) 54 | printOption([]byte("Method"), []byte(s.config.Method)) 55 | printOption([]byte("URL"), []byte(s.config.Url)) 56 | // Print headers 57 | if len(s.config.Headers) > 0 { 58 | for k, v := range s.config.Headers { 59 | printOption([]byte("Header"), []byte(fmt.Sprintf("%s: %s", k, v))) 60 | } 61 | } 62 | // Print POST data 63 | if len(s.config.Data) > 0 { 64 | printOption([]byte("Data"), []byte(s.config.Data)) 65 | } 66 | 67 | // Print extensions 68 | if len(s.config.Extensions) > 0 { 69 | exts := "" 70 | for _, ext := range s.config.Extensions { 71 | exts = fmt.Sprintf("%s%s ", exts, ext) 72 | } 73 | printOption([]byte("Extensions"), []byte(exts)) 74 | } 75 | 76 | // Output file info 77 | if len(s.config.OutputFile) > 0 { 78 | printOption([]byte("Output file"), []byte(s.config.OutputFile)) 79 | printOption([]byte("File format"), []byte(s.config.OutputFormat)) 80 | } 81 | 82 | // Follow redirects? 83 | follow := fmt.Sprintf("%t", s.config.FollowRedirects) 84 | printOption([]byte("Follow redirects"), []byte(follow)) 85 | 86 | // Autocalibration 87 | autocalib := fmt.Sprintf("%t", s.config.AutoCalibration) 88 | printOption([]byte("Calibration"), []byte(autocalib)) 89 | 90 | // Timeout 91 | timeout := fmt.Sprintf("%d", s.config.Timeout) 92 | printOption([]byte("Timeout"), []byte(timeout)) 93 | 94 | // Threads 95 | threads := fmt.Sprintf("%d", s.config.Threads) 96 | printOption([]byte("Threads"), []byte(threads)) 97 | 98 | // Delay? 99 | if s.config.Delay.HasDelay { 100 | delay := "" 101 | if s.config.Delay.IsRange { 102 | delay = fmt.Sprintf("%.2f - %.2f seconds", s.config.Delay.Min, s.config.Delay.Max) 103 | } else { 104 | delay = fmt.Sprintf("%.2f seconds", s.config.Delay.Min) 105 | } 106 | printOption([]byte("Delay"), []byte(delay)) 107 | } 108 | 109 | // Print matchers 110 | for _, f := range s.config.Matchers { 111 | printOption([]byte("Matcher"), []byte(f.Repr())) 112 | } 113 | // Print filters 114 | for _, f := range s.config.Filters { 115 | printOption([]byte("Filter"), []byte(f.Repr())) 116 | } 117 | fmt.Printf("%s\n\n", BANNER_SEP) 118 | return nil 119 | } 120 | 121 | func (s *Stdoutput) Progress(status ffuf.Progress) { 122 | if s.config.Quiet { 123 | // No progress for quiet mode 124 | return 125 | } 126 | 127 | dur := time.Now().Sub(status.StartedAt) 128 | runningSecs := int(dur / time.Second) 129 | var reqRate int 130 | if runningSecs > 0 { 131 | reqRate = int(status.ReqCount / runningSecs) 132 | } else { 133 | reqRate = 0 134 | } 135 | 136 | hours := dur / time.Hour 137 | dur -= hours * time.Hour 138 | mins := dur / time.Minute 139 | dur -= mins * time.Minute 140 | secs := dur / time.Second 141 | 142 | fmt.Fprintf(os.Stderr, "%s:: Progress: [%d/%d] :: Job [%d/%d] :: %d req/sec :: Duration: [%d:%02d:%02d] :: Errors: %d ::", TERMINAL_CLEAR_LINE, status.ReqCount, status.ReqTotal, status.QueuePos, status.QueueTotal, reqRate, hours, mins, secs, status.ErrorCount) 143 | } 144 | 145 | func (s *Stdoutput) Info(infostring string) { 146 | if s.config.Quiet { 147 | fmt.Fprintf(os.Stderr, "%s", infostring) 148 | } else { 149 | if !s.config.Colors { 150 | fmt.Fprintf(os.Stderr, "%s[INFO] %s\n", TERMINAL_CLEAR_LINE, infostring) 151 | } else { 152 | fmt.Fprintf(os.Stderr, "%s[%sINFO%s] %s\n", TERMINAL_CLEAR_LINE, ANSI_BLUE, ANSI_CLEAR, infostring) 153 | } 154 | } 155 | } 156 | 157 | func (s *Stdoutput) Error(errstring string) { 158 | if s.config.Quiet { 159 | fmt.Fprintf(os.Stderr, "%s", errstring) 160 | } else { 161 | if !s.config.Colors { 162 | fmt.Fprintf(os.Stderr, "%s[ERR] %s\n", TERMINAL_CLEAR_LINE, errstring) 163 | } else { 164 | fmt.Fprintf(os.Stderr, "%s[%sERR%s] %s\n", TERMINAL_CLEAR_LINE, ANSI_RED, ANSI_CLEAR, errstring) 165 | } 166 | } 167 | } 168 | 169 | func (s *Stdoutput) Warning(warnstring string) { 170 | if s.config.Quiet { 171 | fmt.Fprintf(os.Stderr, "%s", warnstring) 172 | } else { 173 | if !s.config.Colors { 174 | fmt.Fprintf(os.Stderr, "%s[WARN] %s", TERMINAL_CLEAR_LINE, warnstring) 175 | } else { 176 | fmt.Fprintf(os.Stderr, "%s[%sWARN%s] %s\n", TERMINAL_CLEAR_LINE, ANSI_RED, ANSI_CLEAR, warnstring) 177 | } 178 | } 179 | } 180 | 181 | func (s *Stdoutput) Finalize() error { 182 | var err error 183 | if s.config.OutputFile != "" { 184 | if s.config.OutputFormat == "json" { 185 | err = writeJSON(s.config, s.Results) 186 | } else if s.config.OutputFormat == "ejson" { 187 | err = writeEJSON(s.config, s.Results) 188 | } else if s.config.OutputFormat == "html" { 189 | err = writeHTML(s.config, s.Results) 190 | } else if s.config.OutputFormat == "md" { 191 | err = writeMarkdown(s.config, s.Results) 192 | } else if s.config.OutputFormat == "csv" { 193 | err = writeCSV(s.config, s.Results, false) 194 | } else if s.config.OutputFormat == "ecsv" { 195 | err = writeCSV(s.config, s.Results, true) 196 | } 197 | if err != nil { 198 | s.Error(fmt.Sprintf("%s", err)) 199 | } 200 | } 201 | fmt.Fprintf(os.Stderr, "\n") 202 | return nil 203 | } 204 | 205 | func (s *Stdoutput) Result(resp ffuf.Response) { 206 | // Do we want to write request and response to a file 207 | if len(s.config.OutputDirectory) > 0 { 208 | resp.ResultFile = s.writeResultToFile(resp) 209 | } 210 | // Output the result 211 | s.printResult(resp) 212 | // Check if we need the data later 213 | if s.config.OutputFile != "" { 214 | // No need to store results if we're not going to use them later 215 | inputs := make(map[string][]byte, 0) 216 | for k, v := range resp.Request.Input { 217 | inputs[k] = v 218 | } 219 | sResult := Result{ 220 | Input: inputs, 221 | Position: resp.Request.Position, 222 | StatusCode: resp.StatusCode, 223 | ContentLength: resp.ContentLength, 224 | ContentWords: resp.ContentWords, 225 | ContentLines: resp.ContentLines, 226 | RedirectLocation: resp.GetRedirectLocation(), 227 | Url: resp.Request.Url, 228 | ResultFile: resp.ResultFile, 229 | } 230 | s.Results = append(s.Results, sResult) 231 | } 232 | } 233 | 234 | func (s *Stdoutput) writeResultToFile(resp ffuf.Response) string { 235 | var fileContent, fileName, filePath string 236 | // Create directory if needed 237 | if s.config.OutputDirectory != "" { 238 | err := os.Mkdir(s.config.OutputDirectory, 0750) 239 | if err != nil { 240 | if !os.IsExist(err) { 241 | s.Error(fmt.Sprintf("%s", err)) 242 | return "" 243 | } 244 | } 245 | } 246 | fileContent = fmt.Sprintf("%s\n---- ↑ Request ---- Response ↓ ----\n\n%s", resp.Request.Raw, resp.Raw) 247 | 248 | // Create file name 249 | fileName = fmt.Sprintf("%x", md5.Sum([]byte(fileContent))) 250 | 251 | filePath = path.Join(s.config.OutputDirectory, fileName) 252 | err := ioutil.WriteFile(filePath, []byte(fileContent), 0640) 253 | if err != nil { 254 | s.Error(fmt.Sprintf("%s", err)) 255 | } 256 | return fileName 257 | } 258 | 259 | func (s *Stdoutput) printResult(resp ffuf.Response) { 260 | if s.config.Quiet { 261 | s.resultQuiet(resp) 262 | } else { 263 | if len(resp.Request.Input) > 1 || s.config.Verbose || len(s.config.OutputDirectory) > 0 { 264 | // Print a multi-line result (when using multiple input keywords and wordlists) 265 | s.resultMultiline(resp) 266 | } else { 267 | s.resultNormal(resp) 268 | } 269 | } 270 | } 271 | 272 | func (s *Stdoutput) prepareInputsOneLine(resp ffuf.Response) string { 273 | inputs := "" 274 | if len(resp.Request.Input) > 1 { 275 | for k, v := range resp.Request.Input { 276 | if inSlice(k, s.config.CommandKeywords) { 277 | // If we're using external command for input, display the position instead of input 278 | inputs = fmt.Sprintf("%s%s : %s ", inputs, k, strconv.Itoa(resp.Request.Position)) 279 | } else { 280 | inputs = fmt.Sprintf("%s%s : %s ", inputs, k, v) 281 | } 282 | } 283 | } else { 284 | for k, v := range resp.Request.Input { 285 | if inSlice(k, s.config.CommandKeywords) { 286 | // If we're using external command for input, display the position instead of input 287 | inputs = strconv.Itoa(resp.Request.Position) 288 | } else { 289 | inputs = string(v) 290 | } 291 | } 292 | } 293 | return inputs 294 | } 295 | 296 | func (s *Stdoutput) resultQuiet(resp ffuf.Response) { 297 | fmt.Println(s.prepareInputsOneLine(resp)) 298 | } 299 | 300 | func (s *Stdoutput) resultMultiline(resp ffuf.Response) { 301 | var res_hdr, res_str string 302 | res_str = "%s%s * %s: %s\n" 303 | res_hdr = fmt.Sprintf("%s[Status: %d, Size: %d, Words: %d, Lines: %d]", TERMINAL_CLEAR_LINE, resp.StatusCode, resp.ContentLength, resp.ContentWords, resp.ContentLines) 304 | res_hdr = s.colorize(res_hdr, resp.StatusCode) 305 | reslines := "" 306 | if s.config.Verbose { 307 | reslines = fmt.Sprintf("%s%s| URL | %s\n", reslines, TERMINAL_CLEAR_LINE, resp.Request.Url) 308 | redirectLocation := resp.GetRedirectLocation() 309 | if redirectLocation != "" { 310 | reslines = fmt.Sprintf("%s%s| --> | %s\n", reslines, TERMINAL_CLEAR_LINE, redirectLocation) 311 | } 312 | } 313 | if resp.ResultFile != "" { 314 | reslines = fmt.Sprintf("%s%s| RES | %s\n", reslines, TERMINAL_CLEAR_LINE, resp.ResultFile) 315 | } 316 | for k, v := range resp.Request.Input { 317 | if inSlice(k, s.config.CommandKeywords) { 318 | // If we're using external command for input, display the position instead of input 319 | reslines = fmt.Sprintf(res_str, reslines, TERMINAL_CLEAR_LINE, k, strconv.Itoa(resp.Request.Position)) 320 | } else { 321 | // Wordlist input 322 | reslines = fmt.Sprintf(res_str, reslines, TERMINAL_CLEAR_LINE, k, v) 323 | } 324 | } 325 | fmt.Printf("%s\n%s\n", res_hdr, reslines) 326 | } 327 | 328 | func (s *Stdoutput) resultNormal(resp ffuf.Response) { 329 | var res_str string 330 | res_str = fmt.Sprintf("%s%-23s [Status: %s, Size: %d, Words: %d, Lines: %d]", TERMINAL_CLEAR_LINE, s.prepareInputsOneLine(resp), s.colorize(fmt.Sprintf("%d", resp.StatusCode), resp.StatusCode), resp.ContentLength, resp.ContentWords, resp.ContentLines) 331 | fmt.Println(res_str) 332 | } 333 | 334 | func (s *Stdoutput) colorize(input string, status int64) string { 335 | if !s.config.Colors { 336 | return fmt.Sprintf("%s", input) 337 | } 338 | colorCode := ANSI_CLEAR 339 | if status >= 200 && status < 300 { 340 | colorCode = ANSI_GREEN 341 | } 342 | if status >= 300 && status < 400 { 343 | colorCode = ANSI_BLUE 344 | } 345 | if status >= 400 && status < 500 { 346 | colorCode = ANSI_YELLOW 347 | } 348 | if status >= 500 && status < 600 { 349 | colorCode = ANSI_RED 350 | } 351 | return fmt.Sprintf("%s%s%s", colorCode, input, ANSI_CLEAR) 352 | } 353 | 354 | func printOption(name []byte, value []byte) { 355 | fmt.Printf(" :: %-16s : %s\n", name, value) 356 | } 357 | 358 | func inSlice(key string, slice []string) bool { 359 | for _, v := range slice { 360 | if v == key { 361 | return true 362 | } 363 | } 364 | return false 365 | } 366 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "io/ioutil" 8 | "log" 9 | "net/url" 10 | "os" 11 | "strconv" 12 | "strings" 13 | 14 | "github.com/ffuf/ffuf/pkg/ffuf" 15 | "github.com/ffuf/ffuf/pkg/filter" 16 | "github.com/ffuf/ffuf/pkg/input" 17 | "github.com/ffuf/ffuf/pkg/output" 18 | "github.com/ffuf/ffuf/pkg/runner" 19 | ) 20 | 21 | type cliOptions struct { 22 | extensions string 23 | delay string 24 | filterStatus string 25 | filterSize string 26 | filterRegexp string 27 | filterWords string 28 | filterLines string 29 | matcherStatus string 30 | matcherSize string 31 | matcherRegexp string 32 | matcherWords string 33 | matcherLines string 34 | proxyURL string 35 | outputFormat string 36 | wordlists multiStringFlag 37 | inputcommands multiStringFlag 38 | headers multiStringFlag 39 | cookies multiStringFlag 40 | AutoCalibrationStrings multiStringFlag 41 | showVersion bool 42 | debugLog string 43 | } 44 | 45 | type multiStringFlag []string 46 | 47 | func (m *multiStringFlag) String() string { 48 | return "" 49 | } 50 | 51 | func (m *multiStringFlag) Set(value string) error { 52 | *m = append(*m, value) 53 | return nil 54 | } 55 | 56 | func main() { 57 | ctx, cancel := context.WithCancel(context.Background()) 58 | defer cancel() 59 | conf := ffuf.NewConfig(ctx) 60 | opts := cliOptions{} 61 | var ignored bool 62 | flag.StringVar(&opts.extensions, "e", "", "Comma separated list of extensions to apply. Each extension provided will extend the wordlist entry once. Only extends a wordlist with (default) FUZZ keyword.") 63 | 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.") 64 | flag.Var(&opts.headers, "H", "Header `\"Name: Value\"`, separated by colon. Multiple -H flags are accepted.") 65 | flag.StringVar(&conf.Url, "u", "", "Target URL") 66 | 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'") 67 | flag.BoolVar(&ignored, "k", false, "Dummy flag for backwards compatibility") 68 | 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\"") 69 | flag.StringVar(&opts.filterStatus, "fc", "", "Filter HTTP status codes from response. Comma separated list of codes and ranges") 70 | flag.StringVar(&opts.filterSize, "fs", "", "Filter HTTP response size. Comma separated list of sizes and ranges") 71 | flag.StringVar(&opts.filterRegexp, "fr", "", "Filter regexp") 72 | flag.StringVar(&opts.filterWords, "fw", "", "Filter by amount of words in response. Comma separated list of word counts and ranges") 73 | flag.StringVar(&opts.filterLines, "fl", "", "Filter by amount of lines in response. Comma separated list of line counts and ranges") 74 | flag.StringVar(&conf.Data, "d", "", "POST data") 75 | flag.StringVar(&conf.Data, "data", "", "POST data (alias of -d)") 76 | flag.StringVar(&conf.Data, "data-ascii", "", "POST data (alias of -d)") 77 | flag.StringVar(&conf.Data, "data-binary", "", "POST data (alias of -d)") 78 | flag.BoolVar(&conf.Colors, "c", false, "Colorize output.") 79 | flag.BoolVar(&ignored, "compressed", true, "Dummy flag for copy as curl functionality (ignored)") 80 | flag.Var(&opts.inputcommands, "input-cmd", "Command producing the input. --input-num is required when using this input method. Overrides -w.") 81 | flag.IntVar(&conf.InputNum, "input-num", 100, "Number of inputs to test. Used in conjunction with --input-cmd.") 82 | flag.StringVar(&conf.InputMode, "mode", "clusterbomb", "Multi-wordlist operation mode. Available modes: clusterbomb, pitchfork") 83 | flag.BoolVar(&ignored, "i", true, "Dummy flag for copy as curl functionality (ignored)") 84 | flag.Var(&opts.cookies, "b", "Cookie data `\"NAME1=VALUE1; NAME2=VALUE2\"` for copy as curl functionality.\nResults unpredictable when combined with -H \"Cookie: ...\"") 85 | flag.Var(&opts.cookies, "cookie", "Cookie data (alias of -b)") 86 | 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.") 87 | flag.StringVar(&opts.matcherSize, "ms", "", "Match HTTP response size") 88 | flag.StringVar(&opts.matcherRegexp, "mr", "", "Match regexp") 89 | flag.StringVar(&opts.matcherWords, "mw", "", "Match amount of words in response") 90 | flag.StringVar(&opts.matcherLines, "ml", "", "Match amount of lines in response") 91 | flag.StringVar(&opts.proxyURL, "x", "", "HTTP Proxy URL") 92 | flag.StringVar(&conf.Method, "X", "GET", "HTTP method to use") 93 | flag.StringVar(&conf.OutputFile, "o", "", "Write output to file") 94 | flag.StringVar(&opts.outputFormat, "of", "json", "Output file format. Available formats: json, ejson, html, md, csv, ecsv") 95 | flag.StringVar(&conf.OutputDirectory, "od", "", "Directory path to store matched results to.") 96 | flag.BoolVar(&conf.Quiet, "s", false, "Do not print additional information (silent mode)") 97 | flag.BoolVar(&conf.StopOn403, "sf", false, "Stop when > 95% of responses return 403 Forbidden") 98 | flag.BoolVar(&conf.StopOnErrors, "se", false, "Stop on spurious errors") 99 | flag.BoolVar(&conf.StopOnAll, "sa", false, "Stop on all error cases. Implies -sf and -se. Also stops on spurious 429 response codes.") 100 | flag.BoolVar(&conf.FollowRedirects, "r", false, "Follow redirects") 101 | flag.BoolVar(&conf.Recursion, "recursion", false, "Scan recursively. Only FUZZ keyword is supported, and URL (-u) has to end in it.") 102 | flag.IntVar(&conf.RecursionDepth, "recursion-depth", 0, "Maximum recursion depth.") 103 | flag.BoolVar(&conf.AutoCalibration, "ac", false, "Automatically calibrate filtering options") 104 | flag.Var(&opts.AutoCalibrationStrings, "acc", "Custom auto-calibration string. Can be used multiple times. Implies -ac") 105 | flag.IntVar(&conf.Threads, "t", 40, "Number of concurrent threads.") 106 | flag.IntVar(&conf.Timeout, "timeout", 10, "HTTP request timeout in seconds.") 107 | flag.IntVar(&conf.MaxTime, "maxtime", 0, "Maximum running time in seconds.") 108 | flag.BoolVar(&conf.Verbose, "v", false, "Verbose output, printing full URL and redirect location (if any) with the results.") 109 | flag.BoolVar(&opts.showVersion, "V", false, "Show version information.") 110 | flag.StringVar(&opts.debugLog, "debug-log", "", "Write all of the internal logging to the specified file.") 111 | flag.Parse() 112 | if opts.showVersion { 113 | fmt.Printf("ffuf version: %s\n", ffuf.VERSION) 114 | os.Exit(0) 115 | } 116 | if len(opts.debugLog) != 0 { 117 | f, err := os.OpenFile(opts.debugLog, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 118 | if err != nil { 119 | fmt.Fprintf(os.Stderr, "Disabling logging, encountered error(s): %s\n", err) 120 | log.SetOutput(ioutil.Discard) 121 | } else { 122 | log.SetOutput(f) 123 | defer f.Close() 124 | } 125 | } else { 126 | log.SetOutput(ioutil.Discard) 127 | } 128 | if err := prepareConfig(&opts, &conf); err != nil { 129 | fmt.Fprintf(os.Stderr, "Encountered error(s): %s\n", err) 130 | flag.Usage() 131 | os.Exit(1) 132 | } 133 | job, err := prepareJob(&conf) 134 | if err != nil { 135 | fmt.Fprintf(os.Stderr, "Encountered error(s): %s\n", err) 136 | flag.Usage() 137 | os.Exit(1) 138 | } 139 | if err := prepareFilters(&opts, &conf); err != nil { 140 | fmt.Fprintf(os.Stderr, "Encountered error(s): %s\n", err) 141 | flag.Usage() 142 | os.Exit(1) 143 | } 144 | 145 | if err := filter.CalibrateIfNeeded(job); err != nil { 146 | fmt.Fprintf(os.Stderr, "Error in autocalibration, exiting: %s\n", err) 147 | os.Exit(1) 148 | } 149 | 150 | // Job handles waiting for goroutines to complete itself 151 | job.Start() 152 | } 153 | 154 | func prepareJob(conf *ffuf.Config) (*ffuf.Job, error) { 155 | errs := ffuf.NewMultierror() 156 | var err error 157 | inputprovider, err := input.NewInputProvider(conf) 158 | if err != nil { 159 | errs.Add(err) 160 | } 161 | // TODO: implement error handling for runnerprovider and outputprovider 162 | // We only have http runner right now 163 | runprovider := runner.NewRunnerByName("http", conf) 164 | // Initialize the correct inputprovider 165 | for _, v := range conf.InputProviders { 166 | err = inputprovider.AddProvider(v) 167 | if err != nil { 168 | errs.Add(err) 169 | } 170 | } 171 | // We only have stdout outputprovider right now 172 | outprovider := output.NewOutputProviderByName("stdout", conf) 173 | return &ffuf.Job{ 174 | Config: conf, 175 | Runner: runprovider, 176 | Output: outprovider, 177 | Input: inputprovider, 178 | }, errs.ErrorOrNil() 179 | } 180 | 181 | func prepareFilters(parseOpts *cliOptions, conf *ffuf.Config) error { 182 | errs := ffuf.NewMultierror() 183 | if parseOpts.filterStatus != "" { 184 | if err := filter.AddFilter(conf, "status", parseOpts.filterStatus); err != nil { 185 | errs.Add(err) 186 | } 187 | } 188 | if parseOpts.filterSize != "" { 189 | if err := filter.AddFilter(conf, "size", parseOpts.filterSize); err != nil { 190 | errs.Add(err) 191 | } 192 | } 193 | if parseOpts.filterRegexp != "" { 194 | if err := filter.AddFilter(conf, "regexp", parseOpts.filterRegexp); err != nil { 195 | errs.Add(err) 196 | } 197 | } 198 | if parseOpts.filterWords != "" { 199 | if err := filter.AddFilter(conf, "word", parseOpts.filterWords); err != nil { 200 | errs.Add(err) 201 | } 202 | } 203 | if parseOpts.filterLines != "" { 204 | if err := filter.AddFilter(conf, "line", parseOpts.filterLines); err != nil { 205 | errs.Add(err) 206 | } 207 | } 208 | if parseOpts.matcherStatus != "" { 209 | if err := filter.AddMatcher(conf, "status", parseOpts.matcherStatus); err != nil { 210 | errs.Add(err) 211 | } 212 | } 213 | if parseOpts.matcherSize != "" { 214 | if err := filter.AddMatcher(conf, "size", parseOpts.matcherSize); err != nil { 215 | errs.Add(err) 216 | } 217 | } 218 | if parseOpts.matcherRegexp != "" { 219 | if err := filter.AddMatcher(conf, "regexp", parseOpts.matcherRegexp); err != nil { 220 | errs.Add(err) 221 | } 222 | } 223 | if parseOpts.matcherWords != "" { 224 | if err := filter.AddMatcher(conf, "word", parseOpts.matcherWords); err != nil { 225 | errs.Add(err) 226 | } 227 | } 228 | if parseOpts.matcherLines != "" { 229 | if err := filter.AddMatcher(conf, "line", parseOpts.matcherLines); err != nil { 230 | errs.Add(err) 231 | } 232 | } 233 | return errs.ErrorOrNil() 234 | } 235 | 236 | func prepareConfig(parseOpts *cliOptions, conf *ffuf.Config) error { 237 | //TODO: refactor in a proper flag library that can handle things like required flags 238 | errs := ffuf.NewMultierror() 239 | 240 | var err error 241 | var err2 error 242 | if len(conf.Url) == 0 { 243 | errs.Add(fmt.Errorf("-u flag is required")) 244 | } 245 | // prepare extensions 246 | if parseOpts.extensions != "" { 247 | extensions := strings.Split(parseOpts.extensions, ",") 248 | conf.Extensions = extensions 249 | } 250 | 251 | // Convert cookies to a header 252 | if len(parseOpts.cookies) > 0 { 253 | parseOpts.headers.Set("Cookie: " + strings.Join(parseOpts.cookies, "; ")) 254 | } 255 | 256 | //Prepare inputproviders 257 | for _, v := range parseOpts.wordlists { 258 | wl := strings.SplitN(v, ":", 2) 259 | if len(wl) == 2 { 260 | conf.InputProviders = append(conf.InputProviders, ffuf.InputProviderConfig{ 261 | Name: "wordlist", 262 | Value: wl[0], 263 | Keyword: wl[1], 264 | }) 265 | } else { 266 | conf.InputProviders = append(conf.InputProviders, ffuf.InputProviderConfig{ 267 | Name: "wordlist", 268 | Value: wl[0], 269 | Keyword: "FUZZ", 270 | }) 271 | } 272 | } 273 | for _, v := range parseOpts.inputcommands { 274 | ic := strings.SplitN(v, ":", 2) 275 | if len(ic) == 2 { 276 | conf.InputProviders = append(conf.InputProviders, ffuf.InputProviderConfig{ 277 | Name: "command", 278 | Value: ic[0], 279 | Keyword: ic[1], 280 | }) 281 | conf.CommandKeywords = append(conf.CommandKeywords, ic[0]) 282 | } else { 283 | conf.InputProviders = append(conf.InputProviders, ffuf.InputProviderConfig{ 284 | Name: "command", 285 | Value: ic[0], 286 | Keyword: "FUZZ", 287 | }) 288 | conf.CommandKeywords = append(conf.CommandKeywords, "FUZZ") 289 | } 290 | } 291 | 292 | if len(conf.InputProviders) == 0 { 293 | errs.Add(fmt.Errorf("Either -w or --input-cmd flag is required")) 294 | } 295 | 296 | //Prepare headers 297 | for _, v := range parseOpts.headers { 298 | hs := strings.SplitN(v, ":", 2) 299 | if len(hs) == 2 { 300 | conf.Headers[strings.TrimSpace(hs[0])] = strings.TrimSpace(hs[1]) 301 | } else { 302 | errs.Add(fmt.Errorf("Header defined by -H needs to have a value. \":\" should be used as a separator")) 303 | } 304 | } 305 | //Prepare delay 306 | d := strings.Split(parseOpts.delay, "-") 307 | if len(d) > 2 { 308 | 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\"")) 309 | } else if len(d) == 2 { 310 | conf.Delay.IsRange = true 311 | conf.Delay.HasDelay = true 312 | conf.Delay.Min, err = strconv.ParseFloat(d[0], 64) 313 | conf.Delay.Max, err2 = strconv.ParseFloat(d[1], 64) 314 | if err != nil || err2 != nil { 315 | errs.Add(fmt.Errorf("Delay range min and max values need to be valid floats. For example: 0.1-0.5")) 316 | } 317 | } else if len(parseOpts.delay) > 0 { 318 | conf.Delay.IsRange = false 319 | conf.Delay.HasDelay = true 320 | conf.Delay.Min, err = strconv.ParseFloat(parseOpts.delay, 64) 321 | if err != nil { 322 | 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\"")) 323 | } 324 | } 325 | 326 | // Verify proxy url format 327 | if len(parseOpts.proxyURL) > 0 { 328 | _, err := url.Parse(parseOpts.proxyURL) 329 | if err != nil { 330 | errs.Add(fmt.Errorf("Bad proxy url (-x) format: %s", err)) 331 | } else { 332 | conf.ProxyURL = parseOpts.proxyURL 333 | } 334 | } 335 | 336 | //Check the output file format option 337 | if conf.OutputFile != "" { 338 | //No need to check / error out if output file isn't defined 339 | outputFormats := []string{"json", "ejson", "html", "md", "csv", "ecsv"} 340 | found := false 341 | for _, f := range outputFormats { 342 | if f == parseOpts.outputFormat { 343 | conf.OutputFormat = f 344 | found = true 345 | } 346 | } 347 | if !found { 348 | errs.Add(fmt.Errorf("Unknown output file format (-of): %s", parseOpts.outputFormat)) 349 | } 350 | } 351 | 352 | // Auto-calibration strings 353 | if len(parseOpts.AutoCalibrationStrings) > 0 { 354 | conf.AutoCalibrationStrings = parseOpts.AutoCalibrationStrings 355 | } 356 | // Using -acc implies -ac 357 | if len(conf.AutoCalibrationStrings) > 0 { 358 | conf.AutoCalibration = true 359 | } 360 | 361 | // Handle copy as curl situation where POST method is implied by --data flag. If method is set to anything but GET, NOOP 362 | if conf.Method == "GET" { 363 | if len(conf.Data) > 0 { 364 | conf.Method = "POST" 365 | } 366 | } 367 | 368 | conf.CommandLine = strings.Join(os.Args, " ") 369 | 370 | for _, provider := range conf.InputProviders { 371 | if !keywordPresent(provider.Keyword, conf) { 372 | errmsg := fmt.Sprintf("Keyword %s defined, but not found in headers, method, URL or POST data.", provider.Keyword) 373 | errs.Add(fmt.Errorf(errmsg)) 374 | } 375 | } 376 | 377 | // Do checks for recursion mode 378 | if conf.Recursion { 379 | if !strings.HasSuffix(conf.Url, "FUZZ") { 380 | errmsg := fmt.Sprintf("When using -recursion the URL (-u) must end with FUZZ keyword.") 381 | errs.Add(fmt.Errorf(errmsg)) 382 | } 383 | } 384 | 385 | return errs.ErrorOrNil() 386 | } 387 | 388 | func keywordPresent(keyword string, conf *ffuf.Config) bool { 389 | //Search for keyword from HTTP method, URL and POST data too 390 | if strings.Index(conf.Method, keyword) != -1 { 391 | return true 392 | } 393 | if strings.Index(conf.Url, keyword) != -1 { 394 | return true 395 | } 396 | if strings.Index(conf.Data, keyword) != -1 { 397 | return true 398 | } 399 | for k, v := range conf.Headers { 400 | if strings.Index(k, keyword) != -1 { 401 | return true 402 | } 403 | if strings.Index(v, keyword) != -1 { 404 | return true 405 | } 406 | } 407 | return false 408 | } 409 | --------------------------------------------------------------------------------