diff --git a/api/changelog.d/pathfinding-attack-paths-service-queries.added.md b/api/changelog.d/pathfinding-attack-paths-service-queries.added.md new file mode 100644 index 0000000000..5a3a328875 --- /dev/null +++ b/api/changelog.d/pathfinding-attack-paths-service-queries.added.md @@ -0,0 +1 @@ +Attack Paths adds 20 AWS privilege-escalation detection queries from pathfinding.cloud, covering service PassRole escalations (Batch, Braket, Cognito Identity, ECS, EMR, EMR Serverless, GameLift, Glue, EC2 Image Builder, Kinesis Analytics, HealthOmics, EventBridge Scheduler, SSM, Step Functions), CodeDeploy and Step Functions existing-resource abuse, role permissions-boundary removal with role assumption, and IAM Identity Center permission-set policy injection diff --git a/api/src/backend/api/attack_paths/queries/aws.py b/api/src/backend/api/attack_paths/queries/aws.py index ee5dc909be..6a7a47d863 100644 --- a/api/src/backend/api/attack_paths/queries/aws.py +++ b/api/src/backend/api/attack_paths/queries/aws.py @@ -433,6 +433,100 @@ AWS_APPRUNNER_PRIVESC_UPDATE_SERVICE = AttackPathsQueryDefinition( parameters=[], ) +# BATCH-001 +AWS_BATCH_PRIVESC_PASSROLE_SUBMIT_JOB = AttackPathsQueryDefinition( + id="aws-batch-privesc-passrole-submit-job", + name="AWS Batch Job Submission with Privileged Role (BATCH-001)", + short_description="Register and submit an AWS Batch job that runs with a privileged job-role to gain that role's permissions.", + description="Detect principals who can pass IAM roles, register AWS Batch job definitions, and submit jobs. This lets an actor register a job definition referencing a privileged job role and submit it, executing arbitrary commands as that role.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - BATCH-001 - iam:PassRole + batch:RegisterJobDefinition + batch:SubmitJob", + link="https://pathfinding.cloud/paths/batch-001", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal + + // Find batch:registerjobdefinition permission + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['batch:*', 'batch:registerjobdefinition'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal + + // Find batch:submitjob permission + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem) + WHERE toLower(act3.value) IN ['batch:*', 'batch:submitjob'] + OR act3.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal + + // Pre-aggregate the PassRole statement resources (see docs: Avoiding Cartesian Products) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WITH aws, principal, path_principal, collect(DISTINCT res.value) AS res_values + WITH aws, principal, path_principal, res_values, ('*' IN res_values) AS res_wildcard + + // Target Batch job role that trusts the ecs-tasks.amazonaws.com service and can be passed + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ecs-tasks.amazonaws.com'}}) + WITH path_principal, path_target, res_values, res_wildcard, + target_role.name AS rname, target_role.arn AS rarn + WHERE res_wildcard + OR size([rv IN res_values WHERE rv CONTAINS rname OR rarn CONTAINS rv]) > 0 + + WITH DISTINCT path_principal, path_target + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# BATCH-002 +AWS_BATCH_PRIVESC_SUBMIT_EXISTING_JOB = AttackPathsQueryDefinition( + id="aws-batch-privesc-submit-existing-job", + name="AWS Batch Existing Job Definition Submission (BATCH-002)", + short_description="Submit an existing AWS Batch job definition bound to a privileged job role to run commands as that role.", + description="Detect principals who can submit AWS Batch jobs. Using an existing job definition that references a privileged job role, an actor can submit a job and execute commands as that role without iam:PassRole. The graph does not model which job definition is bound to which role, so this lists every role trusting the ecs-tasks.amazonaws.com service; each result needs manual review to confirm an existing job definition actually uses the role.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - BATCH-002 - batch:SubmitJob", + link="https://pathfinding.cloud/paths/batch-002", + ), + provider="aws", + cypher=f""" + // Find principals with batch:submitjob permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['batch:*', 'batch:submitjob'] + OR act.value = '*' + WITH DISTINCT aws, principal, path_principal + + // Target Batch job role attached to the existing job definition, trusting the ecs-tasks.amazonaws.com service + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ecs-tasks.amazonaws.com'}}) + + WITH DISTINCT path_principal, path_target + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + # BEDROCK-001 AWS_BEDROCK_PRIVESC_PASSROLE_CODE_INTERPRETER = AttackPathsQueryDefinition( id="aws-bedrock-privesc-passrole-code-interpreter", @@ -539,6 +633,58 @@ AWS_BEDROCK_PRIVESC_INVOKE_CODE_INTERPRETER = AttackPathsQueryDefinition( parameters=[], ) +# BRAKET-001 +AWS_BRAKET_PRIVESC_PASSROLE_CREATE_JOB = AttackPathsQueryDefinition( + id="aws-braket-privesc-passrole-create-job", + name="Amazon Braket Job Creation with Privileged Role (BRAKET-001)", + short_description="Create an Amazon Braket hybrid job with a privileged execution role to run arbitrary code as that role.", + description="Detect principals who can pass IAM roles and create Amazon Braket jobs. A Braket job runs a container with an attached execution role, so an actor can execute arbitrary code and obtain that role's credentials.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - BRAKET-001 - iam:PassRole + braket:CreateJob", + link="https://pathfinding.cloud/paths/braket-001", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal + + // Find braket:createjob permission + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['braket:*', 'braket:createjob'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal + + // Pre-aggregate the PassRole statement resources (see docs: Avoiding Cartesian Products) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WITH aws, principal, path_principal, collect(DISTINCT res.value) AS res_values + WITH aws, principal, path_principal, res_values, ('*' IN res_values) AS res_wildcard + + // Target role that trusts the braket.amazonaws.com service and can be passed + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'braket.amazonaws.com'}}) + WITH path_principal, path_target, res_values, res_wildcard, + target_role.name AS rname, target_role.arn AS rarn + WHERE res_wildcard + OR size([rv IN res_values WHERE rv CONTAINS rname OR rarn CONTAINS rv]) > 0 + + WITH DISTINCT path_principal, path_target + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + # CLOUDFORMATION-001 AWS_CLOUDFORMATION_PRIVESC_PASSROLE_CREATE_STACK = AttackPathsQueryDefinition( id="aws-cloudformation-privesc-passrole-create-stack", @@ -955,6 +1101,106 @@ AWS_CODEBUILD_PRIVESC_PASSROLE_CREATE_PROJECT_BATCH = AttackPathsQueryDefinition parameters=[], ) +# CODEDEPLOY-001 +AWS_CODEDEPLOY_PRIVESC_CREATE_DEPLOYMENT = AttackPathsQueryDefinition( + id="aws-codedeploy-privesc-create-deployment", + name="CodeDeploy Deployment with Existing Instance Role (CODEDEPLOY-001)", + short_description="Create a CodeDeploy deployment on an existing application to run lifecycle hooks with the target instances' privileged EC2 role.", + description="Detect principals who can create CodeDeploy deployments. Using an existing application and deployment group bound to EC2 instances with a privileged instance-profile role, an actor can deploy a revision whose lifecycle hooks run on those instances as that role. The graph does not model which deployment group targets which instances, so this lists every role trusting the ec2.amazonaws.com service; each result needs manual review to confirm an existing application/deployment group actually uses the role.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - CODEDEPLOY-001 - codedeploy:CreateDeployment + codedeploy:RegisterApplicationRevision", + link="https://pathfinding.cloud/paths/codedeploy-001", + ), + provider="aws", + cypher=f""" + // Find principals with codedeploy:createdeployment permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['codedeploy:*', 'codedeploy:createdeployment'] + OR act.value = '*' + WITH DISTINCT aws, principal, path_principal + + // Find codedeploy:registerapplicationrevision permission + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['codedeploy:*', 'codedeploy:registerapplicationrevision'] + OR act2.value = '*' + WITH DISTINCT aws, principal, path_principal + + // Find codedeploy:getdeploymentconfig permission + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem) + WHERE toLower(act3.value) IN ['codedeploy:*', 'codedeploy:getdeploymentconfig'] + OR act3.value = '*' + WITH DISTINCT aws, principal, path_principal + + // Target EC2 instance role whose credentials the lifecycle hooks run with, trusting the ec2.amazonaws.com service + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ec2.amazonaws.com'}}) + + WITH DISTINCT path_principal, path_target + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# COGNITOIDENTITY-001 +AWS_COGNITO_PRIVESC_PASSROLE_SET_IDENTITY_POOL_ROLES = AttackPathsQueryDefinition( + id="aws-cognito-privesc-passrole-set-identity-pool-roles", + name="Cognito Identity Pool Role Assignment with Privileged Role (COGNITOIDENTITY-001)", + short_description="Attach a privileged role to a Cognito identity pool and assume it through federated identity to gain its permissions.", + description="Detect principals who can pass IAM roles and set Cognito identity pool roles. An actor can point an identity pool at a privileged role and then obtain credentials for it through the identity pool's federated web-identity trust.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - COGNITOIDENTITY-001 - iam:PassRole + cognito-identity:SetIdentityPoolRoles", + link="https://pathfinding.cloud/paths/cognitoidentity-001", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal + + // Find cognito-identity:setidentitypoolroles permission + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['cognito-identity:*', 'cognito-identity:setidentitypoolroles'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal + + // Pre-aggregate the PassRole statement resources (see docs: Avoiding Cartesian Products) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WITH aws, principal, path_principal, collect(DISTINCT res.value) AS res_values + WITH aws, principal, path_principal, res_values, ('*' IN res_values) AS res_wildcard + + // Target role that trusts the cognito-identity.amazonaws.com service and can be passed + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'cognito-identity.amazonaws.com'}}) + WITH path_principal, path_target, res_values, res_wildcard, + target_role.name AS rname, target_role.arn AS rarn + WHERE res_wildcard + OR size([rv IN res_values WHERE rv CONTAINS rname OR rarn CONTAINS rv]) > 0 + + WITH DISTINCT path_principal, path_target + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + # DATAPIPELINE-001 AWS_DATAPIPELINE_PRIVESC_PASSROLE_CREATE_PIPELINE = AttackPathsQueryDefinition( id="aws-datapipeline-privesc-passrole-create-pipeline", @@ -1562,6 +1808,232 @@ AWS_ECS_PRIVESC_EXECUTE_COMMAND = AttackPathsQueryDefinition( parameters=[], ) +# ECS-009 +AWS_ECS_PRIVESC_PASSROLE_START_EXISTING_TASK = AttackPathsQueryDefinition( + id="aws-ecs-privesc-passrole-start-existing-task", + name="ECS Existing Task Launch with Privileged Role (ECS-009)", + short_description="Start an existing ECS task definition that has a privileged task role to run arbitrary commands as that role.", + description="Detect principals who can pass IAM roles and start ECS tasks. Using an existing task definition bound to a privileged task role, an actor can start the task and gain that role's permissions without registering a new definition.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - ECS-009 - iam:PassRole + ecs:StartTask", + link="https://pathfinding.cloud/paths/ecs-009", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal + + // Find ecs:starttask permission + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['ecs:*', 'ecs:starttask'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal + + // Pre-aggregate the PassRole statement resources (see docs: Avoiding Cartesian Products) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WITH aws, principal, path_principal, collect(DISTINCT res.value) AS res_values + WITH aws, principal, path_principal, res_values, ('*' IN res_values) AS res_wildcard + + // Target role that trusts the ecs-tasks.amazonaws.com service and can be passed + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ecs-tasks.amazonaws.com'}}) + WITH path_principal, path_target, res_values, res_wildcard, + target_role.name AS rname, target_role.arn AS rarn + WHERE res_wildcard + OR size([rv IN res_values WHERE rv CONTAINS rname OR rarn CONTAINS rv]) > 0 + + WITH DISTINCT path_principal, path_target + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# EMR-001 +AWS_EMR_PRIVESC_PASSROLE_RUN_JOB_FLOW = AttackPathsQueryDefinition( + id="aws-emr-privesc-passrole-run-job-flow", + name="EMR Cluster Launch with Privileged Role (EMR-001)", + short_description="Launch an EMR cluster with a privileged EC2 instance (JobFlow) role to execute steps that use that role's permissions.", + description="Detect principals who can pass IAM roles and run EMR job flows. An actor can launch a cluster with a privileged EC2 instance (JobFlow) role and run steps that act with that role's permissions.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - EMR-001 - iam:PassRole + elasticmapreduce:RunJobFlow", + link="https://pathfinding.cloud/paths/emr-001", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal + + // Find elasticmapreduce:runjobflow permission + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['elasticmapreduce:*', 'elasticmapreduce:runjobflow'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal + + // Pre-aggregate the PassRole statement resources (see docs: Avoiding Cartesian Products) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WITH aws, principal, path_principal, collect(DISTINCT res.value) AS res_values + WITH aws, principal, path_principal, res_values, ('*' IN res_values) AS res_wildcard + + // Target EC2 instance (JobFlow) role that trusts the ec2.amazonaws.com service and can be passed + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ec2.amazonaws.com'}}) + WITH path_principal, path_target, res_values, res_wildcard, + target_role.name AS rname, target_role.arn AS rarn + WHERE res_wildcard + OR size([rv IN res_values WHERE rv CONTAINS rname OR rarn CONTAINS rv]) > 0 + + WITH DISTINCT path_principal, path_target + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# EMRSERVERLESS-001 +AWS_EMRSERVERLESS_PRIVESC_PASSROLE_START_JOB = AttackPathsQueryDefinition( + id="aws-emrserverless-privesc-passrole-start-job", + name="EMR Serverless Job Execution with Privileged Role (EMRSERVERLESS-001)", + short_description="Create an EMR Serverless application and run a job with a privileged runtime role to gain its permissions.", + description="Detect principals who can pass IAM roles, create EMR Serverless applications, and start job runs. An actor can run a job with a privileged runtime role and execute arbitrary code as that role.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - EMRSERVERLESS-001 - iam:PassRole + emr-serverless:CreateApplication + emr-serverless:StartJobRun", + link="https://pathfinding.cloud/paths/emrserverless-001", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal + + // Find emr-serverless:createapplication permission + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['emr-serverless:*', 'emr-serverless:createapplication'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal + + // Find emr-serverless:startjobrun permission + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem) + WHERE toLower(act3.value) IN ['emr-serverless:*', 'emr-serverless:startjobrun'] + OR act3.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal + + // Pre-aggregate the PassRole statement resources (see docs: Avoiding Cartesian Products) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WITH aws, principal, path_principal, collect(DISTINCT res.value) AS res_values + WITH aws, principal, path_principal, res_values, ('*' IN res_values) AS res_wildcard + + // Target role that trusts the emr-serverless.amazonaws.com service and can be passed + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'emr-serverless.amazonaws.com'}}) + WITH path_principal, path_target, res_values, res_wildcard, + target_role.name AS rname, target_role.arn AS rarn + WHERE res_wildcard + OR size([rv IN res_values WHERE rv CONTAINS rname OR rarn CONTAINS rv]) > 0 + + WITH DISTINCT path_principal, path_target + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# GAMELIFT-001 +AWS_GAMELIFT_PRIVESC_PASSROLE_CREATE_FLEET = AttackPathsQueryDefinition( + id="aws-gamelift-privesc-passrole-create-fleet", + name="GameLift Fleet Creation with Privileged Role (GAMELIFT-001)", + short_description="Create a GameLift build and fleet with a privileged instance role to run arbitrary code as that role.", + description="Detect principals who can pass IAM roles, upload a GameLift build, and create a fleet. The fleet instances run the build with an attached privileged role, allowing arbitrary code execution as that role.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - GAMELIFT-001 - iam:PassRole + gamelift:CreateBuild + gamelift:RequestUploadCredentials + gamelift:CreateFleet", + link="https://pathfinding.cloud/paths/gamelift-001", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal + + // Find gamelift:createbuild permission + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['gamelift:*', 'gamelift:createbuild'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal + + // Find gamelift:createfleet permission + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem) + WHERE toLower(act3.value) IN ['gamelift:*', 'gamelift:createfleet'] + OR act3.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal + + // Find gamelift:requestuploadcredentials permission + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(act4:AWSPolicyStatementActionItem) + WHERE toLower(act4.value) IN ['gamelift:*', 'gamelift:requestuploadcredentials'] + OR act4.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal + + // Pre-aggregate the PassRole statement resources (see docs: Avoiding Cartesian Products) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WITH aws, principal, path_principal, collect(DISTINCT res.value) AS res_values + WITH aws, principal, path_principal, res_values, ('*' IN res_values) AS res_wildcard + + // Target role that trusts the gamelift.amazonaws.com service and can be passed + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'gamelift.amazonaws.com'}}) + WITH path_principal, path_target, res_values, res_wildcard, + target_role.name AS rname, target_role.arn AS rarn + WHERE res_wildcard + OR size([rv IN res_values WHERE rv CONTAINS rname OR rarn CONTAINS rv]) > 0 + + WITH DISTINCT path_principal, path_target + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + # GLUE-001 AWS_GLUE_PRIVESC_PASSROLE_DEV_ENDPOINT = AttackPathsQueryDefinition( id="aws-glue-privesc-passrole-dev-endpoint", @@ -1867,6 +2339,64 @@ AWS_GLUE_PRIVESC_PASSROLE_UPDATE_JOB_TRIGGER = AttackPathsQueryDefinition( parameters=[], ) +# GLUE-007 +AWS_GLUE_PRIVESC_PASSROLE_CREATE_SESSION = AttackPathsQueryDefinition( + id="aws-glue-privesc-passrole-create-session", + name="Glue Interactive Session with Privileged Role (GLUE-007)", + short_description="Create a Glue interactive session with a privileged role and run statements that execute as that role.", + description="Detect principals who can pass IAM roles, create Glue interactive sessions, and run statements. An actor can open a session bound to a privileged role and run arbitrary code as that role.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - GLUE-007 - iam:PassRole + glue:CreateSession + glue:RunStatement", + link="https://pathfinding.cloud/paths/glue-007", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal + + // Find glue:createsession permission + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['glue:*', 'glue:createsession'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal + + // Find glue:runstatement permission + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem) + WHERE toLower(act3.value) IN ['glue:*', 'glue:runstatement'] + OR act3.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal + + // Pre-aggregate the PassRole statement resources (see docs: Avoiding Cartesian Products) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WITH aws, principal, path_principal, collect(DISTINCT res.value) AS res_values + WITH aws, principal, path_principal, res_values, ('*' IN res_values) AS res_wildcard + + // Target role that trusts the glue.amazonaws.com service and can be passed + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'glue.amazonaws.com'}}) + WITH path_principal, path_target, res_values, res_wildcard, + target_role.name AS rname, target_role.arn AS rarn + WHERE res_wildcard + OR size([rv IN res_values WHERE rv CONTAINS rname OR rarn CONTAINS rv]) > 0 + + WITH DISTINCT path_principal, path_target + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + # IAM-001 AWS_IAM_PRIVESC_CREATE_POLICY_VERSION = AttackPathsQueryDefinition( id="aws-iam-privesc-create-policy-version", @@ -2870,6 +3400,222 @@ AWS_IAM_PRIVESC_PUT_ROLE_POLICY_UPDATE_ASSUME_ROLE = AttackPathsQueryDefinition( parameters=[], ) +# IAM-022 +AWS_IAM_PRIVESC_DELETE_USER_PERMISSIONS_BOUNDARY = AttackPathsQueryDefinition( + id="aws-iam-privesc-delete-user-permissions-boundary", + name="Permissions Boundary Removal for Self-Escalation (IAM-022)", + short_description="IAM users that can remove their own permissions boundary, if one is attached.", + description="Find IAM users whose policies allow iam:DeleteUserPermissionsBoundary on their own user ARN. The graph does not record whether a boundary is attached or whether removing it grants more access, so each result needs manual review.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-022 - iam:DeleteUserPermissionsBoundary", + link="https://pathfinding.cloud/paths/iam-022", + ), + provider="aws", + cypher=f""" + // Find IAM users with iam:DeleteUserPermissionsBoundary permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSUser)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:deleteuserpermissionsboundary'] + OR act.value = '*' + WITH DISTINCT principal, stmt, path_principal + + // Keep only users that can remove the boundary from their own user ARN + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value = principal.arn + OR (res.value ENDS WITH '*' AND principal.arn STARTS WITH left(res.value, size(res.value) - 1)) + + WITH DISTINCT path_principal + WITH collect(path_principal) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-023 +AWS_IAM_PRIVESC_DELETE_ROLE_BOUNDARY_ASSUME_ROLE = AttackPathsQueryDefinition( + id="aws-iam-privesc-delete-role-boundary-assume-role", + name="Role Permissions Boundary Removal with Role Assumption (IAM-023)", + short_description="Delete an assumable role's permissions boundary to unlock its full permissions, then assume it.", + description="Detect principals who can delete a role's permissions boundary and also assume that role. Removing the boundary restores the role's broader attached permissions, which the actor then gains by assuming the role. The graph does not record whether a boundary is actually attached to the target role, so each result needs manual review.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-023 - iam:DeleteRolePermissionsBoundary + sts:AssumeRole", + link="https://pathfinding.cloud/paths/iam-023", + ), + provider="aws", + cypher=f""" + // Find principals with iam:DeleteRolePermissionsBoundary permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:deleterolepermissionsboundary'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt, path_principal + + // Find sts:AssumeRole permission on the same principal + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['sts:*', 'sts:assumerole'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt, path_principal + + // Target role the principal can assume (bidirectional trust via Cartography) + MATCH path_target = (aws)--(target_role:AWSRole)<-[:STS_ASSUMEROLE_ALLOW]-(principal) + + // Keep only when the boundary can be removed from that same assumable role + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + OR res.value = target_role.arn + OR (res.value ENDS WITH '*' AND target_role.arn STARTS WITH left(res.value, size(res.value) - 1)) + + WITH DISTINCT path_principal, path_target + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IMAGEBUILDER-001 +AWS_IMAGEBUILDER_PRIVESC_PASSROLE_CREATE_IMAGE = AttackPathsQueryDefinition( + id="aws-imagebuilder-privesc-passrole-create-image", + name="EC2 Image Builder Pipeline with Privileged Role (IMAGEBUILDER-001)", + short_description="Build an EC2 Image Builder image whose infrastructure instance profile is a privileged role to run arbitrary code as that role.", + description="Detect principals who can pass IAM roles and drive an EC2 Image Builder pipeline. The build runs component code on an instance using a privileged instance-profile role, allowing arbitrary code execution as that role.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IMAGEBUILDER-001 - iam:PassRole + imagebuilder:CreateComponent + imagebuilder:CreateImage", + link="https://pathfinding.cloud/paths/imagebuilder-001", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal + + // Find imagebuilder:createcomponent permission + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['imagebuilder:*', 'imagebuilder:createcomponent'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal + + // Find imagebuilder:createinfrastructureconfiguration permission + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem) + WHERE toLower(act3.value) IN ['imagebuilder:*', 'imagebuilder:createinfrastructureconfiguration'] + OR act3.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal + + // Find imagebuilder:createimage permission + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(act4:AWSPolicyStatementActionItem) + WHERE toLower(act4.value) IN ['imagebuilder:*', 'imagebuilder:createimage'] + OR act4.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal + + // Find imagebuilder:createimagerecipe permission + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(act5:AWSPolicyStatementActionItem) + WHERE toLower(act5.value) IN ['imagebuilder:*', 'imagebuilder:createimagerecipe'] + OR act5.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal + + // Pre-aggregate the PassRole statement resources (see docs: Avoiding Cartesian Products) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WITH aws, principal, path_principal, collect(DISTINCT res.value) AS res_values + WITH aws, principal, path_principal, res_values, ('*' IN res_values) AS res_wildcard + + // Target role that trusts the ec2.amazonaws.com service and can be passed + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ec2.amazonaws.com'}}) + WITH path_principal, path_target, res_values, res_wildcard, + target_role.name AS rname, target_role.arn AS rarn + WHERE res_wildcard + OR size([rv IN res_values WHERE rv CONTAINS rname OR rarn CONTAINS rv]) > 0 + + WITH DISTINCT path_principal, path_target + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# KINESISANALYTICS-001 +AWS_KINESISANALYTICS_PRIVESC_PASSROLE_CREATE_APP = AttackPathsQueryDefinition( + id="aws-kinesisanalytics-privesc-passrole-create-application", + name="Kinesis Data Analytics Application with Privileged Role (KINESISANALYTICS-001)", + short_description="Create and start a Kinesis Data Analytics application with a privileged role to run code as that role.", + description="Detect principals who can pass IAM roles, create Kinesis Data Analytics applications, and start them. The application runs with an attached privileged role, allowing code execution as that role.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - KINESISANALYTICS-001 - iam:PassRole + kinesisanalytics:CreateApplication + kinesisanalytics:StartApplication", + link="https://pathfinding.cloud/paths/kinesisanalytics-001", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal + + // Find kinesisanalytics:createapplication permission + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['kinesisanalytics:*', 'kinesisanalytics:createapplication'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal + + // Find kinesisanalytics:startapplication permission + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem) + WHERE toLower(act3.value) IN ['kinesisanalytics:*', 'kinesisanalytics:startapplication'] + OR act3.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal + + // Pre-aggregate the PassRole statement resources (see docs: Avoiding Cartesian Products) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WITH aws, principal, path_principal, collect(DISTINCT res.value) AS res_values + WITH aws, principal, path_principal, res_values, ('*' IN res_values) AS res_wildcard + + // Target role that trusts the kinesisanalytics.amazonaws.com service and can be passed + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'kinesisanalytics.amazonaws.com'}}) + WITH path_principal, path_target, res_values, res_wildcard, + target_role.name AS rname, target_role.arn AS rarn + WHERE res_wildcard + OR size([rv IN res_values WHERE rv CONTAINS rname OR rarn CONTAINS rv]) > 0 + + WITH DISTINCT path_principal, path_target + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + # LAMBDA-001 AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION = AttackPathsQueryDefinition( id="aws-lambda-privesc-passrole-create-function", @@ -3178,6 +3924,70 @@ AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION_ADD_PERMISSION = AttackPathsQueryDef parameters=[], ) +# OMICS-001 +AWS_OMICS_PRIVESC_PASSROLE_START_RUN = AttackPathsQueryDefinition( + id="aws-omics-privesc-passrole-start-run", + name="HealthOmics Workflow Run with Privileged Role (OMICS-001)", + short_description="Create and start an AWS HealthOmics workflow run with a privileged role to execute as that role.", + description="Detect principals who can pass IAM roles, create HealthOmics workflows, and start runs. A run executes with an attached privileged role, allowing arbitrary workflow code to act as that role.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - OMICS-001 - iam:PassRole + omics:CreateWorkflow + omics:StartRun", + link="https://pathfinding.cloud/paths/omics-001", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal + + // Find omics:createworkflow permission + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['omics:*', 'omics:createworkflow'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal + + // Find omics:startrun permission + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem) + WHERE toLower(act3.value) IN ['omics:*', 'omics:startrun'] + OR act3.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal + + // Find s3:getobject permission (read the workflow definition object) + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(act4:AWSPolicyStatementActionItem) + WHERE toLower(act4.value) IN ['s3:*', 's3:getobject'] + OR act4.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal + + // Pre-aggregate the PassRole statement resources (see docs: Avoiding Cartesian Products) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WITH aws, principal, path_principal, collect(DISTINCT res.value) AS res_values + WITH aws, principal, path_principal, res_values, ('*' IN res_values) AS res_wildcard + + // Target role that trusts the omics.amazonaws.com service and can be passed + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'omics.amazonaws.com'}}) + WITH path_principal, path_target, res_values, res_wildcard, + target_role.name AS rname, target_role.arn AS rarn + WHERE res_wildcard + OR size([rv IN res_values WHERE rv CONTAINS rname OR rarn CONTAINS rv]) > 0 + + WITH DISTINCT path_principal, path_target + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + # SAGEMAKER-001 AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_NOTEBOOK = AttackPathsQueryDefinition( id="aws-sagemaker-privesc-passrole-create-notebook", @@ -3421,6 +4231,58 @@ AWS_SAGEMAKER_PRIVESC_LIFECYCLE_CONFIG_NOTEBOOK = AttackPathsQueryDefinition( parameters=[], ) +# SCHEDULER-001 +AWS_SCHEDULER_PRIVESC_PASSROLE_CREATE_SCHEDULE = AttackPathsQueryDefinition( + id="aws-scheduler-privesc-passrole-create-schedule", + name="EventBridge Scheduler Target with Privileged Role (SCHEDULER-001)", + short_description="Create an EventBridge Scheduler schedule that invokes a target using a privileged role to act as that role.", + description="Detect principals who can pass IAM roles and create EventBridge Scheduler schedules. A schedule invokes its target with an attached privileged role, letting an actor perform privileged API calls as that role.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - SCHEDULER-001 - iam:PassRole + scheduler:CreateSchedule", + link="https://pathfinding.cloud/paths/scheduler-001", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal + + // Find scheduler:createschedule permission + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['scheduler:*', 'scheduler:createschedule'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal + + // Pre-aggregate the PassRole statement resources (see docs: Avoiding Cartesian Products) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WITH aws, principal, path_principal, collect(DISTINCT res.value) AS res_values + WITH aws, principal, path_principal, res_values, ('*' IN res_values) AS res_wildcard + + // Target role that trusts the scheduler.amazonaws.com service and can be passed + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'scheduler.amazonaws.com'}}) + WITH path_principal, path_target, res_values, res_wildcard, + target_role.name AS rname, target_role.arn AS rarn + WHERE res_wildcard + OR size([rv IN res_values WHERE rv CONTAINS rname OR rarn CONTAINS rv]) > 0 + + WITH DISTINCT path_principal, path_target + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + # SSM-001 AWS_SSM_PRIVESC_START_SESSION = AttackPathsQueryDefinition( id="aws-ssm-privesc-start-session", @@ -3495,6 +4357,290 @@ AWS_SSM_PRIVESC_SEND_COMMAND = AttackPathsQueryDefinition( parameters=[], ) +# SSM-003 +AWS_SSM_PRIVESC_PASSROLE_AUTOMATION = AttackPathsQueryDefinition( + id="aws-ssm-privesc-passrole-automation", + name="SSM Automation Document with Privileged Role (SSM-003)", + short_description="Create and run an SSM Automation document with a privileged automation assume-role to act as that role.", + description="Detect principals who can pass IAM roles, create SSM documents, and start automation executions. An automation runs with a privileged assume-role, allowing arbitrary automation steps to act as that role.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - SSM-003 - iam:PassRole + ssm:CreateDocument + ssm:StartAutomationExecution", + link="https://pathfinding.cloud/paths/ssm-003", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal + + // Find ssm:createdocument permission + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['ssm:*', 'ssm:createdocument'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal + + // Find ssm:startautomationexecution permission + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem) + WHERE toLower(act3.value) IN ['ssm:*', 'ssm:startautomationexecution'] + OR act3.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal + + // Pre-aggregate the PassRole statement resources (see docs: Avoiding Cartesian Products) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WITH aws, principal, path_principal, collect(DISTINCT res.value) AS res_values + WITH aws, principal, path_principal, res_values, ('*' IN res_values) AS res_wildcard + + // Target role that trusts the ssm.amazonaws.com service and can be passed + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ssm.amazonaws.com'}}) + WITH path_principal, path_target, res_values, res_wildcard, + target_role.name AS rname, target_role.arn AS rarn + WHERE res_wildcard + OR size([rv IN res_values WHERE rv CONTAINS rname OR rarn CONTAINS rv]) > 0 + + WITH DISTINCT path_principal, path_target + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# SSO-001 +AWS_SSO_PRIVESC_PERMISSION_SET_ESCALATION = AttackPathsQueryDefinition( + id="aws-sso-privesc-permission-set-escalation", + name="Identity Center Permission Set Escalation (SSO-001)", + short_description="Create an administrative Identity Center permission set and assign it to gain organization-wide admin access.", + description="Detect principals that hold sso:CreatePermissionSet, sso:AttachManagedPolicyToPermissionSet, and sso:CreateAccountAssignment together. With all three, a principal can create a new IAM Identity Center permission set, attach the AdministratorAccess managed policy to it, and assign it to their own user or group for any account in the organization, gaining administrative access across the organization through the Identity Center portal.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - SSO-001 - sso:CreatePermissionSet + sso:AttachManagedPolicyToPermissionSet + sso:CreateAccountAssignment", + link="https://pathfinding.cloud/paths/sso-001", + ), + provider="aws", + cypher=f""" + // Find principals with sso:CreatePermissionSet permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['sso:*', 'sso:createpermissionset'] + OR act.value = '*' + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + WITH DISTINCT aws, principal, path_principal + + // Find sso:AttachManagedPolicyToPermissionSet permission on the same principal + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(stmt2:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['sso:*', 'sso:attachmanagedpolicytopermissionset'] + OR act2.value = '*' + MATCH (stmt2)-[:HAS_RESOURCE]->(res2:AWSPolicyStatementResourceItem) + WHERE res2.value = '*' + WITH DISTINCT principal, path_principal + + // Find sso:CreateAccountAssignment permission on the same principal + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(stmt3:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem) + WHERE toLower(act3.value) IN ['sso:*', 'sso:createaccountassignment'] + OR act3.value = '*' + MATCH (stmt3)-[:HAS_RESOURCE]->(res3:AWSPolicyStatementResourceItem) + WHERE res3.value = '*' + + WITH DISTINCT path_principal + WITH collect(path_principal) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# SSO-002 +AWS_SSO_PRIVESC_ATTACH_MANAGED_POLICY = AttackPathsQueryDefinition( + id="aws-sso-privesc-attach-managed-policy-permission-set", + name="Identity Center Managed Policy Attachment (SSO-002)", + short_description="Attach an administrative managed policy to an existing permission set assigned to the actor to gain admin access.", + description="Detect principals with sso:AttachManagedPolicyToPermissionSet. Attaching AdministratorAccess to a permission set already assigned to the actor's identity escalates that assignment to administrative access.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - SSO-002 - sso:AttachManagedPolicyToPermissionSet", + link="https://pathfinding.cloud/paths/sso-002", + ), + provider="aws", + cypher=f""" + // Find principals with sso:attachmanagedpolicytopermissionset permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['sso:*', 'sso:attachmanagedpolicytopermissionset'] + OR act.value = '*' + + // Require the action on a wildcard resource (permission sets are not graph nodes) + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + + WITH DISTINCT path_principal + WITH collect(path_principal) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# SSO-003 +AWS_SSO_PRIVESC_PUT_INLINE_POLICY = AttackPathsQueryDefinition( + id="aws-sso-privesc-put-inline-policy-permission-set", + name="Identity Center Inline Policy Injection (SSO-003)", + short_description="Inject an administrative inline policy into an existing permission set assigned to the actor to gain admin access.", + description="Detect principals with sso:PutInlinePolicyToPermissionSet. Writing an administrative inline policy onto a permission set already assigned to the actor's identity escalates that assignment to administrative access.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - SSO-003 - sso:PutInlinePolicyToPermissionSet", + link="https://pathfinding.cloud/paths/sso-003", + ), + provider="aws", + cypher=f""" + // Find principals with sso:putinlinepolicytopermissionset permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['sso:*', 'sso:putinlinepolicytopermissionset'] + OR act.value = '*' + + // Require the action on a wildcard resource (permission sets are not graph nodes) + MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WHERE res.value = '*' + + WITH DISTINCT path_principal + WITH collect(path_principal) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + + +# STEPFUNCTIONS-001 +AWS_STEPFUNCTIONS_PRIVESC_PASSROLE_CREATE_STATE_MACHINE = AttackPathsQueryDefinition( + id="aws-stepfunctions-privesc-passrole-create-state-machine", + name="Step Functions State Machine with Privileged Role (STEPFUNCTIONS-001)", + short_description="Create and execute a Step Functions state machine with a privileged role to make API calls as that role.", + description="Detect principals who can pass IAM roles, create Step Functions state machines, and start executions. A state machine executes tasks with an attached privileged role, allowing privileged API calls as that role.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - STEPFUNCTIONS-001 - iam:PassRole + states:CreateStateMachine + states:StartExecution", + link="https://pathfinding.cloud/paths/stepfunctions-001", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}}) + MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['iam:*', 'iam:passrole'] + OR act.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal + + // Find states:createstatemachine permission + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['states:*', 'states:createstatemachine'] + OR act2.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal + + // Find states:startexecution permission + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem) + WHERE toLower(act3.value) IN ['states:*', 'states:startexecution'] + OR act3.value = '*' + WITH DISTINCT aws, principal, stmt_passrole, path_principal + + // Pre-aggregate the PassRole statement resources (see docs: Avoiding Cartesian Products) + MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) + WITH aws, principal, path_principal, collect(DISTINCT res.value) AS res_values + WITH aws, principal, path_principal, res_values, ('*' IN res_values) AS res_wildcard + + // Target role that trusts the states.amazonaws.com service and can be passed + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'states.amazonaws.com'}}) + WITH path_principal, path_target, res_values, res_wildcard, + target_role.name AS rname, target_role.arn AS rarn + WHERE res_wildcard + OR size([rv IN res_values WHERE rv CONTAINS rname OR rarn CONTAINS rv]) > 0 + + WITH DISTINCT path_principal, path_target + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# STEPFUNCTIONS-002 +AWS_STEPFUNCTIONS_PRIVESC_UPDATE_STATE_MACHINE = AttackPathsQueryDefinition( + id="aws-stepfunctions-privesc-update-state-machine", + name="Step Functions Existing State Machine Update (STEPFUNCTIONS-002)", + short_description="Update an existing Step Functions state machine and execute it to make API calls as its privileged role.", + description="Detect principals who can update Step Functions state machines and start executions. Editing an existing state machine that already has a privileged role lets an actor run arbitrary tasks as that role without iam:PassRole. The graph does not model which state machine uses which role, so this lists every role trusting the states.amazonaws.com service; each result needs manual review to confirm an existing state machine actually uses the role.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - STEPFUNCTIONS-002 - states:UpdateStateMachine + states:StartExecution", + link="https://pathfinding.cloud/paths/stepfunctions-002", + ), + provider="aws", + cypher=f""" + // Find principals with states:updatestatemachine permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) + WHERE toLower(act.value) IN ['states:*', 'states:updatestatemachine'] + OR act.value = '*' + WITH DISTINCT aws, principal, path_principal + + // Find states:startexecution permission + MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) + WHERE toLower(act2.value) IN ['states:*', 'states:startexecution'] + OR act2.value = '*' + WITH DISTINCT aws, principal, path_principal + + // Target role attached to the existing resource, trusting the states.amazonaws.com service + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'states.amazonaws.com'}}) + + WITH DISTINCT path_principal, path_target + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n + + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + + OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) + + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + # STS-001 AWS_STS_PRIVESC_ASSUME_ROLE = AttackPathsQueryDefinition( id="aws-sts-privesc-assume-role", @@ -3599,97 +4745,6 @@ AWS_STS_PRIVESC_WILDCARD_TRUST = AttackPathsQueryDefinition( parameters=[], ) -# IAM-022 -AWS_IAM_PRIVESC_DELETE_USER_PERMISSIONS_BOUNDARY = AttackPathsQueryDefinition( - id="aws-iam-privesc-delete-user-permissions-boundary", - name="Permissions Boundary Removal for Self-Escalation (IAM-022)", - short_description="IAM users that can remove their own permissions boundary, if one is attached.", - description="Find IAM users whose policies allow iam:DeleteUserPermissionsBoundary on their own user ARN. The graph does not record whether a boundary is attached or whether removing it grants more access, so each result needs manual review.", - attribution=AttackPathsQueryAttribution( - text="pathfinding.cloud - IAM-022 - iam:DeleteUserPermissionsBoundary", - link="https://pathfinding.cloud/paths/iam-022", - ), - provider="aws", - cypher=f""" - // Find IAM users with iam:DeleteUserPermissionsBoundary permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSUser)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) - MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) - WHERE toLower(act.value) IN ['iam:*', 'iam:deleteuserpermissionsboundary'] - OR act.value = '*' - WITH DISTINCT principal, stmt, path_principal - - // Keep only users that can remove the boundary from their own user ARN - MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) - WHERE res.value = '*' - OR res.value = principal.arn - OR (res.value ENDS WITH '*' AND principal.arn STARTS WITH replace(res.value, '*', '')) - - WITH DISTINCT path_principal - WITH collect(path_principal) AS paths - UNWIND paths AS p - UNWIND nodes(p) AS n - - WITH paths, collect(DISTINCT n) AS unique_nodes - UNWIND unique_nodes AS n - - OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) - - RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr - """, - parameters=[], -) - -# SSO-001 -AWS_SSO_PRIVESC_PERMISSION_SET_ESCALATION = AttackPathsQueryDefinition( - id="aws-sso-privesc-permission-set-escalation", - name="Identity Center Permission Set Escalation (SSO-001)", - short_description="Create an administrative Identity Center permission set and assign it to gain organization-wide admin access.", - description="Detect principals that hold sso:CreatePermissionSet, sso:AttachManagedPolicyToPermissionSet, and sso:CreateAccountAssignment together. With all three, a principal can create a new IAM Identity Center permission set, attach the AdministratorAccess managed policy to it, and assign it to their own user or group for any account in the organization, gaining administrative access across the organization through the Identity Center portal.", - attribution=AttackPathsQueryAttribution( - text="pathfinding.cloud - SSO-001 - sso:CreatePermissionSet + sso:AttachManagedPolicyToPermissionSet + sso:CreateAccountAssignment", - link="https://pathfinding.cloud/paths/sso-001", - ), - provider="aws", - cypher=f""" - // Find principals with sso:CreatePermissionSet permission - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}}) - MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem) - WHERE toLower(act.value) IN ['sso:*', 'sso:createpermissionset'] - OR act.value = '*' - MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem) - WHERE res.value = '*' - WITH DISTINCT aws, principal, path_principal - - // Find sso:AttachManagedPolicyToPermissionSet permission on the same principal - MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(stmt2:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem) - WHERE toLower(act2.value) IN ['sso:*', 'sso:attachmanagedpolicytopermissionset'] - OR act2.value = '*' - MATCH (stmt2)-[:HAS_RESOURCE]->(res2:AWSPolicyStatementResourceItem) - WHERE res2.value = '*' - WITH DISTINCT principal, path_principal - - // Find sso:CreateAccountAssignment permission on the same principal - MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(stmt3:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem) - WHERE toLower(act3.value) IN ['sso:*', 'sso:createaccountassignment'] - OR act3.value = '*' - MATCH (stmt3)-[:HAS_RESOURCE]->(res3:AWSPolicyStatementResourceItem) - WHERE res3.value = '*' - - WITH DISTINCT path_principal - WITH collect(path_principal) AS paths - UNWIND paths AS p - UNWIND nodes(p) AS n - - WITH paths, collect(DISTINCT n) AS unique_nodes - UNWIND unique_nodes AS n - - OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) - - RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr - """, - parameters=[], -) - # AWS Queries List AWS_QUERIES: list[AttackPathsQueryDefinition] = [ @@ -3707,8 +4762,11 @@ AWS_QUERIES: list[AttackPathsQueryDefinition] = [ AWS_PUBLIC_IP_RESOURCE_LOOKUP, AWS_APPRUNNER_PRIVESC_PASSROLE_CREATE_SERVICE, AWS_APPRUNNER_PRIVESC_UPDATE_SERVICE, + AWS_BATCH_PRIVESC_PASSROLE_SUBMIT_JOB, + AWS_BATCH_PRIVESC_SUBMIT_EXISTING_JOB, AWS_BEDROCK_PRIVESC_PASSROLE_CODE_INTERPRETER, AWS_BEDROCK_PRIVESC_INVOKE_CODE_INTERPRETER, + AWS_BRAKET_PRIVESC_PASSROLE_CREATE_JOB, AWS_CLOUDFORMATION_PRIVESC_PASSROLE_CREATE_STACK, AWS_CLOUDFORMATION_PRIVESC_UPDATE_STACK, AWS_CLOUDFORMATION_PRIVESC_PASSROLE_CREATE_STACKSET, @@ -3718,6 +4776,8 @@ AWS_QUERIES: list[AttackPathsQueryDefinition] = [ AWS_CODEBUILD_PRIVESC_START_BUILD, AWS_CODEBUILD_PRIVESC_START_BUILD_BATCH, AWS_CODEBUILD_PRIVESC_PASSROLE_CREATE_PROJECT_BATCH, + AWS_CODEDEPLOY_PRIVESC_CREATE_DEPLOYMENT, + AWS_COGNITO_PRIVESC_PASSROLE_SET_IDENTITY_POOL_ROLES, AWS_DATAPIPELINE_PRIVESC_PASSROLE_CREATE_PIPELINE, AWS_EC2_PRIVESC_PASSROLE_IAM, AWS_EC2_PRIVESC_MODIFY_INSTANCE_ATTRIBUTE, @@ -3730,12 +4790,17 @@ AWS_QUERIES: list[AttackPathsQueryDefinition] = [ AWS_ECS_PRIVESC_PASSROLE_RUN_TASK_EXISTING_CLUSTER, AWS_ECS_PRIVESC_PASSROLE_START_TASK_EXISTING_CLUSTER, AWS_ECS_PRIVESC_EXECUTE_COMMAND, + AWS_ECS_PRIVESC_PASSROLE_START_EXISTING_TASK, + AWS_EMR_PRIVESC_PASSROLE_RUN_JOB_FLOW, + AWS_EMRSERVERLESS_PRIVESC_PASSROLE_START_JOB, + AWS_GAMELIFT_PRIVESC_PASSROLE_CREATE_FLEET, AWS_GLUE_PRIVESC_PASSROLE_DEV_ENDPOINT, AWS_GLUE_PRIVESC_UPDATE_DEV_ENDPOINT, AWS_GLUE_PRIVESC_PASSROLE_CREATE_JOB, AWS_GLUE_PRIVESC_PASSROLE_CREATE_JOB_TRIGGER, AWS_GLUE_PRIVESC_PASSROLE_UPDATE_JOB, AWS_GLUE_PRIVESC_PASSROLE_UPDATE_JOB_TRIGGER, + AWS_GLUE_PRIVESC_PASSROLE_CREATE_SESSION, AWS_IAM_PRIVESC_CREATE_POLICY_VERSION, AWS_IAM_PRIVESC_CREATE_ACCESS_KEY, AWS_IAM_PRIVESC_DELETE_CREATE_ACCESS_KEY, @@ -3757,22 +4822,32 @@ AWS_QUERIES: list[AttackPathsQueryDefinition] = [ AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY_UPDATE_ASSUME_ROLE, AWS_IAM_PRIVESC_CREATE_POLICY_VERSION_UPDATE_ASSUME_ROLE, AWS_IAM_PRIVESC_PUT_ROLE_POLICY_UPDATE_ASSUME_ROLE, + AWS_IAM_PRIVESC_DELETE_USER_PERMISSIONS_BOUNDARY, + AWS_IAM_PRIVESC_DELETE_ROLE_BOUNDARY_ASSUME_ROLE, + AWS_IMAGEBUILDER_PRIVESC_PASSROLE_CREATE_IMAGE, + AWS_KINESISANALYTICS_PRIVESC_PASSROLE_CREATE_APP, AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION, AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION_EVENT_SOURCE, AWS_LAMBDA_PRIVESC_UPDATE_FUNCTION_CODE, AWS_LAMBDA_PRIVESC_UPDATE_FUNCTION_CODE_INVOKE, AWS_LAMBDA_PRIVESC_UPDATE_FUNCTION_CODE_ADD_PERMISSION, AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION_ADD_PERMISSION, + AWS_OMICS_PRIVESC_PASSROLE_START_RUN, AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_NOTEBOOK, AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_TRAINING_JOB, AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_PROCESSING_JOB, AWS_SAGEMAKER_PRIVESC_PRESIGNED_NOTEBOOK_URL, AWS_SAGEMAKER_PRIVESC_LIFECYCLE_CONFIG_NOTEBOOK, + AWS_SCHEDULER_PRIVESC_PASSROLE_CREATE_SCHEDULE, AWS_SSM_PRIVESC_START_SESSION, AWS_SSM_PRIVESC_SEND_COMMAND, + AWS_SSM_PRIVESC_PASSROLE_AUTOMATION, + AWS_SSO_PRIVESC_PERMISSION_SET_ESCALATION, + AWS_SSO_PRIVESC_ATTACH_MANAGED_POLICY, + AWS_SSO_PRIVESC_PUT_INLINE_POLICY, + AWS_STEPFUNCTIONS_PRIVESC_PASSROLE_CREATE_STATE_MACHINE, + AWS_STEPFUNCTIONS_PRIVESC_UPDATE_STATE_MACHINE, AWS_STS_PRIVESC_ASSUME_ROLE, AWS_STS_PRIVESC_CROSS_ACCOUNT_TRUST, AWS_STS_PRIVESC_WILDCARD_TRUST, - AWS_IAM_PRIVESC_DELETE_USER_PERMISSIONS_BOUNDARY, - AWS_SSO_PRIVESC_PERMISSION_SET_ESCALATION, ] diff --git a/api/src/backend/api/tests/test_attack_paths_service_privesc_queries.py b/api/src/backend/api/tests/test_attack_paths_service_privesc_queries.py new file mode 100644 index 0000000000..abdca9472c --- /dev/null +++ b/api/src/backend/api/tests/test_attack_paths_service_privesc_queries.py @@ -0,0 +1,133 @@ +""" +Structural validation for the pathfinding.cloud service privilege-escalation +Attack Paths queries added in PROWLER-2279. + +These assert the conventions documented in +`docs/developer-guide/attack-paths-queries.mdx`: list-typed policy properties are +reached through `HAS_*` child-item traversals (never read as node fields), +predicate functions unsupported on Neptune (`any`/`all`/`none`, regex `=~`) are +absent, the finding probe is typed and filters only on `status`, and the +`RETURN` shape preserves the `paths, dpf, dpfr` contract. +""" + +import re + +import pytest +from api.attack_paths.queries.aws import AWS_QUERIES +from api.attack_paths.queries.types import AttackPathsQueryDefinition + +# IDs of the queries introduced for PROWLER-2279 (pathfinding.cloud coverage). +PATHFINDING_2279_QUERY_IDS = [ + "aws-batch-privesc-passrole-submit-job", + "aws-braket-privesc-passrole-create-job", + "aws-cognito-privesc-passrole-set-identity-pool-roles", + "aws-ecs-privesc-passrole-start-existing-task", + "aws-emr-privesc-passrole-run-job-flow", + "aws-emrserverless-privesc-passrole-start-job", + "aws-gamelift-privesc-passrole-create-fleet", + "aws-glue-privesc-passrole-create-session", + "aws-imagebuilder-privesc-passrole-create-image", + "aws-kinesisanalytics-privesc-passrole-create-application", + "aws-omics-privesc-passrole-start-run", + "aws-scheduler-privesc-passrole-create-schedule", + "aws-ssm-privesc-passrole-automation", + "aws-stepfunctions-privesc-passrole-create-state-machine", + "aws-batch-privesc-submit-existing-job", + "aws-codedeploy-privesc-create-deployment", + "aws-stepfunctions-privesc-update-state-machine", + "aws-iam-privesc-delete-role-boundary-assume-role", + "aws-sso-privesc-attach-managed-policy-permission-set", + "aws-sso-privesc-put-inline-policy-permission-set", +] + +_BY_ID = {q.id: q for q in AWS_QUERIES} +NEW_QUERIES = [_BY_ID[qid] for qid in PATHFINDING_2279_QUERY_IDS if qid in _BY_ID] + +NEPTUNE_UNSUPPORTED_PREDICATES = re.compile(r"\b(any|all|none)\s*\(", re.IGNORECASE) +NORMALIZED_STATEMENT_FIELDS = ("action", "resource", "notaction", "notresource") + + +def test_all_2279_queries_registered(): + missing = [qid for qid in PATHFINDING_2279_QUERY_IDS if qid not in _BY_ID] + assert not missing, f"queries not registered in AWS_QUERIES: {missing}" + + +class TestServicePrivescQuerySchema: + @pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id) + def test_is_query_definition(self, query): + assert isinstance(query, AttackPathsQueryDefinition) + + @pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id) + def test_id_kebab_and_aws_prefixed(self, query): + assert query.id.startswith("aws-") + assert re.match(r"^[a-z0-9]+(-[a-z0-9]+)*$", query.id) + + @pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id) + def test_provider_is_aws(self, query): + assert query.provider == "aws" + + @pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id) + def test_has_metadata(self, query): + assert query.name and len(query.name) > 5 + assert query.short_description and len(query.short_description) > 10 + assert query.description and len(query.description) > 20 + assert isinstance(query.parameters, list) + + @pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id) + def test_attribution_links_pathfinding(self, query): + assert query.attribution is not None + assert "pathfinding.cloud" in query.attribution.text + assert query.attribution.link.startswith("https://pathfinding.cloud/paths/") + + +class TestServicePrivescQueryCypher: + @pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id) + def test_anchored_and_provider_scoped(self, query): + assert "(aws:AWSAccount {id: $provider_uid})" in query.cypher + + @pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id) + def test_finding_label_interpolated(self, query): + assert "PROWLER_FINDING_LABEL" not in query.cypher + + @pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id) + def test_typed_status_scoped_finding_probe(self, query): + assert re.search( + r"-\[pfr:HAS_FINDING\]-\(pf:ProwlerFinding \{status: 'FAIL'\}\)", + query.cypher, + ), f"{query.id} lacks the typed, status-scoped finding probe" + + @pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id) + def test_return_contract(self, query): + assert re.search( + r"RETURN paths, collect\(DISTINCT pf\) as dpf, " + r"collect\(DISTINCT pfr\) as dpfr", + query.cypher, + ) + + @pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id) + def test_no_neptune_unsupported_predicates(self, query): + m = NEPTUNE_UNSUPPORTED_PREDICATES.search(query.cypher) + assert m is None, f"{query.id} uses '{m.group().strip()}' (not Neptune-safe)" + + @pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id) + def test_no_regex_operator(self, query): + assert "=~" not in query.cypher + + @pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id) + def test_does_not_read_normalized_list_fields(self, query): + for field in NORMALIZED_STATEMENT_FIELDS: + assert not re.search(rf"\.{field}\b", query.cypher), ( + f"{query.id} reads normalized list field '.{field}' as a property; " + f"traverse the HAS_{field.upper()} edge instead" + ) + + @pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id) + def test_read_only(self, query): + no_comments = "\n".join( + line + for line in query.cypher.split("\n") + if not line.strip().startswith("//") + ) + assert not re.search( + r"\b(CREATE|MERGE|SET|DELETE|REMOVE|DETACH)\b", no_comments, re.IGNORECASE + ) diff --git a/prowler/changelog.d/pathfinding-privesc-combos.added.md b/prowler/changelog.d/pathfinding-privesc-combos.added.md new file mode 100644 index 0000000000..f2dab5dba9 --- /dev/null +++ b/prowler/changelog.d/pathfinding-privesc-combos.added.md @@ -0,0 +1 @@ +The IAM privilege-escalation check now detects 22 additional pathfinding.cloud escalation paths across AWS Batch, Braket, CodeDeploy, Cognito Identity, ECS, EMR, EMR Serverless, GameLift, Glue, EC2 Image Builder, Kinesis Analytics, HealthOmics, EventBridge Scheduler, SSM Automation, Step Functions, IAM permissions boundaries, and IAM Identity Center (SSO) diff --git a/prowler/providers/aws/services/iam/lib/privilege_escalation.py b/prowler/providers/aws/services/iam/lib/privilege_escalation.py index d7f16895d5..cd508bc123 100644 --- a/prowler/providers/aws/services/iam/lib/privilege_escalation.py +++ b/prowler/providers/aws/services/iam/lib/privilege_escalation.py @@ -342,6 +342,122 @@ privilege_escalation_policies_combination = { "bedrock-agentcore:StartBrowserSession", "bedrock-agentcore:ConnectBrowserAutomationStream", }, + # Batch-based privilege escalation patterns (pathfinding.cloud BATCH-001/002) + "PassRole+BatchRegisterJobDef+SubmitJob": { + "iam:PassRole", + "batch:RegisterJobDefinition", + "batch:SubmitJob", + }, + # Prerequisite: Existing Batch job definition with admin role + "BatchSubmitJob": {"batch:SubmitJob"}, + # Braket-based privilege escalation patterns (pathfinding.cloud BRAKET-001) + "PassRole+BraketCreateJob": { + "iam:PassRole", + "braket:CreateJob", + }, + # CodeDeploy-based privilege escalation patterns (pathfinding.cloud CODEDEPLOY-001) + # Prerequisite: Existing CodeDeploy application and deployment group with admin role + "CodeDeployCreateDeployment": { + "codedeploy:CreateDeployment", + "codedeploy:RegisterApplicationRevision", + "codedeploy:GetDeploymentConfig", + }, + # Cognito Identity-based privilege escalation patterns (pathfinding.cloud COGNITOIDENTITY-001) + "PassRole+CognitoSetIdentityPoolRoles": { + "iam:PassRole", + "cognito-identity:SetIdentityPoolRoles", + }, + # ECS StartTask on an existing cluster (pathfinding.cloud ECS-009) + "PassRole+ECSStartTaskExistingCluster": { + "iam:PassRole", + "ecs:StartTask", + }, + # EMR-based privilege escalation patterns (pathfinding.cloud EMR-001) + "PassRole+EMRRunJobFlow": { + "iam:PassRole", + "elasticmapreduce:RunJobFlow", + }, + # EMR Serverless-based privilege escalation patterns (pathfinding.cloud EMRSERVERLESS-001) + "PassRole+EMRServerlessCreateApp+StartJobRun": { + "iam:PassRole", + "emr-serverless:CreateApplication", + "emr-serverless:StartJobRun", + }, + # GameLift-based privilege escalation patterns (pathfinding.cloud GAMELIFT-001) + "PassRole+GameLiftCreateBuild+CreateFleet": { + "iam:PassRole", + "gamelift:CreateBuild", + "gamelift:CreateFleet", + "gamelift:RequestUploadCredentials", + }, + # Glue interactive session-based privilege escalation patterns (pathfinding.cloud GLUE-007) + "PassRole+GlueCreateSession+RunStatement": { + "iam:PassRole", + "glue:CreateSession", + "glue:RunStatement", + }, + # EC2 Image Builder-based privilege escalation patterns (pathfinding.cloud IMAGEBUILDER-001) + "PassRole+ImageBuilderCreateComponent+CreateImage": { + "iam:PassRole", + "imagebuilder:CreateComponent", + "imagebuilder:CreateImageRecipe", + "imagebuilder:CreateInfrastructureConfiguration", + "imagebuilder:CreateImage", + }, + # Kinesis Data Analytics-based privilege escalation patterns (pathfinding.cloud KINESISANALYTICS-001) + "PassRole+KinesisAnalyticsCreateApp+StartApp": { + "iam:PassRole", + "kinesisanalytics:CreateApplication", + "kinesisanalytics:StartApplication", + }, + # HealthOmics-based privilege escalation patterns (pathfinding.cloud OMICS-001) + "PassRole+OmicsCreateWorkflow+StartRun": { + "iam:PassRole", + "omics:CreateWorkflow", + "omics:StartRun", + "s3:GetObject", + }, + # EventBridge Scheduler-based privilege escalation patterns (pathfinding.cloud SCHEDULER-001) + "PassRole+SchedulerCreateSchedule": { + "iam:PassRole", + "scheduler:CreateSchedule", + }, + # SSM Automation document-based privilege escalation patterns (pathfinding.cloud SSM-003) + "PassRole+SSMCreateDocument+StartAutomation": { + "iam:PassRole", + "ssm:CreateDocument", + "ssm:StartAutomationExecution", + }, + # Step Functions-based privilege escalation patterns (pathfinding.cloud STEPFUNCTIONS-001) + "PassRole+StepFunctionsCreateStateMachine+StartExecution": { + "iam:PassRole", + "states:CreateStateMachine", + "states:StartExecution", + }, + # Prerequisite: Existing Step Functions state machine with admin role (pathfinding.cloud STEPFUNCTIONS-002) + "StepFunctionsUpdateStateMachine+StartExecution": { + "states:UpdateStateMachine", + "states:StartExecution", + }, + # IAM permissions boundary removal self-escalation (pathfinding.cloud IAM-022) + "iam:DeleteUserPermissionsBoundary": {"iam:DeleteUserPermissionsBoundary"}, + # Role permissions boundary removal plus role assumption (pathfinding.cloud IAM-023) + "AssumeRole+DeleteRolePermissionsBoundary": { + "sts:AssumeRole", + "iam:DeleteRolePermissionsBoundary", + }, + # IAM Identity Center (SSO)-based privilege escalation patterns (pathfinding.cloud SSO-001) + "SSOCreatePermissionSet+CreateAccountAssignment+AttachManagedPolicy": { + "sso:CreatePermissionSet", + "sso:CreateAccountAssignment", + "sso:AttachManagedPolicyToPermissionSet", + }, + # Prerequisite: Existing permission set assigned to the attacker (pathfinding.cloud SSO-002) + "sso:AttachManagedPolicyToPermissionSet": { + "sso:AttachManagedPolicyToPermissionSet" + }, + # Prerequisite: Existing permission set assigned to the attacker (pathfinding.cloud SSO-003) + "sso:PutInlinePolicyToPermissionSet": {"sso:PutInlinePolicyToPermissionSet"}, # TO-DO: We have to handle AssumeRole just if the resource is * and without conditions # "sts:AssumeRole": {"sts:AssumeRole"}, } diff --git a/tests/providers/aws/services/iam/lib/privilege_escalation_test.py b/tests/providers/aws/services/iam/lib/privilege_escalation_test.py index 018af5e1ec..760e4c3327 100644 --- a/tests/providers/aws/services/iam/lib/privilege_escalation_test.py +++ b/tests/providers/aws/services/iam/lib/privilege_escalation_test.py @@ -169,3 +169,119 @@ class Test_PrivilegeEscalation: assert ( f"'{pattern}'" in result ), f"Expected pattern '{pattern}' not found in result: {result}" + + # New privilege-escalation paths incorporated from pathfinding.cloud (PROWLER-2279): + # a policy granting exactly the path's required actions must be flagged. + PATHFINDING_2279_COMBOS = [ + ( + "batch-001", + ["iam:PassRole", "batch:RegisterJobDefinition", "batch:SubmitJob"], + ), + ("batch-002", ["batch:SubmitJob"]), + ("braket-001", ["iam:PassRole", "braket:CreateJob"]), + ( + "codedeploy-001", + [ + "codedeploy:CreateDeployment", + "codedeploy:RegisterApplicationRevision", + "codedeploy:GetDeploymentConfig", + ], + ), + ( + "cognitoidentity-001", + ["iam:PassRole", "cognito-identity:SetIdentityPoolRoles"], + ), + ("ecs-009", ["iam:PassRole", "ecs:StartTask"]), + ("emr-001", ["iam:PassRole", "elasticmapreduce:RunJobFlow"]), + ( + "emrserverless-001", + [ + "iam:PassRole", + "emr-serverless:CreateApplication", + "emr-serverless:StartJobRun", + ], + ), + ( + "gamelift-001", + [ + "iam:PassRole", + "gamelift:CreateBuild", + "gamelift:CreateFleet", + "gamelift:RequestUploadCredentials", + ], + ), + ("glue-007", ["iam:PassRole", "glue:CreateSession", "glue:RunStatement"]), + ( + "imagebuilder-001", + [ + "iam:PassRole", + "imagebuilder:CreateComponent", + "imagebuilder:CreateImageRecipe", + "imagebuilder:CreateInfrastructureConfiguration", + "imagebuilder:CreateImage", + ], + ), + ( + "kinesisanalytics-001", + [ + "iam:PassRole", + "kinesisanalytics:CreateApplication", + "kinesisanalytics:StartApplication", + ], + ), + ( + "omics-001", + ["iam:PassRole", "omics:CreateWorkflow", "omics:StartRun", "s3:GetObject"], + ), + ("scheduler-001", ["iam:PassRole", "scheduler:CreateSchedule"]), + ( + "ssm-003", + ["iam:PassRole", "ssm:CreateDocument", "ssm:StartAutomationExecution"], + ), + ( + "stepfunctions-001", + ["iam:PassRole", "states:CreateStateMachine", "states:StartExecution"], + ), + ( + "stepfunctions-002", + ["states:UpdateStateMachine", "states:StartExecution"], + ), + ("iam-022", ["iam:DeleteUserPermissionsBoundary"]), + ("iam-023", ["iam:DeleteRolePermissionsBoundary", "sts:AssumeRole"]), + ( + "sso-001", + [ + "sso:CreatePermissionSet", + "sso:CreateAccountAssignment", + "sso:AttachManagedPolicyToPermissionSet", + ], + ), + ("sso-002", ["sso:AttachManagedPolicyToPermissionSet"]), + ("sso-003", ["sso:PutInlinePolicyToPermissionSet"]), + ] + + def test_check_privilege_escalation_pathfinding_2279_paths_detected(self): + for path_id, actions in self.PATHFINDING_2279_COMBOS: + policy = { + "Version": "2012-10-17", + "Statement": [{"Effect": "Allow", "Action": actions, "Resource": "*"}], + } + result = check_privilege_escalation(policy) + assert result, f"pathfinding {path_id} not detected for actions {actions}" + for action in actions: + assert ( + f"'{action}'" in result + ), f"pathfinding {path_id}: action {action} missing from result {result}" + + def test_check_privilege_escalation_multi_action_path_requires_a_second_action( + self, + ): + # A PassRole-only policy must not, on its own, flag any of the new + # PassRole+service paths (guards against over-broad single-action combos). + policy = { + "Version": "2012-10-17", + "Statement": [ + {"Effect": "Allow", "Action": ["iam:PassRole"], "Resource": "*"} + ], + } + assert check_privilege_escalation(policy) == ""