Skip to content

fix(fuzz): prevent fatal concurrent map-write crash in MultiPartForm when scanning multiple targets#7126

Closed
usernametooshort wants to merge 2 commits intoprojectdiscovery:devfrom
usernametooshort:fix/multipartform-singleton-data-race
Closed

fix(fuzz): prevent fatal concurrent map-write crash in MultiPartForm when scanning multiple targets#7126
usernametooshort wants to merge 2 commits intoprojectdiscovery:devfrom
usernametooshort:fix/multipartform-singleton-data-race

Conversation

@usernametooshort
Copy link
Copy Markdown

@usernametooshort usernametooshort commented Mar 5, 2026

Problem

Fixes #7028

Scanning multiple targets with a multipart/form-data template crashes with:

fatal error: concurrent map writes

or (with -race):

WARNING: DATA RACE
Write at ... MultiPartForm.ParseBoundary()

Root Cause

MultiPartForm is the only stateful DataFormat — it stores a mutable boundary string and a filesMetadata map that are written on every Decode/ParseBoundary call. It is registered as a singleton in dataformat.init(), so every goroutine that calls dataformat.Get("multipart/form-data") receives the same pointer.

When N > 1 targets are scanned concurrently, goroutines race to write the same fields:

dataformat.Get("multipart/form-data")   ← all goroutines get same *MultiPartForm
singleton.ParseBoundary(contentType)    ← goroutine A writes m.boundary
singleton.Decode(body)                  ← goroutine B writes m.filesMetadata
→ fatal error: concurrent map writes

Fix

Add a RegisterDataFormatFactory(name, func() DataFormat) alongside the existing RegisterDataFormat(). Get() checks the factory map first and calls the constructor to return a fresh instance per invocation.

MultiPartForm is re-registered as a factory. All other DataFormats (JSON, XML, Raw, Form) remain singletons — they have no mutable state.

// Before — singleton, crashes with concurrent access
RegisterDataFormat(NewMultiPartForm())

// After — factory, each Get() returns independent instance
RegisterDataFormatFactory(MultiPartFormDataFormat, func() DataFormat { return NewMultiPartForm() })

Verification

go test -race ./pkg/fuzz/dataformat/...
# ok  github.com/projectdiscovery/nuclei/v3/pkg/fuzz/dataformat  1.655s

The SDK reproduction test from #7028 also passes without -race detecting any race.

Summary by CodeRabbit

  • Refactor
    • Data format handling reworked so stateful formats are created per use, preventing shared-state issues and improving reliability.
  • Bug Fixes
    • Encoding flow made safer with per-call retrieval and nil checks to avoid runtime errors with stateful formats.

…ent data race

MultiPartForm is the only stateful DataFormat implementation — it stores
a mutable `boundary` string and a `filesMetadata` map that are written
during every Decode/ParseBoundary call.  Registering it as a singleton in
dataformat.init() and returning the same pointer from Get() caused a
fatal concurrent map-write crash (and -race DATA RACE) when multiple
goroutines scanned different multipart/form-data targets simultaneously.

The pattern:
  dataformat.Get("multipart/form-data")  ← all goroutines get same pointer
  singleton.ParseBoundary(...)            ← goroutine A writes m.boundary
  singleton.Decode(...)                   ← goroutine B writes m.filesMetadata
  → fatal error: concurrent map writes

Fix: add a RegisterDataFormatFactory() alongside RegisterDataFormat().
Get() checks the factory map first; if a factory is registered it calls
it to create a fresh instance per invocation.  MultiPartForm is
re-registered as a factory.  All other DataFormats remain singletons
(they have no mutable state so sharing is safe).

Tests pass with -race flag:
  ok  github.com/projectdiscovery/nuclei/v3/pkg/fuzz/dataformat  1.655s

Fixes projectdiscovery#7028
@auto-assign auto-assign Bot requested a review from dwisiswant0 March 5, 2026 20:05
@neo-by-projectdiscovery-dev
Copy link
Copy Markdown

neo-by-projectdiscovery-dev Bot commented Mar 5, 2026

Neo - PR Security Review

No security issues found

Highlights

  • Introduces factory pattern via RegisterDataFormatFactory to prevent concurrent map write crashes
  • Each dataformat.Get() call for multipart/form-data returns a fresh instance instead of shared singleton
  • Fixes fatal error when scanning multiple targets concurrently with multipart templates
Hardening Notes
  • The race condition being fixed is a crash issue (concurrent map writes), not an exploitable security vulnerability
  • The boundary length validation (max 70 chars per RFC-2046) at multipart.go:153 correctly prevents DoS via oversized boundaries
  • The 32MB max memory limit for multipart parsing (multipart.go:170) provides reasonable DoS protection

Comment @pdneo help for available commands. · Open in Neo

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Mar 5, 2026

Walkthrough

A factory mechanism for data formats was added and MultiPartForm was migrated from a singleton to a per-call factory. Get() now returns a fresh instance when a factory is registered, avoiding shared state across concurrent callers.

Changes

Cohort / File(s) Summary
DataFormat factory & registration
pkg/fuzz/dataformat/dataformat.go
Added dataformatFactories map[string]func() DataFormat and RegisterDataFormatFactory(name string, factory func() DataFormat). Init() now registers MultiPartForm via factory. Get() returns factory() when present; Encode() uses Get() and checks for nil. Comments updated to clarify stateless vs stateful semantics.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Poem

🐰 I hop in code, no longer a singleton fright,
New factories spawn each instance just right,
Boundaries and maps kept separate, neat,
Concurrent fuzzers now dance with light feet,
Hooray—no panic, our runs sleep tight!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately describes the main change: preventing concurrent map-write crashes in MultiPartForm by switching from singleton to factory-based instance creation.
Linked Issues check ✅ Passed The PR fully addresses #7028 by implementing factory-based registration for stateful formats, ensuring each Get() call returns independent MultiPartForm instances while preserving singleton behavior for stateless formats.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing the concurrent map-write issue: adding factory registry, registering MultiPartForm as a factory, and updating Get() logic—no unrelated modifications detected.
Docstring Coverage ✅ Passed Docstring coverage is 80.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@pkg/fuzz/dataformat/dataformat.go`:
- Around line 32-36: The package registers the multipart/form-data format only
via RegisterDataFormatFactory (RegisterDataFormatFactory,
MultiPartFormDataFormat, NewMultiPartForm) which breaks package-level Encode()
because Encode() resolves encoders from the dataformats registry and will report
"dataformat multipart/form-data is not supported"; update registration so
multipart is available in both registries: call
RegisterDataFormat(MultiPartFormDataFormat, NewMultiPartForm()) or otherwise
ensure dataformats map gets a concrete DataFormat instance for
MultiPartFormDataFormat in addition to the factory registration (also apply the
same dual-registration fix to the other affected entries around the
RegisterDataFormatFactory calls referenced in the diff).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9139bb44-450e-4cfd-9417-2e509986ce98

📥 Commits

Reviewing files that changed from the base of the PR and between 884d0b5 and 9ed07ee.

📒 Files selected for processing (1)
  • pkg/fuzz/dataformat/dataformat.go

Comment thread pkg/fuzz/dataformat/dataformat.go
@usernametooshort
Copy link
Copy Markdown
Author

Addressed CodeRabbit feedback:

✅ Fixed Encode() to use Get() function for factory lookup
✅ Now correctly retrieves MultiPartForm instances from factory map
✅ Prevents "unsupported dataformat" error when using multipart/form-data

Build verified successfully.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@pkg/fuzz/dataformat/dataformat.go`:
- Around line 69-73: RegisterDataFormatFactory currently only stores the
constructor in dataformatFactories so Get() can create instances, but Decode()
iterates only over dataformats and therefore never sees factory-only formats;
update RegisterDataFormatFactory to also ensure Decode() can auto-detect by
either instantiating a fresh DataFormat (via the provided factory) and
registering it into the package-level dataformats map (ensuring thread-safety)
or modify Decode() to consult dataformatFactories when iterating candidates;
refer to RegisterDataFormatFactory, dataformatFactories, Get(), Decode(), and
dataformats when making the change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b9c7c3ff-de57-42a6-88ec-933bb9d2d8bc

📥 Commits

Reviewing files that changed from the base of the PR and between 9ed07ee and b9ff2bd.

📒 Files selected for processing (1)
  • pkg/fuzz/dataformat/dataformat.go

Comment on lines +69 to +73
// RegisterDataFormatFactory registers a constructor for a stateful dataformat.
// Each call to Get() will invoke the factory and return a fresh instance,
// preventing data races between concurrent goroutines.
func RegisterDataFormatFactory(name string, factory func() DataFormat) {
dataformatFactories[name] = factory
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Factory-registered formats are still invisible to package-level Decode().

RegisterDataFormatFactory only affects Get(), but Decode() still walks dataformats only. That means any format registered exclusively through the new API can be encoded/resolved but will never participate in auto-detection/decoding. Either instantiate factory-backed formats there as well, or document that factory registrations are intentionally Get/Encode-only.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/fuzz/dataformat/dataformat.go` around lines 69 - 73,
RegisterDataFormatFactory currently only stores the constructor in
dataformatFactories so Get() can create instances, but Decode() iterates only
over dataformats and therefore never sees factory-only formats; update
RegisterDataFormatFactory to also ensure Decode() can auto-detect by either
instantiating a fresh DataFormat (via the provided factory) and registering it
into the package-level dataformats map (ensuring thread-safety) or modify
Decode() to consult dataformatFactories when iterating candidates; refer to
RegisterDataFormatFactory, dataformatFactories, Get(), Decode(), and dataformats
when making the change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

3 participants