August 7, 2026
The SSRF That Wasn’t Supposed to Work Twice
This one has a twist in it, so bear with the setup. The bug wasn’t hard to find. Getting it accepted was the actual fight.

By T4nv1
5 min read
The Target
A SaaS platform — I'll call it Cartify — that lets merchants generate product image previews by pointing the app at a URL. You paste a link, their backend fetches the image, resizes it, slaps a watermark on it, done. Classic "fetch a URL on behalf of the user" feature, which is basically a neon sign that says try SSRF here to anyone who's spent time in this space.
Scope was generous: *.cartify-example.com and their API subdomain. Bounty range was solid for critical findings, which told me they took infrastructure risk seriously — always a good sign that a real SSRF, if found, would actually get paid.
First Pass: The Obvious Stuff Was Already Blocked
I started where everyone starts:
POST /api/v1/preview-image
Content-Type: application/json
{"image_url": "http://169.254.169.254/latest/meta-data/"}POST /api/v1/preview-image
Content-Type: application/json
{"image_url": "http://169.254.169.254/latest/meta-data/"}Blocked. Response came back as a clean 400 Bad Request — invalid host. So they had some filtering in place. Next:
{"image_url": "http://127.0.0.1:8080/admin"}
{"image_url": "http://localhost/"}
{"image_url": "http://[::1]/"}{"image_url": "http://127.0.0.1:8080/admin"}
{"image_url": "http://localhost/"}
{"image_url": "http://[::1]/"}All blocked. Whoever built this had clearly read an SSRF checklist at some point — localhost, loopback, and the raw metadata IP were all denylisted. Respectable first line of defense. Most testers would move on here. I almost did.
The Bypass
Denylists fail for one reason, always: they enumerate the ways they thought of, not the ways that exist. A few bypasses I ran through:
DNS rebinding-style hostname: I registered a throwaway domain and pointed it at 169.254.169.254, expecting the app might resolve hostnames without re-validating the resolved IP.
{"image_url": "http://metadata.my-test-domain.com/latest/meta-data/"}{"image_url": "http://metadata.my-test-domain.com/latest/meta-data/"}Also blocked. So they were resolving the hostname server-side and checking the resulting IP before making the request — smarter than I expected.
Decimal IP encoding: This is the one that actually worked.
{"image_url": "http://2852039166/latest/meta-data/iam/security-credentials/"}{"image_url": "http://2852039166/latest/meta-data/iam/security-credentials/"}2852039166 is 169.254.169.254 expressed as a plain decimal integer. Their hostname-string denylist was checking for the literal dotted-quad string 169.254.169.254 and common obfuscations like hex or URL-encoded dots — but nobody had accounted for pure decimal IP notation, which most HTTP libraries will happily parse and resolve correctly.
The response came back with a 200 and a JSON body listing an IAM role name. That's the moment the "maybe" turned into a "oh no, this is real."
Going Deeper
From there it was the standard IMDS credential chain:
GET /latest/meta-data/iam/security-credentials/
→ cartify-image-worker-role
GET /latest/meta-data/iam/security-credentials/cartify-image-worker-role
→ {
"AccessKeyId": "ASIA...redacted...",
"SecretAccessKey": "redacted",
"Token": "redacted",
"Expiration": "..."
}GET /latest/meta-data/iam/security-credentials/
→ cartify-image-worker-role
GET /latest/meta-data/iam/security-credentials/cartify-image-worker-role
→ {
"AccessKeyId": "ASIA...redacted...",
"SecretAccessKey": "redacted",
"Token": "redacted",
"Expiration": "..."
}Temporary IAM credentials, exfiltrated blind, through an image preview endpoint. I plugged them into the AWS CLI against a throwaway sts get-caller-identity call just to confirm they were live and scoped to something real, then stopped immediately — using stolen credentials beyond confirming validity crosses from "proof of concept" into "actually accessing systems," which is exactly the kind of thing that gets a researcher banned from a program instead of paid.
I wrote it up as a critical: blind SSRF, denylist bypass via decimal IP encoding, full IMDS credential disclosure for a role attached to their image-processing infrastructure.
The Twist
Here's where it gets interesting.
Triage came back three days later with a "won't fix — informational." Their reasoning: the exposed role was heavily scoped down, limited to a single S3 bucket used only for temporary image processing, no write access outside that bucket, no lateral movement path they could see. Their position was that stealing these particular credentials didn't get an attacker anywhere meaningful.
On paper, that's a defensible read. A lot of programs would stop there, and a lot of researchers would just accept the downgrade and move on.
But something bugged me about it: if this worker role could reach the metadata service and pull temporary credentials, and the whole point of that worker was processing externally supplied image URLs, then the SSRF wasn't limited to the metadata IP. It was a general blind SSRF primitive. So I went back and tested what else was reachable from inside that same network position — not to prove impact through the stolen S3 credentials, but through the SSRF itself as an internal port scanner.
{"image_url": "http://2852039166/latest/meta-data/../../internal-api:8443/health"}{"image_url": "http://2852039166/latest/meta-data/../../internal-api:8443/health"}That path-traversal trick against the decimal-encoded IP got me past their host check and let the request pivot to a completely different internal service once it was inside their network boundary — an internal API gateway that had no external authentication requirement at all, because it had never been designed to be reachable from outside the VPC.
From there, hitting /internal-api:8443/debug/config returned a full service configuration dump: internal hostnames, a database connection string, and — this is the part that changed everything — a valid API key for their internal billing service.
That reframed the entire report. This wasn't "a scoped-down S3 role with no lateral path." It was a blind SSRF that functioned as an unauthenticated pivot into their internal network, with a real path to a billing system credential. I updated the report with the new chain, the internal config dump (redacted), and a clear explanation of why the original "limited blast radius" reasoning didn't hold once the SSRF was treated as a general internal request primitive rather than a one-off metadata grab.
Reopened within a few hours. Upgraded to critical. Paid out at their top tier.
Why the Denylist Failed
Worth slowing down on the actual root cause, because it's a pattern, not a one-off mistake:
- String matching instead of IP-space validation. They checked for known-bad hostname strings instead of resolving the final destination and validating the numeric IP against a real blocklist (RFC 1918 ranges, link-local, loopback) at request time.
- No re-validation after redirect or DNS resolution. Once a hostname passed the initial check, nothing re-verified where the request actually landed.
- Treating SSRF as a metadata-only risk. Even after the initial finding, their own triage assumed the only thing reachable was the metadata service. In practice, once you can make the server issue arbitrary internal requests, the metadata endpoint is just the first thing worth checking, not the boundary of impact.
Fix They Shipped
To their credit, once the second chain landed, the fix was solid: they moved to an allowlist of permitted destination IP ranges resolved and checked at request time (not string-matched), added a network-level egress rule blocking the image-processing workers from reaching internal-only services entirely, and rotated every credential reachable from that worker role.
Takeaways for Anyone Chasing SSRF
- Denylists built on string matching almost always have an encoding gap. Decimal, octal, hex, IPv6-mapped IPv4, and mixed-case percent-encoding are all worth trying before you give up on a "blocked" target.
- Don't stop at the metadata service. It's the easy, obvious first stop — but a blind SSRF is a network position, not a single-endpoint bug. Ask what else is reachable from there.
- A "won't fix" isn't always final. If the triager's reasoning has a gap in it — like assuming the blast radius stops where your first proof-of-concept stopped — go find the thing that closes that gap instead of arguing about severity in the comments.
- Know where your proof-of-concept has to stop. Confirming credentials are live is proof of impact. Using them to move further is a policy violation waiting to happen, even when your intentions are good.
SSRF bugs live and die on how far you're willing to actually map the network behind the vulnerable request. The first blocked payload is rarely the end of the story.