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: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).
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.
- 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:
iam:PermissionsBoundary condition on CreateRole, PutRolePolicy and AttachRolePolicy, requiring every role the deploy SA creates to carry a boundary that itself denies IAM mutation.
iam:PolicyARN condition on AttachRolePolicy, restricted to the specific customer-managed policies this deployment actually attaches.
- 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
Summary
The
cudly-terraform-deployrole can escalate to full account administrator by creating a new role and attachingAdministratorAccessto it. This is reachable from a leaked or misused GitHub Actions OIDC token today, on currentmain.Found during adversarial review of #1699. It is pre-existing, not introduced by that PR — #1699 in fact reduces net privilege (it adds the
IAMDenyModifyDeployRoleAndPoliciesDeny, 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.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
IAMDenyModifyDeployRoleAndPoliciesdoes not stop itThat Deny (added by #1699) names only
role/cudly-terraform-deployandpolicy/cudly-deploy-*. Computed mechanically from the rendered policy JSON:CreateRoleandCreatePolicyare 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)
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.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.Compounding factor
Per LeanerCloud/cloud-commitments-platform#154,
mainis unprotected whileref:refs/heads/mainis 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.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
main+ trusted OIDC subject — the reachability half)