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

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