* Update .ffufrc to match 405 status code responses by default * Updated README.md with the new default match status codes * Updated default match codes to include 405 Method Not Allowed 405 Method not Allowed is returned by many api endpoints when accessed via an improper method. ffuf sends GET reqeusts by default and if an endpoint only supports POST it will return 405 and ffuf will not think it's a valid endpoint unless specifically told to match 405 status codes * Added choket to contributors * Update CHANGELOG.md
526 lines
14 KiB
Go
526 lines
14 KiB
Go
package ffuf
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"fmt"
|
|
"io/ioutil"
|
|
"net/textproto"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/pelletier/go-toml"
|
|
)
|
|
|
|
type ConfigOptions struct {
|
|
Filter FilterOptions
|
|
General GeneralOptions
|
|
HTTP HTTPOptions
|
|
Input InputOptions
|
|
Matcher MatcherOptions
|
|
Output OutputOptions
|
|
}
|
|
|
|
type HTTPOptions struct {
|
|
Cookies []string
|
|
Data string
|
|
FollowRedirects bool
|
|
Headers []string
|
|
IgnoreBody bool
|
|
Method string
|
|
ProxyURL string
|
|
Recursion bool
|
|
RecursionDepth int
|
|
ReplayProxyURL string
|
|
Timeout int
|
|
URL string
|
|
}
|
|
|
|
type GeneralOptions struct {
|
|
AutoCalibration bool
|
|
AutoCalibrationStrings []string
|
|
Colors bool
|
|
ConfigFile string `toml:"-"`
|
|
Delay string
|
|
MaxTime int
|
|
MaxTimeJob int
|
|
Quiet bool
|
|
Rate int
|
|
ShowVersion bool `toml:"-"`
|
|
StopOn403 bool
|
|
StopOnAll bool
|
|
StopOnErrors bool
|
|
Threads int
|
|
Verbose bool
|
|
}
|
|
|
|
type InputOptions struct {
|
|
DirSearchCompat bool
|
|
Extensions string
|
|
IgnoreWordlistComments bool
|
|
InputMode string
|
|
InputNum int
|
|
Inputcommands []string
|
|
Request string
|
|
RequestProto string
|
|
Wordlists []string
|
|
}
|
|
|
|
type OutputOptions struct {
|
|
DebugLog string
|
|
OutputDirectory string
|
|
OutputFile string
|
|
OutputFormat string
|
|
OutputCreateEmptyFile bool
|
|
}
|
|
|
|
type FilterOptions struct {
|
|
Lines string
|
|
Regexp string
|
|
Size string
|
|
Status string
|
|
Words string
|
|
}
|
|
|
|
type MatcherOptions struct {
|
|
Lines string
|
|
Regexp string
|
|
Size string
|
|
Status string
|
|
Words string
|
|
}
|
|
|
|
//NewConfigOptions returns a newly created ConfigOptions struct with default values
|
|
func NewConfigOptions() *ConfigOptions {
|
|
c := &ConfigOptions{}
|
|
c.Filter.Lines = ""
|
|
c.Filter.Regexp = ""
|
|
c.Filter.Size = ""
|
|
c.Filter.Status = ""
|
|
c.Filter.Words = ""
|
|
c.General.AutoCalibration = false
|
|
c.General.Colors = false
|
|
c.General.Delay = ""
|
|
c.General.MaxTime = 0
|
|
c.General.MaxTimeJob = 0
|
|
c.General.Quiet = false
|
|
c.General.Rate = 0
|
|
c.General.ShowVersion = false
|
|
c.General.StopOn403 = false
|
|
c.General.StopOnAll = false
|
|
c.General.StopOnErrors = false
|
|
c.General.Threads = 40
|
|
c.General.Verbose = false
|
|
c.HTTP.Data = ""
|
|
c.HTTP.FollowRedirects = false
|
|
c.HTTP.IgnoreBody = false
|
|
c.HTTP.Method = ""
|
|
c.HTTP.ProxyURL = ""
|
|
c.HTTP.Recursion = false
|
|
c.HTTP.RecursionDepth = 0
|
|
c.HTTP.ReplayProxyURL = ""
|
|
c.HTTP.Timeout = 10
|
|
c.HTTP.URL = ""
|
|
c.Input.DirSearchCompat = false
|
|
c.Input.Extensions = ""
|
|
c.Input.IgnoreWordlistComments = false
|
|
c.Input.InputMode = "clusterbomb"
|
|
c.Input.InputNum = 100
|
|
c.Input.Request = ""
|
|
c.Input.RequestProto = "https"
|
|
c.Matcher.Lines = ""
|
|
c.Matcher.Regexp = ""
|
|
c.Matcher.Size = ""
|
|
c.Matcher.Status = "200,204,301,302,307,401,403,405"
|
|
c.Matcher.Words = ""
|
|
c.Output.DebugLog = ""
|
|
c.Output.OutputDirectory = ""
|
|
c.Output.OutputFile = ""
|
|
c.Output.OutputFormat = "json"
|
|
c.Output.OutputCreateEmptyFile = false
|
|
return c
|
|
}
|
|
|
|
//ConfigFromOptions parses the values in ConfigOptions struct, ensures that the values are sane,
|
|
// and creates a Config struct out of them.
|
|
func ConfigFromOptions(parseOpts *ConfigOptions, ctx context.Context, cancel context.CancelFunc) (*Config, error) {
|
|
//TODO: refactor in a proper flag library that can handle things like required flags
|
|
errs := NewMultierror()
|
|
conf := NewConfig(ctx, cancel)
|
|
|
|
var err error
|
|
var err2 error
|
|
if len(parseOpts.HTTP.URL) == 0 && parseOpts.Input.Request == "" {
|
|
errs.Add(fmt.Errorf("-u flag or -request flag is required"))
|
|
}
|
|
|
|
// prepare extensions
|
|
if parseOpts.Input.Extensions != "" {
|
|
extensions := strings.Split(parseOpts.Input.Extensions, ",")
|
|
conf.Extensions = extensions
|
|
}
|
|
|
|
// Convert cookies to a header
|
|
if len(parseOpts.HTTP.Cookies) > 0 {
|
|
parseOpts.HTTP.Headers = append(parseOpts.HTTP.Headers, "Cookie: "+strings.Join(parseOpts.HTTP.Cookies, "; "))
|
|
}
|
|
|
|
//Prepare inputproviders
|
|
for _, v := range parseOpts.Input.Wordlists {
|
|
var wl []string
|
|
if runtime.GOOS == "windows" {
|
|
// Try to ensure that Windows file paths like C:\path\to\wordlist.txt:KEYWORD are treated properly
|
|
if FileExists(v) {
|
|
// The wordlist was supplied without a keyword parameter
|
|
wl = []string{v}
|
|
} else {
|
|
filepart := v
|
|
if strings.Contains(filepart, ":") {
|
|
filepart = v[:strings.LastIndex(filepart, ":")]
|
|
}
|
|
|
|
if FileExists(filepart) {
|
|
wl = []string{filepart, v[strings.LastIndex(v, ":")+1:]}
|
|
} else {
|
|
// The file was not found. Use full wordlist parameter value for more concise error message down the line
|
|
wl = []string{v}
|
|
}
|
|
}
|
|
} else {
|
|
wl = strings.SplitN(v, ":", 2)
|
|
}
|
|
if len(wl) == 2 {
|
|
conf.InputProviders = append(conf.InputProviders, InputProviderConfig{
|
|
Name: "wordlist",
|
|
Value: wl[0],
|
|
Keyword: wl[1],
|
|
})
|
|
} else {
|
|
conf.InputProviders = append(conf.InputProviders, InputProviderConfig{
|
|
Name: "wordlist",
|
|
Value: wl[0],
|
|
Keyword: "FUZZ",
|
|
})
|
|
}
|
|
}
|
|
for _, v := range parseOpts.Input.Inputcommands {
|
|
ic := strings.SplitN(v, ":", 2)
|
|
if len(ic) == 2 {
|
|
conf.InputProviders = append(conf.InputProviders, InputProviderConfig{
|
|
Name: "command",
|
|
Value: ic[0],
|
|
Keyword: ic[1],
|
|
})
|
|
conf.CommandKeywords = append(conf.CommandKeywords, ic[0])
|
|
} else {
|
|
conf.InputProviders = append(conf.InputProviders, InputProviderConfig{
|
|
Name: "command",
|
|
Value: ic[0],
|
|
Keyword: "FUZZ",
|
|
})
|
|
conf.CommandKeywords = append(conf.CommandKeywords, "FUZZ")
|
|
}
|
|
}
|
|
|
|
if len(conf.InputProviders) == 0 {
|
|
errs.Add(fmt.Errorf("Either -w or --input-cmd flag is required"))
|
|
}
|
|
|
|
// Prepare the request using body
|
|
if parseOpts.Input.Request != "" {
|
|
err := parseRawRequest(parseOpts, &conf)
|
|
if err != nil {
|
|
errmsg := fmt.Sprintf("Could not parse raw request: %s", err)
|
|
errs.Add(fmt.Errorf(errmsg))
|
|
}
|
|
}
|
|
|
|
//Prepare URL
|
|
if parseOpts.HTTP.URL != "" {
|
|
conf.Url = parseOpts.HTTP.URL
|
|
}
|
|
|
|
//Prepare headers and make canonical
|
|
for _, v := range parseOpts.HTTP.Headers {
|
|
hs := strings.SplitN(v, ":", 2)
|
|
if len(hs) == 2 {
|
|
// trim and make canonical
|
|
// except if used in custom defined header
|
|
var CanonicalNeeded = true
|
|
for _, a := range conf.CommandKeywords {
|
|
if a == hs[0] {
|
|
CanonicalNeeded = false
|
|
}
|
|
}
|
|
// check if part of InputProviders
|
|
if CanonicalNeeded {
|
|
for _, b := range conf.InputProviders {
|
|
if b.Keyword == hs[0] {
|
|
CanonicalNeeded = false
|
|
}
|
|
}
|
|
}
|
|
if CanonicalNeeded {
|
|
var CanonicalHeader = textproto.CanonicalMIMEHeaderKey(strings.TrimSpace(hs[0]))
|
|
conf.Headers[CanonicalHeader] = strings.TrimSpace(hs[1])
|
|
} else {
|
|
conf.Headers[strings.TrimSpace(hs[0])] = strings.TrimSpace(hs[1])
|
|
}
|
|
} else {
|
|
errs.Add(fmt.Errorf("Header defined by -H needs to have a value. \":\" should be used as a separator"))
|
|
}
|
|
}
|
|
|
|
//Prepare delay
|
|
d := strings.Split(parseOpts.General.Delay, "-")
|
|
if len(d) > 2 {
|
|
errs.Add(fmt.Errorf("Delay needs to be either a single float: \"0.1\" or a range of floats, delimited by dash: \"0.1-0.8\""))
|
|
} else if len(d) == 2 {
|
|
conf.Delay.IsRange = true
|
|
conf.Delay.HasDelay = true
|
|
conf.Delay.Min, err = strconv.ParseFloat(d[0], 64)
|
|
conf.Delay.Max, err2 = strconv.ParseFloat(d[1], 64)
|
|
if err != nil || err2 != nil {
|
|
errs.Add(fmt.Errorf("Delay range min and max values need to be valid floats. For example: 0.1-0.5"))
|
|
}
|
|
} else if len(parseOpts.General.Delay) > 0 {
|
|
conf.Delay.IsRange = false
|
|
conf.Delay.HasDelay = true
|
|
conf.Delay.Min, err = strconv.ParseFloat(parseOpts.General.Delay, 64)
|
|
if err != nil {
|
|
errs.Add(fmt.Errorf("Delay needs to be either a single float: \"0.1\" or a range of floats, delimited by dash: \"0.1-0.8\""))
|
|
}
|
|
}
|
|
|
|
// Verify proxy url format
|
|
if len(parseOpts.HTTP.ProxyURL) > 0 {
|
|
_, err := url.Parse(parseOpts.HTTP.ProxyURL)
|
|
if err != nil {
|
|
errs.Add(fmt.Errorf("Bad proxy url (-x) format: %s", err))
|
|
} else {
|
|
conf.ProxyURL = parseOpts.HTTP.ProxyURL
|
|
}
|
|
}
|
|
|
|
// Verify replayproxy url format
|
|
if len(parseOpts.HTTP.ReplayProxyURL) > 0 {
|
|
_, err := url.Parse(parseOpts.HTTP.ReplayProxyURL)
|
|
if err != nil {
|
|
errs.Add(fmt.Errorf("Bad replay-proxy url (-replay-proxy) format: %s", err))
|
|
} else {
|
|
conf.ReplayProxyURL = parseOpts.HTTP.ReplayProxyURL
|
|
}
|
|
}
|
|
|
|
//Check the output file format option
|
|
if parseOpts.Output.OutputFile != "" {
|
|
//No need to check / error out if output file isn't defined
|
|
outputFormats := []string{"all", "json", "ejson", "html", "md", "csv", "ecsv"}
|
|
found := false
|
|
for _, f := range outputFormats {
|
|
if f == parseOpts.Output.OutputFormat {
|
|
conf.OutputFormat = f
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
errs.Add(fmt.Errorf("Unknown output file format (-of): %s", parseOpts.Output.OutputFormat))
|
|
}
|
|
}
|
|
|
|
// Auto-calibration strings
|
|
if len(parseOpts.General.AutoCalibrationStrings) > 0 {
|
|
conf.AutoCalibrationStrings = parseOpts.General.AutoCalibrationStrings
|
|
}
|
|
// Using -acc implies -ac
|
|
if len(parseOpts.General.AutoCalibrationStrings) > 0 {
|
|
conf.AutoCalibration = true
|
|
}
|
|
|
|
if parseOpts.General.Rate < 0 {
|
|
conf.Rate = 0
|
|
} else {
|
|
conf.Rate = int64(parseOpts.General.Rate)
|
|
}
|
|
|
|
if conf.Method == "" {
|
|
if parseOpts.HTTP.Method == "" {
|
|
// Only set if defined on command line, because we might be reparsing the CLI after
|
|
// populating it through raw request in the first iteration
|
|
conf.Method = "GET"
|
|
} else {
|
|
conf.Method = parseOpts.HTTP.Method
|
|
}
|
|
} else {
|
|
if parseOpts.HTTP.Method != "" {
|
|
// Method overridden in CLI
|
|
conf.Method = parseOpts.HTTP.Method
|
|
}
|
|
}
|
|
|
|
if parseOpts.HTTP.Data != "" {
|
|
// Only set if defined on command line, because we might be reparsing the CLI after
|
|
// populating it through raw request in the first iteration
|
|
conf.Data = parseOpts.HTTP.Data
|
|
}
|
|
|
|
// Common stuff
|
|
conf.IgnoreWordlistComments = parseOpts.Input.IgnoreWordlistComments
|
|
conf.DirSearchCompat = parseOpts.Input.DirSearchCompat
|
|
conf.Colors = parseOpts.General.Colors
|
|
conf.InputNum = parseOpts.Input.InputNum
|
|
conf.InputMode = parseOpts.Input.InputMode
|
|
conf.OutputFile = parseOpts.Output.OutputFile
|
|
conf.OutputDirectory = parseOpts.Output.OutputDirectory
|
|
conf.OutputCreateEmptyFile = parseOpts.Output.OutputCreateEmptyFile
|
|
conf.IgnoreBody = parseOpts.HTTP.IgnoreBody
|
|
conf.Quiet = parseOpts.General.Quiet
|
|
conf.StopOn403 = parseOpts.General.StopOn403
|
|
conf.StopOnAll = parseOpts.General.StopOnAll
|
|
conf.StopOnErrors = parseOpts.General.StopOnErrors
|
|
conf.FollowRedirects = parseOpts.HTTP.FollowRedirects
|
|
conf.Recursion = parseOpts.HTTP.Recursion
|
|
conf.RecursionDepth = parseOpts.HTTP.RecursionDepth
|
|
conf.AutoCalibration = parseOpts.General.AutoCalibration
|
|
conf.Threads = parseOpts.General.Threads
|
|
conf.Timeout = parseOpts.HTTP.Timeout
|
|
conf.MaxTime = parseOpts.General.MaxTime
|
|
conf.MaxTimeJob = parseOpts.General.MaxTimeJob
|
|
conf.Verbose = parseOpts.General.Verbose
|
|
|
|
// Handle copy as curl situation where POST method is implied by --data flag. If method is set to anything but GET, NOOP
|
|
if len(conf.Data) > 0 &&
|
|
conf.Method == "GET" &&
|
|
//don't modify the method automatically if a request file is being used as input
|
|
len(parseOpts.Input.Request) == 0 {
|
|
|
|
conf.Method = "POST"
|
|
}
|
|
|
|
conf.CommandLine = strings.Join(os.Args, " ")
|
|
|
|
for _, provider := range conf.InputProviders {
|
|
if !keywordPresent(provider.Keyword, &conf) {
|
|
errmsg := fmt.Sprintf("Keyword %s defined, but not found in headers, method, URL or POST data.", provider.Keyword)
|
|
errs.Add(fmt.Errorf(errmsg))
|
|
}
|
|
}
|
|
|
|
// Do checks for recursion mode
|
|
if parseOpts.HTTP.Recursion {
|
|
if !strings.HasSuffix(conf.Url, "FUZZ") {
|
|
errmsg := "When using -recursion the URL (-u) must end with FUZZ keyword."
|
|
errs.Add(fmt.Errorf(errmsg))
|
|
}
|
|
}
|
|
return &conf, errs.ErrorOrNil()
|
|
}
|
|
|
|
func parseRawRequest(parseOpts *ConfigOptions, conf *Config) error {
|
|
file, err := os.Open(parseOpts.Input.Request)
|
|
if err != nil {
|
|
return fmt.Errorf("could not open request file: %s", err)
|
|
}
|
|
defer file.Close()
|
|
|
|
r := bufio.NewReader(file)
|
|
|
|
s, err := r.ReadString('\n')
|
|
if err != nil {
|
|
return fmt.Errorf("could not read request: %s", err)
|
|
}
|
|
parts := strings.Split(s, " ")
|
|
if len(parts) < 3 {
|
|
return fmt.Errorf("malformed request supplied")
|
|
}
|
|
// Set the request Method
|
|
conf.Method = parts[0]
|
|
|
|
for {
|
|
line, err := r.ReadString('\n')
|
|
line = strings.TrimSpace(line)
|
|
|
|
if err != nil || line == "" {
|
|
break
|
|
}
|
|
|
|
p := strings.SplitN(line, ":", 2)
|
|
if len(p) != 2 {
|
|
continue
|
|
}
|
|
|
|
if strings.EqualFold(p[0], "content-length") {
|
|
continue
|
|
}
|
|
|
|
conf.Headers[strings.TrimSpace(p[0])] = strings.TrimSpace(p[1])
|
|
}
|
|
|
|
// Handle case with the full http url in path. In that case,
|
|
// ignore any host header that we encounter and use the path as request URL
|
|
if strings.HasPrefix(parts[1], "http") {
|
|
parsed, err := url.Parse(parts[1])
|
|
if err != nil {
|
|
return fmt.Errorf("could not parse request URL: %s", err)
|
|
}
|
|
conf.Url = parts[1]
|
|
conf.Headers["Host"] = parsed.Host
|
|
} else {
|
|
// Build the request URL from the request
|
|
conf.Url = parseOpts.Input.RequestProto + "://" + conf.Headers["Host"] + parts[1]
|
|
}
|
|
|
|
// Set the request body
|
|
b, err := ioutil.ReadAll(r)
|
|
if err != nil {
|
|
return fmt.Errorf("could not read request body: %s", err)
|
|
}
|
|
conf.Data = string(b)
|
|
|
|
return nil
|
|
}
|
|
|
|
func keywordPresent(keyword string, conf *Config) bool {
|
|
//Search for keyword from HTTP method, URL and POST data too
|
|
if strings.Contains(conf.Method, keyword) {
|
|
return true
|
|
}
|
|
if strings.Contains(conf.Url, keyword) {
|
|
return true
|
|
}
|
|
if strings.Contains(conf.Data, keyword) {
|
|
return true
|
|
}
|
|
for k, v := range conf.Headers {
|
|
if strings.Contains(k, keyword) {
|
|
return true
|
|
}
|
|
if strings.Contains(v, keyword) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func ReadConfig(configFile string) (*ConfigOptions, error) {
|
|
conf := NewConfigOptions()
|
|
configData, err := ioutil.ReadFile(configFile)
|
|
if err == nil {
|
|
err = toml.Unmarshal(configData, conf)
|
|
}
|
|
return conf, err
|
|
}
|
|
|
|
func ReadDefaultConfig() (*ConfigOptions, error) {
|
|
userhome, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return NewConfigOptions(), err
|
|
}
|
|
defaultconf := filepath.Join(userhome, ".ffufrc")
|
|
return ReadConfig(defaultconf)
|
|
}
|