Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Added time based delay analyzer to fuzzing implementation #5781

Merged
merged 18 commits into from
Nov 19, 2024
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions pkg/fuzz/analyzers/analyzers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package analyzers

import (
"math/rand"
"strconv"
"strings"
"time"

"github.com/projectdiscovery/nuclei/v3/pkg/fuzz"
"github.com/projectdiscovery/retryablehttp-go"
)

// Analyzer is an interface for all the analyzers
// that can be used for the fuzzer
type Analyzer interface {
// Name returns the name of the analyzer
Name() string
// ApplyTransformation applies the transformation to the initial payload.
ApplyInitialTransformation(data string, params map[string]interface{}) string
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should separate this from the Analyzer, as the Analyzer is not a Transformer.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, we can separate Analyzer to another file to keep the code more clean.

// Analyze is the main function for the analyzer
Analyze(options *Options) (bool, string, error)
}

// AnalyzerTemplate is the template for the analyzer
type AnalyzerTemplate struct {
// description: |
// Name is the name of the analyzer to use
// values:
// - time_delay
Name string `json:"name" yaml:"name"`
// description: |
// Parameters is the parameters for the analyzer
//
// Parameters are different for each analyzer. For example, you can customize
// time_delay analyzer with sleep_duration, time_slope_error_range, etc. Refer
// to the docs for each analyzer to get an idea about parameters.
Parameters map[string]interface{} `json:"parameters" yaml:"parameters"`
}

var (
analyzers map[string]Analyzer
)

// RegisterAnalyzer registers a new analyzer
func RegisterAnalyzer(name string, analyzer Analyzer) {
analyzers[name] = analyzer
}

// GetAnalyzer returns the analyzer for a given name
func GetAnalyzer(name string) Analyzer {
return analyzers[name]
}

func init() {
analyzers = make(map[string]Analyzer)
}

// Options contains the options for the analyzer
type Options struct {
FuzzGenerated fuzz.GeneratedRequest
HttpClient *retryablehttp.Client
ResponseTimeDelay time.Duration
AnalyzerParameters map[string]interface{}
}

var (
random = rand.New(rand.NewSource(time.Now().UnixNano()))
)

// ApplyPayloadTransformations applies the payload transformations to the payload
// It supports the below payloads -
// - [RANDNUM] => random number between 1000 and 9999
// - [RANDSTR] => random string of 4 characters
func ApplyPayloadTransformations(value string) string {
randomInt := GetRandomInteger()
randomStr := randStringBytesMask(4)

value = strings.ReplaceAll(value, "[RANDNUM]", strconv.Itoa(randomInt))
value = strings.ReplaceAll(value, "[RANDSTR]", randomStr)
return value
}

const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
const (
letterIdxBits = 6 // 6 bits to represent a letter index
letterIdxMask = 1<<letterIdxBits - 1 // All 1-bits, as many as letterIdxBits
)

func randStringBytesMask(n int) string {
b := make([]byte, n)
for i := 0; i < n; {
if idx := int(random.Int63() & letterIdxMask); idx < len(letterBytes) {
b[i] = letterBytes[idx]
i++
}
}
return string(b)
}

// GetRandomInteger returns a random integer between 1000 and 9999
func GetRandomInteger() int {
return random.Intn(9000) + 1000
}
194 changes: 194 additions & 0 deletions pkg/fuzz/analyzers/time/analyzer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
package time

import (
"fmt"
"io"
"net/http/httptrace"
"strconv"
"strings"
"time"

"github.com/pkg/errors"
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/nuclei/v3/pkg/fuzz/analyzers"
"github.com/projectdiscovery/retryablehttp-go"
)

// Analyzer is a time delay analyzer for the fuzzer
type Analyzer struct{}

const (
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can add comments to explain why these are the default values --it's not obvious at first glance

DefaultSleepDuration = int(5)
DefaultRequestsLimit = int(4)
DefaultTimeCorrelationErrorRange = float64(0.15)
DefaultTimeSlopeErrorRange = float64(0.30)
DefaultTimeUnit = "seconds"

defaultSleepTimeDuration = 5 * time.Second
)

var _ analyzers.Analyzer = &Analyzer{}

func init() {
analyzers.RegisterAnalyzer("time_delay", &Analyzer{})
}

// Name is the name of the analyzer
func (a *Analyzer) Name() string {
return "time_delay"
}

// ApplyInitialTransformation applies the transformation to the initial payload.
//
// It supports the below payloads -
// - [SLEEPTIME] => sleep_duration
// - [INFERENCE] => Inference payload for time delay analyzer
//
// It also applies the payload transformations to the payload
// which includes [RANDNUM] and [RANDSTR]
func (a *Analyzer) ApplyInitialTransformation(data string, params map[string]interface{}) string {
duration := DefaultSleepDuration
if len(params) > 0 {
if v, ok := params["sleep_duration"]; ok {
duration, ok = v.(int)
if !ok {
duration = DefaultSleepDuration
gologger.Warning().Msgf("Invalid sleep_duration parameter type, using default value: %d", duration)
}
}
// Default unit is second. If we get passed milliseconds, multiply
if unit, ok := params["time_unit"]; ok {
duration = a.handleCustomTimeUnit(unit.(string), duration)
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't it be more reliable to use time.ParseDuration directly and skip having a custom handler for it? So we could remove the "time_unit" param entirely and make "sleep_duration" a string that gets parsed into time.Duration. What do you think, @Ice3man543?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, will make it like so.

}
data = strings.ReplaceAll(data, "[SLEEPTIME]", strconv.Itoa(duration))
data = analyzers.ApplyPayloadTransformations(data)

// Also support [INFERENCE] for the time delay analyzer
if strings.Contains(data, "[INFERENCE]") {
randInt := analyzers.GetRandomInteger()
data = strings.ReplaceAll(data, "[INFERENCE]", fmt.Sprintf("%d=%d", randInt, randInt))
}
return data
}

func (a *Analyzer) handleCustomTimeUnit(unit string, duration int) int {
switch unit {
case "milliseconds":
return duration * 1000
}
return duration
}

func (a *Analyzer) parseAnalyzerParameters(params map[string]interface{}) (int, int, float64, float64, string, error) {
requestsLimit := DefaultRequestsLimit
sleepDuration := DefaultSleepDuration
timeCorrelationErrorRange := DefaultTimeCorrelationErrorRange
timeSlopeErrorRange := DefaultTimeSlopeErrorRange
timeUnit := DefaultTimeUnit

if len(params) == 0 {
return requestsLimit, sleepDuration, timeCorrelationErrorRange, timeSlopeErrorRange, timeUnit, nil
}
var ok bool
for k, v := range params {
switch k {
case "time_unit":
timeUnit, ok = v.(string)
case "sleep_duration":
sleepDuration, ok = v.(int)
case "requests_limit":
requestsLimit, ok = v.(int)
case "time_correlation_error_range":
timeCorrelationErrorRange, ok = v.(float64)
case "time_slope_error_range":
timeSlopeErrorRange, ok = v.(float64)
}
if !ok {
return 0, 0, 0, 0, "", errors.Errorf("invalid parameter type for %s", k)
}
}
return requestsLimit, sleepDuration, timeCorrelationErrorRange, timeSlopeErrorRange, timeUnit, nil
}

// Analyze is the main function for the analyzer
func (a *Analyzer) Analyze(options *analyzers.Options) (bool, string, error) {
if options.ResponseTimeDelay < defaultSleepTimeDuration {
return false, "", nil
}

// Parse parameters for this analyzer if any or use default values
requestsLimit, sleepDuration, timeCorrelationErrorRange, timeSlopeErrorRange, customUnit, err :=
a.parseAnalyzerParameters(options.AnalyzerParameters)
if err != nil {
return false, "", err
}

// If custom unit is passed, handle it
if customUnit != DefaultTimeUnit {
sleepDuration = a.handleCustomTimeUnit(customUnit, sleepDuration)
}

reqSender := func(delay int) (float64, error) {
// If custom unit is passed, handle it
if customUnit != DefaultTimeUnit {
delay = a.handleCustomTimeUnit(customUnit, delay)
}

gr := options.FuzzGenerated
replaced := strings.ReplaceAll(gr.OriginalPayload, "[SLEEPTIME]", strconv.Itoa(delay))
replaced = a.ApplyInitialTransformation(replaced, options.AnalyzerParameters)

if err := gr.Component.SetValue(gr.Key, replaced); err != nil {
return 0, errors.Wrap(err, "could not set value in component")
}

rebuilt, err := gr.Component.Rebuild()
if err != nil {
return 0, errors.Wrap(err, "could not rebuild request")
}
gologger.Verbose().Msgf("[%s] Sending request with %d delay for: %s", a.Name(), delay, rebuilt.URL.String())

timeTaken, err := doHTTPRequestWithTimeTracing(rebuilt, options.HttpClient)
if err != nil {
return 0, errors.Wrap(err, "could not do request with time tracing")
}
return timeTaken, nil
}
matched, matchReason, err := checkTimingDependency(
requestsLimit,
sleepDuration,
timeCorrelationErrorRange,
timeSlopeErrorRange,
reqSender,
)
if err != nil {
return false, "", err
}
if matched {
return true, matchReason, nil
}
return false, "", nil
}

// doHTTPRequestWithTimeTracing does a http request with time tracing
func doHTTPRequestWithTimeTracing(req *retryablehttp.Request, httpclient *retryablehttp.Client) (float64, error) {
var ttfb time.Duration
var start time.Time

trace := &httptrace.ClientTrace{
GotFirstResponseByte: func() { ttfb = time.Since(start) },
}
req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace))
start = time.Now()
resp, err := httpclient.Do(req)
if err != nil {
return 0, errors.Wrap(err, "could not do request")
}

_, err = io.ReadAll(resp.Body)
if err != nil {
return 0, errors.Wrap(err, "could not read response body")
}
return ttfb.Seconds(), nil
}
Loading
Loading