-
Notifications
You must be signed in to change notification settings - Fork 2.5k
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
Changes from 17 commits
75dd655
4a8f66f
03a3113
927525b
9592ec2
ddf2955
63c5fb3
ef6557e
a1af2d3
6e2ad5c
7a62bba
12d116b
57bede4
76b5131
cef7a5c
aeb4cad
f6926b5
311198f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 | ||
// 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 | ||
} |
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 ( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wouldn't it be more reliable to use There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
} |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.