CVE-2026-41645
Description
Nuclei is a vulnerability scanner built on a simple YAML-based DSL. From version 3.0.0 to before version 3.8.0, a vulnerability in Nuclei's expression evaluation engine makes it possible for a malicious target server to inject and execute supported DSL expressions. This happens when HTTP response data containing helper/function syntax gets reused by multi-step templates. If the -env-vars / -ev option is explicitly enabled, this can expose host environment variables. That option is off by default, so standard configurations are not affected by the information disclosure risk. This issue has been patched in version 3.8.0.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/projectdiscovery/nuclei/v3Go | >= 3.0.0, < 3.8.0 | 3.8.0 |
Affected products
2<3.8.0+ 1 more
- (no CPE)range: <3.8.0
- cpe:2.3:a:projectdiscovery:nuclei:*:*:*:*:*:go:*:*range: >=3.0.0,<3.8.0
Patches
26c803c74d193fix(expressions): avoid helper eval in literal checks (#7321)
6 files changed · +142 −17
pkg/protocols/common/expressions/expressions.go+25 −13 modified@@ -1,10 +1,10 @@ package expressions import ( + "fmt" "strings" "github.com/Knetic/govaluate" - "github.com/projectdiscovery/gologger" "github.com/projectdiscovery/nuclei/v3/pkg/operators/common/dsl" "github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/marker" @@ -53,28 +53,40 @@ func evaluate(data string, base map[string]interface{}) (string, error) { // - complex: containing helper functions [ + variables] // literals like {{2+2}} are not considered expressions for _, expression := range expressions { + originalExpression := expression // replace variable placeholders with base values expression = replacer.Replace(expression, base) + // turns expressions (either helper functions+base values or base values) compiled, err := govaluate.NewEvaluableExpressionWithFunctions(expression, dsl.HelperFunctions) if err != nil { - gologger.Warning().Msgf("Failed to compile expression '%s': %v", expression, err) - continue - } - // propagate unresolved {{...}} markers from variable values so the - // downstream ContainsUnresolvedVariables check can detect them instead - // of having encoding functions (e.g. base64) hide them - if markers := unresolvedVarMarkers(compiled.Vars(), base); markers != "" { - data = replacer.ReplaceOne(data, expression, markers) - continue + return data, fmt.Errorf("failed to compile expression %q: %w", originalExpression, err) } + result, err := compiled.Evaluate(base) if err != nil { - gologger.Warning().Msgf("Failed to evaluate expression '%s': %v", expression, err) - continue + return data, fmt.Errorf("failed to evaluate expression %q: %w", originalExpression, err) } + + replacement := result + // Preserve unresolved markers only when a helper call would otherwise + // hide them from downstream validation. Plain expressions such as + // comparisons should evaluate normally. + if markers := unresolvedVarMarkers(compiled.Vars(), base); markers != "" { + usesFunctions := false + for _, token := range compiled.Tokens() { + if token.Kind == govaluate.FUNCTION { + usesFunctions = true + break + } + } + if usesFunctions && ContainsUnresolvedVariables(fmt.Sprint(result)) == nil { + replacement = markers + } + } + // replace incrementally - data = replacer.ReplaceOne(data, expression, result) + data = replacer.ReplaceOne(data, expression, replacement) } return data, nil }
pkg/protocols/common/expressions/expressions_test.go+47 −0 modified@@ -105,3 +105,50 @@ func TestEvaluateDoesNotReinterpretResolvedValues(t *testing.T) { }) } } + +func TestEvaluateDoesNotExecuteHelpersFromResolvedValues(t *testing.T) { + var calls int + + withTestHelperFunction(t, "test_side_effect", func(args ...interface{}) (interface{}, error) { + calls++ + return "ok", nil + }) + + value, err := Evaluate("{{body}}", map[string]interface{}{ + "body": "{{test_side_effect(1)}}", + }) + require.NoError(t, err) + require.Equal(t, "{{test_side_effect(1)}}", value) + require.Zero(t, calls) +} + +func TestEvaluateReturnsErrorForInvalidTemplateExpression(t *testing.T) { + _, err := Evaluate("{{base64()}}", map[string]interface{}{}) + require.Error(t, err) + require.ErrorContains(t, err, `failed to evaluate expression "base64()"`) +} + +func TestEvaluateErrorDoesNotLeakResolvedValues(t *testing.T) { + _, err := Evaluate("{{base64('{{secret_token}}', 'extra')}}", map[string]interface{}{ + "secret_token": "top-secret-cia-mi6-kgb-mossad-classified", + }) + require.Error(t, err) + require.ErrorContains(t, err, `failed to evaluate expression "base64('{{secret_token}}', 'extra')"`) + require.NotContains(t, err.Error(), "top-secret-cia-mi6-kgb-mossad-classified") +} + +func TestEvaluatePlainExpressionsWithMarkerLikeValues(t *testing.T) { + value, err := Evaluate("{{body != ''}}", map[string]interface{}{ + "body": "{{contact_id}}", + }) + require.NoError(t, err) + require.Equal(t, "true", value) +} + +func TestEvaluatePreservesVisibleMarkersFromHelperResults(t *testing.T) { + value, err := Evaluate("{{concat(body, '-x')}}", map[string]interface{}{ + "body": "{{contact_id}}", + }) + require.NoError(t, err) + require.Equal(t, "{{contact_id}}-x", value) +}
pkg/protocols/common/expressions/variables.go+5 −4 modified@@ -120,9 +120,10 @@ func hasLiteralsOnly(data string) bool { if err != nil { return false } - if expr != nil { - _, err = expr.Evaluate(nil) - return err == nil + + if expr == nil { + return true } - return true + + return len(expr.Vars()) == 0 }
pkg/protocols/common/expressions/variables_test.go+29 −0 modified@@ -4,9 +4,26 @@ import ( "errors" "testing" + "github.com/Knetic/govaluate" + "github.com/projectdiscovery/nuclei/v3/pkg/operators/common/dsl" "github.com/stretchr/testify/require" ) +func withTestHelperFunction(t *testing.T, name string, fn govaluate.ExpressionFunction) { + t.Helper() + + originalFn, hadFn := dsl.HelperFunctions[name] + dsl.HelperFunctions[name] = fn + + t.Cleanup(func() { + if hadFn { + dsl.HelperFunctions[name] = originalFn + return + } + delete(dsl.HelperFunctions, name) + }) +} + func TestUnresolvedVariablesCheck(t *testing.T) { tests := []struct { data string @@ -26,3 +43,15 @@ func TestUnresolvedVariablesCheck(t *testing.T) { require.Equal(t, test.err, err, "could not get unresolved variables") } } + +func TestUnresolvedVariablesCheckDoesNotExecuteHelpers(t *testing.T) { + var calls int + withTestHelperFunction(t, "test_side_effect", func(args ...interface{}) (interface{}, error) { + calls++ + return "ok", nil + }) + + err := ContainsUnresolvedVariables("{{test_side_effect(1)}}") + require.NoError(t, err) + require.Zero(t, calls) +}
pkg/protocols/javascript/js.go+1 −0 modified@@ -701,6 +701,7 @@ func (request *Request) getArgsCopy(input *contextargs.Context, payloadValues ma if err != nil { requestOptions.Output.Request(requestOptions.TemplateID, input.MetaInput.Input, request.Type().String(), err) requestOptions.Progress.IncrementFailedRequestsBy(1) + return nil, err } // "Port" is a special variable that is considered as network port // and is conditional based on input port and default port specified in input
pkg/protocols/javascript/js_test.go+35 −0 modified@@ -9,8 +9,11 @@ import ( "github.com/projectdiscovery/nuclei/v3/pkg/catalog/config" "github.com/projectdiscovery/nuclei/v3/pkg/catalog/disk" "github.com/projectdiscovery/nuclei/v3/pkg/loader/workflow" + "github.com/projectdiscovery/nuclei/v3/pkg/output" "github.com/projectdiscovery/nuclei/v3/pkg/progress" "github.com/projectdiscovery/nuclei/v3/pkg/protocols" + "github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs" + javascript "github.com/projectdiscovery/nuclei/v3/pkg/protocols/javascript" "github.com/projectdiscovery/nuclei/v3/pkg/templates" "github.com/projectdiscovery/nuclei/v3/pkg/testutils" "github.com/projectdiscovery/ratelimit" @@ -73,3 +76,35 @@ func TestCompile(t *testing.T) { } } } + +func TestExecuteWithResultsReturnsArgEvaluationErrorWithoutPanic(t *testing.T) { + options := testutils.DefaultOptions + tmplInfo := &testutils.TemplateInfo{ID: "execute-with-results-arg-evaluation-error"} + + testutils.Init(options) + + t.Cleanup(func() { + testutils.Cleanup(options) + }) + + executorOptions := testutils.NewMockExecuterOptions(options, tmplInfo) + executorOptions.JsCompiler = templates.GetJsCompiler() + + request := &javascript.Request{ + Args: map[string]interface{}{ + "token": "{{base64()}}", + }, + Code: `module.exports = { success: true, response: "ok" }`, + } + require.NoError(t, request.Compile(executorOptions)) + + target := contextargs.NewWithInput(context.Background(), "https://example.com:443") + + var err error + require.NotPanics(t, func() { + err = request.ExecuteWithResults(target, nil, nil, func(*output.InternalWrappedEvent) { + t.Fatal("unexpected callback on argument evaluation failure") + }) + }) + require.ErrorContains(t, err, `failed to evaluate expression "base64()"`) +}
d2217320162dfix(expressions): only eval template-authored expressions (#7221)
4 files changed · +105 −1
cmd/integration-test/http.go+26 −0 modified@@ -88,6 +88,7 @@ var httpTestcases = []TestCaseInfo{ {Path: "protocols/http/multi-request.yaml", TestCase: &httpMultiRequest{}}, {Path: "protocols/http/http-matcher-extractor-dy-extractor.yaml", TestCase: &httpMatcherExtractorDynamicExtractor{}}, {Path: "protocols/http/multi-http-var-sharing.yaml", TestCase: &httpMultiVarSharing{}}, + {Path: "protocols/http/response-data-literal-reuse.yaml", TestCase: &httpResponseDataLiteralReuse{}}, {Path: "protocols/http/raw-path-single-slash.yaml", TestCase: &httpRawPathSingleSlash{}}, {Path: "protocols/http/raw-unsafe-path-single-slash.yaml", TestCase: &httpRawUnsafePathSingleSlash{}}, } @@ -102,6 +103,31 @@ func (h *httpMultiVarSharing) Execute(filePath string) error { return expectResultsCount(results, 1) } +type httpResponseDataLiteralReuse struct{} + +func (h *httpResponseDataLiteralReuse) Execute(filePath string) error { + router := httprouter.New() + router.GET("/", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + _, _ = fmt.Fprint(w, `{{md5("Hello")}}`) + }) + router.GET("/echo", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + if r.URL.Query().Get("x") != `{{md5("Hello")}}` { + w.WriteHeader(http.StatusBadRequest) + return + } + w.WriteHeader(http.StatusOK) + }) + ts := httptest.NewServer(router) + defer ts.Close() + + results, err := testutils.RunNucleiTemplateAndGetResults(filePath, ts.URL, debug) + if err != nil { + return err + } + + return expectResultsCount(results, 1) +} + type httpMatcherExtractorDynamicExtractor struct{} func (h *httpMatcherExtractorDynamicExtractor) Execute(filePath string) error {
integration_tests/protocols/http/response-data-literal-reuse.yaml+34 −0 added@@ -0,0 +1,34 @@ +id: response-data-literal-reuse + +info: + name: Response data literal reuse + author: dwisiswant0 + severity: info + description: | + Make sure response-derived {{...}} content stays literal when reused in a + later request. + tags: test,http + +http: + - raw: + - | + GET / HTTP/1.1 + Host: {{Hostname}} + + extractors: + - type: regex + name: extracted_body + part: body + regex: + - '(?s)(.*)' + internal: true + + - raw: + - | + GET /echo?x={{extracted_body}} HTTP/1.1 + Host: {{Hostname}} + + matchers: + - type: status + status: + - 200
pkg/protocols/common/expressions/expressions.go+2 −1 modified@@ -43,14 +43,15 @@ func EvaluateByte(data []byte, base map[string]interface{}) ([]byte, error) { } func evaluate(data string, base map[string]interface{}) (string, error) { + expressions := FindExpressions(data, marker.ParenthesisOpen, marker.ParenthesisClose, base) + // replace simple placeholders (key => value) MarkerOpen + key + MarkerClose and General + key + General to value data = replacer.Replace(data, base) // expressions can be: // - simple: containing base values keys (variables) // - complex: containing helper functions [ + variables] // literals like {{2+2}} are not considered expressions - expressions := FindExpressions(data, marker.ParenthesisOpen, marker.ParenthesisClose, base) for _, expression := range expressions { // replace variable placeholders with base values expression = replacer.Replace(expression, base)
pkg/protocols/common/expressions/expressions_test.go+43 −0 modified@@ -62,3 +62,46 @@ func TestEval(t *testing.T) { require.Equal(t, item.expected, value, "could not get correct expression") } } + +func TestEvaluateDoesNotReinterpretResolvedValues(t *testing.T) { + items := []struct { + name string + input string + expected string + extra map[string]interface{} + }{ + { + name: "helper syntax in resolved values stays literal", + input: "/?x={{body}}", + expected: `/?x={{md5("Hello")}}-by-Adelle`, + extra: map[string]interface{}{ + "body": `{{md5("Hello")}}-by-Adelle`, + }, + }, + { + name: "resolved values cannot access other variables", + input: "Authorization: {{body}}", + expected: "Authorization: {{secret_token}}", + extra: map[string]interface{}{ + "body": "{{secret_token}}", + "secret_token": "top-secret-cia-mi6-kgb-mossad-classified", + }, + }, + { + name: "template-authored placeholders inside helper expressions still resolve", + input: "{{base64('{{Host}}')}}", + expected: "MTI3LjAuMC4x", + extra: map[string]interface{}{ + "Host": "127.0.0.1", + }, + }, + } + + for _, item := range items { + t.Run(item.name, func(t *testing.T) { + value, err := Evaluate(item.input, item.extra) + require.NoError(t, err) + require.Equal(t, item.expected, value) + }) + } +}
Vulnerability mechanics
AI mechanics synthesis has not run for this CVE yet.
References
8- github.com/projectdiscovery/nuclei/commit/6c803c74d193f85f8a6d9803ce493fd302cad0ebnvdPatchWEB
- github.com/projectdiscovery/nuclei/commit/d2217320162d5782ca7cb95bef9dda17063818f3nvdPatchWEB
- github.com/projectdiscovery/nuclei/pull/7221nvdIssue TrackingPatchWEB
- github.com/projectdiscovery/nuclei/pull/7321nvdIssue TrackingPatchWEB
- github.com/projectdiscovery/nuclei/security/advisories/GHSA-jm34-66cf-qpvrnvdMitigationPatchVendor AdvisoryWEB
- github.com/advisories/GHSA-jm34-66cf-qpvrghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-41645ghsaADVISORY
- github.com/projectdiscovery/nuclei/releases/tag/v3.8.0nvdProductRelease NotesWEB
News mentions
0No linked articles in our index yet.