Repository navigation
sec(iac/aws): deploy role can create an unbounded role and attach AdministratorAccess to it #1705
Description
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
iam:CreateRoleis allowed onarn:aws:iam::*:role/cudly-*with noiam:PermissionsBoundarycondition, so the new role is unbounded.CreateRoletakes an arbitraryAssumeRolePolicyDocument, so the trust policy can name the deploy role itself (or any principal the attacker holds).iam:AttachRolePolicyis allowed on the samerole/cudly-*pattern. TheResourceis the role ARN and there is noiam:PolicyARNcondition, soarn:aws:iam::aws:policy/AdministratorAccesscan be attached to the role just created.- 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:AssumeRoleis required. Confirmed:grep 'sts:'across all four policies returns onlysts:GetCallerIdentity; the solests:AssumeRolein the tree is the deploy role's own trust policy atrole.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-175attaches exactly four managed policies, all matchingpolicy/cudly-deploy-*, and there are noaws_iam_role_policyinline policies on the role. - Instance-profile route —
iam:AddRoleToInstanceProfileis not denied, so the deploy role can put itself into acudly-*instance profile. But converting that to credentials needsec2:RunInstanceswith that profile, which requiresiam:PassRoleon the contained role — denied byIAMDenyPassDeployRole. 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:
iam:PermissionsBoundarycondition onCreateRole,PutRolePolicyandAttachRolePolicy, requiring every role the deploy SA creates to carry a boundary that itself denies IAM mutation.iam:PolicyARNcondition onAttachRolePolicy, restricted to the specific customer-managed policies this deployment actually attaches.- Consider adding
CreateRole/CreatePolicyto 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
- fix(iac/aws): unblock deploy on rds:DescribeDBInstances and close audited grant gaps #1699 (adds the partial Deny; do not block it on this — it reduces net privilege)
- sec(ci): main is unprotected while ref:refs/heads/main is a trusted OIDC subject for the AWS deploy role cloud-commitments-platform#154 (unprotected
main+ trusted OIDC subject — the reachability half) - chore(iac/aws): drain remaining deploy-role grant gaps found by the #1698 audit (dead scopes + opt-in paths) cloud-commitments-platform#157 (remaining audited grant gaps)
Activity
cristim commentedon Aug 3, 2026
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.
mainaccepts direct pushes with red CI. There is no classic branch protection (/branches/main/protectionreturns 404). One ruleset applies, and despite its name (protect-master-branch) it does targetmainvia~DEFAULT_BRANCHand isactive. But its entire rule set is: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/mainis a trusted OIDC subject forcudly-terraform-deploy— entry 1 of the exact-match allowlist interraform/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.tfgrantsiam:CreateRole(:17),iam:AttachRolePolicy(:13, :143),iam:CreatePolicy(:15),iam:CreatePolicyVersion(:16),iam:PutRolePolicy(:34).4. Nothing constrains it. No
PermissionsBoundary/permissions_boundarycondition appears anywhere in the module, and no Deny namesAdministratorAccessoraws:PolicyARN. The only two Denies areIAMDenyPassDeployRole(:108) andIAMDenyModifyDeployRoleAndPolicies(:140).Why the existing Deny does not cover this
IAMDenyModifyDeployRoleAndPolicieswas 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 attachingAdministratorAccessto that. The comment at:120even 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/internalcounted 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.
sec(iac/aws): gate deploy-role IAM writes on a permissions boundary (#…