Skip to content

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

Closed

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

Activity

  1. cristim commented on Aug 3, 2026

    @cristim
    MemberAuthor

    Re-triaged p1 -> p0, severity/high -> critical, impact/internal -> all-users, urgency/this-sprint -> now. I verified the full chain against live repo settings and the committed policies rather than triaging on the issue text, and every link holds.

    The chain, each link checked

    1. main accepts direct pushes with red CI. There is no classic branch protection (/branches/main/protection returns 404). One ruleset applies, and despite its name (protect-master-branch) it does target main via ~DEFAULT_BRANCH and is active. But its entire rule set is:

    rules on main: deletion, non_fast_forward
    

    No required pull request, no required status checks, no required review. So repo write access is sufficient to land arbitrary code on main. This also confirms the premise of LeanerCloud/cloud-commitments-platform#154 concretely.

    2. ref:refs/heads/main is a trusted OIDC subject for cudly-terraform-deploy — entry 1 of the exact-match allowlist in terraform/environments/aws/ci-cd-permissions/role.tf, applied live earlier today.

    3. The deploy role can create a role and attach any managed policy. policy_data.tf grants iam:CreateRole (:17), iam:AttachRolePolicy (:13, :143), iam:CreatePolicy (:15), iam:CreatePolicyVersion (:16), iam:PutRolePolicy (:34).

    4. Nothing constrains it. No PermissionsBoundary / permissions_boundary condition appears anywhere in the module, and no Deny names AdministratorAccess or aws:PolicyARN. The only two Denies are IAMDenyPassDeployRole (:108) and IAMDenyModifyDeployRoleAndPolicies (:140).

    Why the existing Deny does not cover this

    IAMDenyModifyDeployRoleAndPolicies was added today, in #1699, by me. It stops the deploy role modifying itself or its own policies. It does not stop it creating a new role and attaching AdministratorAccess to that. The comment at :120 even reasons about the deploy role granting itself admin, which is precisely the path that is blocked while the sibling path stays open.

    That is a guard placed on the primary write path while an equivalent secondary path was left unguarded, and it is worth recording as the reason this issue is not covered by work already merged.

    Why p0

    impact/internal counted who operates the tooling. The correct axis is what an exercise of the defect reaches: full administrative control of the production AWS account, which is every user. The precondition is repo write access, not a leaked long-lived secret, and link 1 means that precondition does not even require passing CI.

    Honest limit: I verified grants, denies and repo settings statically. I did not execute the escalation against the live account, and I am not proposing to.

  2. added a commit that references this issue on Aug 6, 2026
    ff808b2
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