工具源码阅读之ffuf

前言

项目地址:https://github.com/ffuf/ffuf

项目结构

│  ├─ffuf // 核心模块
│  ├─filter // 内容过滤
│  ├─input // 输入处理
│  ├─interactive
│  ├─output // 结果输出,包括html、csv、json、markdown
│  └─runner // 启动任务
├─help.go // 帮助信息
└─main.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

  1. 调用BaseRequest方法获得Request请求对象,用于接下来的请求
  2. 判断输入模式是否为sniper模式(FUZZ),若是则调用SniperRequests函数返回特殊的Request对象切片;反之进行正常的请求
  3. 任务执行
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中手动标记区域后读取字典进行爆破

其针对不同的测试姿势提供了三种字典处理方式

pitchfork模式

在需要爆破的地方添加字典对应的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
}

clusterbomb模式

遍历所有字典对需要爆破的地方进行爆破,FUZZ区域标记方法与pitchfork模式相同

sniper模式

文件位置: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模式

参考文章一些实用的编程模式 | 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))

收获

  1. 使用Context控制任务生命周期,有助于实现优雅的停止任务
  2. 通过标签管理字典
  3. 使用漏桶算法控制请求速率

Reference