nuclei/pkg/core/workflow_execute.go
Dwi Siswanto 169d88e3bc
fix(core): race cond in workflow execution
caused by shared context callbacks.

it was exposed after adding concurrent exec to
workflow processing and occurred when multiple
goroutines attempted to write to the same
`ctx.OnResult` callback field simultaneously,
causing data races during workflow template exec.

Signed-off-by: Dwi Siswanto <git@dw1.io>
2025-09-25 09:23:10 +07:00

179 lines
5.8 KiB
Go

package core
import (
"fmt"
"net/http/cookiejar"
"sync/atomic"
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/nuclei/v3/pkg/output"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
"github.com/projectdiscovery/nuclei/v3/pkg/scan"
"github.com/projectdiscovery/nuclei/v3/pkg/workflows"
syncutil "github.com/projectdiscovery/utils/sync"
)
const workflowStepExecutionError = "[%s] Could not execute workflow step: %s\n"
// executeWorkflow runs a workflow on an input and returns true or false
func (e *Engine) executeWorkflow(ctx *scan.ScanContext, w *workflows.Workflow) bool {
results := &atomic.Bool{}
// at this point we should be at the start root execution of a workflow tree, hence we create global shared instances
workflowCookieJar, _ := cookiejar.New(nil)
ctxArgs := contextargs.New(ctx.Context())
ctxArgs.MetaInput = ctx.Input.MetaInput
ctxArgs.CookieJar = workflowCookieJar
// we can know the nesting level only at runtime, so the best we can do here is increase template threads by one unit in case it's equal to 1 to allow
// at least one subtemplate to go through, which it's idempotent to one in-flight template as the parent one is in an idle state
templateThreads := w.Options.Options.TemplateThreads
if templateThreads == 1 {
templateThreads++
}
swg, _ := syncutil.New(syncutil.WithSize(templateThreads))
for _, template := range w.Workflows {
swg.Add()
go func(template *workflows.WorkflowTemplate) {
defer swg.Done()
newCtx := scan.NewScanContext(ctx.Context(), ctx.Input.Clone())
if err := e.runWorkflowStep(template, newCtx, results, swg, w); err != nil {
gologger.Warning().Msgf(workflowStepExecutionError, template.Template, err)
}
}(template)
}
swg.Wait()
return results.Load()
}
// runWorkflowStep runs a workflow step for the workflow. It executes the workflow
// in a recursive manner running all subtemplates and matchers.
func (e *Engine) runWorkflowStep(template *workflows.WorkflowTemplate, ctx *scan.ScanContext, results *atomic.Bool, swg *syncutil.AdaptiveWaitGroup, w *workflows.Workflow) error {
var firstMatched bool
var err error
var mainErr error
if len(template.Matchers) == 0 {
for _, executer := range template.Executers {
executer.Options.Progress.AddToTotal(int64(executer.Executer.Requests()))
// Don't print results with subtemplates, only print results on template.
if len(template.Subtemplates) > 0 {
ctx.OnResult = func(result *output.InternalWrappedEvent) {
if result.OperatorsResult == nil {
return
}
if len(result.Results) > 0 {
firstMatched = true
}
if result.OperatorsResult != nil && result.OperatorsResult.Extracts != nil {
for k, v := range result.OperatorsResult.Extracts {
// normalize items:
switch len(v) {
case 0, 1:
// - key:[item] => key: item
ctx.Input.Set(k, v[0])
default:
// - key:[item_0, ..., item_n] => key0:item_0, keyn:item_n
for vIdx, vVal := range v {
normalizedKIdx := fmt.Sprintf("%s%d", k, vIdx)
ctx.Input.Set(normalizedKIdx, vVal)
}
// also add the original name with full slice
ctx.Input.Set(k, v)
}
}
}
}
_, err = executer.Executer.ExecuteWithResults(ctx)
} else {
var matched bool
matched, err = executer.Executer.Execute(ctx)
if matched {
firstMatched = true
}
}
if w.Options.HostErrorsCache != nil {
w.Options.HostErrorsCache.MarkFailedOrRemove(w.Options.ProtocolType.String(), ctx.Input, err)
}
if err != nil {
if len(template.Executers) == 1 {
mainErr = err
} else {
gologger.Warning().Msgf(workflowStepExecutionError, template.Template, err)
}
continue
}
}
}
if len(template.Subtemplates) == 0 {
results.CompareAndSwap(false, firstMatched)
}
if len(template.Matchers) > 0 {
for _, executer := range template.Executers {
executer.Options.Progress.AddToTotal(int64(executer.Executer.Requests()))
ctx.OnResult = func(event *output.InternalWrappedEvent) {
if event.OperatorsResult == nil {
return
}
if event.OperatorsResult.Extracts != nil {
for k, v := range event.OperatorsResult.Extracts {
ctx.Input.Set(k, v)
}
}
for _, matcher := range template.Matchers {
if !matcher.Match(event.OperatorsResult) {
continue
}
for _, subtemplate := range matcher.Subtemplates {
swg.Add()
go func(subtemplate *workflows.WorkflowTemplate) {
defer swg.Done()
// create a new context with the same input but with unset callbacks
// clone the Input so that other parallel executions won't overwrite the shared variables when subsequent templates are running
subCtx := scan.NewScanContext(ctx.Context(), ctx.Input.Clone())
if err := e.runWorkflowStep(subtemplate, subCtx, results, swg, w); err != nil {
gologger.Warning().Msgf(workflowStepExecutionError, subtemplate.Template, err)
}
}(subtemplate)
}
}
}
_, err := executer.Executer.ExecuteWithResults(ctx)
if err != nil {
if len(template.Executers) == 1 {
mainErr = err
} else {
gologger.Warning().Msgf(workflowStepExecutionError, template.Template, err)
}
continue
}
}
return mainErr
}
if len(template.Subtemplates) > 0 && firstMatched {
for _, subtemplate := range template.Subtemplates {
swg.Add()
go func(template *workflows.WorkflowTemplate) {
// create a new context with the same input but with unset callbacks
subCtx := scan.NewScanContext(ctx.Context(), ctx.Input)
if err := e.runWorkflowStep(template, subCtx, results, swg, w); err != nil {
gologger.Warning().Msgf(workflowStepExecutionError, template.Template, err)
}
swg.Done()
}(subtemplate)
}
}
return mainErr
}