Skip to content

sec(iac): GCP WIF Terraform bypass - IAM user path defeats contains() role check, plus pool-wide grant #1667

Description

@cristim

Severity: CRITICAL. Like #1544, this is customer-facing: iac/embed.go:9 embeds federation, and internal/api/handler_federation.go:641-655 returns federation/gcp-target as the default bundle for target=gcp, so the zip customers download ships main.tf with a pre-filled tfvars. Same endpoint, same customers, same defect class as #1544 - and more exploitable than the bug #1544 reported, because this path has a working bypass rather than just a missing second gate.

#1544's framing said "the shell path lacks the guard its Terraform sibling enforces". That premise is wrong, and PR #1651 disproves it. The Terraform path is the worse of the two.

(a) Live bypass: raw assertion.arn + substring test - HEADLINE

iac/federation/gcp-target/terraform/main.tf:102 maps the attribute raw:

"attribute.aws_role" = "assertion.arn"

and :109 substring-tests it:

"attribute.aws_role.contains('assumed-role/${var.aws_role_name}/')"

With aws_role_name = "CUDly-Execution", an attacker who has only iam:CreateUser in the customer's AWS account:

  1. Creates an IAM user with path /assumed-role/CUDly-Execution/. AWS's documented path regex is (/)|(/[!-~]+/) - every character here is printable ASCII, so the path is legal.
  2. sts:GetCallerIdentity returns arn:aws:iam::<acct>:user/assumed-role/CUDly-Execution/evil.
  3. The provider's account_id check passes (same account). contains('assumed-role/CUDly-Execution/') is true - the crafted path supplies the substring the test looks for.
  4. The pool-wide binding in (b) matches every identity in the pool.

Result: full impersonation of the CUDly GCP service account, which holds compute.commitments.create/update. iam:CreateUser escalated into cross-cloud GCP spend authority.

IAM role paths do not work, because STS drops the path from assumed-role ARNs. It is specifically the IAM user ARN, which retains its path, that defeats the substring test.

The shell script fixed in #1651 is immune, and the reason is the argument for the fix shape: it normalises the assertion down to the role ARN and compares with ==, so a user ARN never equals arn:aws:sts::<acct>:assumed-role/<role> regardless of its path.

(b) Pool-wide impersonation grant

main.tf:162:

member = ... "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.cudly.name}/*"

Byte-identical in shape to the grant removed in #1651. Its inline comment - "wildcard principalSet is intentional; session ARNs include variable session names so exact-match principalSet cannot work" - is precisely the claim #1651 disproves.

Worth noting how far the two customer-facing paths have now diverged: the pool_wide_grants() scan added to setup-gcp-wif.sh in #1651 flags a Terraform-created grant as legacy and refuses to report success. A customer who onboards with Terraform and later runs the shell script is told, correctly, that their setup is unsafe.

(c) No validation on the CEL-interpolated variables

variables.tf:57-67: neither aws_role_name nor oidc_subject has a validation block, and both are interpolated into a CEL string literal. aws_role_name = "x') || true || ('" renders an always-true condition. This is the CEL break-out that validate_principal_value() exists to stop on the shell side.

(d) Stale "Recommended" wording

internal/iacfiles/templates/gcp-wif.tfvars.tmpl ships aws_role_name commented out and labelled "Recommended", while main.tf:139-142 requires it. A downloaded bundle fails terraform apply. Fails closed, but the wording misleads.

Fix direction

Mirror PR #1651:

"attribute.aws_role" = "assertion.arn.contains('assumed-role') ? assertion.arn.extract('{account_arn}assumed-role/') + 'assumed-role/' + assertion.arn.extract('assumed-role/{role_name}/') : assertion.arn"

attribute_condition = "attribute.aws_role == 'arn:aws:sts::${var.aws_account_id}:assumed-role/${var.aws_role_name}'"

member = "principalSet://iam.googleapis.com/${pool.name}/attribute.aws_role/arn:aws:sts::${var.aws_account_id}:assumed-role/${var.aws_role_name}"

plus validation blocks on both string vars rejecting ', ", \, backtick, * and $, and a fix to (d).

This changes attribute_mapping on live deployments, which existing customers federate against, so it needs its own migration note rather than a silent edit.

Related: #1544 (same defect class, shell path, fixed in #1651), #1661 (adjacent gaps in the served CLI template).

Activity

  1. cristim commented on Aug 3, 2026

    @cristim
    MemberAuthor

    Closed by PR #1675 — verification record

    Auto-closed by the Closes keyword; recording what was verified, since the merge rested on this rather than on CI.

    Note on the CodeRabbit signal: #1675's CodeRabbit status read success while its description said "Review rate limited" — it never actually reviewed this p0 customer-facing PR, which presented as "0 unresolved threads". The gate here was an independent adversarial review.

    The fix is exact, and no bypass was found. The condition is == against a normalised value. For it to match, the raw ARN must literally begin with arn:aws:sts::<pinned acct>:assumed-role/ — and the iam vs sts service segment is not attacker-controllable at any path depth. So this issue's exploit (an IAM user at path /assumed-role/CUDly-Execution/) normalises to arn:aws:iam::<acct>:user/assumed-role/CUDly-Execution and is rejected. A longer sibling role name (CUDly-Execution-Admin) is rejected by ==.

    Non-obvious point worth preserving: Google documents two AWS mapping variants, and the simpler extract('assumed-role/{role_name}/') form would still be vulnerable to this exact attack. The fix picked the full-ARN ternary variant.

    Both halves landed. The pool-wide principalSet://.../* binding was genuinely narrowed to the documented exact-attribute-value form, not just the condition tightened. That also resolved an open question the PR had flagged: Google's docs show the ARN unencoded inside the identifier, so no URL-encoding is needed.

    Migration is a genuine in-place update, confirmed from provider source at three versions rather than registry prose: attribute_mapping and attribute_condition have no ForceNew and both enter the updateMask. Not a breaking change.

    Tests were mutation-tested: four distinct mutations of main.tf are each caught by a distinct test.

    Known residue, not blocking

    depends_on and create_before_destroy order the API calls but cannot order GCP's eventual consistency, so an existing customer re-applying may see a transient federation gap of minutes. Self-healing; no Terraform-side fix exists. A two-step -target apply removes the window entirely and is worth documenting.

    Two deliberate narrowings: the pinned ARN hardcodes arn:aws:, so GovCloud/China callers now fail closed; and the account is pinned in the condition (already enforced independently by the provider's aws { account_id } block).

    Cross-PR interaction caught before shipping

    The Terraform module mapped a third attribute (attribute.account) that setup-gcp-wif.sh does not. Once #1673 added exact-set mapping validation, that would have hard-died on every Terraform-onboarded customer — with a wrong verdict and a destructive suggested remedy (delete a live provider). Resolved by dropping the unreferenced attribute; the two mapping sets are now byte-identical, verified by cmp.

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