项目地址:https://github.com/ffuf/ffuf
│ ├─ffuf // 核心模块
│ ├─filter // 内容过滤
│ ├─input // 输入处理
│ ├─interactive
│ ├─output // 结果输出,包括html、csv、json、markdown
│ └─runner // 启动任务
├─help.go // 帮助信息
└─main.go
首先调用ffuf.ReadDefaultConfig()
方法构建ConfigOptions配置对象,然后解析命令行传入参数进行ConfigOptions字段内容覆盖
1
2
3
4
|
var opts *ffuf.ConfigOptions
opts, optserr = ffuf.ReadDefaultConfig()
opts = ParseFlags(opts)
|
若命令行指定了configfile参数则读取该文件进行配置项初始化工作,在这个过程中依然会重新解析命令行参数进行内容覆盖
1
2
3
4
5
6
7
8
9
10
11
12
13
|
if opts.General.ConfigFile != "" {
opts, err = ffuf.ReadConfig(opts.General.ConfigFile)
if err != nil {
fmt.Fprintf(os.Stderr, "Encoutered error(s): %s\n", err)
Usage()
fmt.Fprintf(os.Stderr, "Encoutered error(s): %s\n", err)
os.Exit(1)
}
// Reset the flag package state
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
// Re-parse the cli options
opts = ParseFlags(opts)
}
|
接着创建上下文对象,用于管理接下来的所有操作:
1
2
|
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
|
然后调用ConfigFromOptions方法通过Options结构建立Config结构并检查配置文件内容的合法性
根据注释可以猜测这部分代码是为了实现仿cli的命令行参数的处理
接着调用prepareJob函数初始化输入和输出操作:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
func prepareJob(conf *ffuf.Config) (*ffuf.Job, error) {
job := ffuf.NewJob(conf)
var errs ffuf.Multierror
job.Input, errs = input.NewInputProvider(conf)
// TODO: implement error handling for runnerprovider and outputprovider
// We only have http runner right now
job.Runner = runner.NewRunnerByName("http", conf, false)
// 若设置了重放代理URL则再创建一个runner来提供q
if len(conf.ReplayProxyURL) > 0 {
job.ReplayRunner = runner.NewRunnerByName("http", conf, true)
}
// We only have stdout outputprovider right now
job.Output = output.NewOutputProviderByName("stdout", conf)
return job, errs.ErrorOrNil()
}
|
首先调用NewJob根据config结构的相关配置进行对象初始化。
然后调用NewInputProvider方法初始化输入结构对象用于提供扫描内容与目标。接着通过NewRunnerByName函数初始化runner(任务执行者),目前该函数只封装了NewSimpleRunner函数(client请求函数),即只提供了发起http请求的runner,这点在注释中也阐明了。
接着调用NewOutputProviderByName方法进行即时输出,注释中写明只支持终端输出,不支持文件输出。
prepareJob函数执行完后调用SetupFilters方法设置请求匹配规则,规则通过配置或命令行参数进行指定。接着判断是否启动交互式模式,若启动则调用interactive.Handle(job)
方法,内部实现逻辑不感兴趣,直接跳过。在交互式判断之后调用job.Start()
开始任务执行
文件位置:pkg/ffuf/job.go
- 调用BaseRequest方法获得Request请求对象,用于接下来的请求
- 判断输入模式是否为sniper模式(FUZZ),若是则调用SniperRequests函数返回特殊的Request对象切片;反之进行正常的请求
- 任务执行
1
2
3
4
5
6
7
8
|
// Monitor for SIGTERM and do cleanup properly (writing the output files etc)
j.interruptMonitor()
for j.jobsInQueue() { // 判断Job队列是否为空
j.prepareQueueJob()
j.Reset(true)
j.RunningJob = true
j.startExecution()
}
|
interruptMonitor方法:顾名思义,该方法是拦截监听的功能,具体是监听Ctrl+C的按键信号,然后销毁Job任务队列,关闭Context,实现优雅停止全部任务
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
// 监听停止信号
func (j *Job) interruptMonitor() {
sigChan := make(chan os.Signal, 2)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
go func() {
for range sigChan {
j.Error = "Caught keyboard interrupt (Ctrl-C)\n"
// resume if paused
if j.Paused {
j.pauseWg.Done()
}
// Stop the job
j.Stop()
}
}()
}
//Stop the execution of the Job
func (j *Job) Stop() {
j.Running = false
j.Config.Cancel()
}
|
prepareQueueJob方法:首先获取Keywords
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
// job.go
func (j *Job) prepareQueueJob() {
j.Config.Url = j.queuejobs[j.queuepos].Url
j.currentDepth = j.queuejobs[j.queuepos].depth
//Find all keywords present in new queued job
kws := j.Input.Keywords()
found_kws := make([]string, 0)
for _, k := range kws {
if RequestContainsKeyword(j.queuejobs[j.queuepos].req, k) {
found_kws = append(found_kws, k)
}
}
//And activate / disable inputproviders as needed
j.Input.ActivateKeywords(found_kws)
j.queuepos += 1
}
|
然后通过RequestContainsKeyword函数判断keyword是否已经被用于创建过相关Request对象,判断标准为检测位置是否出现了相关keyword
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
// util.go
//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
}
if strings.Contains(req.Url, kw) {
return true
}
if strings.Contains(req.Method, kw) {
return true
}
if strings.Contains(string(req.Data), kw) {
return true
}
for k, v := range req.Headers {
if strings.Contains(k, kw) || strings.Contains(v, kw) {
return true
}
}
return false
}
|
若创建过则将keyword添加进结果切片中,然后调用ActivateKeywords方法进行关键字活跃度检测,只有出现在已创建的Request对象中才标记为active
1
2
3
4
5
6
7
8
9
10
11
|
// input.go
// ActivateKeywords enables / disables wordlists based on list of active keywords
func (i *MainInputProvider) ActivateKeywords(kws []string) {
for _, p := range i.Providers {
if sliceContains(kws, p.Keyword()) {
p.Active()
} else {
p.Disable()
}
}
}
|
Reset(true)方法:调用j.Input.Reset()
方法重置字典位置,然后启动任务计时器和计数器,若开启cycle选项则将结果添加进Stdoutput对象的全局变量Results切片中;若不开启该选项则调用j.Output.Reset()
方法清空所有数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
// job.go
func (j *Job) Reset(cycle bool) {
j.Input.Reset()
j.Counter = 0
j.skipQueue = false
j.startTimeJob = time.Now()
if cycle {
j.Output.Cycle()
} else {
j.Output.Reset()
}
}
// stdout.go
func (s *Stdoutput) Reset() {
s.CurrentResults = make([]ffuf.Result, 0)
}
func (s *Stdoutput) Cycle() {
s.Results = append(s.Results, s.CurrentResults...)
s.Reset()
}
|
startExecution方法:首先定义sync.WaitGroup变量用于控制并发执行,然后启动一个协程执行j.runBackgroundTasks(&wg)
方法,该方法用于执行任务并实时更新进度,然后输出。除此之外,该函数还通过j.Rate.Adjust()
方法进行发包速率调节,接着通过time.Sleep实现有间隔的请求
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
func (j *Job) runBackgroundTasks(wg *sync.WaitGroup) {
defer wg.Done()
totalProgress := j.Input.Total() // 总输入数量
for j.Counter <= totalProgress && !j.skipQueue {
j.pauseWg.Wait()
if !j.Running { // 是否在运行
break
}
j.updateProgress() // 更新进度,用于输出
if j.Counter == totalProgress {
return
}
if !j.RunningJob {
return
}
j.Rate.Adjust()
// 任务执行间隔一段时间,防ban IP
time.Sleep(time.Millisecond * time.Duration(j.Config.ProgressFrequency))
}
}
func (j *Job) updateProgress() {
prog := Progress{
StartedAt: j.startTimeJob,
ReqCount: j.Counter,
ReqTotal: j.Input.Total(),
ReqSec: j.Rate.CurrentRate(),
QueuePos: j.queuepos,
QueueTotal: len(j.queuejobs),
ErrorCount: j.ErrorCounter,
}
j.Output.Progress(prog)
}
|
回到startExecution方法,在经过基本信息打印后,首先调用了j.CheckStop()
方法,该方法监听了各种程序运行的限制,限制达到就终止程序执行。若j.Running=true,即不退出,则进行任务执行相关信息的更新和任务执行速率的调整(速率调整应该是自实现了漏桶算法使rate维持在指定区间)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
|
func (j *Job) startExecution() {
//...
//Limiter blocks after reaching the buffer, ensuring limited concurrency
limiter := make(chan bool, j.Config.Threads)
for j.Input.Next() && !j.skipQueue {
// Check if we should stop the process
j.CheckStop()
if !j.Running {
defer j.Output.Warning(j.Error)
break
}
j.pauseWg.Wait()
limiter <- true
nextInput := j.Input.Value()
nextPosition := j.Input.Position()
wg.Add(1)
j.Counter++
go func() {
defer func() { <-limiter }()
defer wg.Done()
threadStart := time.Now()
j.runTask(nextInput, nextPosition, false)
j.sleepIfNeeded()
j.Rate.Throttle() // 若速率过快则调用time.Sleep
threadEnd := time.Now()
j.Rate.Tick(threadStart, threadEnd) // 重新计算速率
}()
if !j.RunningJob {
defer j.Output.Warning(j.Error)
return
}
}
wg.Wait()
j.updateProgress()
}
|
ffuf支持多种位置测试:
- method
- url
- post data
- headers【key+value】
PS:代码中没有找到具体关于HOST爆破的内容,应该是在header中手动标记区域后读取字典进行爆破
其针对不同的测试姿势提供了三种字典处理方式
在需要爆破的地方添加字典对应的tab,如http://127.0.0.1/PHP
即为爆破PHP对应的字典内容
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
//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 {
if !p.Active() {
// The inputprovider is disabled
continue
}
if !p.Next() {
// Loop to beginning if the inputprovider has been exhausted
p.ResetPosition()
}
values[p.Keyword()] = p.Value() // 运行时赋值与爆破
p.IncrementPosition()
}
return values
}
|
遍历所有字典对需要爆破的地方进行爆破,FUZZ区域标记方法与pitchfork模式相同
文件位置:pkg/ffuf/request.go
sniper模式只支持单文件字典,当wordlists配置项中内容超过1时会报错:sniper mode only supports one wordlist
此外它也不支持有关键字标识的字典,否则也会报错:sniper mode does not support wordlist keywords
因此在使用时要把wordlists配置项的其他字典全部注释掉,然后只使用该模式需要的无keyword标识的字典
到了这里就有了疑问:sniper模式与clusterbomb模式都是读取所有字典进行遍历,那为什么要这么多此一举搞一个新的模式,还整这么多限制呢?
首先谈一下两者的小区别:FUZZ区域标记符号不同,在optionsparser.go第211行代码可见
1
2
3
4
5
|
// sniper mode needs some additional checking
if conf.InputMode == "sniper" {
template = "§"
//...
}
|
template变量赋值为§
用于标识FUZZ区域,通过对某些字段插入一对§
后标识,如url项应该设置为https://example.org/§§
,到这里和clusterbomb模式的工作原理没什么区别。
接下来说明两者实质上的区别:关注一个配置项:recursion = true
,开启该配置项后会启动FUZZ关键字的识别,可以通过在url后缀额外添加FUZZ关键字标记测试区域(仅支持该位置)
1
2
3
4
5
6
7
8
|
// optionsparser.go
// 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))
}
}
|
注:该模式只是将sniper的字典搭配该模式的模板符号§
重复进行组合扫描,并不会开启tag识别的功能
通过该功能可以实现字典的组合fuzz,不过我暂时还没有想到什么场景下需要这样的一种fuzz方式
虽然golang不支持函数重载,但ffuf通过定义不同名的函数来实现相同效果
不得不说,这种方法还是很“丑陋”的
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
func NewRequest(conf *Config) Request {
var req Request
req.Method = conf.Method
req.Url = conf.Url
req.Headers = make(map[string]string)
return req
}
// BaseRequest returns a base request struct populated from the main config
func BaseRequest(conf *Config) Request {
req := NewRequest(conf)
req.Headers = conf.Headers
req.Data = []byte(conf.Data)
return req
}
// RecursionRequest returns a base request for a recursion target
func RecursionRequest(conf *Config, path string) Request {
r := BaseRequest(conf)
r.Url = path
return r
}
|
定义了Multierror结构用于存储多个error,其实现了Add和ErrorOrNil方法,前者顾名思义,后者用于统一处理收集的errors
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
type Multierror struct {
errors []error
}
//NewMultierror returns a new Multierror
func NewMultierror() Multierror {
return Multierror{}
}
func (m *Multierror) Add(err error) {
m.errors = append(m.errors, err)
}
func (m *Multierror) ErrorOrNil() error {
var errString string
if len(m.errors) > 0 {
errString += fmt.Sprintf("%d errors occured.\n", len(m.errors))
for _, e := range m.errors {
errString += fmt.Sprintf("\t* %s\n", e)
}
return fmt.Errorf("%s", errString)
}
return nil
}
|
这样可以很方便进行统一的错误管理并return,不过当某些error会影响到接下来的代码执行时还是应该老实if+return
- 默认UA:runner/simple.go中UA缺失的时候会自动填充
Fuzz Faster U Fool v版本号
,可以将该段删除然后在配置文件中设置,或者直接内插一段UA列表,每次随机选取
- 通过上面可知启动sniper模式时若wordlists配置项存在tag绑定字典时会报错,可以将这部分逻辑修改为读取该配置项时优先读取无tag标识的,这样能省重复注释的杂活(当然也可以直接通过
-w
指定)
- 定义的切片去重函数UniqStringSlice中使用了
map[string]bool
类型,但实际上有更省内存的实现,即使用struct{}零内存占用代替bool那一点点的消耗
- 输出JSON格式很混乱,可以使用
json.MarshalIndent(outJSON, "", " ")
使输出美观一些(虽然json输出本身就不是很方便)
参考文章一些实用的编程模式 | Options模式可知可以通过可变参数实现函数重载的效果,代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
// option.go
type reqOptions struct {
apply func(reqq *Request)
}
func WithHeaders(headers map[string]string) *reqOptions {
return &reqOptions{
apply: func(reqq *Request) {
reqq.Headers = headers
},
}
}
func WithData(data string) *reqOptions {
return &reqOptions{
apply: func(reqq *Request) {
reqq.Data = []byte(data)
},
}
}
// use
req.BaseRequest(j.Config.Method, j.Config.Url, req.WithHeaders(j.Config.Headers), req.WithData(j.Config.Data))
|
- 使用Context控制任务生命周期,有助于实现优雅的停止任务
- 通过标签管理字典
- 使用漏桶算法控制请求速率