Skip to content

fatal error: concurrent map writes in MultiPartForm.Decode during multipart/form-data fuzzing #7028

@yusei-wy

Description

@yusei-wy

Existing issue checks

Current Behavior

When scanning multiple targets with a template that fuzzes multipart/form-data bodies, MultiPartForm.Decode triggers fatal error: concurrent map writes and the process crashes immediately.

This is a Go runtime fatal error that cannot be caught by recover().

Root Cause

MultiPartForm is the only stateful DataFormat implementation — it stores a boundary string field and a filesMetadata map field. However, it is registered as a singleton in dataformat.init() and returned by dataformat.Get() to every goroutine:

// pkg/fuzz/dataformat/dataformat.go
func init() {
    dataformats = make(map[string]DataFormat)
    RegisterDataFormat(NewMultiPartForm())  // singleton
}

func Get(name string) DataFormat {
    return dataformats[name]  // returns same instance to all callers
}

When multiple targets are processed concurrently, each goroutine calls Body.parseBody("multipart/form-data", req)dataformat.Get("multipart/form-data") → receives the same singleton → concurrent writes to m.boundary and m.filesMetadata:

Engine.executeTemplateWithTargets (spawns concurrent goroutines per target)
  → Engine.executeTemplateOnInput
    → TemplateExecuter.Execute
      → Request.ExecuteWithResults                     [request.go:518]
        → executeFuzzingRule → executeAllFuzzingRules   [request_fuzz.go:68,155]
          → Rule.Execute                                [execute.go:107]
            → Body.Parse → parseBody("multipart/form-data")  [body.go:68,83]
              → dataformat.Get("multipart/form-data")   ← returns singleton
              → singleton.ParseBoundary(contentType)    ← write to m.boundary (DATA RACE)
              → singleton.Decode(body)                  ← write to m.filesMetadata (fatal: concurrent map writes)

Why 1 target does NOT reproduce the issue

executeTemplateWithTargets (pkg/core/executors.go:95-118) creates an unbuffered channel and spawns worker goroutines. With 1 target, only 1 task enters the channel → only 1 worker goroutine processes it → no concurrent access to the singleton.

Rule.Execute (pkg/fuzz/execute.go:91-192) iterates over components and payloads sequentially within a single goroutine.

Therefore, the race condition only manifests with N > 1 targets, which is the primary use case of nuclei (scanning multiple targets).

Reproduction Steps

Prerequisites

  • DAST mode enabled (SDK: nuclei.DASTMode(), CLI: -dast flag)
  • Multiple targets (1 target = 1 goroutine = no concurrent access)
  • -race flag for reliable detection (the race is non-deterministic; without -race, it may crash or silently corrupt data)

Template

Based on integration_tests/fuzz/fuzz-body-multipart-form-sqli.yaml:

id: multipart-fuzz

info:
  name: multipart form body fuzzing
  author: pdteam
  severity: info

http:
  - pre-condition:
      - type: dsl
        dsl:
          - method != "GET"
          - method != "HEAD"
          - contains(content_type, "multipart/form-data")
        condition: and

    payloads:
      injection:
        - "'"
        - "\""
        - ";"

    fuzzing:
      - part: body
        type: postfix
        mode: single
        fuzz:
          - '{{injection}}'

    stop-at-first-match: true
    matchers:
      - type: word
        words:
          - "ok"

Self-contained SDK reproduction test

Copy-paste runnable test. Starts an httptest server, generates 20 multipart/form-data targets in proxify YAML format, and executes via the SDK.

package nuclei_test

import (
	"context"
	"fmt"
	"net/http"
	"net/http/httptest"
	"net/url"
	"os"
	"testing"

	nuclei "github.com/projectdiscovery/nuclei/v3/lib"
	"github.com/projectdiscovery/nuclei/v3/pkg/output"
	"github.com/stretchr/testify/require"
)

func TestMultiPartForm_ConcurrentMapWrites_SDK(t *testing.T) {
	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(200)
		fmt.Fprint(w, "ok")
	}))
	defer ts.Close()

	parsedURL, err := url.Parse(ts.URL)
	require.NoError(t, err)
	host := parsedURL.Host

	tmpTemplate, err := os.CreateTemp(t.TempDir(), "multipart-fuzz-*.yaml")
	require.NoError(t, err)
	_, err = tmpTemplate.WriteString(`id: multipart-fuzz

info:
  name: multipart form body fuzzing
  author: pdteam
  severity: info

http:
  - pre-condition:
      - type: dsl
        dsl:
          - method != "GET"
          - method != "HEAD"
          - contains(content_type, "multipart/form-data")
        condition: and

    payloads:
      injection:
        - "'"
        - "\""
        - ";"

    fuzzing:
      - part: body
        type: postfix
        mode: single
        fuzz:
          - '{{injection}}'

    stop-at-first-match: true
    matchers:
      - type: word
        words:
          - "ok"`)
	require.NoError(t, err)
	require.NoError(t, tmpTemplate.Close())

	tmpInput, err := os.CreateTemp(t.TempDir(), "input-proxify-*.yaml")
	require.NoError(t, err)

	for i := range 20 {
		_, err = fmt.Fprintf(tmpInput, `---
timestamp: 2024-01-01T00:00:00+00:00
url: %s/upload-%d
request:
  header:
    Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
    method: POST
    path: /upload-%d
    host: %s
  raw: |+
    POST /upload-%d HTTP/1.1
    Host: %s
    Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW

    ------WebKitFormBoundary7MA4YWxkTrZu0gW
    Content-Disposition: form-data; name="file"; filename="test.txt"
    Content-Type: text/plain

    file content %d
    ------WebKitFormBoundary7MA4YWxkTrZu0gW
    Content-Disposition: form-data; name="description"

    test upload %d
    ------WebKitFormBoundary7MA4YWxkTrZu0gW--
response:
  header:
    Content-Type: text/plain
  raw: |+
    HTTP/1.1 200 OK
    Content-Type: text/plain

    ok
`, ts.URL, i, i, host, i, host, i, i)
		require.NoError(t, err)
	}
	require.NoError(t, tmpInput.Close())

	ne, err := nuclei.NewNucleiEngineCtx(
		context.Background(),
		nuclei.DASTMode(),
		nuclei.WithTemplatesOrWorkflows(nuclei.TemplateSources{
			Templates: []string{tmpTemplate.Name()},
		}),
		nuclei.DisableUpdateCheck(),
	)
	require.NoError(t, err)
	defer ne.Close()

	err = ne.LoadTargetsWithHttpData(tmpInput.Name(), "yaml")
	require.NoError(t, err)

	err = ne.ExecuteCallbackWithCtx(context.Background(), func(event *output.ResultEvent) {
		fmt.Printf("Result: %s\n", event.TemplateID)
	})
	if err != nil {
		t.Logf("ExecuteCallbackWithCtx error: %v", err)
	}
}

How to run

go test -race -v -count=1 -timeout=120s -run TestMultiPartForm_ConcurrentMapWrites_SDK ./lib/

Output on dev branch (race detected)

==================
WARNING: DATA RACE
Write at 0x00c0002d0240 by goroutine 22936:
  github.com/projectdiscovery/nuclei/v3/pkg/fuzz/dataformat.(*MultiPartForm).ParseBoundary()
      pkg/fuzz/dataformat/multipart.go:146 +0x78
  github.com/projectdiscovery/nuclei/v3/pkg/fuzz/component.(*Body).parseBody()
      pkg/fuzz/component/body.go:83 +0xf8
  github.com/projectdiscovery/nuclei/v3/pkg/fuzz/component.(*Body).Parse()
      pkg/fuzz/component/body.go:68 +0x5bc
  github.com/projectdiscovery/nuclei/v3/pkg/fuzz.(*Rule).Execute()
      pkg/fuzz/execute.go:107 +0x5d8
  github.com/projectdiscovery/nuclei/v3/pkg/protocols/http.(*Request).executeAllFuzzingRules()
      pkg/protocols/http/request_fuzz.go:155 +0x62c
  github.com/projectdiscovery/nuclei/v3/pkg/protocols/http.(*Request).executeFuzzingRule()
      pkg/protocols/http/request_fuzz.go:68 +0x19c
  github.com/projectdiscovery/nuclei/v3/pkg/protocols/http.(*Request).ExecuteWithResults()
      pkg/protocols/http/request.go:518 +0x164
  github.com/projectdiscovery/nuclei/v3/pkg/tmplexec/generic.(*Generic).ExecuteWithResults()
      pkg/tmplexec/generic/exec.go:61 +0x404
  github.com/projectdiscovery/nuclei/v3/pkg/tmplexec.(*TemplateExecuter).Execute()
      pkg/tmplexec/exec.go:212 +0x5a4
  github.com/projectdiscovery/nuclei/v3/pkg/core.(*Engine).executeTemplateOnInput()
      pkg/core/executors.go:246 +0x214
  github.com/projectdiscovery/nuclei/v3/pkg/core.(*Engine).executeTemplateWithTargets.func2.1()
      pkg/core/executors.go:113 +0xd8
  github.com/projectdiscovery/nuclei/v3/pkg/core.(*Engine).executeTemplateWithTargets.func2()
      pkg/core/executors.go:118 +0xe0

Previous write at 0x00c0002d0240 by goroutine 22935:
  [same stack — two goroutines writing to the same singleton concurrently]

--- FAIL: TestMultiPartForm_ConcurrentMapWrites_SDK (2.34s)
    testing.go:1617: race detected during execution of test
FAIL

Production stack trace

fatal error: concurrent map writes

goroutine 896736 [running]:
github.com/projectdiscovery/nuclei/v3/pkg/fuzz/dataformat.(*MultiPartForm).Decode(...)
    pkg/fuzz/dataformat/multipart.go:222 +0xbc7
github.com/projectdiscovery/nuclei/v3/pkg/fuzz/component.(*Body).parseBody(...)
    pkg/fuzz/component/body.go:87 +0x108
github.com/projectdiscovery/nuclei/v3/pkg/fuzz/component.(*Body).Parse(...)
    pkg/fuzz/component/body.go:68 +0x3d6
github.com/projectdiscovery/nuclei/v3/pkg/fuzz.(*Rule).Execute(...)
    pkg/fuzz/execute.go:106 +0x371

Expected Behavior

Concurrent fuzzing of multipart/form-data bodies across multiple targets should work without crashing.

Environment

  • nuclei: dev branch (d771daa)
  • OS: macOS / Linux
  • Go: 1.25.x

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions