Skip to content

mime/multipart: Failure to round trip leading to potential bypass of content based checks #74087

@personnumber3377

Description

@personnumber3377

Go version

go version go1.24.4 linux/amd64

Output of go env in your module/workspace:

AR='ar'
CC='gcc'
CGO_CFLAGS='-O2 -g'
CGO_CPPFLAGS=''
CGO_CXXFLAGS='-O2 -g'
CGO_ENABLED='1'
CGO_FFLAGS='-O2 -g'
CGO_LDFLAGS='-O2 -g'
CXX='g++'
GCCGO='gccgo'
GO111MODULE='auto'
GOAMD64='v1'
GOARCH='amd64'
GOAUTH='netrc'
GOBIN=''
GOCACHE='/home/oof/.cache/go-build'
GOCACHEPROG=''
GODEBUG=''
GOENV='/home/oof/.config/go/env'
GOEXE=''
GOEXPERIMENT=''
GOFIPS140='off'
GOFLAGS=''
GOGCCFLAGS='-fPIC -m64 -pthread -Wl,--no-gc-sections -fmessage-length=0 -ffile-prefix-map=/tmp/go-build1348274841=/tmp/go-build -gno-record-gcc-switches'
GOHOSTARCH='amd64'
GOHOSTOS='linux'
GOINSECURE=''
GOMOD=''
GOMODCACHE='/home/oof/.asdf/installs/golang/1.24.4/packages/pkg/mod'
GONOPROXY=''
GONOSUMDB=''
GOOS='linux'
GOPATH='/home/oof/.asdf/installs/golang/1.24.4/packages'
GOPRIVATE=''
GOPROXY='https://proxy.golang.org,direct'
GOROOT='/home/oof/.asdf/installs/golang/1.24.4/go'
GOSUMDB='sum.golang.org'
GOTELEMETRY='local'
GOTELEMETRYDIR='/home/oof/.config/go/telemetry'
GOTMPDIR=''
GOTOOLCHAIN='auto'
GOTOOLDIR='/home/oof/.asdf/installs/golang/1.24.4/go/pkg/tool/linux_amd64'
GOVCS=''
GOVERSION='go1.24.4'
GOWORK=''
PKG_CONFIG='pkg-config'

What did you do?

This issue was originally reported as a security issue, but was later deemed infeasable and I was told to open a public issue here. ("If you find a scenario where this actually leads into a vulnerability in one of our products, then please get back to us, but otherwise feel free to publicly disclose this issue on GitHub as a public issue. Thanks again for your report and time, The Google Bug Hunter Team")

This program:

package main

import (
	"bytes"
	"fmt"
	"io"
	"mime/multipart"
	"net/textproto"
	// "os"
)

// headerBody is a simple helper struct for comparing parts.
type headerBodyNew struct {
	header textproto.MIMEHeader
	body   string
}

// partsFromReader reads all parts from a multipart.Reader into a slice of headerBody structs.
func partsFromReaderNew(r *multipart.Reader) ([]headerBodyNew, error) {
	var got []headerBodyNew
	for {
		p, err := r.NextPart()
		if err == io.EOF {
			return got, nil
		}
		if err != nil {
			return nil, fmt.Errorf("NextPart: %v", err)
		}
		pbody, err := io.ReadAll(p)
		if err != nil {
			return nil, fmt.Errorf("error reading part: %v", err)
		}
		got = append(got, headerBodyNew{p.Header, string(pbody)})
	}
}

// main runs the standalone fuzz-like logic on sample inputs.
func main() {
	// Define test payload
	seeds := [][]byte{
		[]byte("--boundary\nContent-Transfer-Encoding:quoted-printable\n \n\n=44=41=4E=47=45=52=4F=55=53 \n--boundary--"), // POC payload...
	}

	for idx, data := range seeds {
		fmt.Printf("\n==== Testing Seed #%d ====\n", idx)
		processInput(data)
	}
}

// processInput performs parsing and round-trip logic on a given input
func processInput(data []byte) {
	const fuzzBoundary = "boundary"

	// 1. Parse the input with a Reader.
	r := multipart.NewReader(bytes.NewReader(data), fuzzBoundary)
	parts, err := partsFromReaderNew(r)
	if err != nil {
		fmt.Printf("Skipping input: parsing error: %v\n", err)
		return
	}

	// 2. Write the parts back using a Writer.
	var buf bytes.Buffer
	w := multipart.NewWriter(&buf)
	if err := w.SetBoundary(fuzzBoundary); err != nil {
		fmt.Printf("SetBoundary error: %v\n", err)
		return
	}
	for _, p := range parts {
		pw, err := w.CreatePart(p.header)
		if err != nil {
			fmt.Printf("CreatePart error: %v\n", err)
			return
		}
		if _, err := pw.Write([]byte(p.body)); err != nil {
			fmt.Printf("Write error: %v\n", err)
			return
		}
	}
	if err := w.Close(); err != nil {
		fmt.Printf("Writer.Close error: %v\n", err)
		return
	}

	// 3. Read the round-tripped data with a new Reader.
	r2 := multipart.NewReader(&buf, fuzzBoundary)
	roundtripParts, err := partsFromReaderNew(r2)
	if err != nil {
		fmt.Printf("Roundtrip parsing error: %v\n", err)
		return
	}

	// 4. Compare the original parts to the roundtripped ones.
	for i := range parts {
		gotHeader := roundtripParts[i].header
		wantHeader := parts[i].header

		fmt.Printf("\nPart #%d:\n", i)
		fmt.Println("Original Body:")
		fmt.Println(parts[i].body)
		fmt.Println("Roundtrip Body:")
		fmt.Println(roundtripParts[i].body)

		if len(gotHeader) > len(wantHeader) {
			fmt.Printf("⚠️ Header injection detected in part %d:\nGot headers: %v\nWant headers: %v\n", i, gotHeader, wantHeader)
		}
		if roundtripParts[i].body != parts[i].body {
			fmt.Printf("⚠️ Body mismatch in part %d:\nGot: %q\nWant: %q\n", i, roundtripParts[i].body, parts[i].body)
		}
	}
}

produces this result:

==== Testing Seed #0 ====

Part #0:
Original Body:
=44=41=4E=47=45=52=4F=55=53 
Roundtrip Body:
DANGEROUS
⚠️ Body mismatch in part 0:
Got: "DANGEROUS"
Want: "=44=41=4E=47=45=52=4F=55=53 "

This is because of the special treatment of the Content-Transfer-Encoding header.

What did you see happen?

The new content differs from the original content.

What did you expect to see?

The new content should be the exact same as the original.

Metadata

Metadata

Assignees

No one assigned

    Labels

    NeedsInvestigationSomeone must examine and confirm this is a valid issue and not a duplicate of an existing one.

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions