mirror of
https://github.com/pgsty/minio.git
synced 2026-07-15 16:30:29 +03:00
fix: fake CVE-2026-40028 harden snowball unsigned-trailer auth
Track issue #28 / GHSA-9c4q-hq6p-c237 as fake CVE-2026-40028. Close the Snowball auto-extract auth gap in PutObjectExtractHandler by treating authTypeStreamingUnsignedTrailer the same as ordinary PUTs: honor X-Amz-Decoded-Content-Length, initialize newUnsignedV4ChunkedReader(), and verify the SigV4 request before any tar bytes reach untar(). This removes the forged-signature write primitive that let a single request fan out into arbitrary extracted object creation. Add regression coverage for forged-signature Snowball unsigned-trailer writes, anonymous Snowball requests against non-public buckets, and legitimate signed Snowball extraction with trailing CRC32 trailers. Validate the new tests against the vulnerable parent and patched tree, and confirm with containerized before/after smoke runs that the exploit succeeds pre-fix, fails post-fix, and normal signed Snowball uploads still extract correctly. Co-authored-by: Codex <codex@openai.com> Co-authored-by: Claude Code <claude-code@anthropic.com>
This commit is contained in:
@@ -2289,7 +2289,7 @@ func (api objectAPIHandlers) PutObjectExtractHandler(w http.ResponseWriter, r *h
|
|||||||
// if Content-Length is unknown/missing, deny the request
|
// if Content-Length is unknown/missing, deny the request
|
||||||
size := r.ContentLength
|
size := r.ContentLength
|
||||||
rAuthType := getRequestAuthType(r)
|
rAuthType := getRequestAuthType(r)
|
||||||
if rAuthType == authTypeStreamingSigned || rAuthType == authTypeStreamingSignedTrailer {
|
if rAuthType == authTypeStreamingSigned || rAuthType == authTypeStreamingSignedTrailer || rAuthType == authTypeStreamingUnsignedTrailer {
|
||||||
if sizeStr, ok := r.Header[xhttp.AmzDecodedContentLength]; ok {
|
if sizeStr, ok := r.Header[xhttp.AmzDecodedContentLength]; ok {
|
||||||
if sizeStr[0] == "" {
|
if sizeStr[0] == "" {
|
||||||
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrMissingContentLength), r.URL)
|
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrMissingContentLength), r.URL)
|
||||||
@@ -2343,6 +2343,13 @@ func (api objectAPIHandlers) PutObjectExtractHandler(w http.ResponseWriter, r *h
|
|||||||
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Err), r.URL)
|
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Err), r.URL)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
case authTypeStreamingUnsignedTrailer:
|
||||||
|
// Initialize stream chunked reader with optional trailers.
|
||||||
|
reader, s3Err = newUnsignedV4ChunkedReader(r, true)
|
||||||
|
if s3Err != ErrNone {
|
||||||
|
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Err), r.URL)
|
||||||
|
return
|
||||||
|
}
|
||||||
case authTypeSignedV2, authTypePresignedV2:
|
case authTypeSignedV2, authTypePresignedV2:
|
||||||
s3Err = isReqAuthenticatedV2(r)
|
s3Err = isReqAuthenticatedV2(r)
|
||||||
if s3Err != ErrNone {
|
if s3Err != ErrNone {
|
||||||
|
|||||||
+153
-1
@@ -18,11 +18,15 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/tar"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"hash/crc32"
|
||||||
"io"
|
"io"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -132,6 +136,9 @@ func runAllTests(suite *TestSuiteCommon, c *check) {
|
|||||||
suite.TestUnsignedQueryStringCVE(c)
|
suite.TestUnsignedQueryStringCVE(c)
|
||||||
suite.TestUnsignedQueryStringCVEMultipart(c)
|
suite.TestUnsignedQueryStringCVEMultipart(c)
|
||||||
suite.TestUnsignedTrailerRejectsMultipleAuthSources(c)
|
suite.TestUnsignedTrailerRejectsMultipleAuthSources(c)
|
||||||
|
suite.TestUnsignedTrailerSnowballAnonymousDenied(c)
|
||||||
|
suite.TestUnsignedTrailerSnowballRequiresSignature(c)
|
||||||
|
suite.TestUnsignedTrailerSnowballExtract(c)
|
||||||
suite.TestAnonymousUnsignedTrailer(c)
|
suite.TestAnonymousUnsignedTrailer(c)
|
||||||
suite.TearDownSuite(c)
|
suite.TearDownSuite(c)
|
||||||
}
|
}
|
||||||
@@ -490,6 +497,121 @@ func (s *TestSuiteCommon) TestUnsignedTrailerRejectsMultipleAuthSources(c *check
|
|||||||
verifyError(c, response, "InvalidRequest", "Invalid Request (request has multiple authentication types, please use one)", http.StatusBadRequest)
|
verifyError(c, response, "InvalidRequest", "Invalid Request (request has multiple authentication types, please use one)", http.StatusBadRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *TestSuiteCommon) TestUnsignedTrailerSnowballRequiresSignature(c *check) {
|
||||||
|
c.Helper()
|
||||||
|
|
||||||
|
bucketName := getRandomBucketName()
|
||||||
|
objectName := "snowball-upload.tar"
|
||||||
|
extractedObject := "payload.txt"
|
||||||
|
extractedData := []byte("snowball object\n")
|
||||||
|
|
||||||
|
request, err := newTestSignedRequest(http.MethodPut, getMakeBucketURL(s.endPoint, bucketName),
|
||||||
|
0, nil, s.accessKey, s.secretKey, s.signer)
|
||||||
|
c.Assert(err, nil)
|
||||||
|
|
||||||
|
response, err := s.client.Do(request)
|
||||||
|
c.Assert(err, nil)
|
||||||
|
c.Assert(response.StatusCode, http.StatusOK)
|
||||||
|
|
||||||
|
tarData, err := newTestTarArchive(extractedObject, extractedData)
|
||||||
|
c.Assert(err, nil)
|
||||||
|
|
||||||
|
// Deliberately send raw tar bytes: before the fix this path never
|
||||||
|
// initialized the unsigned-trailer reader, so Snowball accepted the
|
||||||
|
// body without chunked decoding or signature verification.
|
||||||
|
req, err := http.NewRequest(http.MethodPut, getPutObjectURL(s.endPoint, bucketName, objectName), bytes.NewReader(tarData))
|
||||||
|
c.Assert(err, nil)
|
||||||
|
|
||||||
|
req.ContentLength = int64(len(tarData))
|
||||||
|
req.Header.Set(xhttp.AmzSnowballExtract, "true")
|
||||||
|
req.Header.Set(xhttp.AmzContentSha256, unsignedPayloadTrailer)
|
||||||
|
|
||||||
|
err = signRequestV4(req, s.accessKey, s.secretKey)
|
||||||
|
c.Assert(err, nil)
|
||||||
|
|
||||||
|
req.Header.Set("Authorization",
|
||||||
|
regexp.MustCompile(`Signature=[0-9a-f]+`).ReplaceAllString(req.Header.Get("Authorization"), "Signature="+strings.Repeat("0", 64)))
|
||||||
|
|
||||||
|
response, err = s.client.Do(req)
|
||||||
|
c.Assert(err, nil)
|
||||||
|
|
||||||
|
verifyError(c, response, "SignatureDoesNotMatch", "The request signature we calculated does not match the signature you provided. Check your key and signing method.", http.StatusForbidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TestSuiteCommon) TestUnsignedTrailerSnowballAnonymousDenied(c *check) {
|
||||||
|
c.Helper()
|
||||||
|
|
||||||
|
bucketName := getRandomBucketName()
|
||||||
|
objectName := "snowball-upload.tar"
|
||||||
|
|
||||||
|
request, err := newTestSignedRequest(http.MethodPut, getMakeBucketURL(s.endPoint, bucketName),
|
||||||
|
0, nil, s.accessKey, s.secretKey, s.signer)
|
||||||
|
c.Assert(err, nil)
|
||||||
|
|
||||||
|
response, err := s.client.Do(request)
|
||||||
|
c.Assert(err, nil)
|
||||||
|
c.Assert(response.StatusCode, http.StatusOK)
|
||||||
|
|
||||||
|
tarData, err := newTestTarArchive("payload.txt", []byte("snowball object\n"))
|
||||||
|
c.Assert(err, nil)
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodPut, getPutObjectURL(s.endPoint, bucketName, objectName), bytes.NewReader(tarData))
|
||||||
|
c.Assert(err, nil)
|
||||||
|
|
||||||
|
req.ContentLength = int64(len(tarData))
|
||||||
|
req.Header.Set(xhttp.AmzSnowballExtract, "true")
|
||||||
|
req.Header.Set(xhttp.AmzContentSha256, unsignedPayloadTrailer)
|
||||||
|
|
||||||
|
response, err = s.client.Do(req)
|
||||||
|
c.Assert(err, nil)
|
||||||
|
|
||||||
|
verifyError(c, response, "AccessDenied", "Access Denied.", http.StatusForbidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TestSuiteCommon) TestUnsignedTrailerSnowballExtract(c *check) {
|
||||||
|
c.Helper()
|
||||||
|
|
||||||
|
bucketName := getRandomBucketName()
|
||||||
|
objectName := "snowball-upload.tar"
|
||||||
|
extractedObject := "payload.txt"
|
||||||
|
extractedData := []byte("snowball object\n")
|
||||||
|
|
||||||
|
request, err := newTestSignedRequest(http.MethodPut, getMakeBucketURL(s.endPoint, bucketName),
|
||||||
|
0, nil, s.accessKey, s.secretKey, s.signer)
|
||||||
|
c.Assert(err, nil)
|
||||||
|
|
||||||
|
response, err := s.client.Do(request)
|
||||||
|
c.Assert(err, nil)
|
||||||
|
c.Assert(response.StatusCode, http.StatusOK)
|
||||||
|
|
||||||
|
tarData, err := newTestTarArchive(extractedObject, extractedData)
|
||||||
|
c.Assert(err, nil)
|
||||||
|
|
||||||
|
req, err := newStreamingUnsignedTrailerRequest(http.MethodPut, getPutObjectURL(s.endPoint, bucketName, objectName), tarData, UTCNow())
|
||||||
|
c.Assert(err, nil)
|
||||||
|
|
||||||
|
req.Header.Set(xhttp.AmzSnowballExtract, "true")
|
||||||
|
|
||||||
|
err = signRequestV4(req, s.accessKey, s.secretKey)
|
||||||
|
c.Assert(err, nil)
|
||||||
|
|
||||||
|
response, err = s.client.Do(req)
|
||||||
|
c.Assert(err, nil)
|
||||||
|
c.Assert(response.StatusCode, http.StatusOK)
|
||||||
|
|
||||||
|
request, err = newTestSignedRequest(http.MethodGet, getGetObjectURL(s.endPoint, bucketName, extractedObject),
|
||||||
|
0, nil, s.accessKey, s.secretKey, s.signer)
|
||||||
|
c.Assert(err, nil)
|
||||||
|
|
||||||
|
response, err = s.client.Do(request)
|
||||||
|
c.Assert(err, nil)
|
||||||
|
c.Assert(response.StatusCode, http.StatusOK)
|
||||||
|
|
||||||
|
got, err := io.ReadAll(response.Body)
|
||||||
|
c.Assert(err, nil)
|
||||||
|
c.Assert(bytes.Equal(got, extractedData), true)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *TestSuiteCommon) TestAnonymousUnsignedTrailer(c *check) {
|
func (s *TestSuiteCommon) TestAnonymousUnsignedTrailer(c *check) {
|
||||||
c.Helper()
|
c.Helper()
|
||||||
|
|
||||||
@@ -527,6 +649,30 @@ func (s *TestSuiteCommon) TestAnonymousUnsignedTrailer(c *check) {
|
|||||||
c.Assert(bytes.Equal(got, objectData), true)
|
c.Assert(bytes.Equal(got, objectData), true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newTestTarArchive(objectName string, objectData []byte) ([]byte, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
tw := tar.NewWriter(&buf)
|
||||||
|
|
||||||
|
err := tw.WriteHeader(&tar.Header{
|
||||||
|
Name: objectName,
|
||||||
|
Mode: 0o600,
|
||||||
|
Size: int64(len(objectData)),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err = tw.Write(objectData); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = tw.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
func newStreamingUnsignedTrailerRequest(method, targetURL string, data []byte, reqTime time.Time) (*http.Request, error) {
|
func newStreamingUnsignedTrailerRequest(method, targetURL string, data []byte, reqTime time.Time) (*http.Request, error) {
|
||||||
req, err := http.NewRequest(method, targetURL, nil)
|
req, err := http.NewRequest(method, targetURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -535,7 +681,7 @@ func newStreamingUnsignedTrailerRequest(method, targetURL string, data []byte, r
|
|||||||
|
|
||||||
req.Body = io.NopCloser(bytes.NewReader(data))
|
req.Body = io.NopCloser(bytes.NewReader(data))
|
||||||
req.Trailer = http.Header{}
|
req.Trailer = http.Header{}
|
||||||
req.Trailer.Set("x-amz-checksum-crc32", "rK0DXg==")
|
req.Trailer.Set("x-amz-checksum-crc32", crc32Base64(data))
|
||||||
req = signer.StreamingUnsignedV4(req, "", int64(len(data)), reqTime)
|
req = signer.StreamingUnsignedV4(req, "", int64(len(data)), reqTime)
|
||||||
req.Header.Set("X-Amz-Decoded-Content-Length", fmt.Sprintf("%d", len(data)))
|
req.Header.Set("X-Amz-Decoded-Content-Length", fmt.Sprintf("%d", len(data)))
|
||||||
req.Header.Set("Content-Encoding", "aws-chunked")
|
req.Header.Set("Content-Encoding", "aws-chunked")
|
||||||
@@ -544,6 +690,12 @@ func newStreamingUnsignedTrailerRequest(method, targetURL string, data []byte, r
|
|||||||
return req, nil
|
return req, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func crc32Base64(data []byte) string {
|
||||||
|
var crc [4]byte
|
||||||
|
binary.BigEndian.PutUint32(crc[:], crc32.ChecksumIEEE(data))
|
||||||
|
return base64.StdEncoding.EncodeToString(crc[:])
|
||||||
|
}
|
||||||
|
|
||||||
func presignStreamingUnsignedTrailerRequest(req *http.Request, accessKeyID, secretAccessKey string, expires int64, reqTime time.Time) error {
|
func presignStreamingUnsignedTrailerRequest(req *http.Request, accessKeyID, secretAccessKey string, expires int64, reqTime time.Time) error {
|
||||||
if accessKeyID == "" || secretAccessKey == "" {
|
if accessKeyID == "" || secretAccessKey == "" {
|
||||||
return fmt.Errorf("presign cannot be generated without access and secret keys")
|
return fmt.Errorf("presign cannot be generated without access and secret keys")
|
||||||
|
|||||||
Reference in New Issue
Block a user