New functionality to map fired blind payloads back to the initial request (#632)

* Fix ioutil deprecation and use xdg paths instead (wip)

* Clean up deprecated ioutil references, add config directory structure creation and run entry creation

* Add wordlist position setting and FFUFHASH variable

* Save full wordlist paths and print out a raw request when searched

* Cast from string to 32bit integer, 2billion should be enough for a position

* Use correct format strings for float
This commit is contained in:
Joona Hoikkala 2023-02-02 11:51:11 +02:00 committed by GitHub
parent b7adc5038d
commit 9bddff79b9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 578 additions and 154 deletions

View file

@ -1,7 +1,11 @@
## Changelog
- master
- New
- Added a new, dynamic keyword `FFUFHASH` that generates hash from job configuration and wordlist position to map blind payloads back to the initial request.
- New command line parameter for searching a hash: `-search FFUFHASH`
- Changed
- Multiline output prints out alphabetically sorted by keyword
- Default configuration directories now follow `XDG_CONFIG_HOME` variable (less spam in your home directory)
- Fixed issue with autocalibration of line & words filter
- Fixed issue with `-json` when used in conjunction with silent mode

5
go.mod
View file

@ -2,4 +2,7 @@ module github.com/ffuf/ffuf
go 1.13
require github.com/pelletier/go-toml v1.8.1
require (
github.com/adrg/xdg v0.4.0
github.com/pelletier/go-toml v1.8.1
)

15
go.sum
View file

@ -1,3 +1,18 @@
github.com/adrg/xdg v0.4.0 h1:RzRqFcjH4nE5C6oTAxhBtoE2IRyjBSa62SCbyPidvls=
github.com/adrg/xdg v0.4.0/go.mod h1:N6ag73EX4wyxeaoeHctc1mas01KZgsj5tYiAIwqJE/E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pelletier/go-toml v1.8.1 h1:1Nf83orprkJyknT6h7zbuEGUEjcyVlCxSUGTENmNCRM=
github.com/pelletier/go-toml v1.8.1/go.mod h1:T2/BmBdy8dvIRq1a/8aqjN41wvWlN4lrapLU/GW4pbc=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359 h1:2B5p2L5IfGiD7+b9BOoRMC6DgObAVZV+Fsp050NqXik=
golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -16,7 +16,7 @@ type UsageSection struct {
ExpectedFlags []string
}
//PrintSection prints out the section name, description and each of the flags
// PrintSection prints out the section name, description and each of the flags
func (u *UsageSection) PrintSection(max_length int, extended bool) {
// Do not print if extended usage not requested and section marked as hidden
if !extended && u.Hidden {
@ -35,7 +35,7 @@ type UsageFlag struct {
Default string
}
//PrintFlag prints out the flag name, usage string and default value
// PrintFlag prints out the flag name, usage string and default value
func (f *UsageFlag) PrintFlag(max_length int) {
// Create format string, used for padding
format := fmt.Sprintf(" -%%-%ds %%s", max_length)
@ -61,7 +61,7 @@ func Usage() {
Description: "",
Flags: make([]UsageFlag, 0),
Hidden: false,
ExpectedFlags: []string{"ac", "acc", "ack", "ach", "acs", "c", "config", "json", "maxtime", "maxtime-job", "noninteractive", "p", "rate", "s", "sa", "se", "sf", "t", "v", "V"},
ExpectedFlags: []string{"ac", "acc", "ack", "ach", "acs", "c", "config", "json", "maxtime", "maxtime-job", "noninteractive", "p", "rate", "search", "s", "sa", "se", "sf", "t", "v", "V"},
}
u_compat := UsageSection{
Name: "COMPATIBILITY OPTIONS",

70
main.go
View file

@ -4,17 +4,17 @@ import (
"context"
"flag"
"fmt"
"github.com/ffuf/ffuf/pkg/filter"
"io/ioutil"
"log"
"os"
"strings"
"github.com/ffuf/ffuf/pkg/ffuf"
"github.com/ffuf/ffuf/pkg/filter"
"github.com/ffuf/ffuf/pkg/input"
"github.com/ffuf/ffuf/pkg/interactive"
"github.com/ffuf/ffuf/pkg/output"
"github.com/ffuf/ffuf/pkg/runner"
"io"
"log"
"os"
"strings"
"time"
)
type multiStringFlag []string
@ -45,7 +45,7 @@ func (m *wordlistFlag) Set(value string) error {
return nil
}
//ParseFlags parses the command line flags and (re)populates the ConfigOptions struct
// ParseFlags parses the command line flags and (re)populates the ConfigOptions struct
func ParseFlags(opts *ffuf.ConfigOptions) *ffuf.ConfigOptions {
var ignored bool
var cookies, autocalibrationstrings, headers, inputcommands multiStringFlag
@ -96,6 +96,7 @@ func ParseFlags(opts *ffuf.ConfigOptions) *ffuf.ConfigOptions {
flag.StringVar(&opts.Filter.Time, "ft", opts.Filter.Time, "Filter by number of milliseconds to the first response byte, either greater or less than. EG: >100 or <100")
flag.StringVar(&opts.Filter.Words, "fw", opts.Filter.Words, "Filter by amount of words in response. Comma separated list of word counts and ranges")
flag.StringVar(&opts.General.Delay, "p", opts.General.Delay, "Seconds of `delay` between requests, or a range of random delay. For example \"0.1\" or \"0.1-2.0\"")
flag.StringVar(&opts.General.Searchhash, "search", opts.General.Searchhash, "Search for a FFUFHASH payload from ffuf history")
flag.StringVar(&opts.HTTP.Data, "d", opts.HTTP.Data, "POST data")
flag.StringVar(&opts.HTTP.Data, "data", opts.HTTP.Data, "POST data (alias of -d)")
flag.StringVar(&opts.HTTP.Data, "data-ascii", opts.HTTP.Data, "POST data (alias of -d)")
@ -142,13 +143,37 @@ func ParseFlags(opts *ffuf.ConfigOptions) *ffuf.ConfigOptions {
func main() {
var err, optserr error
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// prepare the default config options from default config file
var opts *ffuf.ConfigOptions
opts, optserr = ffuf.ReadDefaultConfig()
opts = ParseFlags(opts)
// Handle searchhash functionality and exit
if opts.General.Searchhash != "" {
coptions, pos, err := ffuf.SearchHash(opts.General.Searchhash)
if err != nil {
fmt.Printf("[ERR] %s\n", err)
os.Exit(1)
}
if len(coptions) > 0 {
fmt.Printf("Request candidate(s) for hash %s\n", opts.General.Searchhash)
}
for _, copt := range coptions {
conf, err := ffuf.ConfigFromOptions(&copt.ConfigOptions, ctx, cancel)
if err != nil {
continue
}
printSearchResults(conf, pos, copt.Time, opts.General.Searchhash)
}
if err != nil {
fmt.Printf("[ERR] %s\n", err)
}
os.Exit(0)
}
if opts.General.ShowVersion {
fmt.Printf("ffuf version: %s\n", ffuf.Version())
os.Exit(0)
@ -157,13 +182,13 @@ func main() {
f, err := os.OpenFile(opts.Output.DebugLog, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
fmt.Fprintf(os.Stderr, "Disabling logging, encountered error(s): %s\n", err)
log.SetOutput(ioutil.Discard)
log.SetOutput(io.Discard)
} else {
log.SetOutput(f)
defer f.Close()
}
} else {
log.SetOutput(ioutil.Discard)
log.SetOutput(io.Discard)
}
if optserr != nil {
log.Printf("Error while opening default config file: %s", optserr)
@ -183,9 +208,7 @@ func main() {
opts = ParseFlags(opts)
}
// Prepare context and set up Config struct
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Set up Config struct
conf, err := ffuf.ConfigFromOptions(opts, ctx, cancel)
if err != nil {
fmt.Fprintf(os.Stderr, "Encountered error(s): %s\n", err)
@ -193,6 +216,7 @@ func main() {
fmt.Fprintf(os.Stderr, "Encountered error(s): %s\n", err)
os.Exit(1)
}
job, err := prepareJob(conf)
if err != nil {
fmt.Fprintf(os.Stderr, "Encountered error(s): %s\n", err)
@ -335,3 +359,23 @@ func SetupFilters(parseOpts *ffuf.ConfigOptions, conf *ffuf.Config) error {
}
return errs.ErrorOrNil()
}
func printSearchResults(conf *ffuf.Config, pos int, exectime time.Time, hash string) {
inp, err := input.NewInputProvider(conf)
if err.ErrorOrNil() != nil {
fmt.Printf("-------------------------------------------\n")
fmt.Println("Encountered error that prevents reproduction of the request:")
fmt.Println(err.ErrorOrNil())
return
}
inp.SetPosition(pos)
inputdata := inp.Value()
inputdata["FFUFHASH"] = []byte(hash)
basereq := ffuf.BaseRequest(conf)
dummyrunner := runner.NewRunnerByName("simple", conf, false)
ffufreq, _ := dummyrunner.Prepare(inputdata, &basereq)
rawreq, _ := dummyrunner.Dump(&ffufreq)
fmt.Printf("-------------------------------------------\n")
fmt.Printf("ffuf job started at: %s\n\n", exectime.Format(time.RFC3339))
fmt.Printf("%s\n", string(rawreq))
}

View file

@ -17,6 +17,7 @@ type Config struct {
ConfigFile string `json:"configfile"`
Context context.Context `json:"-"`
Data string `json:"postdata"`
Debuglog string `json:"debuglog"`
Delay optRange `json:"delay"`
DirSearchCompat bool `json:"dirsearch_compatibility"`
Extensions []string `json:"extensions"`
@ -48,6 +49,8 @@ type Config struct {
RecursionDepth int `json:"recursion_depth"`
RecursionStrategy string `json:"recursion_strategy"`
ReplayProxyURL string `json:"replayproxyurl"`
RequestFile string `json:"requestfile"`
RequestProto string `json:"requestproto"`
SNI string `json:"sni"`
StopOn403 bool `json:"stop_403"`
StopOnAll bool `json:"stop_all"`
@ -56,6 +59,7 @@ type Config struct {
Timeout int `json:"timeout"`
Url string `json:"url"`
Verbose bool `json:"verbose"`
Wordlists []string `json:"wordlists"`
Http2 bool `json:"http2"`
}
@ -75,6 +79,7 @@ func NewConfig(ctx context.Context, cancel context.CancelFunc) Config {
conf.Context = ctx
conf.Cancel = cancel
conf.Data = ""
conf.Debuglog = ""
conf.Delay = optRange{0, 0, false, false}
conf.DirSearchCompat = false
conf.Extensions = make([]string, 0)
@ -99,6 +104,8 @@ func NewConfig(ctx context.Context, cancel context.CancelFunc) Config {
conf.Recursion = false
conf.RecursionDepth = 0
conf.RecursionStrategy = "default"
conf.RequestFile = ""
conf.RequestProto = "https"
conf.SNI = ""
conf.StopOn403 = false
conf.StopOnAll = false
@ -106,6 +113,7 @@ func NewConfig(ctx context.Context, cancel context.CancelFunc) Config {
conf.Timeout = 10
conf.Url = ""
conf.Verbose = false
conf.Wordlists = []string{}
conf.Http2 = false
return conf
}

View file

@ -0,0 +1,127 @@
package ffuf
import (
"fmt"
"strings"
)
func (c *Config) ToOptions() ConfigOptions {
o := ConfigOptions{}
// HTTP options
o.HTTP.Cookies = []string{}
o.HTTP.Data = c.Data
o.HTTP.FollowRedirects = c.FollowRedirects
o.HTTP.Headers = make([]string, 0)
for k, v := range c.Headers {
o.HTTP.Headers = append(o.HTTP.Headers, fmt.Sprintf("%s: %s", k, v))
}
o.HTTP.IgnoreBody = c.IgnoreBody
o.HTTP.Method = c.Method
o.HTTP.ProxyURL = c.ProxyURL
o.HTTP.Recursion = c.Recursion
o.HTTP.RecursionDepth = c.RecursionDepth
o.HTTP.RecursionStrategy = c.RecursionStrategy
o.HTTP.ReplayProxyURL = c.ReplayProxyURL
o.HTTP.SNI = c.SNI
o.HTTP.Timeout = c.Timeout
o.HTTP.URL = c.Url
o.HTTP.Http2 = c.Http2
o.General.AutoCalibration = c.AutoCalibration
o.General.AutoCalibrationKeyword = c.AutoCalibrationKeyword
o.General.AutoCalibrationPerHost = c.AutoCalibrationPerHost
o.General.AutoCalibrationStrategy = c.AutoCalibrationStrategy
o.General.AutoCalibrationStrings = c.AutoCalibrationStrings
o.General.Colors = c.Colors
o.General.ConfigFile = ""
if c.Delay.HasDelay {
if c.Delay.IsRange {
o.General.Delay = fmt.Sprintf("%.2f-%.2f", c.Delay.Min, c.Delay.Max)
} else {
o.General.Delay = fmt.Sprintf("%.2f", c.Delay.Min)
}
} else {
o.General.Delay = ""
}
o.General.Json = c.Json
o.General.MaxTime = c.MaxTime
o.General.MaxTimeJob = c.MaxTimeJob
o.General.Noninteractive = c.Noninteractive
o.General.Quiet = c.Quiet
o.General.Rate = int(c.Rate)
o.General.StopOn403 = c.StopOn403
o.General.StopOnAll = c.StopOnAll
o.General.StopOnErrors = c.StopOnErrors
o.General.Threads = c.Threads
o.General.Verbose = c.Verbose
o.Input.DirSearchCompat = c.DirSearchCompat
o.Input.Extensions = strings.Join(c.Extensions, ",")
o.Input.IgnoreWordlistComments = c.IgnoreWordlistComments
o.Input.InputMode = c.InputMode
o.Input.InputNum = c.InputNum
o.Input.InputShell = c.InputShell
o.Input.Inputcommands = []string{}
for _, v := range c.InputProviders {
if v.Name == "command" {
o.Input.Inputcommands = append(o.Input.Inputcommands, fmt.Sprintf("%s:%s", v.Value, v.Keyword))
}
}
o.Input.Request = c.RequestFile
o.Input.RequestProto = c.RequestProto
o.Input.Wordlists = c.Wordlists
o.Output.DebugLog = c.Debuglog
o.Output.OutputDirectory = c.OutputDirectory
o.Output.OutputFile = c.OutputFile
o.Output.OutputFormat = c.OutputFormat
o.Output.OutputSkipEmptyFile = c.OutputSkipEmptyFile
o.Filter.Mode = c.FilterMode
o.Filter.Lines = ""
o.Filter.Regexp = ""
o.Filter.Size = ""
o.Filter.Status = ""
o.Filter.Time = ""
o.Filter.Words = ""
for name, filter := range c.MatcherManager.GetFilters() {
switch name {
case "line":
o.Filter.Lines = filter.Repr()
case "regexp":
o.Filter.Regexp = filter.Repr()
case "size":
o.Filter.Size = filter.Repr()
case "status":
o.Filter.Status = filter.Repr()
case "time":
o.Filter.Time = filter.Repr()
case "words":
o.Filter.Words = filter.Repr()
}
}
o.Matcher.Mode = c.MatcherMode
o.Matcher.Lines = ""
o.Matcher.Regexp = ""
o.Matcher.Size = ""
o.Matcher.Status = ""
o.Matcher.Time = ""
o.Matcher.Words = ""
for name, filter := range c.MatcherManager.GetMatchers() {
switch name {
case "line":
o.Matcher.Lines = filter.Repr()
case "regexp":
o.Matcher.Regexp = filter.Repr()
case "size":
o.Matcher.Size = filter.Repr()
case "status":
o.Matcher.Status = filter.Repr()
case "time":
o.Matcher.Time = filter.Repr()
case "words":
o.Matcher.Words = filter.Repr()
}
}
return o
}

View file

@ -1,8 +1,15 @@
package ffuf
import (
"github.com/adrg/xdg"
"path/filepath"
)
var (
//VERSION holds the current version number
VERSION = "1.5.0"
//VERSION_APPENDIX holds additional version definition
VERSION_APPENDIX = "-dev"
CONFIGDIR = filepath.Join(xdg.ConfigHome, "ffuf")
HISTORYDIR = filepath.Join(CONFIGDIR, "history")
)

90
pkg/ffuf/history.go Normal file
View file

@ -0,0 +1,90 @@
package ffuf
import (
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
type ConfigOptionsHistory struct {
ConfigOptions
Time time.Time `json:"time"`
}
func WriteHistoryEntry(conf *Config) (string, error) {
options := ConfigOptionsHistory{
ConfigOptions: conf.ToOptions(),
Time: time.Now(),
}
jsonoptions, err := json.Marshal(options)
if err != nil {
return "", err
}
hashstr := calculateHistoryHash(jsonoptions)
err = createConfigDir(filepath.Join(HISTORYDIR, hashstr))
if err != nil {
return "", err
}
err = os.WriteFile(filepath.Join(HISTORYDIR, hashstr, "options"), jsonoptions, 0640)
return hashstr, err
}
func calculateHistoryHash(options []byte) string {
return fmt.Sprintf("%x", sha256.Sum256(options))
}
func SearchHash(hash string) ([]ConfigOptionsHistory, int, error) {
coptions := make([]ConfigOptionsHistory, 0)
if len(hash) < 6 {
return coptions, 0, errors.New("bad FFUFHASH value")
}
historypart := hash[0:5]
position, err := strconv.ParseInt(hash[5:], 16, 32)
if err != nil {
return coptions, 0, errors.New("bad positional value in FFUFHASH")
}
all_dirs, err := os.ReadDir(HISTORYDIR)
if err != nil {
return coptions, 0, err
}
matched_dirs := make([]string, 0)
for _, filename := range all_dirs {
if filename.IsDir() {
if strings.HasPrefix(strings.ToLower(filename.Name()), strings.ToLower(historypart)) {
matched_dirs = append(matched_dirs, filename.Name())
}
}
}
for _, dirname := range matched_dirs {
copts, err := configFromHistory(filepath.Join(HISTORYDIR, dirname))
if err != nil {
continue
}
coptions = append(coptions, copts)
}
return coptions, int(position), err
}
func configFromHistory(dirname string) (ConfigOptionsHistory, error) {
jsonOptions, err := os.ReadFile(filepath.Join(dirname, "options"))
if err != nil {
return ConfigOptionsHistory{}, err
}
tmpOptions := ConfigOptionsHistory{}
err = json.Unmarshal(jsonOptions, &tmpOptions)
return tmpOptions, err
/*
// These are dummy values for this use case
ctx, cancel := context.WithCancel(context.Background())
conf, err := ConfigFromOptions(&tmpOptions.ConfigOptions, ctx, cancel)
job.Input, errs = input.NewInputProvider(conf)
return conf, tmpOptions.Time, err
*/
}

View file

@ -1,8 +1,10 @@
package ffuf
import "time"
import (
"time"
)
//MatcherManager provides functions for managing matchers and filters
// MatcherManager provides functions for managing matchers and filters
type MatcherManager interface {
SetCalibrated(calibrated bool)
SetCalibratedForHost(host string, calibrated bool)
@ -17,36 +19,39 @@ type MatcherManager interface {
Calibrated() bool
}
//FilterProvider is a generic interface for both Matchers and Filters
// FilterProvider is a generic interface for both Matchers and Filters
type FilterProvider interface {
Filter(response *Response) (bool, error)
Repr() string
ReprVerbose() string
}
//RunnerProvider is an interface for request executors
// RunnerProvider is an interface for request executors
type RunnerProvider interface {
Prepare(input map[string][]byte, basereq *Request) (Request, error)
Execute(req *Request) (Response, error)
Dump(req *Request) ([]byte, error)
}
//InputProvider interface handles the input data for RunnerProvider
// InputProvider interface handles the input data for RunnerProvider
type InputProvider interface {
ActivateKeywords([]string)
AddProvider(InputProviderConfig) error
Keywords() []string
Next() bool
Position() int
SetPosition(int)
Reset()
Value() map[string][]byte
Total() int
}
//InternalInputProvider interface handles providing input data to InputProvider
// InternalInputProvider interface handles providing input data to InputProvider
type InternalInputProvider interface {
Keyword() string
Next() bool
Position() int
SetPosition(int)
ResetPosition()
IncrementPosition()
Value() []byte
@ -56,7 +61,7 @@ type InternalInputProvider interface {
Disable()
}
//OutputProvider is responsible of providing output from the RunnerProvider
// OutputProvider is responsible of providing output from the RunnerProvider
type OutputProvider interface {
Banner()
Finalize() error

View file

@ -19,6 +19,7 @@ type Job struct {
Runner RunnerProvider
ReplayRunner RunnerProvider
Output OutputProvider
Jobhash string
Counter int
ErrorCounter int
SpuriousErrorCounter int
@ -180,6 +181,7 @@ func (j *Job) prepareQueueJob() {
//And activate / disable inputproviders as needed
j.Input.ActivateKeywords(found_kws)
j.queuepos += 1
j.Jobhash, _ = WriteHistoryEntry(j.Config)
}
// SkipQueue allows to skip the current job and advance to the next queued recursion job
@ -255,6 +257,8 @@ func (j *Job) startExecution() {
<-j.Rate.RateLimiter.C
nextInput := j.Input.Value()
nextPosition := j.Input.Position()
// Add FFUFHASH and its value
nextInput["FFUFHASH"] = j.ffufHash(nextPosition)
wg.Add(1)
j.Counter++
@ -377,6 +381,16 @@ func (j *Job) isMatch(resp Response) bool {
return true
}
func (j *Job) ffufHash(pos int) []byte {
hashstring := ""
r := []rune(j.Jobhash)
if len(r) > 5 {
hashstring = string(r[:5])
}
hashstring += fmt.Sprintf("%x", pos)
return []byte(hashstring)
}
func (j *Job) runTask(input map[string][]byte, position int, retried bool) {
basereq := j.queuejobs[j.queuepos-1].req
req, err := j.Runner.Prepare(input, &basereq)

View file

@ -4,7 +4,7 @@ import (
"bufio"
"context"
"fmt"
"io/ioutil"
"io"
"net/textproto"
"net/url"
"os"
@ -17,97 +17,98 @@ import (
)
type ConfigOptions struct {
Filter FilterOptions
General GeneralOptions
HTTP HTTPOptions
Input InputOptions
Matcher MatcherOptions
Output OutputOptions
Filter FilterOptions `json:"filters"`
General GeneralOptions `json:"general"`
HTTP HTTPOptions `json:"http"`
Input InputOptions `json:"input"`
Matcher MatcherOptions `json:"matchers"`
Output OutputOptions `json:"output"`
}
type HTTPOptions struct {
Cookies []string
Data string
FollowRedirects bool
Headers []string
IgnoreBody bool
Method string
ProxyURL string
Recursion bool
RecursionDepth int
RecursionStrategy string
ReplayProxyURL string
SNI string
Timeout int
URL string
Http2 bool
Cookies []string `json:"-"` // this is appended in headers
Data string `json:"data"`
FollowRedirects bool `json:"follow_redirects"`
Headers []string `json:"headers"`
IgnoreBody bool `json:"ignore_body"`
Method string `json:"method"`
ProxyURL string `json:"proxy_url"`
Recursion bool `json:"recursion"`
RecursionDepth int `json:"recursion_depth"`
RecursionStrategy string `json:"recursion_strategy"`
ReplayProxyURL string `json:"replay_proxy_url"`
SNI string `json:"sni"`
Timeout int `json:"timeout"`
URL string `json:"url"`
Http2 bool `json:"http2"`
}
type GeneralOptions struct {
AutoCalibration bool
AutoCalibrationKeyword string
AutoCalibrationPerHost bool
AutoCalibrationStrategy string
AutoCalibrationStrings []string
Colors bool
ConfigFile string `toml:"-"`
Delay string
Json bool
MaxTime int
MaxTimeJob int
Noninteractive bool
Quiet bool
Rate int
ShowVersion bool `toml:"-"`
StopOn403 bool
StopOnAll bool
StopOnErrors bool
Threads int
Verbose bool
AutoCalibration bool `json:"autocalibration"`
AutoCalibrationKeyword string `json:"autocalibration_keyword"`
AutoCalibrationPerHost bool `json:"autocalibration_per_host"`
AutoCalibrationStrategy string `json:"autocalibration_strategy"`
AutoCalibrationStrings []string `json:"autocalibration_strings"`
Colors bool `json:"colors"`
ConfigFile string `toml:"-" json:"config_file"`
Delay string `json:"delay"`
Json bool `json:"json"`
MaxTime int `json:"maxtime"`
MaxTimeJob int `json:"maxtime_job"`
Noninteractive bool `json:"noninteractive"`
Quiet bool `json:"quiet"`
Rate int `json:"rate"`
Searchhash string `json:"-"`
ShowVersion bool `toml:"-" json:"-"`
StopOn403 bool `json:"stop_on_403"`
StopOnAll bool `json:"stop_on_all"`
StopOnErrors bool `json:"stop_on_errors"`
Threads int `json:"threads"`
Verbose bool `json:"verbose"`
}
type InputOptions struct {
DirSearchCompat bool
Extensions string
IgnoreWordlistComments bool
InputMode string
InputNum int
InputShell string
Inputcommands []string
Request string
RequestProto string
Wordlists []string
DirSearchCompat bool `json:"dirsearch_compat"`
Extensions string `json:"extensions"`
IgnoreWordlistComments bool `json:"ignore_wordlist_comments"`
InputMode string `json:"input_mode"`
InputNum int `json:"input_num"`
InputShell string `json:"input_shell"`
Inputcommands []string `json:"input_commands"`
Request string `json:"request_file"`
RequestProto string `json:"request_proto"`
Wordlists []string `json:"wordlists"`
}
type OutputOptions struct {
DebugLog string
OutputDirectory string
OutputFile string
OutputFormat string
OutputSkipEmptyFile bool
DebugLog string `json:"debug_log"`
OutputDirectory string `json:"output_directory"`
OutputFile string `json:"output_file"`
OutputFormat string `json:"output_format"`
OutputSkipEmptyFile bool `json:"output_skip_empty"`
}
type FilterOptions struct {
Mode string
Lines string
Regexp string
Size string
Status string
Time string
Words string
Mode string `json:"mode"`
Lines string `json:"lines"`
Regexp string `json:"regexp"`
Size string `json:"size"`
Status string `json:"status"`
Time string `json:"time"`
Words string `json:"words"`
}
type MatcherOptions struct {
Mode string
Lines string
Regexp string
Size string
Status string
Time string
Words string
Mode string `json:"mode"`
Lines string `json:"lines"`
Regexp string `json:"regexp"`
Size string `json:"size"`
Status string `json:"status"`
Time string `json:"time"`
Words string `json:"words"`
}
//NewConfigOptions returns a newly created ConfigOptions struct with default values
// NewConfigOptions returns a newly created ConfigOptions struct with default values
func NewConfigOptions() *ConfigOptions {
c := &ConfigOptions{}
c.Filter.Mode = "or"
@ -128,6 +129,7 @@ func NewConfigOptions() *ConfigOptions {
c.General.Noninteractive = false
c.General.Quiet = false
c.General.Rate = 0
c.General.Searchhash = ""
c.General.ShowVersion = false
c.General.StopOn403 = false
c.General.StopOnAll = false
@ -169,7 +171,7 @@ func NewConfigOptions() *ConfigOptions {
return c
}
//ConfigFromOptions parses the values in ConfigOptions struct, ensures that the values are sane,
// 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
@ -220,6 +222,7 @@ func ConfigFromOptions(parseOpts *ConfigOptions, ctx context.Context, cancel con
}
}
tmpWordlists := make([]string, 0)
for _, v := range parseOpts.Input.Wordlists {
var wl []string
if runtime.GOOS == "windows" {
@ -243,6 +246,11 @@ func ConfigFromOptions(parseOpts *ConfigOptions, ctx context.Context, cancel con
} else {
wl = strings.SplitN(v, ":", 2)
}
// Try to use absolute paths for wordlists
fullpath, err := filepath.Abs(wl[0])
if err == nil {
wl[0] = fullpath
}
if len(wl) == 2 {
if conf.InputMode == "sniper" {
errs.Add(fmt.Errorf("sniper mode does not support wordlist keywords"))
@ -261,7 +269,9 @@ func ConfigFromOptions(parseOpts *ConfigOptions, ctx context.Context, cancel con
Template: template,
})
}
tmpWordlists = append(tmpWordlists, strings.Join(wl, ":"))
}
conf.Wordlists = tmpWordlists
for _, v := range parseOpts.Input.Inputcommands {
ic := strings.SplitN(v, ":", 2)
@ -535,6 +545,8 @@ func ConfigFromOptions(parseOpts *ConfigOptions, ctx context.Context, cancel con
}
func parseRawRequest(parseOpts *ConfigOptions, conf *Config) error {
conf.RequestFile = parseOpts.Input.Request
conf.RequestProto = parseOpts.Input.RequestProto
file, err := os.Open(parseOpts.Input.Request)
if err != nil {
return fmt.Errorf("could not open request file: %s", err)
@ -589,7 +601,7 @@ func parseRawRequest(parseOpts *ConfigOptions, conf *Config) error {
}
// Set the request body
b, err := ioutil.ReadAll(r)
b, err := io.ReadAll(r)
if err != nil {
return fmt.Errorf("could not read request body: %s", err)
}
@ -669,7 +681,7 @@ func templatePresent(template string, conf *Config) bool {
func ReadConfig(configFile string) (*ConfigOptions, error) {
conf := NewConfigOptions()
configData, err := ioutil.ReadFile(configFile)
configData, err := os.ReadFile(configFile)
if err == nil {
err = toml.Unmarshal(configData, conf)
}
@ -677,10 +689,14 @@ func ReadConfig(configFile string) (*ConfigOptions, error) {
}
func ReadDefaultConfig() (*ConfigOptions, error) {
userhome, err := os.UserHomeDir()
if err != nil {
return NewConfigOptions(), err
// Try to create configuration directory, ignore the potential error
_ = CheckOrCreateConfigDir()
conffile := filepath.Join(CONFIGDIR, ".ffufrc")
if !FileExists(conffile) {
userhome, err := os.UserHomeDir()
if err == nil {
conffile = filepath.Join(userhome, ".ffufrc")
}
}
defaultconf := filepath.Join(userhome, ".ffufrc")
return ReadConfig(defaultconf)
return ReadConfig(conffile)
}

View file

@ -1,6 +1,7 @@
package ffuf
import (
"errors"
"fmt"
"math/rand"
"net/url"
@ -8,10 +9,10 @@ import (
"strings"
)
//used for random string generation in calibration function
// used for random string generation in calibration function
var chars = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
//RandomString returns a random string of length of parameter n
// RandomString returns a random string of length of parameter n
func RandomString(n int) string {
s := make([]rune, n)
for i := range s {
@ -20,7 +21,7 @@ func RandomString(n int) string {
return string(s)
}
//UniqStringSlice returns an unordered slice of unique strings. The duplicates are dropped
// UniqStringSlice returns an unordered slice of unique strings. The duplicates are dropped
func UniqStringSlice(inslice []string) []string {
found := map[string]bool{}
@ -34,8 +35,8 @@ func UniqStringSlice(inslice []string) []string {
return ret
}
//FileExists checks if the filepath exists and is not a directory.
//Returns false in case it's not possible to describe the named file.
// FileExists checks if the filepath exists and is not a directory.
// Returns false in case it's not possible to describe the named file.
func FileExists(path string) bool {
md, err := os.Stat(path)
if err != nil {
@ -45,7 +46,7 @@ func FileExists(path string) bool {
return !md.IsDir()
}
//RequestContainsKeyword checks if a keyword is present in any field of a request
// RequestContainsKeyword checks if a keyword is present in any field of a request
func RequestContainsKeyword(req Request, kw string) bool {
if strings.Contains(req.Host, kw) {
return true
@ -67,7 +68,7 @@ func RequestContainsKeyword(req Request, kw string) bool {
return false
}
//HostURLFromRequest gets a host + path without the filename or last part of the URL path
// HostURLFromRequest gets a host + path without the filename or last part of the URL path
func HostURLFromRequest(req Request) string {
u, _ := url.Parse(req.Url)
u.Host = req.Host
@ -76,7 +77,29 @@ func HostURLFromRequest(req Request) string {
return u.Host + trimpath
}
//Version returns the ffuf version string
// Version returns the ffuf version string
func Version() string {
return fmt.Sprintf("%s%s", VERSION, VERSION_APPENDIX)
}
func CheckOrCreateConfigDir() error {
var err error
err = createConfigDir(CONFIGDIR)
if err != nil {
return err
}
err = createConfigDir(HISTORYDIR)
return err
}
func createConfigDir(path string) error {
_, err := os.Stat(path)
if err != nil {
var pError *os.PathError
if errors.As(err, &pError) {
return os.MkdirAll(path, 0750)
}
return err
}
return nil
}

View file

@ -34,32 +34,37 @@ func NewCommandInput(keyword string, value string, conf *ffuf.Config) (*CommandI
return &cmd, nil
}
//Keyword returns the keyword assigned to this InternalInputProvider
// Keyword returns the keyword assigned to this InternalInputProvider
func (c *CommandInput) Keyword() string {
return c.keyword
}
//Position will return the current position in the input list
// Position will return the current position in the input list
func (c *CommandInput) Position() int {
return c.count
}
//ResetPosition will reset the current position of the InternalInputProvider
// SetPosition will set the current position of the inputprovider
func (c *CommandInput) SetPosition(pos int) {
c.count = pos
}
// ResetPosition will reset the current position of the InternalInputProvider
func (c *CommandInput) ResetPosition() {
c.count = 0
}
//IncrementPosition increments the current position in the inputprovider
// IncrementPosition increments the current position in the inputprovider
func (c *CommandInput) IncrementPosition() {
c.count += 1
}
//Next will increment the cursor position, and return a boolean telling if there's iterations left
// Next will increment the cursor position, and return a boolean telling if there's iterations left
func (c *CommandInput) Next() bool {
return c.count < c.config.InputNum
}
//Value returns the input from command stdoutput
// Value returns the input from command stdoutput
func (c *CommandInput) Value() []byte {
var stdout bytes.Buffer
os.Setenv("FFUF_NUM", strconv.Itoa(c.count))
@ -72,7 +77,7 @@ func (c *CommandInput) Value() []byte {
return stdout.Bytes()
}
//Total returns the size of wordlist
// Total returns the size of wordlist
func (c *CommandInput) Total() int {
return c.config.InputNum
}

View file

@ -62,12 +62,21 @@ func (i *MainInputProvider) ActivateKeywords(kws []string) {
}
}
//Position will return the current position of progress
// Position will return the current position of progress
func (i *MainInputProvider) Position() int {
return i.position
}
//Keywords returns a slice of all keywords in the inputprovider
// SetPosition will reset the MainInputProvider to a specific position
func (i *MainInputProvider) SetPosition(pos int) {
if i.Config.InputMode == "clusterbomb" || i.Config.InputMode == "sniper" {
i.setclusterbombPosition(pos)
} else {
i.setpitchforkPosition(pos)
}
}
// Keywords returns a slice of all keywords in the inputprovider
func (i *MainInputProvider) Keywords() []string {
kws := make([]string, 0)
for _, p := range i.Providers {
@ -76,7 +85,7 @@ func (i *MainInputProvider) Keywords() []string {
return kws
}
//Next will increment the cursor position, and return a boolean telling if there's inputs left
// Next will increment the cursor position, and return a boolean telling if there's inputs left
func (i *MainInputProvider) Next() bool {
if i.position >= i.Total() {
return false
@ -85,7 +94,7 @@ func (i *MainInputProvider) Next() bool {
return true
}
//Value returns a map of inputs for keywords
// Value returns a map of inputs for keywords
func (i *MainInputProvider) Value() map[string][]byte {
retval := make(map[string][]byte)
if i.Config.InputMode == "clusterbomb" || i.Config.InputMode == "sniper" {
@ -97,7 +106,7 @@ func (i *MainInputProvider) Value() map[string][]byte {
return retval
}
//Reset resets all the inputproviders and counters
// Reset resets all the inputproviders and counters
func (i *MainInputProvider) Reset() {
for _, p := range i.Providers {
p.ResetPosition()
@ -106,8 +115,8 @@ func (i *MainInputProvider) Reset() {
i.msbIterator = 0
}
//pitchforkValue returns a map of keyword:value pairs including all inputs.
//This mode will iterate through wordlists in lockstep.
// pitchforkValue returns a map of keyword:value pairs including all inputs.
// This mode will iterate through wordlists in lockstep.
func (i *MainInputProvider) pitchforkValue() map[string][]byte {
values := make(map[string][]byte)
for _, p := range i.Providers {
@ -125,8 +134,14 @@ func (i *MainInputProvider) pitchforkValue() map[string][]byte {
return values
}
//clusterbombValue returns map of keyword:value pairs including all inputs.
//this mode will iterate through all possible combinations.
func (i *MainInputProvider) setpitchforkPosition(pos int) {
for _, p := range i.Providers {
p.SetPosition(pos)
}
}
// clusterbombValue returns map of keyword:value pairs including all inputs.
// this mode will iterate through all possible combinations.
func (i *MainInputProvider) clusterbombValue() map[string][]byte {
values := make(map[string][]byte)
// Should we signal the next InputProvider in the slice to increment
@ -163,6 +178,18 @@ func (i *MainInputProvider) clusterbombValue() map[string][]byte {
return values
}
func (i *MainInputProvider) setclusterbombPosition(pos int) {
i.Reset()
if pos > i.Total() {
// noop
return
}
for i.position < pos-1 {
i.Next()
i.Value()
}
}
func (i *MainInputProvider) clusterbombIteratorReset() {
index := 0
for _, p := range i.Providers {
@ -179,7 +206,7 @@ func (i *MainInputProvider) clusterbombIteratorReset() {
}
}
//Total returns the amount of input combinations available
// Total returns the amount of input combinations available
func (i *MainInputProvider) Total() int {
count := 0
if i.Config.InputMode == "pitchfork" {
@ -204,7 +231,7 @@ func (i *MainInputProvider) Total() int {
return count
}
//sliceContains is a helper function that returns true if a string is included in a string slice
// sliceContains is a helper function that returns true if a string is included in a string slice
func sliceContains(sslice []string, str string) bool {
for _, v := range sslice {
if v == str {

View file

@ -42,57 +42,62 @@ func NewWordlistInput(keyword string, value string, conf *ffuf.Config) (*Wordlis
return &wl, err
}
//Position will return the current position in the input list
// Position will return the current position in the input list
func (w *WordlistInput) Position() int {
return w.position
}
//ResetPosition resets the position back to beginning of the wordlist.
// SetPosition sets the current position of the inputprovider
func (w *WordlistInput) SetPosition(pos int) {
w.position = pos
}
// ResetPosition resets the position back to beginning of the wordlist.
func (w *WordlistInput) ResetPosition() {
w.position = 0
}
//Keyword returns the keyword assigned to this InternalInputProvider
// Keyword returns the keyword assigned to this InternalInputProvider
func (w *WordlistInput) Keyword() string {
return w.keyword
}
//Next will return a boolean telling if there's words left in the list
// Next will return a boolean telling if there's words left in the list
func (w *WordlistInput) Next() bool {
return w.position < len(w.data)
}
//IncrementPosition will increment the current position in the inputprovider data slice
// IncrementPosition will increment the current position in the inputprovider data slice
func (w *WordlistInput) IncrementPosition() {
w.position += 1
}
//Value returns the value from wordlist at current cursor position
// Value returns the value from wordlist at current cursor position
func (w *WordlistInput) Value() []byte {
return w.data[w.position]
}
//Total returns the size of wordlist
// Total returns the size of wordlist
func (w *WordlistInput) Total() int {
return len(w.data)
}
//Active returns boolean if the inputprovider is active
// Active returns boolean if the inputprovider is active
func (w *WordlistInput) Active() bool {
return w.active
}
//Enable sets the inputprovider as active
// Enable sets the inputprovider as active
func (w *WordlistInput) Enable() {
w.active = true
}
//Disable disables the inputprovider
// Disable disables the inputprovider
func (w *WordlistInput) Disable() {
w.active = false
}
//validFile checks that the wordlist file exists and can be read
// validFile checks that the wordlist file exists and can be read
func (w *WordlistInput) validFile(path string) (bool, error) {
_, err := os.Stat(path)
if err != nil {
@ -106,7 +111,7 @@ func (w *WordlistInput) validFile(path string) (bool, error) {
return true, nil
}
//readFile reads the file line by line to a byte slice
// readFile reads the file line by line to a byte slice
func (w *WordlistInput) readFile(path string) error {
var file *os.File
var err error

View file

@ -2,7 +2,7 @@ package output
import (
"encoding/json"
"io/ioutil"
"os"
"time"
"github.com/ffuf/ffuf/pkg/ffuf"
@ -49,7 +49,7 @@ func writeEJSON(filename string, config *ffuf.Config, res []ffuf.Result) error {
if err != nil {
return err
}
err = ioutil.WriteFile(filename, outBytes, 0644)
err = os.WriteFile(filename, outBytes, 0644)
if err != nil {
return err
}
@ -89,7 +89,7 @@ func writeJSON(filename string, config *ffuf.Config, res []ffuf.Result) error {
if err != nil {
return err
}
err = ioutil.WriteFile(filename, outBytes, 0644)
err = os.WriteFile(filename, outBytes, 0644)
if err != nil {
return err
}

View file

@ -4,9 +4,9 @@ import (
"crypto/md5"
"encoding/json"
"fmt"
"io/ioutil"
"os"
"path"
"sort"
"strconv"
"strings"
"time"
@ -28,6 +28,7 @@ const (
type Stdoutput struct {
config *ffuf.Config
fuzzkeywords []string
Results []ffuf.Result
CurrentResults []ffuf.Result
}
@ -37,6 +38,11 @@ func NewStdoutput(conf *ffuf.Config) *Stdoutput {
outp.config = conf
outp.Results = make([]ffuf.Result, 0)
outp.CurrentResults = make([]ffuf.Result, 0)
outp.fuzzkeywords = make([]string, 0)
for _, ip := range conf.InputProviders {
outp.fuzzkeywords = append(outp.fuzzkeywords, ip.Keyword)
}
sort.Strings(outp.fuzzkeywords)
return &outp
}
@ -352,7 +358,7 @@ func (s *Stdoutput) writeResultToFile(resp ffuf.Response) string {
fileName = fmt.Sprintf("%x", md5.Sum([]byte(fileContent)))
filePath = path.Join(s.config.OutputDirectory, fileName)
err := ioutil.WriteFile(filePath, []byte(fileContent), 0640)
err := os.WriteFile(filePath, []byte(fileContent), 0640)
if err != nil {
s.Error(err.Error())
}
@ -416,13 +422,13 @@ func (s *Stdoutput) resultMultiline(res ffuf.Result) {
if res.ResultFile != "" {
reslines = fmt.Sprintf("%s%s| RES | %s\n", reslines, TERMINAL_CLEAR_LINE, res.ResultFile)
}
for k, v := range res.Input {
for _, k := range s.fuzzkeywords {
if inSlice(k, s.config.CommandKeywords) {
// If we're using external command for input, display the position instead of input
reslines = fmt.Sprintf(res_str, reslines, TERMINAL_CLEAR_LINE, k, strconv.Itoa(res.Position))
} else {
// Wordlist input
reslines = fmt.Sprintf(res_str, reslines, TERMINAL_CLEAR_LINE, k, v)
reslines = fmt.Sprintf(res_str, reslines, TERMINAL_CLEAR_LINE, k, res.Input[k])
}
}
fmt.Printf("%s\n%s\n", res_hdr, reslines)

View file

@ -4,7 +4,7 @@ import (
"bytes"
"crypto/tls"
"fmt"
"io/ioutil"
"io"
"net"
"net/http"
"net/http/httptrace"
@ -18,7 +18,7 @@ import (
"github.com/ffuf/ffuf/pkg/ffuf"
)
//Download results < 5MB
// Download results < 5MB
const MAX_DOWNLOAD_SIZE = 5242880
type SimpleRunner struct {
@ -47,7 +47,7 @@ func NewSimpleRunner(conf *ffuf.Config, replay bool) ffuf.RunnerProvider {
CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse },
Timeout: time.Duration(time.Duration(conf.Timeout) * time.Second),
Transport: &http.Transport{
ForceAttemptHTTP2: conf.Http2,
ForceAttemptHTTP2: conf.Http2,
Proxy: proxyURL,
MaxIdleConns: 1000,
MaxIdleConnsPerHost: 500,
@ -131,7 +131,6 @@ func (r *SimpleRunner) Execute(req *ffuf.Request) (ffuf.Response, error) {
if len(r.config.OutputDirectory) > 0 {
rawreq, _ = httputil.DumpRequestOut(httpreq, true)
}
httpresp, err := r.client.Do(httpreq)
if err != nil {
return ffuf.Response{}, err
@ -156,7 +155,7 @@ func (r *SimpleRunner) Execute(req *ffuf.Request) (ffuf.Response, error) {
resp.Raw = string(rawresp)
}
if respbody, err := ioutil.ReadAll(httpresp.Body); err == nil {
if respbody, err := io.ReadAll(httpresp.Body); err == nil {
resp.ContentLength = int64(len(string(respbody)))
resp.Data = respbody
}
@ -169,3 +168,29 @@ func (r *SimpleRunner) Execute(req *ffuf.Request) (ffuf.Response, error) {
return resp, nil
}
func (r *SimpleRunner) Dump(req *ffuf.Request) ([]byte, error) {
var httpreq *http.Request
var err error
data := bytes.NewReader(req.Data)
httpreq, err = http.NewRequestWithContext(r.config.Context, req.Method, req.Url, data)
if err != nil {
return []byte{}, err
}
// set default User-Agent header if not present
if _, ok := req.Headers["User-Agent"]; !ok {
req.Headers["User-Agent"] = fmt.Sprintf("%s v%s", "Fuzz Faster U Fool", ffuf.Version())
}
// Handle Go http.Request special cases
if _, ok := req.Headers["Host"]; ok {
httpreq.Host = req.Headers["Host"]
}
req.Host = httpreq.Host
for k, v := range req.Headers {
httpreq.Header.Set(k, v)
}
return httputil.DumpRequestOut(httpreq, true)
}