Skip to content

sec(iac/aws): deploy role can create an unbounded role and attach AdministratorAccess to it #1705

Description

@cristim

Summary

The cudly-terraform-deploy role can escalate to full account administrator by creating a new role and attaching AdministratorAccess to it. This is reachable from a leaked or misused GitHub Actions OIDC token today, on current main.

Found during adversarial review of #1699. It is pre-existing, not introduced by that PR — #1699 in fact reduces net privilege (it adds the IAMDenyModifyDeployRoleAndPolicies Deny, which did not exist before). This issue is the gap that Deny does not cover.

The chain

  1. iam:CreateRole is allowed on arn:aws:iam::*:role/cudly-* with no iam:PermissionsBoundary condition, so the new role is unbounded. CreateRole takes an arbitrary AssumeRolePolicyDocument, so the trust policy can name the deploy role itself (or any principal the attacker holds).
  2. iam:AttachRolePolicy is allowed on the same role/cudly-* pattern. The Resource is the role ARN and there is no iam:PolicyARN condition, so arn:aws:iam::aws:policy/AdministratorAccess can be attached to the role just created.
  3. Assume it. Same-account, and the trust policy names the principal directly — per AWS's documented same-account evaluation, a resource-based policy alone suffices; no identity-side sts:AssumeRole is required. Confirmed: grep 'sts:' across all four policies returns only sts:GetCallerIdentity; the sole sts:AssumeRole in the tree is the deploy role's own trust policy at role.tf:13.

Result: full account admin, persistent, surviving rotation of the original token.

Why IAMDenyModifyDeployRoleAndPolicies does not stop it

That Deny (added by #1699) names only role/cudly-terraform-deploy and policy/cudly-deploy-*. Computed mechanically from the rendered policy JSON:

DENIED (10):     AttachRolePolicy, CreatePolicyVersion, DeletePolicy, DeletePolicyVersion,
                 DeleteRole, DeleteRolePolicy, DetachRolePolicy, PutRolePolicy,
                 UpdateAssumeRolePolicy, UpdateRole, UpdateRoleDescription
NOT DENIED (13): AddRoleToInstanceProfile, CreateInstanceProfile, CreatePolicy, CreateRole,
                 DeleteInstanceProfile, RemoveRoleFromInstanceProfile, TagInstanceProfile,
                 TagPolicy, TagRole, UntagInstanceProfile, UntagPolicy, UntagRole

CreateRole and CreatePolicy are the load-bearing omissions: the Deny protects the deploy role from being modified, but not the account from having a new unbounded role minted beside it.

Doors that ARE closed (tested, not assumed)

  • A policy outside cudly-deploy-* attached to the role — disproved. role.tf:158-175 attaches exactly four managed policies, all matching policy/cudly-deploy-*, and there are no aws_iam_role_policy inline policies on the role.
  • Instance-profile route — iam:AddRoleToInstanceProfile is not denied, so the deploy role can put itself into a cudly-* instance profile. But converting that to credentials needs ec2:RunInstances with that profile, which requires iam:PassRole on the contained role — denied by IAMDenyPassDeployRole. That interaction holds.
  • Tag actions grant no privilege; no ABAC conditions in these policies key on role or policy tags.

Compounding factor

Per LeanerCloud/cloud-commitments-platform#154, main is unprotected while ref:refs/heads/main is a trusted OIDC subject for this role. So the chain is: repo write access → deploy-role token → full account admin, with no human gate anywhere.

Suggested fix

A design change, not a hotfix — do not attempt it as part of an outage fix:

  1. iam:PermissionsBoundary condition on CreateRole, PutRolePolicy and AttachRolePolicy, requiring every role the deploy SA creates to carry a boundary that itself denies IAM mutation.
  2. iam:PolicyARN condition on AttachRolePolicy, restricted to the specific customer-managed policies this deployment actually attaches.
  3. Consider adding CreateRole / CreatePolicy to the Deny for any name outside the set Terraform genuinely manages.

Verify each against a real plan before applying — this grant has 403'd four separate times on plausible-looking policies (#1496, #1514, #1671, #1698), so a change that looks correct in the policy text is not evidence it works.

Related

No activity

Activity on this issue will appear here.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions