RC RANDOM CHAOS

Your valid credentials are the breach.

Technical analysis of a coordinated GitHub Actions workflow compromise across 5,561 repositories, with detection guidance for audit log and EDR telemetry.

· 7 min read

5,561 GitHub repositories received malicious commits within a six-hour window. The commits modified GitHub Actions workflow files. They were authored by accounts that already had push rights. The commit messages matched the cadence of routine bot maintenance - dependency bumps, workflow syntax updates, runner image pins. The diffs were small. The signature was missing or matched a previously trusted bot identity. The push velocity across the window averaged roughly fifteen affected repositories per minute. This is supply chain compromise executed at the workflow layer, and the telemetry that should have caught it sits in places most teams do not correlate.

The entry point is the personal access token or the GitHub App installation token. Token theft from a developer endpoint, from a leaked .env, from a misconfigured artefact, from a compromised npm package post-install script reading ~/.config/gh/hosts.yml - the source varies. The outcome converges. The attacker holds a credential with repo scope or workflow scope, or both, across an organisation’s repositories. MITRE T1078.004, valid accounts: cloud accounts. From the GitHub API perspective, every action that follows is authenticated and authorised. No anomaly fires on credential validity. The credential is valid.

The payload is a workflow file modification. The attacker either edits an existing .github/workflows/*.yml or commits a new file under a name that mimics existing patterns - ci-maintenance.yml, dependabot-sync.yml, codeql-config.yml. The workflow registers on a trigger that fires without human review. push to any branch. pull_request_target from forks. schedule on a short cron. workflow_run chained to an existing CI job. pull_request_target is the high-value selector. It runs in the context of the base repository with read/write tokens, not the fork’s restricted token. A PR opened from a malicious fork executes the modified workflow against the protected branch’s secrets.

The job body does three things. It enumerates secrets.* and environment variables. It exfiltrates the contents over an outbound HTTP request to attacker infrastructure - frequently a domain registered within the previous seventy-two hours, frequently fronted by a free TLS certificate, frequently routed through a CDN to obscure the origin. It optionally writes a second-stage payload into the build artefact or container image the workflow produces. The exfil step is where defenders have signal. The GitHub-hosted runner makes the outbound request. The request is logged in the runner’s job log only if the workflow author chose to log it. Most of these payloads pipe to curl with -s and discard stderr. The job log shows nothing useful. The network connection from the ephemeral runner is invisible to the customer’s own EGRESS controls because the runner is not on the customer’s network.

The technique that makes the 5,561 number possible is automation against the GitHub REST and GraphQL APIs. A single compromised token with organisation-wide reach iterates the repository list, clones each repository or uses the contents API to fetch the workflow directory, applies a templated patch, and pushes via the API directly without ever materialising the repository on disk. The rate limit for an authenticated token is 5,000 requests per hour for REST and 5,000 points per hour for GraphQL. The arithmetic for 5,561 repositories across six hours is well within limits if the operation is batched and uses conditional requests. The attacker is not breaking rate limits. The attacker is operating inside them.

The commit camouflage is deliberate. The author email is set to the address of a known bot - dependabot[bot]@users.noreply.github.com, github-actions[bot]@users.noreply.github.com, or a project-specific automation account. GitHub does not enforce that the email in the commit matches the authenticated user pushing it. The commit shows the bot avatar and name in the web UI. Branch protection rules that require signed commits will reject the push unless the attacker has access to a signing key. Most repositories do not require signed commits on the default branch. Most that do require signing exempt bot identities through GitHub App installations, which sign with the App’s GPG key automatically. If the compromised credential is a GitHub App installation token, signed-commit enforcement does not stop the push.

The MITRE mapping is layered. T1195.002, compromise software supply chain, at the strategic level. T1078.004 for the credential abuse. T1098.001, account manipulation: additional cloud credentials, if the attacker mints new tokens or deploy keys for persistence. T1552.004, unsecured credentials: private keys, if the post-exploitation step harvests SSH keys from runner secrets. T1567.002, exfiltration to cloud storage, when the destination is an S3 bucket or equivalent rather than a bespoke endpoint. The MITRE model is useful here only insofar as it forces the question of where each step lives in the customer’s visibility.

The telemetry reality is the part most teams underestimate. GitHub Audit Log captures workflows.created_workflow_run, workflows.completed_workflow_run, repo.add_member, protected_branch.policy_override, git.push events when enterprise audit log streaming is configured. Without streaming to a SIEM, those events live inside GitHub’s interface and expire from the searchable window. The audit log does not capture file-level diffs. It records that a push occurred, by which actor, against which ref. To detect a malicious workflow change, you need either the audit log push event correlated with a repository content scan, or a GitHub Advanced Security workflow that runs on every push to .github/workflows/** and flags modifications. Neither is enabled by default.

EDR on developer endpoints sees the token theft, not the downstream effect. If the initial credential was harvested by a malicious dependency, Sysmon Event ID 1 records the process creation of the post-install script. Event ID 11 records the file read against the credential store. Event ID 3 records the outbound connection. The challenge is that these events occur on a developer workstation hours or days before the GitHub-side actions. Correlating the workstation event to the downstream organisation-wide compromise requires both feeds in the same platform with a join key - typically the developer’s identity or the token hash, neither of which is trivially available.

The runner-side telemetry is the blind spot. GitHub-hosted runners are ephemeral VMs operated by Microsoft. Customers do not get network flow logs, process telemetry, or filesystem visibility into the runner during job execution. Self-hosted runners are the inverse: the customer owns the host and can deploy Sysmon, EDR, or eBPF-based monitoring. Self-hosted runners introduce their own risk class - persistent runners executing untrusted workflows from public forks - but they restore visibility. For organisations on GitHub-hosted runners exclusively, detection has to happen at the GitHub API layer or at the egress edge of the destination, not at the runner.

Detection logic that works against this pattern operates on three signals. First, audit log events for pushes that modify files under .github/workflows/ outside change windows or by accounts that do not normally touch CI. The control is a rule that alerts on any workflow file modification not authored by a member of a defined CI maintainer group. Second, a content-level scan of every push to workflow paths, looking for new outbound network primitives - curl, wget, nc, Invoke-WebRequest, Net.WebClient - in jobs that did not previously contain them. Diff-based scanning is more accurate than full-file pattern matching because most legitimate workflows already contain outbound calls. Third, secret access pattern analysis. A workflow that reads secrets.* for secrets it did not previously read is anomalous. GitHub does not expose which secrets a job referenced in audit log output, but the workflow file itself records the reference and can be parsed at push time.

To check whether you were hit, the operative queries are these. Pull the audit log for the affected window. Filter for git.push events targeting paths under .github/workflows/. Cross-reference the actor against your CI maintainer roster. For every push outside the roster, retrieve the commit and diff the workflow file against the prior version. Look for additions of outbound network calls, additions of secrets.* references that were not previously present, additions of base64-encoded blobs in run: steps, and changes to triggers - particularly the appearance of pull_request_target on workflows that previously used pull_request. Revoke and rotate every secret referenced in any flagged workflow. Revoke and rotate every personal access token and GitHub App installation token in the organisation. Treat token rotation as the boundary of containment, not the patch.

The patch boundary on the attack is the credential lifecycle, not a software version. There is no CVE here. The platform behaved as documented. The compromise lived in the trust granted to a token, the absence of branch protection on workflow paths, and the absence of workflow-file change detection in the audit pipeline. Post-rotation, residual exposure persists in any artefact published during the window - container images, npm packages, language-specific build outputs - and in any downstream consumer that pulled those artefacts before the malicious workflow was reverted. The blast radius of a six-hour window in CI/CD is measured in dependency graph depth, not in elapsed time.

Share

Keep Reading

Stay in the loop

New writing delivered when it's ready. No schedule, no spam.