An automated campaign is using stolen GitHub tokens to plant workflows that print a repository's secrets into its GitHub Actions logs, which the attackers then download before deleting the runs. The attacks have succeeded in private repositories and GitHub Enterprise environments, according to an analysis by cloud security company Wiz.

A GitHub safeguard that pauses suspicious workflow runs stopped every public-repository run Wiz checked, but it does not cover private repositories or GitHub Enterprise Server.

Each workflow sits on a new branch named ci-sync- followed by six random characters. Deleting a branch, or the run it started, does not undo the exposure. Every secret the repository could read should be rotated, and the token that pushed the branch revoked.

Wiz has tracked the campaign since September 15 and calls it Gheist, after the user agent gheist that the attackers' tool sends with GitHub API requests.

Wiz has recovered more than 13,500 planted branches from more than 850 compromised accounts, from personal projects to the private repositories of enterprise organizations. Wiz's analysis does not say how many organizations had secrets exposed.

In private repositories and on GitHub Enterprise Server, GitHub audit logs show runs on the planted branches finishing and then being deleted by the gheist user agent. AWS CloudTrail logs show the organization's own workflows also running on those branches and assuming AWS roles, a sign that GitHub Actions ran there without approval.

In enterprise organizations, the tool cycled through tokens from several users and automation accounts within minutes and cloned private repositories before planting the workflow. Hours later, cloud credentials from the same organization were used to enumerate AWS Secrets Manager. Wiz's analysis does not say where those credentials came from or who used them.

As of October 24, the campaign was still running and changing. About two-thirds of the accounts involved were no longer available on GitHub. Wiz reported the activity to GitHub and says their disappearance likely reflects a response.

Anyone with write access to a GitHub repository can read every secret configured in it, according to GitHub's documentation. A stolen token with that access is enough.

For each repository a token can reach, the tool creates a ci-sync- branch and commits one file, .github/workflows/sync-check.yml, with the message ci: sync config. The commit is unsigned and includes the token owner's name; pushing it starts the workflow.

The workflow prints the job's environment and every secret the job can read, encoded in base32. GitHub masks secrets in logs, but its documentation says this is not guaranteed once a value has been encoded or otherwise changed. Wiz says the base32 text no longer matches the original secret, so it prints in full.

The workflow sets an empty permissions block. That limits the job's GITHUB_TOKEN, which the attackers do not need, and does not stop the job from reading secrets.

One version of the tool also runs a job for each deployment environment in the repository, so secrets limited to those environments print too. These are often production credentials, according to Wiz.

The tool then downloads the log through GitHub's API and deletes the run seconds later. Because the secrets leave through GitHub itself, there is no attacker server or outbound connection to block.

Since July 28, GitHub has held workflow runs it identifies as potentially malicious until a collaborator with write access approves them. GitHub says the hold "currently applies to public repositories on github.com only" and that GitHub Enterprise Server does not have it at this time.

Every public Gheist run Wiz checked, across all versions of the tool, was still waiting for approval, so its payload never ran.

The hold only catches runs that GitHub flags. A new variant of GhostAction, a separate campaign, ran in public repositories until GitHub began blocking it on October 9, according to research from security company GitGuardian. Wiz says Gheist is distinct from GhostAction, which uses different workflow files.

Around October 19, one version of the tool began pushing a second job meant for self-hosted runners, the machines organizations run themselves to process GitHub Actions jobs. It appears to target only repositories that have such a runner registered.

The job is written to search the runner machine for credentials, including 31 types of tokens: private keys, process environments, AWS instance role credentials, runner credential files, and the Kubernetes service account token. Wiz describes the job as buggy, and its analysis reports no successful run.

The sources Wiz could trace for the stolen tokens include infostealer logs and tokens exposed in earlier supply-chain attacks, such as the March compromise of the Trivy vulnerability scanner.

Other attackers also use the same accounts. On October 6, 391 accounts, many of them also used by Gheist, renamed about 1,461 repositories to appear to be cracked software and spread malware. The renaming wiped 610 of the planted branches.

Some victims also appear to be infected with PolinRider, malware that researchers have linked to North Korea. On October 19 and 20, three of them re-pushed their Gheist commits with a loader added to a vite.config.js file.

Megalodon, a campaign that pushed secret-stealing workflows into thousands of GitHub repositories in May, had also hit 57 of the accounts.

How to Check Your Repositories

Wiz recommends these checks:

  • Look for branches named ci-sync- followed by six characters, where the only change is a new .github/workflows/sync-check.yml file with the commit message ci: sync config.
  • In GitHub audit logs, look for workflow runs on ci-sync-* branches that were deleted within a minute of finishing, and for any activity from the user agent gheist.
  • If your workflows sign in to a cloud provider with OIDC, check cloud audit logs for role assumptions or token exchanges whose subject includes a ci-sync- branch. This works even if you do not collect GitHub logs.
  • On self-hosted runners, look for the files .gdmp_pat, .gdmp_patg and .gdmp_sweep, and the string gdmp-pivot in job logs.

OIDC lets workflows sign in with short-lived tokens instead of stored cloud keys. By default, a token's subject includes the branch name only when the job does not use a deployment environment, according to GitHub's OIDC documentation.

The published indicators are:

  • User agent: gheist
  • Branch: ^ci-sync-[0-9a-z]{6}$
  • Commit message: ci: sync config, adding only .github/workflows/sync-check.yml
  • String: toJSON(secrets) }}' | base32 -w 120
  • String: ===== gdmp-pivot:
  • File: .gdmp_pat
  • File: .gdmp_patg
  • File: .gdmp_sweep
  • Git blob SHA (plain workflow): f3da2b8b357dcda02fd455d314be2a95007516a6
  • Git blob SHA (timeout version): d09535857a741bbb976bdc084a9a021713133291
  • Git blob SHA (separator version): 1dc5af9cce950aad1eb1546ecce8565b117540c4
  • Git blob SHA (self-hosted job version): c665b778b2d822453293003e7ca0bbb2cb08e39d

Versions of the workflow that list a repository's deployment environments vary by repository, so they have no fixed hash.

What to Do If You Find a ci-sync Branch

Wiz advises these steps for an affected repository:

  • Rotate every repository, organization, and environment secret the repository could use.
  • Revoke the tokens that pushed the commits, and check their owners' devices for infostealers.
  • If a ci-sync branch ran on a self-hosted runner, treat the machine and its cloud instance role as compromised.
  • Do not approve held sync-check runs, and do not run code from ci-sync branches.

To make a stolen token less useful, Wiz also recommends these steps:

  • Require reviewers on environments that hold production secrets.
  • Limit OIDC trust policies to protected branches or environments.
  • Use GitHub Apps or short-lived fine-grained tokens instead of personal access tokens.
  • Run self-hosted runners as short-lived machines without Docker socket access or open access to cloud metadata.

A job cannot read an environment's secrets until a required reviewer approves it, according to GitHub's documentation.

Found this article interesting? Follow us on Google News, Twitter and LinkedIn to read more exclusive content we post.