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:
Feng Ruohang
2026-04-16 13:52:53 +08:00
parent f444b6f37e
commit efb6e5b00b
2 changed files with 161 additions and 2 deletions
+8 -1
View File
@@ -2289,7 +2289,7 @@ func (api objectAPIHandlers) PutObjectExtractHandler(w http.ResponseWriter, r *h
// if Content-Length is unknown/missing, deny the request
size := r.ContentLength
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[0] == "" {
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)
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:
s3Err = isReqAuthenticatedV2(r)
if s3Err != ErrNone {
+153 -1
View File
@@ -18,11 +18,15 @@
package cmd
import (
"archive/tar"
"bytes"
"context"
"encoding/base64"
"encoding/binary"
"encoding/json"
"encoding/xml"
"fmt"
"hash/crc32"
"io"
"math/rand"
"net/http"
@@ -132,6 +136,9 @@ func runAllTests(suite *TestSuiteCommon, c *check) {
suite.TestUnsignedQueryStringCVE(c)
suite.TestUnsignedQueryStringCVEMultipart(c)
suite.TestUnsignedTrailerRejectsMultipleAuthSources(c)
suite.TestUnsignedTrailerSnowballAnonymousDenied(c)
suite.TestUnsignedTrailerSnowballRequiresSignature(c)
suite.TestUnsignedTrailerSnowballExtract(c)
suite.TestAnonymousUnsignedTrailer(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)
}
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) {
c.Helper()
@@ -527,6 +649,30 @@ func (s *TestSuiteCommon) TestAnonymousUnsignedTrailer(c *check) {
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) {
req, err := http.NewRequest(method, targetURL, nil)
if err != nil {
@@ -535,7 +681,7 @@ func newStreamingUnsignedTrailerRequest(method, targetURL string, data []byte, r
req.Body = io.NopCloser(bytes.NewReader(data))
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.Header.Set("X-Amz-Decoded-Content-Length", fmt.Sprintf("%d", len(data)))
req.Header.Set("Content-Encoding", "aws-chunked")
@@ -544,6 +690,12 @@ func newStreamingUnsignedTrailerRequest(method, targetURL string, data []byte, r
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 {
if accessKeyID == "" || secretAccessKey == "" {
return fmt.Errorf("presign cannot be generated without access and secret keys")