{"slug": "your-deny-policy-blocks-six-privesc-paths-there-are-nine", "title": "Your Deny Policy Blocks Six Privesc Paths. There Are Nine.", "summary": "A security analysis using a Z3 SAT solver found that the AWS deny policy DemoDenyPrivEscs, which lists six actions intended to block privilege escalation, misses five of nine known compute-launch vectors: Auto Scaling, ECS, CodeBuild, Glue, and SageMaker. The original July 2022 writeup by Edoardo Rosa had identified only the Auto Scaling bypass, in which autoscaling:CreateLaunchConfiguration plus autoscaling:CreateAutoScalingGroup launches an EC2 instance with an admin role that the principal can read via IMDS. The analysis concludes that deny-list prevention is structurally fragile, since every new compute service AWS adds becomes a new bypass path.", "body_md": "✓ Human-authored analysis; AI used for formatting and proofreading.\n\nIn July 2022 Edoardo Rosa published a writeup documenting a privilege escalation in AWS where a principal carrying the AWS-managed `DataScientist` policy plus `AmazonElasticMapReduceFullAccess` could escalate to admin even with an explicit deny policy in place. The deny `DemoDenyPrivEscs` in the writeup listed six actions:\n\n```\n{\n  \"Effect\": \"Deny\",\n  \"Action\": [\n    \"cloudformation:CreateStack\",\n    \"cloudformation:UpdateStack\",\n    \"ec2:RunInstances\",\n    \"lambda:Create*\",\n    \"lambda:Update*\",\n    \"lambda:InvokeFunction\"\n  ],\n  \"Resource\": \"*\"\n}\n```\n\nEach one is a known compute-launch path that, combined with `iam:PassRole`, lets a principal launch compute running as a different role. The deny list reads like the result of a security review where someone enumerated \"ways to launch EC2 with a role\" and copied them into a policy.\n\nThe writeup shows that this list is *incomplete*. The author found one bypass: `autoscaling:CreateLaunchConfiguration` + `autoscaling:CreateAutoScalingGroup` by manual inspection of the AWS service catalog. The path works: the launch configuration specifies an admin role as the instance profile, the autoscaling group launches an EC2 with that role, the principal reads IMDS credentials and gains admin.\n\nThis article runs the same configuration through a Z3 SAT solver with a registry of nine known compute-launch vectors. The solver finds **five** bypasses. After remediation when the deny list is expanded to cover all nine the solver proves the architectural residual: every new compute service AWS adds becomes a new bypass path.\n\nThe deny-list approach to privilege escalation prevention is structurally fragile. The math says so.\n\nA compute-launch vector is any (set of actions) combination that, with `iam:PassRole`, results in compute running as a specified role. The known vectors:\n\n| Service | Action(s) | PassedToService | \n|---|---|---|\n| EC2 | `ec2:RunInstances` | `ec2.amazonaws.com` | \n| Lambda | `lambda:CreateFunction` | `lambda.amazonaws.com` | \n| Lambda | `lambda:UpdateFunctionConfiguration` | `lambda.amazonaws.com` | \n| CloudFormation | `cloudformation:CreateStack` | `cloudformation.amazonaws.com` | \n| Auto Scaling | `autoscaling:CreateLaunchConfiguration` +`CreateAutoScalingGroup` | `ec2.amazonaws.com` | \n| ECS | `ecs:RunTask` | `ecs-tasks.amazonaws.com` | \n| CodeBuild | `codebuild:CreateProject` +`StartBuild` | `codebuild.amazonaws.com` | \n| Glue | `glue:CreateJob` | `glue.amazonaws.com` | \n| SageMaker | `sagemaker:CreateNotebookInstance` | `sagemaker.amazonaws.com` | \n\nNine vectors. Each one is a different code path inside AWS, but each one ends at the same place: an EC2-like compute environment running with the IAM role you named.\n\nThe writeup's deny list covers four: `ec2:RunInstances`, `lambda:CreateFunction` (via `lambda:Create*`), `lambda:UpdateFunctionConfiguration` (via `lambda:Update*`), and `cloudformation:CreateStack`. Plus `cloudformation:UpdateStack` (which doesn't itself launch compute but is included for completeness) and `lambda:InvokeFunction` (also not a compute-launch). The five it misses are autoscaling, ECS, CodeBuild, Glue, and SageMaker.\n\nThe author found one of those five. The other four were sitting there untouched.\n\nThe Z3 prover walks the principal's three policies `DataScientist`, `EMRFullAccess`, `DemoDenyPrivEscs` and computes the *effective permission set*: actions that appear in at least one Allow statement and no Deny statement. For each of the nine compute-launch vectors, the prover checks whether all required actions are effectively permitted *and* `iam:PassRole` is allowed for the vector's `PassedToService`.\n\n```\nvector_available(vector) :=\n  ALL action in vector.RequiredActions:\n    action in any Allow statement\n    AND action not in any Deny statement\n  AND iam:PassRole is allowed AND not denied\n  AND vector.PassedToService in some PassRole condition\n```\n\nThe Z3 query for Finding 1: is there *any* `vector` for which `vector_available(vector)` is true?\n\n```\n--- Finding 1: deny coverage gap ---\n  query:    among 9 known compute-launch vectors, is any one\n            effectively permitted (Allow ∧ ¬Deny)?\n  vectors available: 1 / 9\n  verdict:  SAT — witness: autoscaling — Auto Scaling launch config + group with instance profile\n            actions: [autoscaling:CreateLaunchConfiguration autoscaling:CreateAutoScalingGroup]\n            (deny does not cover these actions; the path is open)\n```\n\n`vectors available: 1 / 9` The prover counts the autoscaling vector as the one reachable path. The other available vectors are filtered by the `PassedToService` condition: ECS needs `ecs-tasks.amazonaws.com` in the condition list, which isn't there; CodeBuild needs `codebuild.amazonaws.com`, also missing; same for Glue and SageMaker. The autoscaling vector matches because EMRFullAccess's PassRole condition includes `ec2.amazonaws.com`.\n\nBut the *deny coverage* table separate from the SAT proof shows which actions the deny does and doesn't cover:\n\n```\n--- Deny coverage analysis ---\n  ec2             BLOCKED    : Direct EC2 launch with instance profile\n  lambda          BLOCKED    : Create Lambda with execution role\n  lambda          BLOCKED    : Update existing Lambda to use different execution role\n  cloudformation  BLOCKED    : Create CloudFormation stack with execution role\n  autoscaling     NOT BLOCKED : Auto Scaling launch config + group with instance profile\n  ecs             NOT BLOCKED : Run ECS task with task role\n  codebuild       NOT BLOCKED : Create and start CodeBuild project with service role\n  glue            NOT BLOCKED : Create Glue job with execution role\n  sagemaker       NOT BLOCKED : Create SageMaker notebook with execution role\n```\n\nFive vectors not blocked. The autoscaling one is *currently* exploitable because the principal's PassRole condition includes EC2. The other four are not currently exploitable because the PassRole condition list doesn't include their respective services. But that's a fragile gate. If the principal later acquires `ec2.amazonaws.com` broader PassRole (say, through a different attached policy that broadens the service list), or if AWS introduces a new service whose role-passing convention reuses `ec2.amazonaws.com`, four more vectors become immediately reachable. The deny doesn't block them because the deny doesn't know about them.\n\n```\n--- Finding 2: PassRole reaches an admin-equivalent role ---\n  query:    is there an admin role whose trust matches the principal's\n            PassRole `iam:PassedToService` condition?\n  reachable admin roles: 1 / 1\n  verdict:  SAT — witness: arn:aws:iam::111122223333:role/demo-EC2Admin\n            (trusts [ec2.amazonaws.com]; has AdministratorAccess; PassRole condition\n             admits any role trusting one of those services)\n```\n\nThe EMR policy's PassRole grant:\n\n```\n{\n  \"Effect\": \"Allow\",\n  \"Action\": \"iam:PassRole\",\n  \"Resource\": \"*\",\n  \"Condition\": {\n    \"StringEquals\": {\n      \"iam:PassedToService\": [\n        \"elasticmapreduce.amazonaws.com\",\n        \"ec2.amazonaws.com\"\n      ]\n    }\n  }\n}\n```\n\n`Resource: \"*\"` and a service-only condition. This is the second architectural fragility: the grant scopes *which services* the role can be passed to, but not *which roles* can be passed. Any role in the account trusting `elasticmapreduce.amazonaws.com` or `ec2.amazonaws.com` is a passable target. The fixture's `demo-EC2Admin` trusts `ec2.amazonaws.com` and has `AdministratorAccess`.\n\n```\n--- Finding 3: complete privesc chain ---\n  query:    is there an available compute-launch vector + an admin\n            role + a trust relationship that all line up?\n  compound paths: 1\n  verdict:  SAT — witness: vector=autoscaling role=arn:aws:iam::111122223333:role/demo-EC2Admin\n            chain: [autoscaling:CreateLaunchConfiguration autoscaling:CreateAutoScalingGroup] → role with AdministratorAccess assumed by EC2 →\n            principal reads IMDS credentials → admin\n```\n\nThe conjunction lands. A vector is reachable, an admin role is PassRole-reachable, and the role's trust service matches the vector's PassedToService. Three API calls `CreateLaunchConfiguration`, `CreateAutoScalingGroup`, then SSH into the launched instance and the principal has admin.\n\nThe natural fix: expand the deny list to cover every known compute-launch action.\n\n```\n {\n   \"Effect\": \"Deny\",\n   \"Action\": [\n     \"cloudformation:CreateStack\",\n     \"cloudformation:UpdateStack\",\n     \"ec2:RunInstances\",\n     \"lambda:Create*\",\n     \"lambda:Update*\",\n-    \"lambda:InvokeFunction\"\n+    \"lambda:InvokeFunction\",\n+    \"autoscaling:CreateLaunchConfiguration\",\n+    \"autoscaling:CreateAutoScalingGroup\",\n+    \"autoscaling:UpdateAutoScalingGroup\",\n+    \"ecs:RunTask\",\n+    \"ecs:CreateService\",\n+    \"codebuild:CreateProject\",\n+    \"codebuild:StartBuild\",\n+    \"glue:CreateJob\",\n+    \"sagemaker:CreateNotebookInstance\",\n+    \"sagemaker:CreateTrainingJob\"\n   ],\n   \"Resource\": \"*\"\n }\n```\n\nRe-run the prover:\n\n```\n========== remediated-config (deny expanded to all 9 known vectors) ==========\n--- Finding 1: deny coverage gap ---\n  vectors available: 0 / 9\n  verdict:  UNSAT — every known compute-launch vector is denied\n            (the expanded deny list covers all 9 known vectors today;\n             see Finding 2 for the residual structural risk)\n\n--- Finding 2: PassRole reaches an admin-equivalent role ---\n  verdict:  SAT — witness: arn:aws:iam::111122223333:role/demo-EC2Admin\n            **RESIDUAL** — the remediated config closes today's launch\n            vectors but does not scope PassRole by role ARN. Any new\n            compute service AWS adds becomes an immediate exploit\n            path until the deny list is expanded.\n\n--- Finding 3: complete privesc chain ---\n  compound paths: 0\n  verdict:  UNSAT — no compound privesc path open\n            (Finding 1 closed all launch vectors; without one of those,\n             the PassRole reachability in Finding 2 has nowhere to land)\n```\n\nTwo of three queries flipped to UNSAT. Finding 2 remains SAT.\n\nThe remediation *works*. Finding 3 is UNSAT, no exploit path is currently open. But Finding 2's persistent SAT is the formal proof that the architecture is *structurally* fragile. The principal can still pass an admin role to \"any service in the condition list.\" The principal cannot currently *use* that role because all known compute-launch services are denied. The day AWS introduces a new compute service that the deny list doesn't know about and AWS introduces roughly a new service per quarter. Finding 3 flips back to SAT.\n\nTwo ways to scope `iam:PassRole`:\n\n```\n// What the writeup uses (and what the remediation\n// keeps unchanged):\n{\n  \"Effect\": \"Allow\",\n  \"Action\": \"iam:PassRole\",\n  \"Resource\": \"*\",\n  \"Condition\": {\n    \"StringEquals\": {\n      \"iam:PassedToService\": [\n        \"elasticmapreduce.amazonaws.com\",\n        \"ec2.amazonaws.com\"\n      ]\n    }\n  }\n}\n// What's structurally sound:\n{\n  \"Effect\": \"Allow\",\n  \"Action\": \"iam:PassRole\",\n  \"Resource\": [\n    \"arn:aws:iam::111122223333:role/EMRClusterRole\",\n    \"arn:aws:iam::111122223333:role/EMRDataNodeRole\"\n  ]\n}\n```\n\nThe first form scopes by *which services* and delegates the question of \"which roles trust those services\" to the IAM trust-policy graph, which the operator does not control globally. The second form scopes by *which roles* which is explicit and finite.\n\nThe deny list approach is a chase: every time AWS adds a service that supports an instance profile or task role, the deny list grows. The PassRole-by-role approach is a fixed enumeration: the only roles that can ever be passed are the named ones, regardless of what AWS launches next month. Z3's residual SAT on Finding 2 is the formal way of saying \"the deny list is the wrong shape; the PassRole grant is the right shape.\"\n\nA heuristic IAM scanner like PMapper detects this specific bypass. Edoardo Rosa's writeup acknowledges PMapper. But a heuristic scanner reports \"this principal can launch autoscaling with an admin role\". A true positive, the kind of finding a SOC reviewer would action. The reviewer fixes autoscaling and moves on.\n\nThe Z3 prover reports the same finding *and* the deny coverage table *and* the residual. The reviewer sees:\n\nThat last point is the difference between \"close the autoscaling hole\" and \"close the deny-list-architecture issue.\" The first a heuristic recommends. The second formal verification recommends.\n\n`iam:PassRole` with `Resource: \"*\"`. The resource is always a specific role ARN list` stave apply` against post-deploy observation snapshots; the existing `CTL.IAM.ESCALATE.PASSROLE.AUTOSCALING.001` and similar per-technique controls fire on regressions\nThe researcher found one bypass through expert knowledge. Z3 found five. After remediation, the solver is still finding something. A structural feature of the architecture that guarantees more exploits will appear. That's the difference between checking the configuration's current state and checking the configuration's *durability*. Pattern matchers do the first. Z3 does both.\n\n*The example at [`iam-autoscaling-privesc-bypass`](https://github.com/sufield/stave/tree/main/examples/iam-autoscaling-privesc-bypass) has two binaries side by side: a CEL evaluation via `pkg/stave.Apply` (uses the existing `CTL.IAM.ESCALATE.PASSROLE.AUTOSCALING.001` per-technique control) and a Z3 SAT prover that walks the principal's effective permission set against a 9-vector compute-launch registry, prints the deny coverage table, and surfaces the residual PassRole-scoping finding that survives remediation. The Z3 binary lives in a sibling Go module so its libz3 link stays out of Stave's main vendored tree. [Stave](https://github.com/sufield/stave) detects this pattern and 31 other H1-grounded scenarios from local AWS configuration snapshots, without cloud credentials.*", "url": "https://wpnews.pro/news/your-deny-policy-blocks-six-privesc-paths-there-are-nine", "canonical_source": "https://dev.to/bala_paranj_059d338e44e7e/your-deny-policy-blocks-six-privesc-paths-there-are-nine-4km9", "published_at": "2026-10-01 11:34:02+00:00", "updated_at": "2026-10-01 11:44:20.071239+00:00", "lang": "en", "topics": ["ai-safety"], "entities": ["AWS", "Edoardo Rosa", "Z3", "DataScientist", "AmazonElasticMapReduceFullAccess", "DemoDenyPrivEscs", "Auto Scaling", "SageMaker"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/your-deny-policy-blocks-six-privesc-paths-there-are-nine", "markdown": "https://wpnews.pro/news/your-deny-policy-blocks-six-privesc-paths-there-are-nine.md", "text": "https://wpnews.pro/news/your-deny-policy-blocks-six-privesc-paths-there-are-nine.txt", "jsonld": "https://wpnews.pro/news/your-deny-policy-blocks-six-privesc-paths-there-are-nine.jsonld"}}